@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
package/src/ui/cli/agent-ops.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
1354
|
-
|
|
1355
|
-
|
|
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 =
|
|
2131
|
-
|
|
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: {
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
+
}
|