@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
@@ -0,0 +1,362 @@
1
+ /**
2
+ * Session rotation (den#4, rule R2: one session per objective). A session
3
+ * that never ends re-reads its whole context on every call, so a session
4
+ * ends when its pull request merges or after a few compactions, and the
5
+ * role relaunches from a handoff in a fresh conversation.
6
+ *
7
+ * agent-files/<id>/rotation.json the compaction count of the agent's
8
+ * current conversation (the SessionStart
9
+ * "compact" hook's), and whether a rotation is due
10
+ * agent-files/<id>/handoff.md what the session wrote when it rotated
11
+ * (handoff.ts checks it)
12
+ * agent-files/<id>/handoffs/ handoffs Den relaunched from, kept
13
+ *
14
+ * Triggers (org config `agents.rotate`):
15
+ * - `after_compactions` (default 3): SessionStart "compact" counts each
16
+ * compaction that happened; at that many, a rotation is due;
17
+ * - the agent's pull request merged: the record's outcome
18
+ * (`prMerged`, den#6), which `<cli> usage` fills in.
19
+ * `standing: false` keeps standing personas (planner, orchestrator) out:
20
+ * they never rotate. Default true: only their context resets, they keep
21
+ * their name.
22
+ *
23
+ * A rotation is "ready" once it is due and handoff.md passes the check.
24
+ * Then Den declines a plain Resume and offers Relaunch from handoff.
25
+ * Everything here is this machine's files; nothing is sent.
26
+ */
27
+ import * as fs from "fs";
28
+ import * as path from "path";
29
+ import { checkHandoff } from "./handoff.js";
30
+ import { agentFilesDir, writePrivate, type AgentRecord } from "./records.js";
31
+
32
+ export const ROTATION_FILE = "rotation.json";
33
+ export const HANDOFF_FILE = "handoff.md";
34
+ export const HANDOFFS_DIR = "handoffs";
35
+
36
+ /** Why a session rotates. */
37
+ export type RotateReason = "compactions" | "pr-merged";
38
+
39
+ export interface RotationState {
40
+ /**
41
+ * The conversation counted: Claude Code's session_id, which is the
42
+ * record's resumeToken. A count for another conversation is stale.
43
+ */
44
+ conversation?: string;
45
+ compactions: number;
46
+ /** Rotate after this many compactions; 0: this agent never rotates. */
47
+ after: number;
48
+ /** The last compaction (ISO 8601) and what triggered it (manual, auto). */
49
+ lastCompactAt?: string;
50
+ lastTrigger?: string;
51
+ /**
52
+ * PreCompact's trigger, kept until SessionStart "compact" counts the
53
+ * compaction (SessionStart's input has no trigger).
54
+ */
55
+ pendingTrigger?: string;
56
+ /** Set when a trigger fired. */
57
+ due?: RotateReason;
58
+ dueAt?: string;
59
+ }
60
+
61
+ /** `agents.rotate` with its defaults applied. */
62
+ export interface RotateSettings {
63
+ /** Compactions before a session rotates (at least 1). */
64
+ afterCompactions: number;
65
+ /** Whether standing personas rotate too. */
66
+ standing: boolean;
67
+ }
68
+
69
+ export const DEFAULT_ROTATE: RotateSettings = {
70
+ afterCompactions: 3,
71
+ standing: true,
72
+ };
73
+
74
+ /** A rotation Den did (kept in the record, newest last). */
75
+ export interface RotationEntry {
76
+ /** When it relaunched (ISO 8601). */
77
+ at: string;
78
+ reason: RotateReason;
79
+ /** Compactions of the conversation that ended. */
80
+ compactions: number;
81
+ /** The conversation that ended (its resume token). */
82
+ conversation?: string;
83
+ }
84
+
85
+ /** The compactions after which `persona` rotates; 0: it never does. */
86
+ export function rotateAfter(
87
+ settings: RotateSettings,
88
+ persona: { standing?: boolean },
89
+ ): number {
90
+ if (persona.standing && !settings.standing) return 0;
91
+ return settings.afterCompactions;
92
+ }
93
+
94
+ export const rotationFile = (filesDir: string) =>
95
+ path.join(filesDir, ROTATION_FILE);
96
+ export const handoffFile = (filesDir: string) =>
97
+ path.join(filesDir, HANDOFF_FILE);
98
+
99
+ const count = (v: unknown) =>
100
+ typeof v === "number" && Number.isInteger(v) && v >= 0 ? v : 0;
101
+
102
+ /** The rotation state in `filesDir`; null when there is none (or it is unreadable). */
103
+ export function readRotation(filesDir: string): RotationState | null {
104
+ let v: unknown;
105
+ try {
106
+ v = JSON.parse(fs.readFileSync(rotationFile(filesDir), "utf-8"));
107
+ } catch {
108
+ return null;
109
+ }
110
+ if (!v || typeof v !== "object" || Array.isArray(v)) return null;
111
+ const o = v as Record<string, unknown>;
112
+ const s: RotationState = {
113
+ compactions: count(o.compactions),
114
+ after: count(o.after),
115
+ };
116
+ if (typeof o.conversation === "string") s.conversation = o.conversation;
117
+ if (typeof o.lastCompactAt === "string") s.lastCompactAt = o.lastCompactAt;
118
+ if (typeof o.lastTrigger === "string") s.lastTrigger = o.lastTrigger;
119
+ if (typeof o.pendingTrigger === "string") s.pendingTrigger = o.pendingTrigger;
120
+ if (o.due === "compactions" || o.due === "pr-merged") s.due = o.due;
121
+ if (typeof o.dueAt === "string") s.dueAt = o.dueAt;
122
+ return s;
123
+ }
124
+
125
+ export function writeRotation(filesDir: string, s: RotationState): void {
126
+ writePrivate(rotationFile(filesDir), JSON.stringify(s, null, 2) + "\n");
127
+ }
128
+
129
+ /**
130
+ * A launch's rotation state. A new conversation starts at 0. A resume of
131
+ * the same one keeps its count and takes the current `after`.
132
+ */
133
+ export function launchRotation(
134
+ filesDir: string,
135
+ conversation: string | undefined,
136
+ after: number,
137
+ ): RotationState {
138
+ const prev = readRotation(filesDir);
139
+ const same =
140
+ prev && conversation !== undefined && prev.conversation === conversation;
141
+ const s: RotationState = same
142
+ ? { ...prev, after }
143
+ : {
144
+ ...(conversation !== undefined ? { conversation } : {}),
145
+ compactions: 0,
146
+ after,
147
+ };
148
+ if (after === 0) {
149
+ delete s.due;
150
+ delete s.dueAt;
151
+ }
152
+ writeRotation(filesDir, s);
153
+ return s;
154
+ }
155
+
156
+ /**
157
+ * One compaction that happened (SessionStart with source "compact"; never
158
+ * PreCompact, which runs even when nothing is compacted). `input` is the
159
+ * hook input: `session_id` and `trigger` (manual, auto) are read when present.
160
+ * A different conversation than the one counted starts the count again.
161
+ */
162
+ export function noteCompaction(
163
+ filesDir: string,
164
+ input: unknown,
165
+ after: number,
166
+ now: Date = new Date(),
167
+ ): RotationState {
168
+ const i = (input && typeof input === "object" ? input : {}) as Record<
169
+ string,
170
+ unknown
171
+ >;
172
+ const conversation =
173
+ typeof i.session_id === "string" && i.session_id ? i.session_id : undefined;
174
+ const prev = readRotation(filesDir);
175
+ const fresh =
176
+ !prev ||
177
+ (conversation !== undefined &&
178
+ prev.conversation !== undefined &&
179
+ prev.conversation !== conversation);
180
+ const s: RotationState = fresh
181
+ ? { compactions: 0, after }
182
+ : { ...prev, after };
183
+ if (conversation !== undefined) s.conversation = conversation;
184
+ s.compactions += 1;
185
+ s.lastCompactAt = now.toISOString();
186
+ const trigger =
187
+ typeof i.trigger === "string"
188
+ ? i.trigger
189
+ : fresh
190
+ ? undefined
191
+ : prev?.pendingTrigger;
192
+ if (trigger) s.lastTrigger = trigger.slice(0, 20);
193
+ delete s.pendingTrigger;
194
+ if (after > 0 && s.compactions >= after && !s.due) {
195
+ s.due = "compactions";
196
+ s.dueAt = now.toISOString();
197
+ }
198
+ writeRotation(filesDir, s);
199
+ return s;
200
+ }
201
+
202
+ /**
203
+ * PreCompact: keep its trigger (manual, auto) for the count. Nothing is
204
+ * counted; a state for another conversation is left alone.
205
+ */
206
+ export function notePendingCompaction(
207
+ filesDir: string,
208
+ conversation: string | undefined,
209
+ trigger: unknown,
210
+ after: number,
211
+ ): void {
212
+ if (typeof trigger !== "string") return;
213
+ const prev = readRotation(filesDir);
214
+ if (prev?.conversation && conversation && prev.conversation !== conversation)
215
+ return;
216
+ writeRotation(filesDir, {
217
+ ...(prev ?? { compactions: 0, after }),
218
+ ...(conversation ? { conversation } : {}),
219
+ pendingTrigger: trigger.slice(0, 20),
220
+ });
221
+ }
222
+
223
+ /** Mark a rotation due (its pull request merged), when the agent rotates. */
224
+ export function markDue(
225
+ filesDir: string,
226
+ reason: RotateReason,
227
+ conversation?: string,
228
+ now: Date = new Date(),
229
+ ): boolean {
230
+ const s = readRotation(filesDir);
231
+ if (!s || s.after === 0 || s.due) return false;
232
+ if (conversation && s.conversation && s.conversation !== conversation)
233
+ return false;
234
+ writeRotation(filesDir, { ...s, due: reason, dueAt: now.toISOString() });
235
+ return true;
236
+ }
237
+
238
+ /** Whether the state says a rotation is due, for this conversation. */
239
+ export function dueFor(
240
+ s: RotationState | null,
241
+ conversation?: string,
242
+ ): RotateReason | undefined {
243
+ if (!s || s.after === 0) return undefined;
244
+ if (conversation && s.conversation && s.conversation !== conversation)
245
+ return undefined;
246
+ if (s.due) return s.due;
247
+ return s.compactions >= s.after ? "compactions" : undefined;
248
+ }
249
+
250
+ export type HandoffStatus = "ok" | "missing" | "invalid";
251
+
252
+ /** handoff.md in `filesDir`: missing, or checked. */
253
+ export function readHandoff(filesDir: string): {
254
+ status: HandoffStatus;
255
+ text?: string;
256
+ problems: string[];
257
+ } {
258
+ let text: string;
259
+ try {
260
+ text = fs.readFileSync(handoffFile(filesDir), "utf-8");
261
+ } catch {
262
+ return { status: "missing", problems: [] };
263
+ }
264
+ const r = checkHandoff(text);
265
+ return { status: r.ok ? "ok" : "invalid", text, problems: r.problems };
266
+ }
267
+
268
+ /** An agent's rotation, as the Agents page and the CLI show it. */
269
+ export interface RotationInfo {
270
+ /** Why it rotates, when a trigger fired. */
271
+ due?: RotateReason;
272
+ compactions: number;
273
+ /** 0: this agent never rotates. */
274
+ after: number;
275
+ handoff: HandoffStatus;
276
+ /** What is wrong with handoff.md, when it is invalid. */
277
+ problems?: string[];
278
+ /** Due and handoff.md passes: Relaunch from handoff, not Resume. */
279
+ ready: boolean;
280
+ }
281
+
282
+ /**
283
+ * Agent `rec`'s rotation: null when it has no rotation state for its
284
+ * current conversation (it never ran under 0.4.9, or the state is stale).
285
+ */
286
+ export function rotationInfo(
287
+ stateDir: string,
288
+ rec: AgentRecord,
289
+ ): RotationInfo | null {
290
+ const files = agentFilesDir(stateDir, rec.id);
291
+ const s = readRotation(files);
292
+ if (!s) return null;
293
+ if (s.conversation && rec.resumeToken && s.conversation !== rec.resumeToken)
294
+ return null;
295
+ const due =
296
+ dueFor(s) ??
297
+ (s.after > 0 && rec.outcome?.prMerged ? "pr-merged" : undefined);
298
+ const h = due
299
+ ? readHandoff(files)
300
+ : { status: "missing" as const, problems: [] };
301
+ return {
302
+ ...(due ? { due } : {}),
303
+ compactions: s.compactions,
304
+ after: s.after,
305
+ handoff: h.status,
306
+ ...(h.status === "invalid" ? { problems: h.problems } : {}),
307
+ ready: Boolean(due) && h.status === "ok",
308
+ };
309
+ }
310
+
311
+ /**
312
+ * Move handoff.md aside (handoffs/<tag>.md) once Den relaunched from it, so
313
+ * the next rotation needs a new one. Returns where it went.
314
+ */
315
+ export function archiveHandoff(filesDir: string, tag: string): string {
316
+ const dir = path.join(filesDir, HANDOFFS_DIR);
317
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
318
+ const safe = tag.replace(/[^A-Za-z0-9._-]/g, "-").slice(0, 80);
319
+ const to = path.join(dir, `${safe}.md`);
320
+ fs.renameSync(handoffFile(filesDir), to);
321
+ return to;
322
+ }
323
+
324
+ /** Why, in words: "3 compactions", "PR merged". */
325
+ export function reasonText(reason: RotateReason, compactions: number): string {
326
+ return reason === "pr-merged"
327
+ ? "PR merged"
328
+ : `${compactions} ${compactions === 1 ? "compaction" : "compactions"}`;
329
+ }
330
+
331
+ /** What the hooks tell a session whose rotation is due. */
332
+ export function rotationInstruction(
333
+ filesDir: string,
334
+ reason: RotateReason,
335
+ compactions: number,
336
+ ): string {
337
+ const why =
338
+ reason === "pr-merged"
339
+ ? "its pull request merged"
340
+ : `it has compacted ${compactions} times`;
341
+ return [
342
+ `Den: this session is done, because ${why} (session rotation).`,
343
+ `Write your handoff now, to ${handoffFile(filesDir)}, with these headings, each filled in:`,
344
+ "## Objective, ## State, ## Open items, ## How to QA, ## Next step.",
345
+ "Write plainly and explain every ID. Then end your turn.",
346
+ "Den relaunches your role from it in a fresh conversation.",
347
+ ].join("\n");
348
+ }
349
+
350
+ /** The first prompt of a session relaunched from `handoff`. */
351
+ export function handoffPrompt(
352
+ handoff: string,
353
+ reason: RotateReason,
354
+ compactions: number,
355
+ ): string {
356
+ return [
357
+ `Den relaunched you from the handoff your last session wrote. That session ended after ${reasonText(reason, compactions)} (session rotation).`,
358
+ "Read it, then carry on from its Next step.",
359
+ "",
360
+ handoff.trim(),
361
+ ].join("\n");
362
+ }
@@ -89,6 +89,16 @@ export interface LaunchInput {
89
89
  resumeToken?: string;
90
90
  /** The CLI detect found (a setting, the login PATH, …); else searched again. */
91
91
  bin?: string;
92
+ /**
93
+ * Session rotation (den#4): the compactions after which this agent's
94
+ * session rotates (0: never). Den's hooks count them. Default 3.
95
+ */
96
+ rotateAfter?: number;
97
+ /**
98
+ * The new conversation's first prompt (a relaunch from handoff.md).
99
+ * Only a runtime with `firstPrompt: true` is given one.
100
+ */
101
+ firstPrompt?: string;
92
102
  }
