@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
@@ -71,7 +71,10 @@ import {
71
71
  type CatalogEntry,
72
72
  } from "../../shared/agents/catalog.js";
73
73
  import { checkRuntimeBin } from "../../shared/agents/catalog-runtime.js";
74
- import { agentSessionName } from "../../shared/agents/session-name.js";
74
+ import {
75
+ agentSessionName,
76
+ peerSessionName,
77
+ } from "../../shared/agents/session-name.js";
75
78
  import { cliEntrypoint } from "../../shared/engine.js";
76
79
  import { getDistRoot } from "../../shared/paths.js";
77
80
  import { envKey } from "../../shared/product.js";
@@ -145,9 +148,25 @@ import {
145
148
  openElsewhere,
146
149
  type RuntimeConversation,
147
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";
148
163
  import {
149
164
  archiveHandoff,
150
- DEFAULT_ROTATE,
165
+ keepConversation,
166
+ noteAsked,
167
+ noteFellBack,
168
+ rotationInstruction,
169
+ syncAfter,
151
170
  handoffFile,
152
171
  handoffPrompt,
153
172
  launchRotation,
@@ -155,9 +174,10 @@ import {
155
174
  readRotation,
156
175
  writeRotation,
157
176
  reasonText,
158
- rotateAfter,
159
177
  rotationInfo,
178
+ type RotationEntry,
160
179
  type RotationInfo,
180
+ type RotationPolicy,
161
181
  } from "../../shared/agents/rotation.js";
162
182
  import {
163
183
  binSettingKey,
@@ -234,6 +254,8 @@ export interface AgentsDeps extends SessionsDeps {
234
254
  ) => void;
235
255
  /** The user.yml a person edits to set it (default: config/local/user.yml). */
236
256
  userConfigFile?: () => string;
257
+ /** D28: workspace `ws`'s services and their folders (the service scope). */
258
+ workspaceServices?: (ws: string) => { address: string; path: string }[];
237
259
  }
238
260
 
239
261
  export interface AgentsResolved extends Resolved {
@@ -249,6 +271,7 @@ export interface AgentsResolved extends Resolved {
249
271
  userCatalog: (taken: readonly string[]) => CatalogEntry[];
250
272
  saveRuntimeBin: NonNullable<AgentsDeps["saveRuntimeBin"]>;
251
273
  userConfigFile: () => string;
274
+ workspaceServices: (ws: string) => { address: string; path: string }[];
252
275
  }
253
276
 
254
277
  /** The early-exit watch: at most this long, polling the host. */
@@ -300,6 +323,8 @@ const refused = (message: string, hints: string[] = [], status = 409) =>
300
323
  new AgentError(message, { status, hints });
301
324
 
302
325
  let workspaceDirFn: ((ws: string) => string) | null = null;
326
+ let servicesFn: ((ws: string) => { address: string; path: string }[]) | null =
327
+ null;
303
328
  let foldersFn: ((ws: string) => { under: string[]; exact?: string[] }) | null =
304
329
  null;
305
330
  /** The workspaces config, for start and resume (loaded on first use). */
@@ -319,6 +344,15 @@ export async function loadAgentWorkspaces(d: AgentsDeps): Promise<void> {
319
344
  if (!cfg) throw usage(`workspace "${name}" does not exist`, 404);
320
345
  return ws.conversationFolders(name, cfg, getReposRoot());
321
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
+ };
322
356
  }
323
357
 
324
358
  /** The distribution's bin shim (as `mcp connect` registers), else Den's CLI. */
@@ -371,6 +405,8 @@ export function resolveAgentsDeps(d: AgentsDeps): AgentsResolved {
371
405
  ? setWorkspaceRuntimeBin(ws, id, bin)
372
406
  : setUserRuntimeBin(id, bin)),
373
407
  userConfigFile: d.userConfigFile ?? userConfigPath,
408
+ workspaceServices:
409
+ d.workspaceServices ?? ((ws) => (servicesFn ? servicesFn(ws) : [])),
374
410
  });
375
411
  }
376
412
 
@@ -508,6 +544,8 @@ export interface AgentRow {
508
544
  rotation?: RotationInfo;
509
545
  /** How many times Den relaunched it from a handoff. */
510
546
  rotations?: number;
547
+ /** D28: the last one (when, why, the old conversation, by itself or not). */
548
+ lastRotation?: RotationEntry;
511
549
  session?: SessionInfo;
512
550
  }
513
551
 
