@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.
@@ -70,6 +70,10 @@ import { HostError } from "../../shared/sessions/client.js";
70
70
  import { checkHandback } from "../../shared/agents/handback.js";
71
71
  import { checkHandoff } from "../../shared/agents/handoff.js";
72
72
  import { handoffFile, reasonText } from "../../shared/agents/rotation.js";
73
+ import {
74
+ parseRotateValue,
75
+ type OverrideScope,
76
+ } from "../../shared/agents/rotate-settings.js";
73
77
  import {
74
78
  agentFilesDir,
75
79
  listRecords,
@@ -85,11 +89,15 @@ import {
85
89
  listRuntimes,
86
90
  loadAgentWorkspaces,
87
91
  relaunchAgent,
92
+ resumeOrRelaunch,
93
+ keepAgentConversation,
94
+ askForHandoff,
95
+ rotationSettings,
96
+ setRotationSetting,
88
97
  removeAgent,
89
98
  removeStopped,
90
99
  renameAgent,
91
100
  resolveAgentsDeps,
92
- resumeAgent,
93
101
  resumeInterrupted,
94
102
  startAgent,
95
103
  stopAgent,
@@ -121,7 +129,7 @@ export {
121
129
 
122
130
  export const AGENTS_HELP = {
123
131
  usage:
124
- "agents <list|start|stop|resume|relaunch|import|persona|rename|rm|personas|doctor|handback-check|handoff-check> ...",
132
+ "agents <list|start|stop|resume|relaunch|rotate|keep|handoff|import|persona|rename|rm|personas|doctor|handback-check|handoff-check> ...",
125
133
  lines: [
126
134
  "Agents: a persona and a workspace, run by a runtime in a session that",
127
135
  "outlives the web server (see `{cli} sessions`).",
@@ -146,6 +154,19 @@ export const AGENTS_HELP = {
146
154
  " compactions (default 3) or its pull request merged, and its handoff.md",
147
155
  " passes the check, a fresh conversation (same persona, workspace, folder,",
148
156
  " grants) whose first prompt is the handoff",
157
+ "{cli} agents rotate [--agent|--persona|--workspace|--service <x>] [-w <ws>] <key> <value>",
158
+ "{cli} agents rotate [scope] --unset <key> · {cli} agents rotate · {cli} agents rotate <id> --show",
159
+ " rotation settings: after_compactions <n>|never (default 3), on_due",
160
+ " auto|ask|off (default auto: Den asks for the handoff at idle and",
161
+ " relaunches by itself), on_pr_merged true|false. No scope: this install.",
162
+ " --persona <p> -w <ws>: the persona in that workspace. The most specific",
163
+ " wins: agent, persona in a workspace, persona, workspace, service,",
164
+ " install, org config. --show: an agent's values and where each comes from",
165
+ "{cli} agents keep <id> [-w <ws>] [--never]",
166
+ " keep its conversation: the compaction count starts again; --never: this",
167
+ " agent never rotates. A person's only",
168
+ "{cli} agents handoff <id> [-w <ws>]",
169
+ " ask a running agent, at idle, to write its handoff",
149
170
  "{cli} agents import -w <ws> [--runtime <r>] [--json]",
150
171
  "{cli} agents import -w <ws> --session <id> --persona <p> [--runtime <r>] [--id <id>] [--model <m>]",
151
172
  " bring in a conversation started outside Den: the first lists the runtime's",
@@ -477,7 +498,15 @@ async function stop(args: string[], d: AgentsResolved): Promise<number> {
477
498
  async function resume(args: string[], d: AgentsResolved): Promise<number> {
478
499
  if (args.includes("--all-interrupted")) return resumeAll(args, d);
479
500
  const { id, ws } = idAndWorkspace("agents resume", args);
480
- const s = await resumeAgent(d, id, ws);
501
+ // D28: a due agent under auto relaunches from its handoff instead.
502
+ const r = await resumeOrRelaunch(d, id, ws);
503
+ if (r.kind === "relaunched")
504
+ return report(
505
+ d,
506
+ r.s,
507
+ `relaunched agent ${r.s.rec.id} from its handoff (the last session ended after ${r.s.why}) as ${r.s.rec.sessionName} in ${r.s.rec.cwd}`,
508
+ );
509
+ const s = r.s;
481
510
  if (s.fresh) d.out(`agent ${s.rec.id}: ${FRESH_NOTE}`);
482
511
  return report(
483
512
  d,
@@ -487,6 +516,135 @@ async function resume(args: string[], d: AgentsResolved): Promise<number> {
487
516
  }
488
517
 
489
518
  /** `agents relaunch <id> [-w <ws>]` (den#4). */
519
+ /** D28: refused in an agent's session (an agent could stop its own rotation). */
520
+ function personOnly(where: string, d: AgentsResolved): void {
521
+ if (d.env[ACTOR_TOKEN_ENV] !== undefined)
522
+ throw new AgentError(
523
+ `${where}: only a person can change rotation (this is an agent's session)`,
524
+ );
525
+ }
526
+
527
+ const ROTATE_SCOPE_FLAGS: Record<string, OverrideScope> = {
528
+ "--agent": "agent",
529
+ "--persona": "persona",
530
+ "--workspace": "workspace",
531
+ "--service": "service",
532
+ };
533
+
534
+ /**
535
+ * `agents rotate` (D28): with no arguments, the settings and overrides;
536
+ * `[--agent|--persona|--workspace|--service <x>] [-w <ws>] <key> <value>`
537
+ * sets one (no scope flag: this install's), `--unset` clears it. `--persona
538
+ * <p> -w <ws>`: the persona in that workspace. `agents rotate <id> --show`:
539
+ * that agent's effective values and where each comes from.
540
+ */
541
+ async function rotate(args: string[], d: AgentsResolved): Promise<number> {
542
+ const where = "agents rotate";
543
+ if (args.includes("--show")) {
544
+ const { id, ws } = idAndWorkspace(
545
+ where,
546
+ args.filter((a) => a !== "--show"),
547
+ );
548
+ const r = rotationSettings(d, id, ws);
549
+ if (!r.effective) {
550
+ d.err(`✗ ${where}: ${id}'s persona or the config can't be read`);
551
+ return 1;
552
+ }
553
+ const e = r.effective;
554
+ d.out(`agent ${e.agent}:`);
555
+ d.out(
556
+ ` after_compactions ${e.afterCompactions === 0 ? "never" : e.afterCompactions} (from ${e.from?.after_compactions})`,
557
+ );
558
+ d.out(` on_due ${e.onDue} (from ${e.from?.on_due})`);
559
+ d.out(
560
+ ` on_pr_merged ${e.onPrMerged} (from ${e.from?.on_pr_merged})`,
561
+ );
562
+ return 0;
563
+ }
564
+ if (!args.length) {
565
+ const r = rotationSettings(d);
566
+ const vals = (v: object) =>
567
+ Object.entries(v)
568
+ .map(([k, x]) => `${k}=${String(x)}`)
569
+ .join(" ") || "(none)";
570
+ d.out(`default: ${vals(r.defaults)}`);
571
+ d.out(`org config: ${vals(r.org)}`);
572
+ d.out(`this install: ${vals(r.install)}`);
573
+ for (const [scope, byTarget] of Object.entries(r.overrides))
574
+ for (const [target, v] of Object.entries(byTarget ?? {}))
575
+ d.out(`${scope} ${target}: ${vals(v)}`);
576
+ return 0;
577
+ }
578
+ personOnly(where, d);
579
+ const unset = args.includes("--unset");
580
+ const rest = args.filter((a) => a !== "--unset");
581
+ let scope: OverrideScope | "install" = "install";
582
+ let target: string | undefined;
583
+ let ws: string | undefined;
584
+ const pos: string[] = [];
585
+ for (let i = 0; i < rest.length; i++) {
586
+ const a = rest[i];
587
+ if (a === "-w" || a === "--ws") {
588
+ ws = rest[++i];
589
+ if (!ws) throw new UsageError(`${where}: ${a} needs a workspace`);
590
+ } else if (a in ROTATE_SCOPE_FLAGS) {
591
+ if (scope !== "install")
592
+ throw new UsageError(`${where}: one scope at a time`);
593
+ scope = ROTATE_SCOPE_FLAGS[a];
594
+ target = rest[++i];
595
+ if (!target) throw new UsageError(`${where}: ${a} needs a name`);
596
+ } else if (a.startsWith("--"))
597
+ throw new UsageError(`${where}: unknown flag ${a}`);
598
+ else pos.push(a);
599
+ }
600
+ if (ws) {
601
+ if (scope !== "persona")
602
+ throw new UsageError(
603
+ `${where}: -w goes with --persona (the persona in that workspace); use --workspace <ws> for a workspace`,
604
+ );
605
+ scope = "persona-workspace";
606
+ target = `${target}@${ws}`;
607
+ }
608
+ const [key, raw] = pos;
609
+ if (!key || (unset ? pos.length !== 1 : pos.length !== 2))
610
+ throw new UsageError(
611
+ `${where} [--agent|--persona|--workspace|--service <x>] <key> <value> (or --unset <key>)`,
612
+ );
613
+ setRotationSetting(d, {
614
+ scope,
615
+ ...(target !== undefined ? { target } : {}),
616
+ key,
617
+ value: unset ? null : parseRotateValue(key, raw!),
618
+ });
619
+ const at = scope === "install" ? "this install" : `${scope} ${target}`;
620
+ d.out(unset ? `${key} cleared at ${at}` : `${key} = ${raw} at ${at}`);
621
+ return 0;
622
+ }
623
+
624
+ /** `agents keep <id> [-w <ws>] [--never]` (D28 Keep this conversation). */
625
+ function keep(args: string[], d: AgentsResolved): number {
626
+ const where = "agents keep";
627
+ personOnly(where, d);
628
+ const never = args.includes("--never");
629
+ const { id, ws } = idAndWorkspace(
630
+ where,
631
+ args.filter((a) => a !== "--never"),
632
+ );
633
+ const rec = keepAgentConversation(d, id, ws, { never });
634
+ d.out(
635
+ `kept ${rec.id}'s conversation: its compaction count starts again${never ? "; it never rotates (agents rotate --agent " + rec.id + " --unset after_compactions to undo)" : ""}`,
636
+ );
637
+ return 0;
638
+ }
639
+
640
+ /** `agents handoff <id> [-w <ws>]` (D28 Write handoff). */
641
+ async function handoff(args: string[], d: AgentsResolved): Promise<number> {
642
+ const { id, ws } = idAndWorkspace("agents handoff", args);
643
+ const rec = await askForHandoff(d, id, ws);
644
+ d.out(`asked ${rec.id} to write its handoff; Den relaunches it from it`);
645
+ return 0;
646
+ }
647
+
490
648
  async function relaunch(args: string[], d: AgentsResolved): Promise<number> {
491
649
  const { id, ws } = idAndWorkspace("agents relaunch", args);
492
650
  const s = await relaunchAgent(d, id, ws);
@@ -790,6 +948,14 @@ export async function cmdAgents(
790
948
  case "relaunch":
791
949
  await loadAgentWorkspaces(deps);
792
950
  return await relaunch(rest, d);
951
+ case "rotate":
952
+ await loadAgentWorkspaces(deps);
953
+ return await rotate(rest, d);
954
+ case "keep":
955
+ return keep(rest, d);
956
+ case "handoff":
957
+ await loadAgentWorkspaces(deps);
958
+ return await handoff(rest, d);
793
959
  case "import":
794
960
  await loadAgentWorkspaces(deps);
795
961
  return await bringIn(rest, d);
@@ -25,9 +25,9 @@ import {
25
25
  type AgentsResolved,
26
26
  type RuntimeChoice,
27
27
  type Started,
28
+ rotationOf,
28
29
  } from "./agent-ops.js";
29
30
  import { readRecord } from "../../shared/agents/records.js";
30
- import { rotationInfo } from "../../shared/agents/rotation.js";
31
31
  import {
32
32
  SERVER_AGENT_ID,
33
33
  SERVER_PERSONA_ID,
@@ -355,7 +355,7 @@ export async function startServerAgent(
355
355
  rec.runtime === runtime &&
356
356
  rec.model === model
357
357
  ) {
358
- if (rotationInfo(d.stateDir(), rec)?.ready)
358
+ if (rotationOf(d, rec)?.ready)
359
359
  return done("relaunched", await relaunchAgent(d, rec.id));
360
360
  if (resumeRefusal(rec) === null)
361
361
  return done("resumed", await resumeAgent(d, rec.id, SERVER_SCOPE));
@@ -70,6 +70,7 @@ import {
70
70
  } from "./server-identity.js";
71
71
 
72
72
  import { product } from "../../shared/product.js";
73
+ import { webServiceAction, type WebServiceDeps } from "./web-service.js";
73
74
  import {
74
75
  CLAIM_CODE_TTL_MS,
75
76
  claimLink,
@@ -1265,6 +1266,12 @@ export function cmdWebToken(
1265
1266
  return 0;
1266
1267
  }
1267
1268
 
1269
+ /** Tests: the launchd service seams (web-service.ts); never the real launchctl. */
1270
+ let webServiceDeps: WebServiceDeps | undefined;
1271
+ export function setWebServiceDepsForTest(d: WebServiceDeps | undefined): void {
1272
+ webServiceDeps = d;
1273
+ }
1274
+
1268
1275
  export async function cmdWeb(args: string[]): Promise<void> {
1269
1276
  // A subcommand with its own help answers its own --help (it is
1270
1277
  // dispatched first); the rest (up, down, restart, open) share this one.
@@ -1314,7 +1321,26 @@ export async function cmdWeb(args: string[]): Promise<void> {
1314
1321
  const identity = await refuseForeignServer(options.port, ownServer());
1315
1322
  const vouched = identity.kind === "ours" ? identity.pid : undefined;
1316
1323
 
1317
- if (options.action === "down") {
1324
+ // With the launchd service installed, it owns the server: restart and up
1325
+ // go through launchd, down is refused (launchd would start it again).
1326
+ const svc = await webServiceAction(
1327
+ options.action,
1328
+ options.port,
1329
+ identity.kind === "ours",
1330
+ (p) => waitForDenWeb(p),
1331
+ webServiceDeps,
1332
+ );
1333
+ if (svc.handled) {
1334
+ if (svc.code !== 0) {
1335
+ process.exitCode = svc.code;
1336
+ return;
1337
+ }
1338
+ recordRunningDenWebServer(options.port);
1339
+ }
1340
+
1341
+ if (svc.handled) {
1342
+ /* the service runs it: on to opening the browser */
1343
+ } else if (options.action === "down") {
1318
1344
  const stopped = await stopDenWebServer(options.port, vouched);
1319
1345
  if (stopped.stopped) {
1320
1346
  const mode = stopped.forced ? "force stopped" : "stopped";
@@ -1343,7 +1369,7 @@ export async function cmdWeb(args: string[]): Promise<void> {
1343
1369
  if (line) console.log(chalk.dim(`\n ${line}`));
1344
1370
  }
1345
1371
 
1346
- if (options.action === "restart") {
1372
+ if (options.action === "restart" && !svc.handled) {
1347
1373
  const stopped = await stopDenWebServer(options.port, vouched);
1348
1374
  if (stopped.pids.length > 0 && !stopped.stopped) {
1349
1375
  throw new Error(
@@ -1369,8 +1395,9 @@ export async function cmdWeb(args: string[]): Promise<void> {
1369
1395
  );
1370
1396
  if (ignored) console.log(chalk.yellow(`\n ⚠ ${ignored}`));
1371
1397
 
1372
- const apiRunning = await isDenWebApiRunning(options.port);
1373
- let running = apiRunning && (await isDenWebUiReady(options.port));
1398
+ const apiRunning = svc.handled || (await isDenWebApiRunning(options.port));
1399
+ let running =
1400
+ svc.handled || (apiRunning && (await isDenWebUiReady(options.port)));
1374
1401
  let started: StartedServer | null = null;
1375
1402
 
1376
1403
  if (apiRunning && !running) {
@@ -1415,7 +1442,7 @@ export async function cmdWeb(args: string[]): Promise<void> {
1415
1442
  console.log(chalk.red(` ERROR Failed to start ${product().cli} web`));
1416
1443
  throw error;
1417
1444
  }
1418
- } else {
1445
+ } else if (!svc.handled) {
1419
1446
  recordRunningDenWebServer(options.port);
1420
1447
  console.log(
1421
1448
  chalk.green(`\n OK ${product().title} web is already running at ${url}`),
@@ -0,0 +1,160 @@
1
+ /**
2
+ * `web up | down | restart` when this install's web server runs as a
3
+ * launchd service (`<cli> service install`).
4
+ *
5
+ * Before, `web restart` stopped the job's server and started a plain one.
6
+ * launchd brought the job straight back (KeepAlive), it took the port, and
7
+ * the plain server died seconds after "OK ... started". Now, with the
8
+ * service installed and the command on its port:
9
+ *
10
+ * - restart: `launchctl kickstart -k`, then the job's new pid and the
11
+ * service's log;
12
+ * - up: a job that is not answering is started the same way;
13
+ * - down: refused, because launchd would only start it again; it names
14
+ * `<cli> service uninstall`.
15
+ *
16
+ * In always mode the job is in the system domain: kickstart there needs
17
+ * sudo, so the command is printed for the person to run, never run here.
18
+ */
19
+ import { spawnSync } from "child_process";
20
+ import * as os from "os";
21
+ import * as path from "path";
22
+ import {
23
+ readDenConfig,
24
+ readWebServiceConfig,
25
+ } from "../../shared/den-config.js";
26
+ import { readWebPort } from "../../shared/edge/config.js";
27
+ import { getDistRoot } from "../../shared/paths.js";
28
+ import { product } from "../../shared/product.js";
29
+ import { serviceJobState, type ServiceJob } from "./doctor-where.js";
30
+
31
+ export interface WebService {
32
+ label: string;
33
+ /** The service's web.log. */
34
+ log: string;
35
+ job: ServiceJob;
36
+ }
37
+
38
+ export interface WebServiceDeps {
39
+ /** The installed service for this install's port, or null. */
40
+ service?: (port: number) => WebService | null;
41
+ /** launchctl kickstart [-k] <domain>/<label>; its exit code. */
42
+ kickstart?: (target: string, kill: boolean) => number;
43
+ /** The pid launchd runs the job as, or null. */
44
+ jobPid?: (svc: WebService) => number | null;
45
+ /** Until the job's server answers as this install's (true) or time runs out. */
46
+ waitReady?: (port: number) => Promise<boolean>;
47
+ log?: (line: string) => void;
48
+ /** Between pid checks while the job comes back (default 250 ms). */
49
+ pollMs?: number;
50
+ /** How long to wait for the job's new pid (default 30 s). */
51
+ pidTimeoutMs?: number;
52
+ }
53
+
54
+ export type WebServiceResult =
55
+ | { handled: false }
56
+ | { handled: true; code: number; pid?: number };
57
+
58
+ function defaultService(port: number): WebService | null {
59
+ if (process.platform !== "darwin") return null;
60
+ const distRoot = getDistRoot();
61
+ // The service serves this install's configured port only.
62
+ if (port !== readWebPort(distRoot)) return null;
63
+ const { label, logs } = readWebServiceConfig(
64
+ readDenConfig(),
65
+ distRoot,
66
+ os.homedir(),
67
+ );
68
+ const job = serviceJobState(label, os.homedir(), process.getuid?.() ?? 0);
69
+ if (!job || job.state !== "loaded") return null;
70
+ return { label, log: path.join(logs, "web.log"), job };
71
+ }
72
+
73
+ function defaultKickstart(target: string, kill: boolean): number {
74
+ return (
75
+ spawnSync("launchctl", ["kickstart", ...(kill ? ["-k"] : []), target], {
76
+ stdio: "ignore",
77
+ timeout: 10_000,
78
+ }).status ?? 1
79
+ );
80
+ }
81
+
82
+ function defaultJobPid(svc: WebService): number | null {
83
+ const r = spawnSync(
84
+ "launchctl",
85
+ ["print", `${svc.job.domain}/${svc.label}`],
86
+ { encoding: "utf-8", timeout: 3000 },
87
+ );
88
+ if (r.status !== 0) return null;
89
+ const m = /^\s*pid = (\d+)/m.exec(r.stdout ?? "");
90
+ return m ? Number(m[1]) : null;
91
+ }
92
+
93
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
94
+
95
+ /**
96
+ * What `web <action>` does when the service is installed. `answering`:
97
+ * this install's server already answers on the port. Not handled: no
98
+ * service (or another port), and `up` while it answers.
99
+ */
100
+ export async function webServiceAction(
101
+ action: "up" | "down" | "restart" | "open",
102
+ port: number,
103
+ answering: boolean,
104
+ waitReadyDefault: (port: number) => Promise<boolean>,
105
+ deps: WebServiceDeps = {},
106
+ ): Promise<WebServiceResult> {
107
+ if (action === "open") return { handled: false };
108
+ const svc = (deps.service ?? defaultService)(port);
109
+ if (!svc) return { handled: false };
110
+ if (action === "up" && answering) return { handled: false };
111
+ const log = deps.log ?? ((l: string) => console.log(l));
112
+ const { cli, title } = product();
113
+ const target = `${svc.job.domain}/${svc.label}`;
114
+
115
+ if (action === "down") {
116
+ log(
117
+ `✗ ${title} web runs as the service ${svc.label} (${svc.job.mode}): stopping it only makes launchd start it again.`,
118
+ );
119
+ log(` To stop it for good: ${cli} service uninstall`);
120
+ log(` To restart it: ${cli} web restart`);
121
+ return { handled: true, code: 1 };
122
+ }
123
+
124
+ if (svc.job.mode === "always") {
125
+ log(
126
+ `${title} web runs as the service ${svc.label} (always). Restarting it needs sudo:`,
127
+ );
128
+ log(` sudo launchctl kickstart -k ${target}`);
129
+ return { handled: true, code: 1 };
130
+ }
131
+
132
+ const jobPid = deps.jobPid ?? defaultJobPid;
133
+ const before = jobPid(svc);
134
+ const code = (deps.kickstart ?? defaultKickstart)(target, true);
135
+ if (code !== 0) {
136
+ log(`✗ launchctl kickstart -k ${target} failed (exit ${code})`);
137
+ log(` See ${svc.log}`);
138
+ return { handled: true, code: 1 };
139
+ }
140
+ // A new pid, then the server answering.
141
+ const deadline = Date.now() + (deps.pidTimeoutMs ?? 30_000);
142
+ let pid = jobPid(svc);
143
+ while ((pid === null || pid === before) && Date.now() < deadline) {
144
+ await sleep(deps.pollMs ?? 250);
145
+ pid = jobPid(svc);
146
+ }
147
+ const ready =
148
+ pid !== null &&
149
+ pid !== before &&
150
+ (await (deps.waitReady ?? waitReadyDefault)(port));
151
+ if (!ready) {
152
+ log(`✗ the service ${svc.label} did not come back. See ${svc.log}`);
153
+ return { handled: true, code: 1 };
154
+ }
155
+ log(
156
+ `OK ${title} web ${action === "up" ? "started" : "restarted"} by its service ${svc.label} (pid ${pid})`,
157
+ );
158
+ log(` Log: ${svc.log}`);
159
+ return { handled: true, code: 0, pid: pid! };
160
+ }