93
103
 
94
104
  export interface LaunchOutput {
@@ -126,10 +136,27 @@ export interface Runtime {
126
136
  */
127
137
  find?: RuntimeFind;
128
138
  detect(env?: NodeJS.ProcessEnv, opts?: DetectOptions): RuntimeDetect;
139
+ /**
140
+ * It can start a conversation with a first prompt (LaunchInput
141
+ * firstPrompt): Relaunch from handoff needs it (den#4).
142
+ */
143
+ firstPrompt?: boolean;
129
144
  /** Throws LaunchError when this persona or this machine can't run it. */
130
145
  launch(input: LaunchInput): LaunchOutput;
131
146
  /** A new run that continues the last one; null when there is nothing to resume. */
132
147
  resume?(input: ResumeInput): Omit<LaunchOutput, "resumeToken"> | null;
148
+ /**
149
+ * Whether the runtime has a conversation for `token` on this machine
150
+ * (den-claude: its transcript file). Only ever a fast path: false may
151
+ * just mean it isn't written yet.
152
+ */
153
+ conversationExists?(token: string, env?: NodeJS.ProcessEnv): boolean;
154
+ /**
155
+ * What the runtime prints when it exits at once (0.4.8): `noConversation`
156
+ * when a resume finds nothing to continue, `idInUse` when a new
157
+ * conversation's id is taken. Den then tries the other way, once.
158
+ */
159
+ launchFailures?: { noConversation?: RegExp; idInUse?: RegExp };
133
160
  /** Where permission prompts surface (Q-AG-2). */
134
161
  approvals?: "terminal" | "hook";
135
162
  /**
@@ -20,6 +20,7 @@ import { validateOrgSources, type WorkOrgsConfig } from "./org-sources.js";
20
20
  import { ConfigError, isConfigError } from "./config-error.js";
21
21
  import { validateCatalog, type CatalogEntry } from "./agents/catalog.js";
22
22
  import type { Guardrails } from "./agents/guardrails.js";
23
+ import { DEFAULT_ROTATE, type RotateSettings } from "./agents/rotation.js";
23
24
  import type { TestAccountsConfig } from "./auth/test-accounts.js";
24
25
 
25
26
  import { product } from "./product.js";
@@ -391,6 +392,11 @@ export interface UserConfig {
391
392
  runtimes?: Record<string, { bin?: unknown } | null>;
392
393
  /** More runtime catalog entries (AG-0.5, agents/catalog.ts). */
393
394
  runtime_catalog?: unknown;
395
+ /**
396
+ * AG-14.1b: forget stopped and exited agents after this long: never
397
+ * (the default), 1d or 7d. Interrupted agents are never forgotten.
398
+ */
399
+ remove_stopped_after?: unknown;
394
400
  };
395
401
  /**
396
402
  * The web server's per-machine keys (src/shared/web-session.ts
@@ -465,6 +471,29 @@ export function configuredRuntimeCatalog(
465
471
  );
466
472
  }
467
473
 
474
+ /** The values `agents.remove_stopped_after` takes, in days (AG-14.1b). */
475
+ export const REMOVE_STOPPED_AFTER: Readonly<Record<string, number>> = {
476
+ never: 0,
477
+ "1d": 1,
478
+ "7d": 7,
479
+ };
480
+
481
+ /**
482
+ * user.yml's `agents.remove_stopped_after`, in days (0: never, the
483
+ * default). Throws naming the key when it is none of never, 1d, 7d.
484
+ */
485
+ export function configuredRemoveStoppedAfter(
486
+ read: () => UserConfig = readUserConfig,
487
+ ): number {
488
+ const v = read().agents?.remove_stopped_after;
489
+ if (v === undefined || v === null) return 0;
490
+ if (typeof v === "string" && Object.hasOwn(REMOVE_STOPPED_AFTER, v))
491
+ return REMOVE_STOPPED_AFTER[v];
492
+ throw new Error(
493
+ `agents.remove_stopped_after (${path.basename(USER_CONFIG_PATH)}): must be never, 1d or 7d (got ${JSON.stringify(v)})`,
494
+ );
495
+ }
496
+
468
497
  /**
469
498
  * Set (a path) or clear (null) `agents.runtimes.<id>.bin` in user.yml,
470
499
  * keeping everything else there. The caller checks the path first.
@@ -773,6 +802,8 @@ export const MCP_ACTIONS = [
773
802
  "service_restart",
774
803
  "workspace_sync",
775
804
  "run_tests",
805
+ // AG-14.4: resume another agent of its workspace (or a granted one).
806
+ "agent_resume",
776
807
  ] as const;
777
808
  export type McpAction = (typeof MCP_ACTIONS)[number];
778
809
 
@@ -849,6 +880,13 @@ export interface AgentsConfig {
849
880
  * Den doesn't ship, with the same fields and checks as a built-in one.
850
881
  */
851
882
  runtime_catalog?: unknown[];
883
+ /**
884
+ * Session rotation (den#4), `{ after_compactions, standing }`: a session
885
+ * ends after `after_compactions` compactions (default 3) or once its pull
886
+ * request merged, and its role relaunches from its handoff. `standing:
887
+ * false` keeps standing personas from rotating (default true).
888
+ */
889
+ rotate?: { after_compactions?: unknown; standing?: unknown };
852
890
  /**
853
891
  * Any other key is an agent id: `agents.<id>.grants` widens that agent's
854
892
  * MCP scope to other workspaces (AG spec §5b). Config only: an agent
@@ -872,6 +910,7 @@ export const AGENTS_RESERVED_KEYS = [
872
910
  "persona_packs",
873
911
  "runtime_catalog",
874
912
  "default_pack",
913
+ "rotate",
875
914
  ];
876
915
 
877
916
  /** One agent's own block, `agents.<id>`. */
@@ -893,6 +932,37 @@ export interface ResolvedAgentsConfig {
893
932
  agents: Record<string, AgentGrantsConfig>;
894
933
  /** The distribution's runtime catalog entries (AG-0.5), checked. */
895
934
  runtimeCatalog: CatalogEntry[];
935
+ /** agents.rotate with its defaults (den#4); unset: DEFAULT_ROTATE. */
936
+ rotate?: RotateSettings;
937
+ }
938
+
939
+ /** `agents.rotate` checked; any other key, or a bad value, throws naming it. */
940
+ function rotateBlock(v: unknown): RotateSettings {
941
+ const out: RotateSettings = { ...DEFAULT_ROTATE };
942
+ if (v === undefined || v === null) return out;
943
+ if (!isMapping(v))
944
+ throw new Error(
945
+ "agents.rotate: must be a mapping ({ after_compactions: <n>, standing: true|false })",
946
+ );
947
+ for (const [k, x] of Object.entries(v)) {
948
+ if (k === "after_compactions") {
949
+ if (typeof x !== "number" || !Number.isInteger(x) || x < 1 || x > 100)
950
+ throw new Error(
951
+ `agents.rotate.after_compactions: must be a whole number from 1 to 100 (got ${JSON.stringify(x)})`,
952
+ );
953
+ out.afterCompactions = x;
954
+ } else if (k === "standing") {
955
+ if (typeof x !== "boolean")
956
+ throw new Error(
957
+ `agents.rotate.standing: must be true or false (got ${JSON.stringify(x)})`,
958
+ );
959
+ out.standing = x;
960
+ } else
961
+ throw new Error(
962
+ `agents.rotate.${k}: unknown key (takes after_compactions, standing)`,
963
+ );
964
+ }
965
+ return out;
896
966
  }
897
967
 
898
968
  /** An agent id or workspace name: what WORKSPACE_NAME_RE accepts. */
@@ -955,6 +1025,7 @@ export function readAgentsConfig(
955
1025
  raw.runtime_catalog,
956
1026
  "agents.runtime_catalog",
957
1027
  ),
1028
+ rotate: rotateBlock(raw.rotate),
958
1029
  };