@@ -563,15 +601,19 @@ export function agentRows(
563
601
  records: AgentRecord[] = [],
564
602
  /** Den's state dir: each agent's rotation is read there (den#4). */
565
603
  stateDir?: string,
604
+ /** D28: each agent's effective rotation settings (policyResolver). */
605
+ policyOf?: (rec: AgentRecord) => RotationPolicy | undefined,
566
606
  ): AgentRow[] {
567
607
  const bySession = new Map(records.map((r) => [r.session.id, r]));
568
608
  const rows: AgentRow[] = [];
569
609
  const shown = new Set<string>();
570
610
  const row = (rec: AgentRecord, s?: SessionInfo): AgentRow => {
571
611
  const st = rowState(rec, s);
572
- const rot = stateDir ? rotationInfo(stateDir, rec) : null;
612
+ const rot = stateDir ? rotationInfo(stateDir, rec, policyOf?.(rec)) : null;
573
613
  // A rotated conversation is not resumed: it relaunches from its handoff.
574
- 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");
575
617
  const why =
576
618
  isStoppedState(st) && !resumable
577
619
  ? (resumeRefusal(rec) ?? (rot?.ready ? rotatedRefusal(rec, rot) : null))
@@ -584,7 +626,7 @@ export function agentRows(
584
626
  state: st,
585
627
  startedAt: s?.startedAt ?? rec.startedAt,
586
628
  resumable,
587
- ...(!rot?.ready && !personaChangeRefusal(rec)
629
+ ...(!(rot?.ready && rot.onDue !== "off") && !personaChangeRefusal(rec)
588
630
  ? { personaChange: true }
589
631
  : {}),
590
632
  ...(why ? { noResume: why } : {}),
@@ -594,7 +636,12 @@ export function agentRows(
594
636
  ...(rec.outcome ? { outcome: rec.outcome } : {}),
595
637
  ...(rec.usage ? { usage: rec.usage } : {}),
596
638
  ...(rot ? { rotation: rot } : {}),
597
- ...(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
+ : {}),
598
645
  ...(s ? { session: contractInfo(s) } : {}),
599
646
  };
600
647
  };
@@ -692,7 +739,7 @@ export async function listAgents(
692
739
  const sessions = all.filter((s) => inWorkspace(s, d.dist, ws));
693
740
  const records = listRecords(d.stateDir()).filter((r) => r.workspace === ws);
694
741
  noteEnded(d.stateDir(), sessions, records);
695
- const rows = agentRows(sessions, records, d.stateDir());
742
+ const rows = agentRows(sessions, records, d.stateDir(), policyResolver(d));
696
743
  return opts.stopped ? rows.filter((r) => isStoppedState(r.state)) : rows;
697
744
  }
698
745
 
@@ -728,6 +775,113 @@ function personas(
728
775
  }
729
776
  }
730
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
+
731
885
  /** A persona as listed: everything but its role prompt. */
732
886
  export type ListedPersona = Omit<Persona, "prompt" | "guardrailsSet"> & {
733
887
  /** Its permission mode as Den's intent (AG-13), when it is one. */
@@ -1350,9 +1504,12 @@ function launchInput(
1350
1504
  if (!fs.statSync(dir, { throwIfNoEntry: false })?.isDirectory())
1351
1505
  throw refused(`workspace ${ws}'s directory ${dir} does not exist`);
1352
1506
  // den#4: only Den's hooks count compactions; without them, no rotation.
1353
- const after = persona.guardrails?.hooks
1354
- ? rotateAfter(cfg.rotate ?? DEFAULT_ROTATE, persona)
1355
- : 0;
1507
+ const after = effectiveRotation(
1508
+ d,
1509
+ cfg,
1510
+ { id, persona: persona.id, workspace: ws, cwd: dir },
1511
+ persona,
1512
+ ).afterCompactions;
1356
1513
  return {
1357
1514
  persona,
1358
1515
  workspace: { name: ws, dir },
@@ -1637,11 +1794,17 @@ export async function startAgent(
1637
1794
  );
1638
1795
  ptyOrRefuse(d);
1639
1796
  const allow = allowedFor(d, persona, ch.extras);
1797
+ // D24: the name peers see it by, from the name it will have (below).
1798
+ const peerName = peerSessionName(d.cli, ws, {
1799
+ id,
1800
+ displayName: given ?? prev?.displayName ?? persona.displayName,
1801
+ });
1640
1802
  const input = {
1641
1803
  ...launchInput(d, cfg, persona, ws, id, model, det.path, undefined, {
1642
1804
  ...(ch.permission ? { permission: ch.permission } : {}),
1643
1805
  ...(rt.permissions ? { allow } : {}),
1644
1806
  }),
1807
+ sessionLabel: peerName,
1645
1808
  ...(more.firstPrompt ? { firstPrompt: more.firstPrompt } : {}),
1646
1809
  // 0.4.18: Start agent here: in a folder of the workspace's.
1647
1810
  ...(req.folder !== undefined && !more.bringIn
@@ -1699,6 +1862,7 @@ export async function startAgent(
1699
1862
  workspace: ws,
1700
1863
  cwd: input.worktree,
1701
1864
  sessionName: sessionName(d.dist, ws, id),
1865
+ peerName,
1702
1866
  // AG-4b: a name given to the agent is the agent's, not its
1703
1867
  // conversation's: Start fresh and Relaunch keep it. AG-10.8:
1704
1868
  // else its persona's name ("Rita").
@@ -2127,8 +2291,9 @@ export async function resumeAgent(
2127
2291
  ]);
2128
2292
  // den#4: a rotated conversation ends there; its role relaunches from
2129
2293
  // the handoff, in a fresh one.
2130
- const rot = rotationInfo(d.stateDir(), rec);
2131
- 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")
2132
2297
  throw new AgentError(
2133
2298
  `agent ${rec.id} rotated after ${reasonText(rot.due!, rot.compactions)}: its conversation is not resumed`,
2134
2299
  {
@@ -2175,8 +2340,11 @@ export async function resumeAgent(
2175
2340
  },
2176
2341
  );
2177
2342
  // Resume in the cwd the agent had, wherever the workspace points now.
2343
+ // D24 (and D25: a rename since) the name peers see it by.
2344
+ const peerName = peerSessionName(d.cli, rec.workspace, rec);
2178
2345
  const at = {
2179
2346
  ...input,
2347
+ sessionLabel: peerName,
2180
2348
  worktree: rec.cwd,
2181
2349
  ...(rec.resumeToken ? { resumeToken: rec.resumeToken } : {}),
2182
2350
  };
@@ -2211,7 +2379,12 @@ export async function resumeAgent(
2211
2379
  d,
2212
2380
  c,
2213
2381
  {
2214
- rec: { ...base, cwd: rec.cwd, ...(token ? { launched: true } : {}) },
2382
+ rec: {
2383
+ ...base,
2384
+ cwd: rec.cwd,
2385
+ peerName,
2386
+ ...(token ? { launched: true } : {}),
2387
+ },
2215
2388
  argv: out.argv,
2216
2389
  env: out.env,
2217
2390
  passthrough: rt.envPassthrough,
@@ -2548,12 +2721,14 @@ export async function relaunchAgent(
2548
2721
  d: AgentsResolved,
2549
2722
  id: string,
2550
2723
  ws?: string,
2724
+ /** D28: Den relaunched it by itself (on_due auto). */
2725
+ o: { auto?: boolean } = {},
2551
2726
  ): Promise<Relaunched> {
2552
2727
  const rec = agentRecord(d, id, ws);
2553
2728
  syncCatalog(d);
2554
2729
  const rt = runtimeFor(rec.runtime);
2555
2730
  const files = agentFilesDir(d.stateDir(), rec.id);
2556
- const rot = rotationInfo(d.stateDir(), rec);
2731
+ const rot = rotationOf(d, rec);
2557
2732
  const fresh = `Start fresh: ${d.cli} agents start -w ${rec.workspace} --persona ${rec.persona} --id ${rec.id}`;
2558
2733
  const resume = `Resume: ${d.cli} agents resume ${rec.id}`;
2559
2734
  if (!rt.firstPrompt)
@@ -2586,6 +2761,7 @@ export async function relaunchAgent(
2586
2761
  reason: rot.due,
2587
2762
  compactions: rot.compactions,
2588
2763
  ...(rec.resumeToken ? { conversation: rec.resumeToken } : {}),
2764
+ ...(o.auto ? { auto: true } : {}),
2589
2765
  };
2590
2766
  let kept = "";
2591
2767
  const before = readRotation(files);
@@ -2787,7 +2963,7 @@ export async function markInterrupted(
2787
2963
  if (resumeRefusal(rec)) continue;
2788
2964
  try {
2789
2965
  // den#4: a rotated one relaunches from its handoff instead.
2790
- const r = rotationInfo(d.stateDir(), rec)?.ready
2966
+ const r = rotationOf(d, rec)?.ready
2791
2967
  ? await relaunchAgent(d, rec.id)
2792
2968
  : await resumeAgent(d, rec.id);
2793
2969
  if (r.early) found.failed.push({ agent: rec.id, why: "exited at once" });
@@ -2819,7 +2995,7 @@ export async function resumeInterrupted(
2819
2995
  const resumed: Resumed[] = [];
2820
2996
  const failed: { agent: string; why: string }[] = [];
2821
2997
  for (const rec of recs) {
2822
- const rot = rotationInfo(d.stateDir(), rec);
2998
+ const rot = rotationOf(d, rec);
2823
2999
  const why =
2824
3000
  resumeRefusal(rec) ?? (rot?.ready ? rotatedRefusal(rec, rot) : null);
2825
3001
  if (why) {
@@ -3001,3 +3177,279 @@ export async function startPlainSession(
3001
3177
  return { info };
3002
3178
  });
3003
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
+ }