@monster-orange/den 0.4.21 → 0.4.23
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 +115 -0
- package/docs/distributions.md +186 -42
- package/package.json +1 -1
- package/service/install.sh +213 -63
- package/src/shared/agents/den-claude.ts +13 -1
- package/src/shared/agents/guardrails/rotation-hook.ts +5 -0
- package/src/shared/agents/records.ts +6 -0
- package/src/shared/agents/rotate-settings.ts +309 -0
- package/src/shared/agents/rotation.ts +110 -5
- package/src/shared/agents/runtime.ts +6 -0
- package/src/shared/agents/session-name.ts +21 -1
- package/src/shared/den-config.ts +27 -12
- package/src/shared/launchd-session.ts +92 -0
- package/src/shared/sessions/client.ts +24 -0
- package/src/shared/sessions/host.ts +114 -5
- package/src/shared/sessions/types.ts +5 -0
- package/src/ui/cli/agent-ops.ts +469 -17
- package/src/ui/cli/agents-command.ts +200 -3
- package/src/ui/cli/doctor-command.ts +72 -0
- package/src/ui/cli/doctor-where.ts +203 -0
- package/src/ui/cli/index.ts +1 -1
- package/src/ui/cli/server-agent-ops.ts +2 -2
- package/src/ui/cli/service-command.ts +217 -11
- package/src/ui/cli/sessions-command.ts +46 -12
- package/src/ui/cli/web-command.ts +36 -5
- package/src/ui/cli/web-service.ts +160 -0
- package/src/ui/server/agent-routes.ts +149 -5
- package/src/ui/server/index.ts +17 -5
- package/src/ui/server/public/agents-page.js +316 -27
- package/src/ui/server/public/agents.html +62 -0
- package/src/ui/server/public/i18n/es.js +58 -2
- package/src/ui/server/public/i18n/ja.js +54 -2
- package/src/ui/server/public/settings-registry.js +204 -0
- package/src/ui/server/public/settings.html +43 -4
- package/src/ui/server/public/shell.js +56 -2
- package/src/ui/server/route-classes.ts +21 -0
- package/src/ui/server/session-routes.ts +27 -5
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* D28: session-rotation settings and where each comes from.
|
|
3
|
+
*
|
|
4
|
+
* after_compactions a whole number (1–100), or "never"; default 3
|
|
5
|
+
* on_due auto | ask | off; default auto
|
|
6
|
+
* auto: Den asks the agent for its handoff at its next
|
|
7
|
+
* idle and relaunches it from it by itself
|
|
8
|
+
* ask: the callout's buttons (Relaunch now, Write
|
|
9
|
+
* handoff, Keep this conversation, Settings)
|
|
10
|
+
* off: nothing happens; the count is still shown
|
|
11
|
+
* on_pr_merged true | false; default true
|
|
12
|
+
*
|
|
13
|
+
* Scopes, the most specific wins, each key on its own:
|
|
14
|
+
* agent › persona in a workspace › persona › workspace › service (the
|
|
15
|
+
* workspace service whose folder holds the agent's) › install (Settings ›
|
|
16
|
+
* Agents › Rotation, this machine) › org (`agents.rotate` in the org
|
|
17
|
+
* config) › Den's default.
|
|
18
|
+
*
|
|
19
|
+
* The install value and every override live in
|
|
20
|
+
* `<stateDir>/agent-rotation.json` (0600; never under agents/, where every
|
|
21
|
+
* file is an agent's record). The org value is the org config's.
|
|
22
|
+
*/
|
|
23
|
+
import * as fs from "fs";
|
|
24
|
+
import * as path from "path";
|
|
25
|
+
import { writePrivate } from "./records.js";
|
|
26
|
+
|
|
27
|
+
export type OnDue = "auto" | "ask" | "off";
|
|
28
|
+
|
|
29
|
+
/** One scope's settings, as written: any key may be absent. */
|
|
30
|
+
export interface RotateValues {
|
|
31
|
+
after_compactions?: number | "never";
|
|
32
|
+
on_due?: OnDue;
|
|
33
|
+
on_pr_merged?: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type RotateKey = keyof RotateValues;
|
|
37
|
+
export const ROTATE_KEYS: readonly RotateKey[] = [
|
|
38
|
+
"after_compactions",
|
|
39
|
+
"on_due",
|
|
40
|
+
"on_pr_merged",
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
/** The scopes an override can be set at (most specific first). */
|
|
44
|
+
export const OVERRIDE_SCOPES = [
|
|
45
|
+
"agent",
|
|
46
|
+
"persona-workspace",
|
|
47
|
+
"persona",
|
|
48
|
+
"workspace",
|
|
49
|
+
"service",
|
|
50
|
+
] as const;
|
|
51
|
+
export type OverrideScope = (typeof OVERRIDE_SCOPES)[number];
|
|
52
|
+
export type RotateScope = OverrideScope | "install" | "org" | "default";
|
|
53
|
+
|
|
54
|
+
export const ROTATE_DEFAULTS: Required<RotateValues> = {
|
|
55
|
+
after_compactions: 3,
|
|
56
|
+
on_due: "auto",
|
|
57
|
+
on_pr_merged: true,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** `<stateDir>/agent-rotation.json`. */
|
|
61
|
+
export interface RotateFile {
|
|
62
|
+
install?: RotateValues;
|
|
63
|
+
overrides?: Partial<Record<OverrideScope, Record<string, RotateValues>>>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Who an agent is, for resolving its settings. */
|
|
67
|
+
export interface RotateSubject {
|
|
68
|
+
agent: string;
|
|
69
|
+
persona: string;
|
|
70
|
+
workspace: string;
|
|
71
|
+
/** The workspace service whose folder holds the agent's, if any. */
|
|
72
|
+
service?: string;
|
|
73
|
+
/** A standing persona (planner, orchestrator): `standing: false` in the org config keeps it from rotating. */
|
|
74
|
+
standing?: boolean;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Where a value came from: "default", or a scope and its target. */
|
|
78
|
+
export interface RotateSource {
|
|
79
|
+
scope: RotateScope;
|
|
80
|
+
/** The agent id, persona, "persona@workspace", workspace or service. */
|
|
81
|
+
target?: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface EffectiveRotate {
|
|
85
|
+
/** 0: never rotates. */
|
|
86
|
+
afterCompactions: number;
|
|
87
|
+
onDue: OnDue;
|
|
88
|
+
onPrMerged: boolean;
|
|
89
|
+
source: Record<RotateKey, RotateSource>;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The org config's `agents.rotate`, checked (den-config.ts). */
|
|
93
|
+
export interface OrgRotate extends RotateValues {
|
|
94
|
+
/** Dropped by D28 (persona scope replaces it); still honoured. */
|
|
95
|
+
standing?: boolean;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export const rotateFilePath = (stateDir: string) =>
|
|
99
|
+
path.join(stateDir, "agent-rotation.json");
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* One value checked: the problem in words, or null. `after_compactions`
|
|
103
|
+
* takes 1–100 or "never" (0 also means never, from the CLI).
|
|
104
|
+
*/
|
|
105
|
+
export function checkRotateValue(key: string, v: unknown): string | null {
|
|
106
|
+
if (key === "after_compactions")
|
|
107
|
+
return v === "never" ||
|
|
108
|
+
(typeof v === "number" && Number.isInteger(v) && v >= 1 && v <= 100)
|
|
109
|
+
? null
|
|
110
|
+
: `after_compactions: a whole number from 1 to 100, or never (got ${JSON.stringify(v)})`;
|
|
111
|
+
if (key === "on_due")
|
|
112
|
+
return v === "auto" || v === "ask" || v === "off"
|
|
113
|
+
? null
|
|
114
|
+
: `on_due: auto, ask or off (got ${JSON.stringify(v)})`;
|
|
115
|
+
if (key === "on_pr_merged")
|
|
116
|
+
return typeof v === "boolean"
|
|
117
|
+
? null
|
|
118
|
+
: `on_pr_merged: true or false (got ${JSON.stringify(v)})`;
|
|
119
|
+
return `${key}: unknown key (takes ${ROTATE_KEYS.join(", ")})`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** A command-line value as the key takes it ("3", "never", "ask", "false"). */
|
|
123
|
+
export function parseRotateValue(key: string, raw: string): unknown {
|
|
124
|
+
if (key === "after_compactions")
|
|
125
|
+
return raw === "never" || raw === "0"
|
|
126
|
+
? "never"
|
|
127
|
+
: /^\d+$/.test(raw)
|
|
128
|
+
? Number(raw)
|
|
129
|
+
: raw;
|
|
130
|
+
if (key === "on_pr_merged")
|
|
131
|
+
return raw === "true" ? true : raw === "false" ? false : raw;
|
|
132
|
+
return raw;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Only the valid keys of `v` (a bad file never breaks an agent). */
|
|
136
|
+
function cleanValues(v: unknown): RotateValues {
|
|
137
|
+
const out: RotateValues = {};
|
|
138
|
+
if (!v || typeof v !== "object" || Array.isArray(v)) return out;
|
|
139
|
+
for (const k of ROTATE_KEYS) {
|
|
140
|
+
const x = (v as Record<string, unknown>)[k];
|
|
141
|
+
if (x !== undefined && checkRotateValue(k, x) === null)
|
|
142
|
+
(out as Record<string, unknown>)[k] = x;
|
|
143
|
+
}
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** agent-rotation.json, cleaned; {} when absent or unreadable. */
|
|
148
|
+
export function readRotateFile(stateDir: string): RotateFile {
|
|
149
|
+
let v: unknown;
|
|
150
|
+
try {
|
|
151
|
+
v = JSON.parse(fs.readFileSync(rotateFilePath(stateDir), "utf-8"));
|
|
152
|
+
} catch {
|
|
153
|
+
return {};
|
|
154
|
+
}
|
|
155
|
+
if (!v || typeof v !== "object" || Array.isArray(v)) return {};
|
|
156
|
+
const o = v as Record<string, unknown>;
|
|
157
|
+
const out: RotateFile = {};
|
|
158
|
+
const inst = cleanValues(o.install);
|
|
159
|
+
if (Object.keys(inst).length) out.install = inst;
|
|
160
|
+
if (o.overrides && typeof o.overrides === "object") {
|
|
161
|
+
const ov: RotateFile["overrides"] = {};
|
|
162
|
+
for (const scope of OVERRIDE_SCOPES) {
|
|
163
|
+
const byTarget = (o.overrides as Record<string, unknown>)[scope];
|
|
164
|
+
if (!byTarget || typeof byTarget !== "object") continue;
|
|
165
|
+
for (const [target, vals] of Object.entries(byTarget)) {
|
|
166
|
+
const c = cleanValues(vals);
|
|
167
|
+
if (Object.keys(c).length) (ov[scope] ??= {})[target] = c;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (Object.keys(ov).length) out.overrides = ov;
|
|
171
|
+
}
|
|
172
|
+
return out;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function writeRotateFile(stateDir: string, f: RotateFile): void {
|
|
176
|
+
fs.mkdirSync(path.dirname(rotateFilePath(stateDir)), {
|
|
177
|
+
recursive: true,
|
|
178
|
+
mode: 0o700,
|
|
179
|
+
});
|
|
180
|
+
writePrivate(rotateFilePath(stateDir), JSON.stringify(f, null, 2) + "\n");
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Set (or with `value` undefined, clear) one key at one scope. `scope`
|
|
185
|
+
* "install" takes no target. Throws, naming the problem, on a bad key or
|
|
186
|
+
* value. Returns the file as written.
|
|
187
|
+
*/
|
|
188
|
+
export function setRotateValue(
|
|
189
|
+
stateDir: string,
|
|
190
|
+
scope: OverrideScope | "install",
|
|
191
|
+
target: string | undefined,
|
|
192
|
+
key: string,
|
|
193
|
+
value: unknown,
|
|
194
|
+
): RotateFile {
|
|
195
|
+
if (value !== undefined) {
|
|
196
|
+
const bad = checkRotateValue(key, value);
|
|
197
|
+
if (bad) throw new Error(bad);
|
|
198
|
+
} else if (!(ROTATE_KEYS as readonly string[]).includes(key))
|
|
199
|
+
throw new Error(checkRotateValue(key, undefined)!);
|
|
200
|
+
if (scope !== "install" && !target)
|
|
201
|
+
throw new Error(`a ${scope} override needs the ${scope} it is for`);
|
|
202
|
+
const f = readRotateFile(stateDir);
|
|
203
|
+
const k = key as RotateKey;
|
|
204
|
+
const apply = (vals: RotateValues | undefined): RotateValues => {
|
|
205
|
+
const next: RotateValues = { ...(vals ?? {}) };
|
|
206
|
+
if (value === undefined) delete next[k];
|
|
207
|
+
else (next as Record<string, unknown>)[k] = value;
|
|
208
|
+
return next;
|
|
209
|
+
};
|
|
210
|
+
if (scope === "install") {
|
|
211
|
+
const next = apply(f.install);
|
|
212
|
+
if (Object.keys(next).length) f.install = next;
|
|
213
|
+
else delete f.install;
|
|
214
|
+
} else {
|
|
215
|
+
const ov = (f.overrides ??= {});
|
|
216
|
+
const byTarget = (ov[scope] ??= {});
|
|
217
|
+
const next = apply(byTarget[target!]);
|
|
218
|
+
if (Object.keys(next).length) byTarget[target!] = next;
|
|
219
|
+
else delete byTarget[target!];
|
|
220
|
+
if (!Object.keys(byTarget).length) delete ov[scope];
|
|
221
|
+
if (!Object.keys(ov).length) delete f.overrides;
|
|
222
|
+
}
|
|
223
|
+
writeRotateFile(stateDir, f);
|
|
224
|
+
return f;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** The target an override scope uses for `s`, or undefined. */
|
|
228
|
+
export function targetFor(
|
|
229
|
+
scope: OverrideScope,
|
|
230
|
+
s: RotateSubject,
|
|
231
|
+
): string | undefined {
|
|
232
|
+
switch (scope) {
|
|
233
|
+
case "agent":
|
|
234
|
+
return s.agent;
|
|
235
|
+
case "persona-workspace":
|
|
236
|
+
return `${s.persona}@${s.workspace}`;
|
|
237
|
+
case "persona":
|
|
238
|
+
return s.persona;
|
|
239
|
+
case "workspace":
|
|
240
|
+
return s.workspace;
|
|
241
|
+
case "service":
|
|
242
|
+
return s.service;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Agent `s`'s settings, each key from the most specific scope that sets it. */
|
|
247
|
+
export function resolveRotate(
|
|
248
|
+
s: RotateSubject,
|
|
249
|
+
file: RotateFile,
|
|
250
|
+
org: OrgRotate | undefined,
|
|
251
|
+
): EffectiveRotate {
|
|
252
|
+
const layers: { source: RotateSource; values: RotateValues }[] = [];
|
|
253
|
+
for (const scope of OVERRIDE_SCOPES) {
|
|
254
|
+
const target = targetFor(scope, s);
|
|
255
|
+
const values = target ? file.overrides?.[scope]?.[target] : undefined;
|
|
256
|
+
if (values) layers.push({ source: { scope, target }, values });
|
|
257
|
+
}
|
|
258
|
+
if (file.install)
|
|
259
|
+
layers.push({ source: { scope: "install" }, values: file.install });
|
|
260
|
+
if (org) {
|
|
261
|
+
const values: RotateValues = { ...org };
|
|
262
|
+
delete (values as OrgRotate).standing;
|
|
263
|
+
// den#4's `standing: false`: standing personas never rotate.
|
|
264
|
+
if (org.standing === false && s.standing)
|
|
265
|
+
values.after_compactions = "never";
|
|
266
|
+
layers.push({ source: { scope: "org" }, values });
|
|
267
|
+
}
|
|
268
|
+
const pick = <K extends RotateKey>(k: K) => {
|
|
269
|
+
for (const l of layers)
|
|
270
|
+
if (l.values[k] !== undefined)
|
|
271
|
+
return {
|
|
272
|
+
v: l.values[k] as Required<RotateValues>[K],
|
|
273
|
+
source: l.source,
|
|
274
|
+
};
|
|
275
|
+
return {
|
|
276
|
+
v: ROTATE_DEFAULTS[k],
|
|
277
|
+
source: { scope: "default" } as RotateSource,
|
|
278
|
+
};
|
|
279
|
+
};
|
|
280
|
+
const a = pick("after_compactions");
|
|
281
|
+
const o = pick("on_due");
|
|
282
|
+
const p = pick("on_pr_merged");
|
|
283
|
+
return {
|
|
284
|
+
afterCompactions: a.v === "never" ? 0 : a.v,
|
|
285
|
+
onDue: o.v,
|
|
286
|
+
onPrMerged: p.v,
|
|
287
|
+
source: {
|
|
288
|
+
after_compactions: a.source,
|
|
289
|
+
on_due: o.source,
|
|
290
|
+
on_pr_merged: p.source,
|
|
291
|
+
},
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** "persona planner", "agent art", "this install", "org config", "default". */
|
|
296
|
+
export function sourceText(src: RotateSource): string {
|
|
297
|
+
switch (src.scope) {
|
|
298
|
+
case "default":
|
|
299
|
+
return "default";
|
|
300
|
+
case "install":
|
|
301
|
+
return "this install";
|
|
302
|
+
case "org":
|
|
303
|
+
return "org config";
|
|
304
|
+
case "persona-workspace":
|
|
305
|
+
return `persona ${src.target!.replace("@", " in ")}`;
|
|
306
|
+
default:
|
|
307
|
+
return `${src.scope} ${src.target}`;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
@@ -56,6 +56,13 @@ export interface RotationState {
|
|
|
56
56
|
/** Set when a trigger fired. */
|
|
57
57
|
due?: RotateReason;
|
|
58
58
|
dueAt?: string;
|
|
59
|
+
/** D28: how many times Den asked for the handoff, and the last time. */
|
|
60
|
+
asked?: number;
|
|
61
|
+
askedAt?: string;
|
|
62
|
+
/** D28: auto gave up asking (2 asks, still no handoff): ask mode. */
|
|
63
|
+
fellBack?: boolean;
|
|
64
|
+
/** D28 "Relaunch now" before a handoff: relaunch once it passes, whatever on_due says. */
|
|
65
|
+
relaunchWanted?: boolean;
|
|
59
66
|
}
|
|
60
67
|
|
|
61
68
|
/** `agents.rotate` with its defaults applied. */
|
|
@@ -80,6 +87,8 @@ export interface RotationEntry {
|
|
|
80
87
|
compactions: number;
|
|
81
88
|
/** The conversation that ended (its resume token). */
|
|
82
89
|
conversation?: string;
|
|
90
|
+
/** D28: Den relaunched it by itself (on_due auto). */
|
|
91
|
+
auto?: boolean;
|
|
83
92
|
}
|
|
84
93
|
|
|
85
94
|
/** The compactions after which `persona` rotates; 0: it never does. */
|
|
@@ -119,6 +128,10 @@ export function readRotation(filesDir: string): RotationState | null {
|
|
|
119
128
|
if (typeof o.pendingTrigger === "string") s.pendingTrigger = o.pendingTrigger;
|
|
120
129
|
if (o.due === "compactions" || o.due === "pr-merged") s.due = o.due;
|
|
121
130
|
if (typeof o.dueAt === "string") s.dueAt = o.dueAt;
|
|
131
|
+
if (count(o.asked)) s.asked = count(o.asked);
|
|
132
|
+
if (typeof o.askedAt === "string") s.askedAt = o.askedAt;
|
|
133
|
+
if (o.fellBack === true) s.fellBack = true;
|
|
134
|
+
if (o.relaunchWanted === true) s.relaunchWanted = true;
|
|
122
135
|
return s;
|
|
123
136
|
}
|
|
124
137
|
|
|
@@ -235,6 +248,51 @@ export function markDue(
|
|
|
235
248
|
return true;
|
|
236
249
|
}
|
|
237
250
|
|
|
251
|
+
/**
|
|
252
|
+
* D28 "Keep this conversation": the count starts again from 0 for the
|
|
253
|
+
* conversation it has, and nothing is due. Returns false with no state.
|
|
254
|
+
*/
|
|
255
|
+
export function keepConversation(filesDir: string): boolean {
|
|
256
|
+
const s = readRotation(filesDir);
|
|
257
|
+
if (!s) return false;
|
|
258
|
+
writeRotation(filesDir, {
|
|
259
|
+
...(s.conversation ? { conversation: s.conversation } : {}),
|
|
260
|
+
compactions: 0,
|
|
261
|
+
after: s.after,
|
|
262
|
+
});
|
|
263
|
+
return true;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** D28: one more ask for the handoff, noted; `relaunch`: Relaunch now asked. */
|
|
267
|
+
export function noteAsked(
|
|
268
|
+
filesDir: string,
|
|
269
|
+
now: Date = new Date(),
|
|
270
|
+
relaunch = false,
|
|
271
|
+
): RotationState | null {
|
|
272
|
+
const s = readRotation(filesDir);
|
|
273
|
+
if (!s) return null;
|
|
274
|
+
const next: RotationState = {
|
|
275
|
+
...s,
|
|
276
|
+
asked: (s.asked ?? 0) + 1,
|
|
277
|
+
askedAt: now.toISOString(),
|
|
278
|
+
...(relaunch ? { relaunchWanted: true } : {}),
|
|
279
|
+
};
|
|
280
|
+
writeRotation(filesDir, next);
|
|
281
|
+
return next;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** D28: auto gave up asking; the callout's buttons take over. */
|
|
285
|
+
export function noteFellBack(filesDir: string): void {
|
|
286
|
+
const s = readRotation(filesDir);
|
|
287
|
+
if (s && !s.fellBack) writeRotation(filesDir, { ...s, fellBack: true });
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** D28: the limit the hook counts against, when the settings changed. */
|
|
291
|
+
export function syncAfter(filesDir: string, after: number): void {
|
|
292
|
+
const s = readRotation(filesDir);
|
|
293
|
+
if (s && s.after !== after) writeRotation(filesDir, { ...s, after });
|
|
294
|
+
}
|
|
295
|
+
|
|
238
296
|
/** Clear a rotation due for `reason` (a merge mark gh no longer confirms). */
|
|
239
297
|
export function clearDue(filesDir: string, reason: RotateReason): boolean {
|
|
240
298
|
const s = readRotation(filesDir);
|
|
@@ -274,8 +332,31 @@ export function readHandoff(filesDir: string): {
|
|
|
274
332
|
return { status: r.ok ? "ok" : "invalid", text, problems: r.problems };
|
|
275
333
|
}
|
|
276
334
|
|
|
335
|
+
/**
|
|
336
|
+
* D28: an agent's effective rotation settings (rotate-settings.ts), when
|
|
337
|
+
* the caller resolved them; without, the state's own limit and den#4's
|
|
338
|
+
* rules apply.
|
|
339
|
+
*/
|
|
340
|
+
export interface RotationPolicy {
|
|
341
|
+
/** 0: never rotates. */
|
|
342
|
+
afterCompactions: number;
|
|
343
|
+
onDue: "auto" | "ask" | "off";
|
|
344
|
+
onPrMerged: boolean;
|
|
345
|
+
/** Where each value comes from, in words ("persona planner"). */
|
|
346
|
+
from?: { after_compactions: string; on_due: string; on_pr_merged: string };
|
|
347
|
+
}
|
|
348
|
+
|
|
277
349
|
/** An agent's rotation, as the Agents page and the CLI show it. */
|
|
278
350
|
export interface RotationInfo {
|
|
351
|
+
/** D28: what happens when it is due, and where the settings come from. */
|
|
352
|
+
onDue?: RotationPolicy["onDue"];
|
|
353
|
+
from?: RotationPolicy["from"];
|
|
354
|
+
/** D28: Den asked it for its handoff this many times (auto). */
|
|
355
|
+
asked?: number;
|
|
356
|
+
/** D28: auto fell back to ask (the handoff still failed after 2 asks). */
|
|
357
|
+
fellBack?: boolean;
|
|
358
|
+
/** D28: Relaunch now was asked before the handoff: Den relaunches once it passes. */
|
|
359
|
+
relaunchWanted?: boolean;
|
|
279
360
|
/** Why it rotates, when a trigger fired. */
|
|
280
361
|
due?: RotateReason;
|
|
281
362
|
compactions: number;
|
|
@@ -295,20 +376,44 @@ export interface RotationInfo {
|
|
|
295
376
|
export function rotationInfo(
|
|
296
377
|
stateDir: string,
|
|
297
378
|
rec: AgentRecord,
|
|
379
|
+
policy?: RotationPolicy,
|
|
298
380
|
): RotationInfo | null {
|
|
299
381
|
const files = agentFilesDir(stateDir, rec.id);
|
|
300
|
-
const
|
|
301
|
-
if (!
|
|
302
|
-
if (
|
|
382
|
+
const read = readRotation(files);
|
|
383
|
+
if (!read) return null;
|
|
384
|
+
if (
|
|
385
|
+
read.conversation &&
|
|
386
|
+
rec.resumeToken &&
|
|
387
|
+
read.conversation !== rec.resumeToken
|
|
388
|
+
)
|
|
303
389
|
return null;
|
|
390
|
+
// D28: the limit in force now, not the one the launch counted against.
|
|
391
|
+
const s: RotationState = policy
|
|
392
|
+
? { ...read, after: policy.afterCompactions }
|
|
393
|
+
: read;
|
|
394
|
+
if (
|
|
395
|
+
policy &&
|
|
396
|
+
s.after > 0 &&
|
|
397
|
+
s.compactions < s.after &&
|
|
398
|
+
s.due === "compactions"
|
|
399
|
+
) {
|
|
400
|
+
delete s.due;
|
|
401
|
+
}
|
|
402
|
+
if (policy && !policy.onPrMerged && s.due === "pr-merged") delete s.due;
|
|
403
|
+
const merged = policy ? policy.onPrMerged : true;
|
|
304
404
|
const due =
|
|
305
405
|
dueFor(s) ??
|
|
306
|
-
(s.after > 0 && rec.outcome?.prMerged ? "pr-merged" : undefined);
|
|
406
|
+
(s.after > 0 && merged && rec.outcome?.prMerged ? "pr-merged" : undefined);
|
|
307
407
|
const h = due
|
|
308
408
|
? readHandoff(files)
|
|
309
409
|
: { status: "missing" as const, problems: [] };
|
|
310
410
|
return {
|
|
311
411
|
...(due ? { due } : {}),
|
|
412
|
+
...(policy ? { onDue: policy.onDue } : {}),
|
|
413
|
+
...(policy?.from ? { from: policy.from } : {}),
|
|
414
|
+
...(read.asked ? { asked: read.asked } : {}),
|
|
415
|
+
...(read.fellBack ? { fellBack: true } : {}),
|
|
416
|
+
...(read.relaunchWanted ? { relaunchWanted: true } : {}),
|
|
312
417
|
compactions: s.compactions,
|
|
313
418
|
after: s.after,
|
|
314
419
|
handoff: h.status,
|
|
@@ -346,7 +451,7 @@ export function rotationInstruction(
|
|
|
346
451
|
const why =
|
|
347
452
|
reason === "pr-merged"
|
|
348
453
|
? "its pull request merged"
|
|
349
|
-
: `it has compacted ${compactions} times`;
|
|
454
|
+
: `it has compacted ${compactions === 1 ? "once" : `${compactions} times`}`;
|
|
350
455
|
return [
|
|
351
456
|
`Den: this session is done, because ${why} (session rotation).`,
|
|
352
457
|
`Write your handoff now, to ${handoffFile(filesDir)}, with these headings, each filled in:`,
|
|
@@ -120,6 +120,12 @@ export interface LaunchInput {
|
|
|
120
120
|
* Only a runtime with `firstPrompt: true` is given one.
|
|
121
121
|
*/
|
|
122
122
|
firstPrompt?: string;
|
|
123
|
+
/**
|
|
124
|
+
* D24: the name other sessions see this one by (peerSessionName:
|
|
125
|
+
* `<cli>-<ws>:<name>`). A runtime that can name its session (den-claude:
|
|
126
|
+
* `--name`) does, at launch and at resume.
|
|
127
|
+
*/
|
|
128
|
+
sessionLabel?: string;
|
|
123
129
|
}
|
|
124
130
|
|
|
125
131
|
export interface LaunchOutput {
|
|
@@ -12,7 +12,10 @@
|
|
|
12
12
|
* token: Start fresh forgets it). An adapter that fails or throws is no
|
|
13
13
|
* name; it never breaks the list.
|
|
14
14
|
*
|
|
15
|
-
* Den
|
|
15
|
+
* D24: Den names the runtime's session for other sessions to see
|
|
16
|
+
* (peerSessionName: `<cli>-<ws>:<name>`, Claude Code's `--name`) at launch
|
|
17
|
+
* and resume. That name read back is Den's own (the record's peerName),
|
|
18
|
+
* never a rename.
|
|
16
19
|
*/
|
|
17
20
|
import * as fs from "fs";
|
|
18
21
|
import * as path from "path";
|
|
@@ -71,6 +74,8 @@ function runtimeName(
|
|
|
71
74
|
} catch {
|
|
72
75
|
name = null;
|
|
73
76
|
}
|
|
77
|
+
// D24: Den's own --name, read back, is not the runtime's rename.
|
|
78
|
+
if (name && name === rec.peerName) name = null;
|
|
74
79
|
// null: nothing new (not caught up, not found); the last name stays.
|
|
75
80
|
if (name && name !== seen?.name) {
|
|
76
81
|
seen = { token, name, at: now() };
|
|
@@ -83,6 +88,21 @@ function runtimeName(
|
|
|
83
88
|
return seen;
|
|
84
89
|
}
|
|
85
90
|
|
|
91
|
+
/**
|
|
92
|
+
* D24: the name other sessions (ListAgents) see agent `rec` by:
|
|
93
|
+
* `<cli>-<ws>:<its name, else its id>`. No control characters: it is a
|
|
94
|
+
* process argument.
|
|
95
|
+
*/
|
|
96
|
+
export function peerSessionName(
|
|
97
|
+
cli: string,
|
|
98
|
+
ws: string,
|
|
99
|
+
rec: Pick<AgentRecord, "id" | "displayName">,
|
|
100
|
+
): string {
|
|
101
|
+
// eslint-disable-next-line no-control-regex
|
|
102
|
+
const name = (rec.displayName ?? "").replace(/[\u0000-\u001f\u007f]/g, "");
|
|
103
|
+
return `${cli}-${ws}:${name.trim() || rec.id}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
86
106
|
/**
|
|
87
107
|
* The agent's session name: the latest of Den's rename and the runtime's,
|
|
88
108
|
* or undefined when neither has one. Never throws.
|
package/src/shared/den-config.ts
CHANGED
|
@@ -21,6 +21,7 @@ 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
23
|
import { DEFAULT_ROTATE, type RotateSettings } from "./agents/rotation.js";
|
|
24
|
+
import { checkRotateValue, type OrgRotate } from "./agents/rotate-settings.js";
|
|
24
25
|
import {
|
|
25
26
|
checkCategoriesConfig,
|
|
26
27
|
checkRolesConfig,
|
|
@@ -965,6 +966,8 @@ export interface ResolvedAgentsConfig {
|
|
|
965
966
|
runtimeCatalog: CatalogEntry[];
|
|
966
967
|
/** agents.rotate with its defaults (den#4); unset: DEFAULT_ROTATE. */
|
|
967
968
|
rotate?: RotateSettings;
|
|
969
|
+
/** D28: agents.rotate as written, the org scope. */
|
|
970
|
+
rotateOrg?: OrgRotate;
|
|
968
971
|
/** agents.categories (AG-10.5): the distribution's, after Den's. */
|
|
969
972
|
categories?: Category[];
|
|
970
973
|
/** agents.roles (AG-11.1): new roles and Den roles' preset extensions. */
|
|
@@ -972,32 +975,41 @@ export interface ResolvedAgentsConfig {
|
|
|
972
975
|
}
|
|
973
976
|
|
|
974
977
|
/** `agents.rotate` checked; any other key, or a bad value, throws naming it. */
|
|
975
|
-
|
|
978
|
+
/**
|
|
979
|
+
* `agents.rotate` checked, both ways: den#4's settings (afterCompactions 0
|
|
980
|
+
* for never) and, for D28's scopes, the org's values as written.
|
|
981
|
+
*/
|
|
982
|
+
export function rotateOrgBlock(v: unknown): {
|
|
983
|
+
settings: RotateSettings;
|
|
984
|
+
org: OrgRotate;
|
|
985
|
+
} {
|
|
976
986
|
const out: RotateSettings = { ...DEFAULT_ROTATE };
|
|
977
|
-
|
|
987
|
+
const org: OrgRotate = {};
|
|
988
|
+
if (v === undefined || v === null) return { settings: out, org };
|
|
978
989
|
if (!isMapping(v))
|
|
979
990
|
throw new Error(
|
|
980
|
-
"agents.rotate: must be a mapping ({ after_compactions: <n
|
|
991
|
+
"agents.rotate: must be a mapping ({ after_compactions: <n>|never, on_due: auto|ask|off, on_pr_merged: true|false })",
|
|
981
992
|
);
|
|
982
993
|
for (const [k, x] of Object.entries(v)) {
|
|
983
|
-
if (k === "after_compactions") {
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
994
|
+
if (k === "after_compactions" || k === "on_due" || k === "on_pr_merged") {
|
|
995
|
+
const bad = checkRotateValue(k, x);
|
|
996
|
+
if (bad) throw new Error(`agents.rotate.${bad}`);
|
|
997
|
+
(org as Record<string, unknown>)[k] = x;
|
|
998
|
+
if (k === "after_compactions")
|
|
999
|
+
out.afterCompactions = x === "never" ? 0 : (x as number);
|
|
989
1000
|
} else if (k === "standing") {
|
|
990
1001
|
if (typeof x !== "boolean")
|
|
991
1002
|
throw new Error(
|
|
992
1003
|
`agents.rotate.standing: must be true or false (got ${JSON.stringify(x)})`,
|
|
993
1004
|
);
|
|
994
1005
|
out.standing = x;
|
|
1006
|
+
org.standing = x;
|
|
995
1007
|
} else
|
|
996
1008
|
throw new Error(
|
|
997
|
-
`agents.rotate.${k}: unknown key (takes after_compactions,
|
|
1009
|
+
`agents.rotate.${k}: unknown key (takes after_compactions, on_due, on_pr_merged)`,
|
|
998
1010
|
);
|
|
999
1011
|
}
|
|
1000
|
-
return out;
|
|
1012
|
+
return { settings: out, org };
|
|
1001
1013
|
}
|
|
1002
1014
|
|
|
1003
1015
|
/** An agent id or workspace name: what WORKSPACE_NAME_RE accepts. */
|
|
@@ -1060,7 +1072,10 @@ export function readAgentsConfig(
|
|
|
1060
1072
|
raw.runtime_catalog,
|
|
1061
1073
|
"agents.runtime_catalog",
|
|
1062
1074
|
),
|
|
1063
|
-
|
|
1075
|
+
...(() => {
|
|
1076
|
+
const r = rotateOrgBlock(raw.rotate);
|
|
1077
|
+
return { rotate: r.settings, rotateOrg: r.org };
|
|
1078
|
+
})(),
|
|
1064
1079
|
};
|
|
1065
1080
|
out.categories = checkCategoriesConfig(raw.categories);
|
|
1066
1081
|
out.roles = checkRolesConfig(raw.roles, [
|