959
1030
  const max = raw.max_concurrent;
960
1031
  if (max !== undefined) {
@@ -988,7 +1059,7 @@ export function readAgentsConfig(
988
1059
  const where = `agents.${id}`;
989
1060
  if (!AGENT_KEY_RE.test(id) || !isMapping(block))
990
1061
  throw new Error(
991
- `${where}: unknown key (agents: takes max_concurrent, persona_packs, runtime_catalog, default_pack, ` +
1062
+ `${where}: unknown key (agents: takes max_concurrent, persona_packs, runtime_catalog, default_pack, rotate, ` +
992
1063
  `or an agent id mapping to { grants: [...] })`,
993
1064
  );
994
1065
  for (const k of Object.keys(block))
@@ -0,0 +1,32 @@
1
+ /**
2
+ * A pull request's state from gh, to confirm a merge an agent ran
3
+ * (session rotation's "PR merged" trigger, den#4). It runs gh, which asks
4
+ * GitHub; usage itself stays local (src/shared/usage never sends).
5
+ */
6
+ import { execFile } from "child_process";
7
+
8
+ /**
9
+ * `gh pr view [<ref>] [-R <repo>] --json state -q .state`;
10
+ * rejects on any error. Only the ref and repo go out, to GitHub, by gh.
11
+ */
12
+ export function ghPrState(env: NodeJS.ProcessEnv) {
13
+ return (ref: string, o: { repo?: string; cwd: string }) =>
14
+ new Promise<string | undefined>((res, rej) => {
15
+ const args = [
16
+ "pr",
17
+ "view",
18
+ ...(ref ? [ref] : []),
19
+ ...(o.repo ? ["-R", o.repo] : []),
20
+ "--json",
21
+ "state",
22
+ "-q",
23
+ ".state",
24
+ ];
25
+ execFile(
26
+ "gh",
27
+ args,
28
+ { cwd: o.cwd, env, timeout: 15_000, maxBuffer: 64 * 1024 },
29
+ (e, stdout) => (e ? rej(e) : res(String(stdout).trim())),
30
+ );
31
+ });
32
+ }
@@ -58,6 +58,9 @@ export const KIND_TIERS: Readonly<Record<string, PluginTier>> = {
58
58
  secrets: "server",
59
59
  identity: "server",
60
60
  "health-source": "server",
61
+ // den#6: reads a runtime's own transcripts on this machine, for tokens
62
+ // and outcomes per agent; never sends them anywhere.
63
+ usage: "server",
61
64
  };
62
65
 
63
66
  export interface PluginProvide {
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Where Claude Code keeps its transcripts. No registration here, so the
3
+ * den-claude runtime can ask (conversationExists) without loading the
4
+ * usage sources (sources.ts registers one when it loads).
5
+ */
6
+ import * as os from "os";
7
+ import * as path from "path";
8
+
9
+ /** Claude Code's transcripts: $CLAUDE_CONFIG_DIR/projects, else ~/.claude/projects. */
10
+ export function claudeProjectsDir(
11
+ env: NodeJS.ProcessEnv = process.env,
12
+ ): string {
13
+ const base =
14
+ env.CLAUDE_CONFIG_DIR || path.join(env.HOME || os.homedir(), ".claude");
15
+ return path.join(base, "projects");
16
+ }