@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.
Files changed (37) hide show
  1. package/CHANGELOG.md +115 -0
  2. package/docs/distributions.md +186 -42
  3. package/package.json +1 -1
  4. package/service/install.sh +213 -63
  5. package/src/shared/agents/den-claude.ts +13 -1
  6. package/src/shared/agents/guardrails/rotation-hook.ts +5 -0
  7. package/src/shared/agents/records.ts +6 -0
  8. package/src/shared/agents/rotate-settings.ts +309 -0
  9. package/src/shared/agents/rotation.ts +110 -5
  10. package/src/shared/agents/runtime.ts +6 -0
  11. package/src/shared/agents/session-name.ts +21 -1
  12. package/src/shared/den-config.ts +27 -12
  13. package/src/shared/launchd-session.ts +92 -0
  14. package/src/shared/sessions/client.ts +24 -0
  15. package/src/shared/sessions/host.ts +114 -5
  16. package/src/shared/sessions/types.ts +5 -0
  17. package/src/ui/cli/agent-ops.ts +469 -17
  18. package/src/ui/cli/agents-command.ts +200 -3
  19. package/src/ui/cli/doctor-command.ts +72 -0
  20. package/src/ui/cli/doctor-where.ts +203 -0
  21. package/src/ui/cli/index.ts +1 -1
  22. package/src/ui/cli/server-agent-ops.ts +2 -2
  23. package/src/ui/cli/service-command.ts +217 -11
  24. package/src/ui/cli/sessions-command.ts +46 -12
  25. package/src/ui/cli/web-command.ts +36 -5
  26. package/src/ui/cli/web-service.ts +160 -0
  27. package/src/ui/server/agent-routes.ts +149 -5
  28. package/src/ui/server/index.ts +17 -5
  29. package/src/ui/server/public/agents-page.js +316 -27
  30. package/src/ui/server/public/agents.html +62 -0
  31. package/src/ui/server/public/i18n/es.js +58 -2
  32. package/src/ui/server/public/i18n/ja.js +54 -2
  33. package/src/ui/server/public/settings-registry.js +204 -0
  34. package/src/ui/server/public/settings.html +43 -4
  35. package/src/ui/server/public/shell.js +56 -2
  36. package/src/ui/server/route-classes.ts +21 -0
  37. 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 s = readRotation(files);
301
- if (!s) return null;
302
- if (s.conversation && rec.resumeToken && s.conversation !== rec.resumeToken)
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 never writes a name back into the runtime.
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.
@@ -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
- function rotateBlock(v: unknown): RotateSettings {
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
- if (v === undefined || v === null) return out;
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>, standing: true|false })",
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
- if (typeof x !== "number" || !Number.isInteger(x) || x < 1 || x > 100)
985
- throw new Error(
986
- `agents.rotate.after_compactions: must be a whole number from 1 to 100 (got ${JSON.stringify(x)})`,
987
- );
988
- out.afterCompactions = x;
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, standing)`,
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
- rotate: rotateBlock(raw.rotate),
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, [