@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
@@ -99,8 +99,10 @@ import {
99
99
  import {
100
100
  readWorkspacesFile,
101
101
  conversationFolders,
102
+ listWorkspaceServices,
102
103
  resolveWorkspaceDir,
103
104
  } from "../../shared/environment-manager/workspaces.js";
105
+ import { readCatalog } from "../../shared/environment-manager/catalog.js";
104
106
  import { getStateDir } from "../../shared/paths.js";
105
107
  import { HostError } from "../../shared/sessions/client.js";
106
108
  import {
@@ -133,7 +135,13 @@ import {
133
135
  changePersona,
134
136
  listConversations,
135
137
  relaunchAgent,
136
- resumeAgent,
138
+ resumeOrRelaunch,
139
+ keepAgentConversation,
140
+ askForHandoff,
141
+ rotationSettings,
142
+ setRotationSetting,
143
+ rotationTick,
144
+ ROTATION_TICK_MS,
137
145
  startAgent,
138
146
  startFresh,
139
147
  stopAgent,
@@ -167,7 +175,7 @@ export interface AgentRouteDeps {
167
175
  }
168
176
 
169
177
  const AGENTS_PATH =
170
- /^\/api\/workspaces\/([^/]+)\/agents(?:\/([^/]+)\/(stop|resume|fresh|relaunch|remove|name|paths|persona))?$/;
178
+ /^\/api\/workspaces\/([^/]+)\/agents(?:\/([^/]+)\/(stop|resume|fresh|relaunch|remove|name|paths|persona|keep|handoff))?$/;
171
179
 
172
180
  const RUNTIME_PATH = /^\/api\/agents\/runtimes\/([^/]+)\/path$/;
173
181
  const PERSONA_PATH = /^\/api\/personas\/([^/]+)$/;
@@ -203,6 +211,15 @@ export function webDeps(over: AgentsDeps = {}): AgentsResolved {
203
211
  return conversationFolders(ws, cfg, getReposRoot());
204
212
  },
205
213
  }),
214
+ // D28: the service scope of rotation settings.
215
+ workspaceServices: (ws) => {
216
+ const cfg = readWorkspacesFile().workspaces[ws];
217
+ if (!cfg) return [];
218
+ return listWorkspaceServices(cfg, readCatalog(), ws).map((r) => ({
219
+ address: r.address,
220
+ path: r.repoPath,
221
+ }));
222
+ },
206
223
  stdout: NO_TTY,
207
224
  out: () => undefined,
208
225
  err: () => undefined,
@@ -406,6 +423,8 @@ export async function handleAgentRoutes(
406
423
  pathname === "/api/agents/clear-stopped" ||
407
424
  pathname === "/api/agents/restore" ||
408
425
  pathname === "/api/agents/resume-interrupted";
426
+ if (pathname === "/api/agents/rotation")
427
+ return rotationRoute(req, res, webDeps(deps.agents));
409
428
  if (
410
429
  pathname !== "/api/personas" &&
411
430
  pathname !== "/api/agents/runtimes" &&
@@ -490,6 +509,8 @@ export async function handleAgentRoutes(
490
509
  | "name"
491
510
  | "paths"
492
511
  | "persona"
512
+ | "keep"
513
+ | "handoff"
493
514
  | undefined;
494
515
 
495
516
  if (!action && method === "GET") {
@@ -571,6 +592,12 @@ export async function handleAgentRoutes(
571
592
  "only a person can change an agent's persona (an agent can't change its own or another's)",
572
593
  { status: 403 },
573
594
  );
595
+ // D28: an agent could keep itself from ever rotating.
596
+ if (verb === "keep" && (agent || actor))
597
+ throw new AgentError(
598
+ "only a person can keep an agent's conversation past its rotation",
599
+ { status: 403 },
600
+ );
574
601
  if (verb === "resume" && agent) {
575
602
  const why = agentResumeRefusal(agent, ws, id!, mcpActions());
576
603
  if (why) throw new AgentError(why, { status: 403 });
@@ -651,6 +678,38 @@ export async function handleAgentRoutes(
651
678
  what = `start ${s.rec.id} fresh (${s.rec.persona}, ${s.rec.runtime}) in ${ws}`;
652
679
  audit(what, s.early ? "exited at once" : undefined);
653
680
  sendStarted(res, s, d);
681
+ } else if (verb === "keep") {
682
+ const body = await jsonBody(req);
683
+ for (const k of Object.keys(body))
684
+ if (k !== "never")
685
+ throw new AgentError(`${k}: unknown field (keep takes never)`, {
686
+ usage: true,
687
+ });
688
+ if (body.never !== undefined && typeof body.never !== "boolean")
689
+ throw new AgentError("never must be true or false", { usage: true });
690
+ const rec = keepAgentConversation(d, id!, ws, {
691
+ never: body.never === true,
692
+ });
693
+ what = `keep ${rec.id}'s conversation in ${ws}${body.never ? " (never rotate)" : ""}`;
694
+ audit(what);
695
+ sendJson(res, { ok: true, agent: rec.id, never: body.never === true });
696
+ } else if (verb === "handoff") {
697
+ const body = await jsonBody(req);
698
+ for (const k of Object.keys(body))
699
+ if (k !== "relaunch")
700
+ throw new AgentError(`${k}: unknown field (handoff takes relaunch)`, {
701
+ usage: true,
702
+ });
703
+ const relaunch = body.relaunch === true;
704
+ const rec = await askForHandoff(d, id!, ws, undefined, { relaunch });
705
+ what = `ask ${rec.id} for its handoff in ${ws}${relaunch ? ", then relaunch" : ""}${rec.resumed ? " (resumed to write it)" : ""}`;
706
+ audit(what);
707
+ sendJson(res, {
708
+ ok: true,
709
+ agent: rec.id,
710
+ relaunch,
711
+ ...(rec.resumed ? { resumed: true } : {}),
712
+ });
654
713
  } else if (verb === "relaunch") {
655
714
  const s = await relaunchAgent(d, id!, ws);
656
715
  what = `relaunch ${s.rec.id} from its handoff (${s.why}) in ${ws}`;
@@ -732,9 +791,16 @@ export async function handleAgentRoutes(
732
791
  audit(what);
733
792
  sendJson(res, { ok: true, removed });
734
793
  } else {
735
- const s = await resumeAgent(d, id!, ws);
736
- audit(what);
737
- sendStarted(res, s, d);
794
+ // D28: a due agent under auto relaunches from its handoff instead.
795
+ const r = await resumeOrRelaunch(d, id!, ws);
796
+ if (r.kind === "relaunched") {
797
+ what = `relaunch ${r.s.rec.id} from its handoff (${r.s.why}) in ${ws}, on Resume`;
798
+ audit(what, r.s.early ? "exited at once" : undefined);
799
+ sendStarted(res, r.s, d, { relaunched: true });
800
+ } else {
801
+ audit(what);
802
+ sendStarted(res, r.s, d);
803
+ }
738
804
  }
739
805
  } catch (e) {
740
806
  audit(what, e instanceof Error ? e.message : String(e));
@@ -1209,8 +1275,86 @@ export function startAgentUpkeep(
1209
1275
  log(` ⚠ agents.remove_stopped_after: ${(e as Error).message}`);
1210
1276
  }
1211
1277
  };
1278
+ // D28: due agents under on_due auto, at idle: asked for the handoff,
1279
+ // then relaunched from it. One look at a time.
1280
+ let looking = false;
1281
+ const rotate = async () => {
1282
+ if (looking) return;
1283
+ looking = true;
1284
+ try {
1285
+ for (const e of await rotationTick(d)) {
1286
+ const who = `${e.agent} in ${e.workspace}`;
1287
+ if (e.kind === "relaunched") {
1288
+ const what = `relaunch ${who} from its handoff (${e.why}), by itself; the old conversation ${e.old ?? "?"} is kept`;
1289
+ recordAction(null, { verb: "agents", detail: what });
1290
+ log(`Relaunched ${who} from its handoff after ${e.why}`);
1291
+ } else if (e.kind === "asked")
1292
+ log(`Asked ${who} for its handoff (${e.asked} of 2)`);
1293
+ else if (e.kind === "fell-back")
1294
+ log(
1295
+ ` ⚠ ${who} wrote no handoff after 2 asks: its callout asks the person`,
1296
+ );
1297
+ else log(` ⚠ rotation of ${who}: ${e.why}`);
1298
+ }
1299
+ } catch (e) {
1300
+ log(` ⚠ agents.rotate: ${(e as Error).message}`);
1301
+ } finally {
1302
+ looking = false;
1303
+ }
1304
+ };
1212
1305
  setTimeout(() => {
1213
1306
  void restart().then(prune);
1214
1307
  }, deps.delayMs ?? 2000).unref();
1215
1308
  setInterval(() => void prune(), PRUNE_EVERY_MS).unref();
1309
+ setInterval(() => void rotate(), ROTATION_TICK_MS).unref();
1310
+ }
1311
+
1312
+ /**
1313
+ * D28, `/api/agents/rotation`. GET: the settings (install, overrides,
1314
+ * defaults) and, with `?ws=&agent=`, that agent's effective values and
1315
+ * where each comes from. PUT `{ scope, target?, key, value }` sets one
1316
+ * (value null clears it): a person's only.
1317
+ */
1318
+ async function rotationRoute(
1319
+ req: http.IncomingMessage,
1320
+ res: http.ServerResponse,
1321
+ d: AgentsResolved,
1322
+ ): Promise<boolean> {
1323
+ const method = req.method ?? "GET";
1324
+ try {
1325
+ if (method === "GET" || method === "HEAD") {
1326
+ const q = new URL(req.url ?? "/", "http://localhost").searchParams;
1327
+ const ws = q.get("ws") ?? undefined;
1328
+ const agent = q.get("agent") ?? undefined;
1329
+ if ((ws && !NAME_RE.test(ws)) || (agent && !NAME_RE.test(agent)))
1330
+ throw new AgentError("not a name", { usage: true });
1331
+ sendJson(res, rotationSettings(d, agent, ws));
1332
+ return true;
1333
+ }
1334
+ if (method !== "PUT") return methodNotAllowed(res, "GET, PUT");
1335
+ if (agentPrincipalOf(req) || auditActorOf(req))
1336
+ throw new AgentError("only a person can change rotation settings", {
1337
+ status: 403,
1338
+ });
1339
+ const body = await jsonBody(req);
1340
+ for (const k of Object.keys(body))
1341
+ if (!["scope", "target", "key", "value"].includes(k))
1342
+ throw new AgentError(
1343
+ `${k}: unknown field (takes scope, target, key, value)`,
1344
+ { usage: true },
1345
+ );
1346
+ const out = setRotationSetting(d, body);
1347
+ recordAction(
1348
+ null,
1349
+ {
1350
+ verb: "agents",
1351
+ detail: `rotation ${String(body.scope)}${body.target ? ` ${String(body.target)}` : ""} ${String(body.key)} = ${body.value === null ? "(unset)" : JSON.stringify(body.value)}`,
1352
+ },
1353
+ principalOf(req),
1354
+ );
1355
+ sendJson(res, { ok: true, ...out });
1356
+ } catch (e) {
1357
+ sendError(res, e);
1358
+ }
1359
+ return true;
1216
1360
  }
@@ -868,7 +868,12 @@ import {
868
868
  import { heldActions } from "./held-actions.js";
869
869
  import type { ApprovalQueue } from "./approvals.js";
870
870
  import { processPathRules } from "../cli/onboard-reads.js";
871
- import { handleAgentRoutes, startAgentUpkeep } from "./agent-routes.js";
871
+ import {
872
+ handleAgentRoutes,
873
+ startAgentUpkeep,
874
+ webDeps as agentWebDeps,
875
+ } from "./agent-routes.js";
876
+ import { policyResolver } from "../cli/agent-ops.js";
872
877
  import { handleServerAgentRoutes } from "./server-agent-routes.js";
873
878
  import { freshDen } from "../cli/server-agent-ops.js";
874
879
  import {
@@ -918,6 +923,7 @@ import {
918
923
 
919
924
  import { product } from "../../shared/product.js";
920
925
  import { readBrand } from "../../shared/brand.js";
926
+ import { launchdSession } from "../../shared/launchd-session.js";
921
927
  /** config `web.port` (user > team), falling back to the built-in default. */
922
928
  function readWebPortSafe(): number {
923
929
  try {
@@ -4422,6 +4428,9 @@ async function handleRequest(
4422
4428
  // another distribution's server (see cli/server-identity.ts).
4423
4429
  root: ROOT,
4424
4430
  product: product().name,
4431
+ // D20: the launchd session it runs in (Aqua ends at logout); doctor
4432
+ // shows it. null off macOS.
4433
+ launchd: launchdSession(),
4425
4434
  startedAt: new Date(SERVER_STARTED_AT).toISOString(),
4426
4435
  uptimeMs: Date.now() - SERVER_STARTED_AT,
4427
4436
  stale,
@@ -4744,7 +4753,10 @@ async function handleRequest(
4744
4753
  res,
4745
4754
  pathname,
4746
4755
  guard.ok && (!guard.beyond || !!guard.session || !!guard.principal),
4747
- { waitingAgents: waitingAgentIds },
4756
+ {
4757
+ waitingAgents: waitingAgentIds,
4758
+ rotationPolicy: () => policyResolver(agentWebDeps()),
4759
+ },
4748
4760
  )
4749
4761
  )
4750
4762
  return;
@@ -5575,9 +5587,9 @@ server.on("error", (err: NodeJS.ErrnoException) => {
5575
5587
  if (err.code === "EADDRINUSE") {
5576
5588
  console.error(
5577
5589
  `\n ✗ Port ${PORT} is already in use.\n` +
5578
- `\n Another ${product().cli} instance may be running. To fix:\n` +
5579
- `\n lsof -ti :${PORT} | xargs kill # kill whatever is on the port` +
5580
- `\n make web # then restart\n`,
5590
+ `\n Another ${product().cli} server may be running, or its service holds the port.` +
5591
+ `\n ${product().cli} service status # is the service running it?` +
5592
+ `\n ${product().cli} web restart # restarts the service, or the plain server\n`,
5581
5593
  );
5582
5594
  process.exit(1);
5583
5595
  }