@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.
- package/CHANGELOG.md +174 -0
- package/README.md +7 -7
- package/docs/distributions.md +243 -1
- package/package.json +1 -1
- package/schemas/den.plugin.v1.json +3 -2
- package/src/shared/agents/den-claude.ts +57 -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/personas.ts +11 -1
- package/src/shared/agents/records.ts +122 -1
- package/src/shared/agents/rotation.ts +362 -0
- package/src/shared/agents/runtime.ts +27 -0
- package/src/shared/den-config.ts +72 -1
- package/src/shared/git/gh-pr-state.ts +32 -0
- package/src/shared/plugins/manifest.ts +3 -0
- package/src/shared/usage/paths.ts +16 -0
- package/src/shared/usage/report.ts +377 -0
- package/src/shared/usage/sources.ts +127 -0
- package/src/shared/usage/transcript.ts +319 -0
- package/src/ui/cli/agent-ops.ts +678 -51
- package/src/ui/cli/agents-command.ts +192 -22
- package/src/ui/cli/index.ts +13 -1
- package/src/ui/cli/usage-command.ts +323 -0
- package/src/ui/mcp/api.ts +10 -1
- package/src/ui/mcp/server.ts +34 -0
- package/src/ui/mcp/tools.ts +50 -0
- package/src/ui/server/agent-resume-grant.ts +53 -0
- package/src/ui/server/agent-routes.ts +247 -9
- package/src/ui/server/audit.ts +68 -1
- package/src/ui/server/index.ts +16 -5
- package/src/ui/server/public/agents-page.js +400 -13
- package/src/ui/server/public/agents-section.js +17 -3
- package/src/ui/server/public/agents.html +44 -0
- package/src/ui/server/public/i18n/es.js +40 -0
- package/src/ui/server/public/i18n/ja.js +36 -0
- package/src/ui/server/public/shell.js +79 -0
- package/src/ui/server/request-guard.ts +21 -1
- package/src/ui/server/route-classes.ts +29 -0
- package/src/ui/server/session-routes.ts +60 -5
- 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
|
/**
|
package/src/shared/den-config.ts
CHANGED
|
@@ -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
|
+
}
|