@monster-orange/den 0.4.22 → 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.
@@ -148,9 +148,25 @@ import {
148
148
  openElsewhere,
149
149
  type RuntimeConversation,
150
150
  } from "../../shared/agents/conversations.js";
151
+ import {
152
+ OVERRIDE_SCOPES,
153
+ readRotateFile,
154
+ resolveRotate,
155
+ ROTATE_DEFAULTS,
156
+ setRotateValue,
157
+ sourceText,
158
+ type OrgRotate,
159
+ type OverrideScope,
160
+ type RotateFile,
161
+ type RotateValues,
162
+ } from "../../shared/agents/rotate-settings.js";
151
163
  import {
152
164
  archiveHandoff,
153
- DEFAULT_ROTATE,
165
+ keepConversation,
166
+ noteAsked,
167
+ noteFellBack,
168
+ rotationInstruction,
169
+ syncAfter,
154
170
  handoffFile,
155
171
  handoffPrompt,
156
172
  launchRotation,
@@ -158,9 +174,10 @@ import {
158
174
  readRotation,
159
175
  writeRotation,
160
176
  reasonText,
161
- rotateAfter,
162
177
  rotationInfo,
178
+ type RotationEntry,
163
179
  type RotationInfo,
180
+ type RotationPolicy,
164
181
  } from "../../shared/agents/rotation.js";
165
182
  import {
166
183
  binSettingKey,
@@ -237,6 +254,8 @@ export interface AgentsDeps extends SessionsDeps {
237
254
  ) => void;
238
255
  /** The user.yml a person edits to set it (default: config/local/user.yml). */
239
256
  userConfigFile?: () => string;
257
+ /** D28: workspace `ws`'s services and their folders (the service scope). */
258
+ workspaceServices?: (ws: string) => { address: string; path: string }[];
240
259
  }
241
260
 
242
261
  export interface AgentsResolved extends Resolved {
@@ -252,6 +271,7 @@ export interface AgentsResolved extends Resolved {
252
271
  userCatalog: (taken: readonly string[]) => CatalogEntry[];
253
272
  saveRuntimeBin: NonNullable<AgentsDeps["saveRuntimeBin"]>;
254
273
  userConfigFile: () => string;
274
+ workspaceServices: (ws: string) => { address: string; path: string }[];
255
275
  }
256
276
 
257
277
  /** The early-exit watch: at most this long, polling the host. */
@@ -303,6 +323,8 @@ const refused = (message: string, hints: string[] = [], status = 409) =>
303
323
  new AgentError(message, { status, hints });
304
324
 
305
325
  let workspaceDirFn: ((ws: string) => string) | null = null;
326
+ let servicesFn: ((ws: string) => { address: string; path: string }[]) | null =
327
+ null;
306
328
  let foldersFn: ((ws: string) => { under: string[]; exact?: string[] }) | null =
307
329
  null;
308
330
  /** The workspaces config, for start and resume (loaded on first use). */
@@ -322,6 +344,15 @@ export async function loadAgentWorkspaces(d: AgentsDeps): Promise<void> {
322
344
  if (!cfg) throw usage(`workspace "${name}" does not exist`, 404);
323
345
  return ws.conversationFolders(name, cfg, getReposRoot());
324
346
  };
347
+ const { readCatalog } =
348
+ await import("../../shared/environment-manager/catalog.js");
349
+ servicesFn = (name) => {
350
+ const cfg = ws.readWorkspacesFile().workspaces[name];
351
+ if (!cfg) return [];
352
+ return ws
353
+ .listWorkspaceServices(cfg, readCatalog(), name)
354
+ .map((r) => ({ address: r.address, path: r.repoPath }));
355
+ };
325
356
  }
326
357
 
327
358
  /** The distribution's bin shim (as `mcp connect` registers), else Den's CLI. */
@@ -374,6 +405,8 @@ export function resolveAgentsDeps(d: AgentsDeps): AgentsResolved {
374
405
  ? setWorkspaceRuntimeBin(ws, id, bin)
375
406
  : setUserRuntimeBin(id, bin)),
376
407
  userConfigFile: d.userConfigFile ?? userConfigPath,
408
+ workspaceServices:
409
+ d.workspaceServices ?? ((ws) => (servicesFn ? servicesFn(ws) : [])),
377
410
  });
