@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
|
@@ -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
|
-
|
|
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
|
+
}
|
package/src/ui/cli/index.ts
CHANGED
|
@@ -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 (
|
|
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));
|