@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
@@ -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,10 +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,
99
+ renameAgent,
90
100
  resolveAgentsDeps,
91
- resumeAgent,
92
101
  resumeInterrupted,
93
102
  startAgent,
94
103
  stopAgent,
@@ -99,6 +108,7 @@ import {
99
108
  type RuntimeChoice,
100
109
  type Started,
101
110
  } from "./agent-ops.js";
111
+ import { peerSessionName } from "../../shared/agents/session-name.js";
102
112
  import {
103
113
  columns,
104
114
  guarded,
@@ -119,7 +129,7 @@ export {
119
129
 
120
130
  export const AGENTS_HELP = {
121
131
  usage:
122
- "agents <list|start|stop|resume|relaunch|import|persona|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> ...",
123
133
  lines: [
124
134
  "Agents: a persona and a workspace, run by a runtime in a session that",
125
135
  "outlives the web server (see `{cli} sessions`).",
@@ -144,6 +154,19 @@ export const AGENTS_HELP = {
144
154
  " compactions (default 3) or its pull request merged, and its handoff.md",
145
155
  " passes the check, a fresh conversation (same persona, workspace, folder,",
146
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",
147
170
  "{cli} agents import -w <ws> [--runtime <r>] [--json]",
148
171
  "{cli} agents import -w <ws> --session <id> --persona <p> [--runtime <r>] [--id <id>] [--model <m>]",
149
172
  " bring in a conversation started outside Den: the first lists the runtime's",
@@ -156,6 +179,11 @@ export const AGENTS_HELP = {
156
179
  " persona's role, permissions and commands; id, name, folder and model stay.",
157
180
  " Mid-turn it asks: --after-turn waits for the turn to end, --now stops it.",
158
181
  " A person's only (refused in an agent's session)",
182
+ "{cli} agents rename <id> <name> [-w <ws>]",
183
+ "{cli} agents rename <id> --clear [-w <ws>]",
184
+ " name an agent (its tab, row and pane header); the id never changes.",
185
+ " Other sessions see it as {cli}-<ws>:<name> from its next start or resume.",
186
+ " --clear: back to its id",
159
187
  "{cli} agents rm <id> [-w <ws>]",
160
188
  "{cli} agents rm --stopped [-w <ws>]",
161
189
  " forget a stopped, interrupted or exited agent (never a running one);",
@@ -470,7 +498,15 @@ async function stop(args: string[], d: AgentsResolved): Promise<number> {
470
498
  async function resume(args: string[], d: AgentsResolved): Promise<number> {
471
499
  if (args.includes("--all-interrupted")) return resumeAll(args, d);
472
500
  const { id, ws } = idAndWorkspace("agents resume", args);
473
- 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;
474
510
  if (s.fresh) d.out(`agent ${s.rec.id}: ${FRESH_NOTE}`);
475
511
  return report(
476
512
  d,
@@ -480,6 +516,135 @@ async function resume(args: string[], d: AgentsResolved): Promise<number> {
480
516
  }
481
517
 
482
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
+
483
648
  async function relaunch(args: string[], d: AgentsResolved): Promise<number> {
484
649
  const { id, ws } = idAndWorkspace("agents relaunch", args);
485
650
  const s = await relaunchAgent(d, id, ws);
@@ -513,6 +678,28 @@ async function resumeAll(args: string[], d: AgentsResolved): Promise<number> {
513
678
  }
514
679
 
515
680
  /** `agents rm <id> [-w <ws>]`, `agents rm --stopped [-w <ws>]` (AG-14.1b). */
681
+ /** D25: `agents rename <id> <name>|--clear [-w <ws>]`. */
682
+ function rename(args: string[], d: AgentsResolved): number {
683
+ const where = "agents rename";
684
+ const p = parseArgs(where, args, { "--workspace": true, "--clear": false });
685
+ const [id, ...words] = p.positional;
686
+ if (!id) throw new UsageError(`${where} needs <id> and a name, or --clear`);
687
+ const name = words.join(" ").trim();
688
+ if (p.flags["--clear"] && name)
689
+ throw new UsageError(`${where}: give a name or --clear, not both`);
690
+ if (!p.flags["--clear"] && !name)
691
+ throw new UsageError(`${where} needs a name (or --clear)`);
692
+ const ws =
693
+ workspaceFlag(where, p) ?? readRecord(d.stateDir(), id)?.workspace ?? "";
694
+ const rec = renameAgent(d, id, ws, p.flags["--clear"] ? null : name);
695
+ d.out(
696
+ rec.displayName
697
+ ? `renamed agent ${id} to "${rec.displayName}"; others see it as ${peerSessionName(d.cli, rec.workspace, rec)} from its next start or resume`
698
+ : `agent ${id} has no name now; others see it as ${peerSessionName(d.cli, rec.workspace, rec)} from its next start or resume`,
699
+ );
700
+ return 0;
701
+ }
702
+
516
703
  async function rm(args: string[], d: AgentsResolved): Promise<number> {
517
704
  const where = "agents rm";
518
705
  const p = parseArgs(where, args, { "--workspace": true, "--stopped": false });
@@ -761,12 +948,22 @@ export async function cmdAgents(
761
948
  case "relaunch":
762
949
  await loadAgentWorkspaces(deps);
763
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);
764
959
  case "import":
765
960
  await loadAgentWorkspaces(deps);
766
961
  return await bringIn(rest, d);
767
962
  case "persona":
768
963
  await loadAgentWorkspaces(deps);
769
964
  return await persona(rest, d);
965
+ case "rename":
966
+ return rename(rest, d);
770
967
  case "rm":
771
968
  await loadAgentWorkspaces(deps);
772
969
  return await rm(rest, d);
@@ -67,6 +67,13 @@ import {
67
67
  } from "./doctor-onboard.js";
68
68
  import { printJson } from "./json-out.js";
69
69
  import { ownerCheck, ownerLine } from "./doctor-owner.js";
70
+ import {
71
+ formatWhere,
72
+ serviceJobState,
73
+ whereRows,
74
+ type WhereDeps,
75
+ type WhereRow,
76
+ } from "./doctor-where.js";
70
77
 
71
78
  export interface DoctorRow {
72
79
  kind: string;
@@ -387,6 +394,62 @@ export async function doctorJson(
387
394
  return { ok: !checks.some((c) => c.status === "fail"), checks };
388
395
  }
389
396
 
397
+ /** D18/D20: the real probes for doctor's "where it runs" rows. */
398
+ export async function defaultWhereDeps(): Promise<WhereDeps> {
399
+ const os = await import("os");
400
+ const { readDenConfig: cfg, readWebServiceConfig } =
401
+ await import("../../shared/den-config.js");
402
+ const { readWebPort } = await import("../../shared/edge/config.js");
403
+ const { getDistRoot } = await import("../../shared/paths.js");
404
+ const { connectHost } = await import("../../shared/sessions/client.js");
405
+ const { sessionPaths } = await import("../../shared/sessions/paths.js");
406
+ return {
407
+ cli: product().cli,
408
+ platform: process.platform,
409
+ server: async () => {
410
+ const port = readWebPort(getDistRoot());
411
+ const res = await fetch(`http://localhost:${port}/api/server-info`, {
412
+ signal: AbortSignal.timeout(1500),
413
+ });
414
+ if (!res.ok) return null;
415
+ const body = (await res.json()) as { launchd?: string | null };
416
+ return { launchd: body.launchd ?? null };
417
+ },
418
+ host: async () => {
419
+ const c = await connectHost(sessionPaths(getStateDir()).socket);
420
+ try {
421
+ return {
422
+ launchd: c.hostLaunchd,
423
+ access: await c.access().catch(() => null),
424
+ };
425
+ } finally {
426
+ c.close();
427
+ }
428
+ },
429
+ service: () => {
430
+ try {
431
+ const { label } = readWebServiceConfig(
432
+ cfg(),
433
+ getDistRoot(),
434
+ os.homedir(),
435
+ );
436
+ return serviceJobState(label, os.homedir(), process.getuid?.() ?? 0);
437
+ } catch {
438
+ return null;
439
+ }
440
+ },
441
+ };
442
+ }
443
+
444
+ /** A "where it runs" row as a `doctor --json` check. */
445
+ export function whereCheck(r: WhereRow): OnboardCheck {
446
+ return {
447
+ id: `where:${r.what.replace(" ", "-")}`,
448
+ status: r.status,
449
+ detail: `${r.session}${r.detail ? `: ${r.detail}` : ""}`,
450
+ };
451
+ }
452
+
390
453
  export async function cmdDoctor(args: string[]): Promise<number> {
391
454
  const probe = args.includes("--probe");
392
455
  const json = args.includes("--json");
@@ -415,6 +478,9 @@ export async function cmdDoctor(args: string[]): Promise<number> {
415
478
  const r = await doctorJson(
416
479
  defaultOnboardDeps((url) => probeWebServer(url)),
417
480
  );
481
+ r.checks.push(
482
+ ...(await whereRows(await defaultWhereDeps())).map(whereCheck),
483
+ );
418
484
  printJson(r);
419
485
  return r.ok ? 0 : 1;
420
486
  }
@@ -427,6 +493,12 @@ export async function cmdDoctor(args: string[]): Promise<number> {
427
493
  console.log("");
428
494
  console.log(ownerLine(owner));
429
495
  const ownerProblem = owner.status === "fail";
496
+ // D18/D20: where the server and the session host run (logout).
497
+ for (const l of formatWhere(
498
+ await whereRows(await defaultWhereDeps()),
499
+ readBrand().name,
500
+ ))
501
+ console.log(l);
430
502
  for (const l of pollingLines(eventsFile(), readBrand().name)) console.log(l);
431
503
  const cli = product().cli;
432
504
  if (!probe) {
@@ -0,0 +1,203 @@
1
+ /**
2
+ * D18/D20: doctor's "where it runs" rows (macOS only).
3
+ *
4
+ * server the web server's launchd session (/api/server-info)
5
+ * session host the session host's (its hello), where every agent runs
6
+ * full disk access whether the host (and so every agent) has it, and
7
+ * the executable to grant it to: Den's node copy, never the
8
+ * shared node
9
+ * service the installed launchd job: always (a LaunchDaemon, kept
10
+ * across logout) or login (a LaunchAgent in the desktop
11
+ * session), and "plist present, job not loaded" with the
12
+ * bootstrap that loads it
13
+ *
14
+ * A process in the desktop session (Aqua) stops at logout: a warning with
15
+ * the fix. None of these fails doctor.
16
+ */
17
+ import { spawnSync } from "child_process";
18
+ import * as fs from "fs";
19
+ import * as path from "path";
20
+ import {
21
+ ALWAYS_UNPROVEN,
22
+ endsAtLogout,
23
+ sessionWarning,
24
+ } from "../../shared/launchd-session.js";
25
+
26
+ export type ServiceMode = "always" | "login";
27
+
28
+ export interface ServiceJob {
29
+ /** unknown: launchctl could not be asked. */
30
+ state: "loaded" | "not-loaded" | "unknown";
31
+ mode: ServiceMode;
32
+ plist: string;
33
+ domain: string;
34
+ }
35
+
36
+ export interface WhereDeps {
37
+ cli: string;
38
+ platform: NodeJS.Platform;
39
+ /** The web server's launchd session; null when it does not answer. */
40
+ server: () => Promise<{ launchd?: string | null } | null>;
41
+ /** The running session host's; null when none runs. */
42
+ host: () => Promise<{
43
+ launchd: string | null;
44
+ access?: { exe: string; fullDiskAccess: boolean | null } | null;
45
+ } | null>;
46
+ service: () => ServiceJob | null;
47
+ }
48
+
49
+ export interface WhereRow {
50
+ what: "server" | "session host" | "full disk access" | "service";
51
+ session: string;
52
+ status: "ok" | "warn";
53
+ detail?: string;
54
+ }
55
+
56
+ function sessionRow(
57
+ what: "server" | "session host",
58
+ launchd: string | null | undefined,
59
+ cli: string,
60
+ ): WhereRow {
61
+ const session = launchd || "unknown";
62
+ return {
63
+ what,
64
+ session,
65
+ status: endsAtLogout(launchd) ? "warn" : "ok",
66
+ ...(sessionWarning(launchd, cli, what)
67
+ ? { detail: sessionWarning(launchd, cli, what)! }
68
+ : {}),
69
+ };
70
+ }
71
+
72
+ export async function whereRows(d: WhereDeps): Promise<WhereRow[]> {
73
+ if (d.platform !== "darwin") return [];
74
+ const rows: WhereRow[] = [];
75
+ const server = await d.server().catch(() => null);
76
+ rows.push(
77
+ server
78
+ ? sessionRow("server", server.launchd, d.cli)
79
+ : {
80
+ // Not started yet is not a problem (a fresh install).
81
+ what: "server",
82
+ session: "not running",
83
+ status: "ok",
84
+ detail: `start it: ${d.cli} web up, or ${d.cli} service install`,
85
+ },
86
+ );
87
+ const host = await d.host().catch(() => null);
88
+ rows.push(
89
+ host
90
+ ? sessionRow("session host", host.launchd, d.cli)
91
+ : {
92
+ what: "session host",
93
+ session: "not running",
94
+ status: "ok",
95
+ detail: "it starts when the first terminal or agent does",
96
+ },
97
+ );
98
+ const fda = host?.access;
99
+ if (fda && fda.fullDiskAccess !== null) {
100
+ const own = /-sessions-host$/.test(fda.exe);
101
+ rows.push(
102
+ fda.fullDiskAccess
103
+ ? {
104
+ what: "full disk access",
105
+ session: "granted",
106
+ status: "ok",
107
+ detail: `to ${fda.exe}`,
108
+ }
109
+ : {
110
+ what: "full disk access",
111
+ session: "not granted",
112
+ status: "warn",
113
+ detail: own
114
+ ? `agents can't open protected folders (other volumes, Desktop, Documents): System Settings → Privacy & Security → Full Disk Access → + → ${fda.exe}, then ${d.cli} sessions host --stop (it restarts with the grant)`
115
+ : `agents can't open protected folders. The host runs the shared node (${fda.exe}): ${d.cli} service install gives it its own copy to grant, so the grant never goes to every node program`,
116
+ },
117
+ );
118
+ }
119
+ const svc = d.service();
120
+ if (!svc)
121
+ rows.push({
122
+ what: "service",
123
+ session: "not installed",
124
+ status: "ok",
125
+ detail: `${d.cli} service install (login, the default; --mode always is opt-in and ${ALWAYS_UNPROVEN})`,
126
+ });
127
+ else if (svc.state === "not-loaded")
128
+ rows.push({
129
+ what: "service",
130
+ session: `${svc.mode} (${svc.domain})`,
131
+ status: "warn",
132
+ detail: `plist present, job not loaded: ${svc.domain === "system" ? "sudo " : ""}launchctl bootstrap ${svc.domain} ${svc.plist}`,
133
+ });
134
+ else
135
+ rows.push({
136
+ what: "service",
137
+ session: `${svc.mode} (${svc.domain})`,
138
+ status: "ok",
139
+ ...(svc.state === "unknown"
140
+ ? { detail: "launchctl could not be asked whether it is loaded" }
141
+ : svc.mode === "always"
142
+ ? { detail: `opt-in: ${ALWAYS_UNPROVEN}` }
143
+ : {}),
144
+ });
145
+ return rows;
146
+ }
147
+
148
+ export interface JobProbe {
149
+ exists: (p: string) => boolean;
150
+ /** `launchctl print <domain>/<label>`'s exit status (127: no launchctl). */
151
+ print: (target: string) => number | null;
152
+ }
153
+
154
+ const realProbe: JobProbe = {
155
+ exists: (p) => fs.existsSync(p),
156
+ print: (target) =>
157
+ spawnSync("launchctl", ["print", target], {
158
+ stdio: "ignore",
159
+ timeout: 3000,
160
+ }).status,
161
+ };
162
+
163
+ /**
164
+ * The installed service job for `label`: the LaunchDaemon (always) when
165
+ * its plist exists, else the LaunchAgent (login); null when neither does.
166
+ */
167
+ export function serviceJobState(
168
+ label: string,
169
+ home: string,
170
+ uid: number,
171
+ probe: JobProbe = realProbe,
172
+ ): ServiceJob | null {
173
+ const daemon = path.join("/Library/LaunchDaemons", `${label}.plist`);
174
+ const agent = path.join(home, "Library", "LaunchAgents", `${label}.plist`);
175
+ const found: [ServiceMode, string, string] | null = probe.exists(daemon)
176
+ ? ["always", daemon, "system"]
177
+ : probe.exists(agent)
178
+ ? ["login", agent, `gui/${uid}`]
179
+ : null;
180
+ if (!found) return null;
181
+ const [mode, plist, domain] = found;
182
+ const code = probe.print(`${domain}/${label}`);
183
+ const state =
184
+ code === 0
185
+ ? "loaded"
186
+ : code === null || code === 127
187
+ ? "unknown"
188
+ : "not-loaded";
189
+ return { state, mode, plist, domain };
190
+ }
191
+
192
+ /** The rows as doctor prints them, under a heading. */
193
+ export function formatWhere(rows: WhereRow[], title: string): string[] {
194
+ if (!rows.length) return [];
195
+ const lines = ["", `${title} doctor: where it runs`, ""];
196
+ const w = Math.max(...rows.map((r) => r.session.length));
197
+ for (const r of rows) {
198
+ const mark = r.status === "ok" ? "✓" : "⚠";
199
+ lines.push(` ${r.what.padEnd(16)} ${r.session.padEnd(w)} ${mark}`);
200
+ if (r.detail) lines.push(` ${r.status === "ok" ? "" : "⚠ "}${r.detail}`);
201
+ }
202
+ return lines;
203
+ }
@@ -5317,7 +5317,7 @@ class DenCLI {
5317
5317
  " mcp --call <tool> Call one MCP tool and print its result (--list: tool names)",
5318
5318
  );
5319
5319
  console.log(
5320
- " service install Run the web UI as a launchd service (status, print, uninstall)",
5320
+ " service install Run the web UI as a launchd service (status, print, uninstall, help)",
5321
5321
  );
5322
5322
  console.log(
5323
5323
  " theme add <repo>@<ref> Install a web UI theme (list, validate, remove)",
@@ -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));