378
411
  }
379
412
 
@@ -511,6 +544,8 @@ export interface AgentRow {
511
544
  rotation?: RotationInfo;
512
545
  /** How many times Den relaunched it from a handoff. */
513
546
  rotations?: number;
547
+ /** D28: the last one (when, why, the old conversation, by itself or not). */
548
+ lastRotation?: RotationEntry;
514
549
  session?: SessionInfo;
515
550
  }
516
551
 
@@ -566,15 +601,19 @@ export function agentRows(
566
601
  records: AgentRecord[] = [],
567
602
  /** Den's state dir: each agent's rotation is read there (den#4). */
568
603
  stateDir?: string,
604
+ /** D28: each agent's effective rotation settings (policyResolver). */
605
+ policyOf?: (rec: AgentRecord) => RotationPolicy | undefined,
569
606
  ): AgentRow[] {
570
607
  const bySession = new Map(records.map((r) => [r.session.id, r]));
571
608
  const rows: AgentRow[] = [];
572
609
  const shown = new Set<string>();
573
610
  const row = (rec: AgentRecord, s?: SessionInfo): AgentRow => {
574
611
  const st = rowState(rec, s);
575
- const rot = stateDir ? rotationInfo(stateDir, rec) : null;
612
+ const rot = stateDir ? rotationInfo(stateDir, rec, policyOf?.(rec)) : null;
576
613
  // A rotated conversation is not resumed: it relaunches from its handoff.
577
- const resumable = canResume(rec, st) && !rot?.ready;
614
+ // D28: under on_due off, its conversation goes on: Resume stays.
615
+ const resumable =
616
+ canResume(rec, st) && !(rot?.ready && rot.onDue !== "off");
578
617
  const why =
579
618
  isStoppedState(st) && !resumable
580
619
  ? (resumeRefusal(rec) ?? (rot?.ready ? rotatedRefusal(rec, rot) : null))
@@ -587,7 +626,7 @@ export function agentRows(
587
626
  state: st,
588
627
  startedAt: s?.startedAt ?? rec.startedAt,
589
628
  resumable,
590
- ...(!rot?.ready && !personaChangeRefusal(rec)
629
+ ...(!(rot?.ready && rot.onDue !== "off") && !personaChangeRefusal(rec)
591
630
  ? { personaChange: true }
592
631
  : {}),
593
632
  ...(why ? { noResume: why } : {}),
@@ -597,7 +636,12 @@ export function agentRows(
597
636
  ...(rec.outcome ? { outcome: rec.outcome } : {}),
598
637
  ...(rec.usage ? { usage: rec.usage } : {}),
599
638
  ...(rot ? { rotation: rot } : {}),
600
- ...(rec.rotations?.length ? { rotations: rec.rotations.length } : {}),
639
+ ...(rec.rotations?.length
640
+ ? {
641
+ rotations: rec.rotations.length,
642
+ lastRotation: rec.rotations[rec.rotations.length - 1],
643
+ }
644
+ : {}),
601
645
  ...(s ? { session: contractInfo(s) } : {}),
602
646
  };
603
647
  };
@@ -695,7 +739,7 @@ export async function listAgents(
695
739
  const sessions = all.filter((s) => inWorkspace(s, d.dist, ws));
696
740
  const records = listRecords(d.stateDir()).filter((r) => r.workspace === ws);
697
741
  noteEnded(d.stateDir(), sessions, records);
698
- const rows = agentRows(sessions, records, d.stateDir());
742
+ const rows = agentRows(sessions, records, d.stateDir(), policyResolver(d));
699
743
  return opts.stopped ? rows.filter((r) => isStoppedState(r.state)) : rows;
700
744
  }
701
745
 
@@ -731,6 +775,113 @@ function personas(
731
775
  }
732
776
  }
733
777
 
778
+ /**
779
+ * D28: the workspace service whose folder holds `cwd` (the deepest), for
780
+ * the service scope; undefined outside every service or on any error.
781
+ */
782
+ function serviceAt(
783
+ d: AgentsResolved,
784
+ ws: string,
785
+ cwd: string | undefined,
786
+ ): string | undefined {
787
+ if (!cwd) return undefined;
788
+ try {
789
+ let best: { address: string; path: string } | undefined;
790
+ for (const svc of d.workspaceServices(ws)) {
791
+ const rel = path.relative(svc.path, cwd);
792
+ if (rel.startsWith("..") || path.isAbsolute(rel)) continue;
793
+ if (!best || svc.path.length > best.path.length) best = svc;
794
+ }
795
+ return best?.address;
796
+ } catch {
797
+ return undefined;
798
+ }
799
+ }
800
+
801
+ /**
802
+ * D28: a resolver of each agent's rotation settings, the config and the
803
+ * personas read once. A config or persona that can't be read: undefined
804
+ * (den#4's own rules), never an error in a list.
805
+ */
806
+ export function policyResolver(
807
+ d: AgentsResolved,
808
+ ): (rec: AgentRecord) => RotationPolicy | undefined {
809
+ let cfg: ResolvedAgentsConfig | null | undefined;
810
+ const byWs = new Map<string, Map<string, Persona> | null>();
811
+ return (rec) => {
812
+ if (cfg === undefined) {
813
+ try {
814
+ cfg = d.agentsConfig();
815
+ } catch {
816
+ cfg = null;
817
+ }
818
+ }
819
+ if (!cfg) return undefined;
820
+ const key = isServerScope(rec.workspace) ? rec.workspace : "";
821
+ if (!byWs.has(key)) {
822
+ try {
823
+ byWs.set(key, personas(d, cfg, rec.workspace));
824
+ } catch {
825
+ byWs.set(key, null);
826
+ }
827
+ }
828
+ const all = byWs.get(key);
829
+ if (!all) return undefined;
830
+ return effectiveRotation(d, cfg, rec, all.get(rec.persona));
831
+ };
832
+ }
833
+
834
+ /** D28: agent `rec`'s rotation under its effective settings. */
835
+ export function rotationOf(
836
+ d: AgentsResolved,
837
+ rec: AgentRecord,
838
+ ): RotationInfo | null {
839
+ return rotationInfo(d.stateDir(), rec, policyResolver(d)(rec));
840
+ }
841
+
842
+ /**
843
+ * D28: agent `id`'s effective rotation settings and where each comes
844
+ * from. `persona` is its persona (standing, and whether Den's hooks count
845
+ * compactions: without them it never rotates).
846
+ */
847
+ export function effectiveRotation(
848
+ d: AgentsResolved,
849
+ cfg: ResolvedAgentsConfig,
850
+ who: { id: string; persona: string; workspace: string; cwd?: string },
851
+ persona: Persona | undefined,
852
+ ): RotationPolicy {
853
+ const r = resolveRotate(
854
+ {
855
+ agent: who.id,
856
+ persona: who.persona,
857
+ workspace: who.workspace,
858
+ ...(serviceAt(d, who.workspace, who.cwd)
859
+ ? { service: serviceAt(d, who.workspace, who.cwd) }
860
+ : {}),
861
+ ...(persona?.standing ? { standing: true } : {}),
862
+ },
863
+ readRotateFile(d.stateDir()),
864
+ cfg.rotateOrg ??
865
+ (cfg.rotate
866
+ ? {
867
+ after_compactions: cfg.rotate.afterCompactions || "never",
868
+ ...(cfg.rotate.standing === false ? { standing: false } : {}),
869
+ }
870
+ : undefined),
871
+ );
872
+ return {
873
+ afterCompactions:
874
+ persona && !persona.guardrails?.hooks ? 0 : r.afterCompactions,
875
+ onDue: r.onDue,
876
+ onPrMerged: r.onPrMerged,
877
+ from: {
878
+ after_compactions: sourceText(r.source.after_compactions),
879
+ on_due: sourceText(r.source.on_due),
880
+ on_pr_merged: sourceText(r.source.on_pr_merged),
881
+ },
882
+ };
883
+ }
884
+
734
885
  /** A persona as listed: everything but its role prompt. */
735
886
  export type ListedPersona = Omit<Persona, "prompt" | "guardrailsSet"> & {
736
887
  /** Its permission mode as Den's intent (AG-13), when it is one. */
@@ -1353,9 +1504,12 @@ function launchInput(
1353
1504
  if (!fs.statSync(dir, { throwIfNoEntry: false })?.isDirectory())
1354
1505
  throw refused(`workspace ${ws}'s directory ${dir} does not exist`);
1355
1506
  // den#4: only Den's hooks count compactions; without them, no rotation.
1356
- const after = persona.guardrails?.hooks
1357
- ? rotateAfter(cfg.rotate ?? DEFAULT_ROTATE, persona)
1358
- : 0;
1507
+ const after = effectiveRotation(
1508
+ d,
1509
+ cfg,
1510
+ { id, persona: persona.id, workspace: ws, cwd: dir },
1511
+ persona,
1512
+ ).afterCompactions;
1359
1513
  return {
1360
1514
  persona,
1361
1515
  workspace: { name: ws, dir },
@@ -2137,8 +2291,9 @@ export async function resumeAgent(
2137
2291
  ]);
2138
2292
  // den#4: a rotated conversation ends there; its role relaunches from
2139
2293
  // the handoff, in a fresh one.
2140
- const rot = rotationInfo(d.stateDir(), rec);
2141
- if (rot?.ready)
2294
+ const rot = rotationOf(d, rec);
2295
+ // D28: on_due off: nothing happens, its conversation goes on.
2296
+ if (rot?.ready && rot.onDue !== "off")
2142
2297
  throw new AgentError(
2143
2298
  `agent ${rec.id} rotated after ${reasonText(rot.due!, rot.compactions)}: its conversation is not resumed`,
2144
2299
  {
@@ -2566,12 +2721,14 @@ export async function relaunchAgent(
2566
2721
  d: AgentsResolved,
2567
2722
  id: string,
2568
2723
  ws?: string,
2724
+ /** D28: Den relaunched it by itself (on_due auto). */
2725
+ o: { auto?: boolean } = {},
2569
2726
  ): Promise<Relaunched> {
2570
2727
  const rec = agentRecord(d, id, ws);
2571
2728
  syncCatalog(d);
2572
2729
  const rt = runtimeFor(rec.runtime);
2573
2730
  const files = agentFilesDir(d.stateDir(), rec.id);
2574
- const rot = rotationInfo(d.stateDir(), rec);
2731
+ const rot = rotationOf(d, rec);
2575
2732
  const fresh = `Start fresh: ${d.cli} agents start -w ${rec.workspace} --persona ${rec.persona} --id ${rec.id}`;
2576
2733
  const resume = `Resume: ${d.cli} agents resume ${rec.id}`;
2577
2734
  if (!rt.firstPrompt)
@@ -2604,6 +2761,7 @@ export async function relaunchAgent(
2604
2761
  reason: rot.due,
2605
2762
  compactions: rot.compactions,
2606
2763
  ...(rec.resumeToken ? { conversation: rec.resumeToken } : {}),
2764
+ ...(o.auto ? { auto: true } : {}),
2607
2765
  };
2608
2766
  let kept = "";
2609
2767
  const before = readRotation(files);
@@ -2805,7 +2963,7 @@ export async function markInterrupted(
2805
2963
  if (resumeRefusal(rec)) continue;
2806
2964
  try {
2807
2965
  // den#4: a rotated one relaunches from its handoff instead.
2808
- const r = rotationInfo(d.stateDir(), rec)?.ready
2966
+ const r = rotationOf(d, rec)?.ready
2809
2967
  ? await relaunchAgent(d, rec.id)
2810
2968
  : await resumeAgent(d, rec.id);
2811
2969
  if (r.early) found.failed.push({ agent: rec.id, why: "exited at once" });
@@ -2837,7 +2995,7 @@ export async function resumeInterrupted(
2837
2995
  const resumed: Resumed[] = [];
2838
2996
  const failed: { agent: string; why: string }[] = [];
2839
2997
  for (const rec of recs) {
2840
- const rot = rotationInfo(d.stateDir(), rec);
2998
+ const rot = rotationOf(d, rec);
2841
2999
  const why =
2842
3000
  resumeRefusal(rec) ?? (rot?.ready ? rotatedRefusal(rec, rot) : null);
2843
3001
  if (why) {
@@ -3019,3 +3177,279 @@ export async function startPlainSession(
3019
3177
  return { info };
3020
3178
  });
3021
3179
  }
3180
+
3181
+ // ── D28: rotation by itself (on_due auto), and the callout's actions ────────
3182
+
3183
+ /** How often the server looks at due agents. */
3184
+ export const ROTATION_TICK_MS = 15_000;
3185
+ /** Between two asks for the handoff, at least this long. */
3186
+ export const ROTATION_ASK_GAP_MS = 120_000;
3187
+ /** Asks before auto falls back to ask. */
3188
+ export const ROTATION_MAX_ASKS = 2;
3189
+
3190
+ export type RotationEvent =
3191
+ | { kind: "asked"; agent: string; workspace: string; asked: number }
3192
+ | { kind: "fell-back"; agent: string; workspace: string }
3193
+ | {
3194
+ kind: "relaunched";
3195
+ agent: string;
3196
+ workspace: string;
3197
+ why: string;
3198
+ /** The conversation that ended (kept). */
3199
+ old?: string;
3200
+ }
3201
+ | { kind: "failed"; agent: string; workspace: string; why: string };
3202
+
3203
+ /**
3204
+ * D28, one look at every running agent whose rotation is due. Each one
3205
+ * whose settings say `auto`, at idle (never mid-turn; unknown counts as
3206
+ * busy): its handoff passes, so Den relaunches it; or Den asks it for the
3207
+ * handoff (at most ROTATION_MAX_ASKS times, ROTATION_ASK_GAP_MS apart),
3208
+ * then falls back to ask. The limit the hook counts against follows the
3209
+ * settings. Never throws: each agent's failure is an event.
3210
+ */
3211
+ export async function rotationTick(
3212
+ d: AgentsResolved,
3213
+ now: Date = new Date(),
3214
+ o: { askGapMs?: number; send?: typeof sendFirstInstruction } = {},
3215
+ ): Promise<RotationEvent[]> {
3216
+ const events: RotationEvent[] = [];
3217
+ let sessions: SessionInfo[];
3218
+ try {
3219
+ sessions = await hostSessions(d);
3220
+ } catch {
3221
+ return events;
3222
+ }
3223
+ const policyOf = policyResolver(d);
3224
+ const gap = o.askGapMs ?? ROTATION_ASK_GAP_MS;
3225
+ const send = o.send ?? sendFirstInstruction;
3226
+ for (const rec of listRecords(d.stateDir())) {
3227
+ const who = { agent: rec.id, workspace: rec.workspace };
3228
+ try {
3229
+ const s = rec.session ? sessionOf(sessions, rec) : undefined;
3230
+ if (!s || s.exited) continue;
3231
+ const pol = policyOf(rec);
3232
+ if (!pol) continue;
3233
+ const files = agentFilesDir(d.stateDir(), rec.id);
3234
+ const st = readRotation(files);
3235
+ if (!st) continue;
3236
+ if (
3237
+ st.conversation &&
3238
+ rec.resumeToken &&
3239
+ st.conversation !== rec.resumeToken
3240
+ )
3241
+ continue;
3242
+ syncAfter(files, pol.afterCompactions);
3243
+ const rot = rotationInfo(d.stateDir(), rec, pol);
3244
+ if (!rot?.due) continue;
3245
+ // Relaunch now, asked before the handoff, relaunches whatever on_due says.
3246
+ const wanted = rot.relaunchWanted === true;
3247
+ if (!wanted && (pol.onDue !== "auto" || rot.fellBack)) continue;
3248
+ if ((await agentBusy(d, rec)) !== false) continue;
3249
+ if (rot.handoff === "ok") {
3250
+ const r = await relaunchAgent(d, rec.id, rec.workspace, {
3251
+ auto: true,
3252
+ });
3253
+ events.push({
3254
+ kind: "relaunched",
3255
+ ...who,
3256
+ why: r.why,
3257
+ ...(rec.resumeToken ? { old: rec.resumeToken } : {}),
3258
+ });
3259
+ continue;
3260
+ }
3261
+ const last = st.askedAt ? Date.parse(st.askedAt) : 0;
3262
+ if (now.getTime() - last < gap) continue;
3263
+ if (wanted) continue; // asked once, by the person: no more asks
3264
+ if ((st.asked ?? 0) >= ROTATION_MAX_ASKS) {
3265
+ noteFellBack(files);
3266
+ events.push({ kind: "fell-back", ...who });
3267
+ continue;
3268
+ }
3269
+ const text = rotationInstruction(files, rot.due, rot.compactions);
3270
+ if (
3271
+ await send(d, s.ref.id, rec.runtime, text, {
3272
+ quietMs: 500,
3273
+ maxMs: 5000,
3274
+ })
3275
+ ) {
3276
+ const n = noteAsked(files, now);
3277
+ events.push({ kind: "asked", ...who, asked: n?.asked ?? 1 });
3278
+ }
3279
+ } catch (e) {
3280
+ events.push({ kind: "failed", ...who, why: (e as Error).message });
3281
+ }
3282
+ }
3283
+ return events;
3284
+ }
3285
+
3286
+ /**
3287
+ * D28 "Keep this conversation": its count starts again, nothing is due.
3288
+ * `never`: also this agent's override after_compactions never ("don't ask
3289
+ * again for this agent").
3290
+ */
3291
+ export function keepAgentConversation(
3292
+ d: AgentsResolved,
3293
+ id: string,
3294
+ ws?: string,
3295
+ o: { never?: boolean } = {},
3296
+ ): AgentRecord {
3297
+ const rec = agentRecord(d, id, ws);
3298
+ if (!keepConversation(agentFilesDir(d.stateDir(), rec.id)))
3299
+ throw refused(`agent ${rec.id} has no rotation to reset`);
3300
+ if (o.never)
3301
+ setRotateValue(d.stateDir(), "agent", rec.id, "after_compactions", "never");
3302
+ return rec;
3303
+ }
3304
+
3305
+ /**
3306
+ * D28 "Write handoff": types the handoff instruction into agent `id`'s
3307
+ * running session, at idle. Refused while it is mid-turn or not running.
3308
+ */
3309
+ export async function askForHandoff(
3310
+ d: AgentsResolved,
3311
+ id: string,
3312
+ ws?: string,
3313
+ send: typeof sendFirstInstruction = sendFirstInstruction,
3314
+ /** Relaunch now: Den relaunches it once the handoff passes. */
3315
+ o: { relaunch?: boolean } = {},
3316
+ ): Promise<AgentRecord & { resumed?: boolean }> {
3317
+ const rec = agentRecord(d, id, ws);
3318
+ const s = sessionOf(await hostSessions(d), rec);
3319
+ if (!s || s.exited) {
3320
+ // Stopped, no handoff yet: its conversation resumes to write one (the
3321
+ // resume hook gives it the instruction), then Den relaunches it.
3322
+ const files = agentFilesDir(d.stateDir(), rec.id);
3323
+ if (!o.relaunch)
3324
+ throw refused(
3325
+ `agent ${rec.id} is not running: resume it to write its handoff`,
3326
+ [`Resume: ${d.cli} agents resume ${rec.id}`],
3327
+ );
3328
+ const r = await resumeAgent(d, rec.id, ws);
3329
+ noteAsked(files, new Date(), true);
3330
+ return { ...r.rec, resumed: true };
3331
+ }
3332
+ if ((await agentBusy(d, rec)) === true)
3333
+ throw refused(`agent ${rec.id} is mid-turn: ask again when its turn ends`);
3334
+ const rot = rotationOf(d, rec);
3335
+ const files = agentFilesDir(d.stateDir(), rec.id);
3336
+ const text = rotationInstruction(
3337
+ files,
3338
+ rot?.due ?? "compactions",
3339
+ rot?.compactions ?? readRotation(files)?.compactions ?? 0,
3340
+ );
3341
+ if (
3342
+ !(await send(d, s.ref.id, rec.runtime, text, { quietMs: 500, maxMs: 5000 }))
3343
+ )
3344
+ throw refused(`agent ${rec.id}'s session did not take the prompt`);
3345
+ noteAsked(files, new Date(), o.relaunch === true);
3346
+ return rec;
3347
+ }
3348
+
3349
+ /**
3350
+ * D28: Resume (or Start) on an agent follows its on_due: a due agent whose
3351
+ * handoff passes relaunches from it under auto; ask refuses with the
3352
+ * callout's choices (code "rotated"); off, or nothing due, resumes.
3353
+ */
3354
+ export async function resumeOrRelaunch(
3355
+ d: AgentsResolved,
3356
+ id: string,
3357
+ ws?: string,
3358
+ ): Promise<
3359
+ { kind: "resumed"; s: Resumed } | { kind: "relaunched"; s: Relaunched }
3360
+ > {
3361
+ const rec = agentRecord(d, id, ws);
3362
+ const rot = rotationOf(d, rec);
3363
+ if (rot?.ready && rot.onDue === "auto")
3364
+ return {
3365
+ kind: "relaunched",
3366
+ s: await relaunchAgent(d, id, ws, { auto: true }),
3367
+ };
3368
+ return { kind: "resumed", s: await resumeAgent(d, id, ws) };
3369
+ }
3370
+
3371
+ /** D28: the rotation settings, and agent `agent`'s effective values. */
3372
+ export function rotationSettings(
3373
+ d: AgentsResolved,
3374
+ agent?: string,
3375
+ ws?: string,
3376
+ ): {
3377
+ defaults: typeof ROTATE_DEFAULTS;
3378
+ org: OrgRotate;
3379
+ install: RotateValues;
3380
+ overrides: NonNullable<RotateFile["overrides"]>;
3381
+ effective?: RotationPolicy & { agent: string };
3382
+ } {
3383
+ const f = readRotateFile(d.stateDir());
3384
+ let org: OrgRotate = {};
3385
+ try {
3386
+ org = d.agentsConfig().rotateOrg ?? {};
3387
+ } catch {
3388
+ org = {};
3389
+ }
3390
+ let effective: (RotationPolicy & { agent: string }) | undefined;
3391
+ if (agent) {
3392
+ const rec = agentRecord(d, agent, ws);
3393
+ const pol = policyResolver(d)(rec);
3394
+ if (pol) effective = { agent: rec.id, ...pol };
3395
+ }
3396
+ return {
3397
+ defaults: ROTATE_DEFAULTS,
3398
+ org,
3399
+ install: f.install ?? {},
3400
+ overrides: f.overrides ?? {},
3401
+ ...(effective ? { effective } : {}),
3402
+ };
3403
+ }
3404
+
3405
+ /**
3406
+ * D28: set one rotation setting from a request body `{ scope, target?,
3407
+ * key, value }` (value null: cleared). A target is checked to be a name
3408
+ * (persona-workspace: `<persona>@<workspace>`).
3409
+ */
3410
+ export function setRotationSetting(
3411
+ d: AgentsResolved,
3412
+ body: Record<string, unknown>,
3413
+ ): { file: RotateFile } {
3414
+ const scope = body.scope;
3415
+ if (
3416
+ scope !== "install" &&
3417
+ !(OVERRIDE_SCOPES as readonly unknown[]).includes(scope)
3418
+ )
3419
+ throw usage(
3420
+ `scope: one of install, ${OVERRIDE_SCOPES.join(", ")} (got ${JSON.stringify(scope)})`,
3421
+ );
3422
+ const target = body.target;
3423
+ if (scope !== "install") {
3424
+ const ok =
3425
+ typeof target === "string" &&
3426
+ (scope === "persona-workspace"
3427
+ ? /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}@[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(
3428
+ target,
3429
+ )
3430
+ : scope === "service"
3431
+ ? /^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$/.test(target)
3432
+ : /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(target));
3433
+ if (!ok)
3434
+ throw usage(
3435
+ scope === "persona-workspace"
3436
+ ? "target: <persona>@<workspace>"
3437
+ : `target: the ${String(scope)} it is for`,
3438
+ );
3439
+ } else if (target !== undefined && target !== null)
3440
+ throw usage("target: install takes none");
3441
+ if (typeof body.key !== "string") throw usage("key is required");
3442
+ try {
3443
+ return {
3444
+ file: setRotateValue(
3445
+ d.stateDir(),
3446
+ scope as OverrideScope | "install",
3447
+ scope === "install" ? undefined : (target as string),
3448
+ body.key,
3449
+ body.value === null ? undefined : body.value,
3450
+ ),
3451
+ };
3452
+ } catch (e) {
3453
+ throw usage((e as Error).message);
3454
+ }
3455
+ }