@monster-orange/den 0.4.22 → 0.4.24
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 +73 -0
- package/docs/distributions.md +91 -30
- package/package.json +1 -1
- package/service/install.sh +5 -3
- package/src/shared/agents/guardrails/rotation-hook.ts +5 -0
- package/src/shared/agents/rotate-settings.ts +309 -0
- package/src/shared/agents/rotation.ts +110 -5
- package/src/shared/den-config.ts +27 -12
- package/src/ui/cli/agent-ops.ts +514 -15
- package/src/ui/cli/agents-command.ts +169 -3
- package/src/ui/cli/doctor-command.ts +24 -1
- package/src/ui/cli/doctor-where.ts +59 -13
- package/src/ui/cli/server-agent-ops.ts +2 -2
- package/src/ui/cli/web-command.ts +32 -5
- package/src/ui/cli/web-service.ts +160 -0
- package/src/ui/server/agent-routes.ts +161 -5
- package/src/ui/server/index.ts +13 -5
- package/src/ui/server/public/agents-page.js +214 -22
- package/src/ui/server/public/agents.html +32 -0
- package/src/ui/server/public/combobox.js +150 -0
- package/src/ui/server/public/error-page.js +26 -14
- package/src/ui/server/public/i18n/es.js +47 -4
- package/src/ui/server/public/i18n/ja.js +43 -4
- package/src/ui/server/public/settings-registry.js +318 -0
- package/src/ui/server/public/settings.html +166 -4
- package/src/ui/server/public/shell.js +62 -16
- package/src/ui/server/public/svc-panel.js +54 -15
- package/src/ui/server/route-classes.ts +21 -0
- package/src/ui/server/session-routes.ts +23 -5
- package/src/ui/server/ui-assets.ts +2 -0
|
@@ -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
|
-
|
|
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);
|
|
@@ -69,11 +69,13 @@ import { printJson } from "./json-out.js";
|
|
|
69
69
|
import { ownerCheck, ownerLine } from "./doctor-owner.js";
|
|
70
70
|
import {
|
|
71
71
|
formatWhere,
|
|
72
|
+
launchdJobPid,
|
|
72
73
|
serviceJobState,
|
|
73
74
|
whereRows,
|
|
74
75
|
type WhereDeps,
|
|
75
76
|
type WhereRow,
|
|
76
77
|
} from "./doctor-where.js";
|
|
78
|
+
import { hostLabel } from "./service-command.js";
|
|
77
79
|
|
|
78
80
|
export interface DoctorRow {
|
|
79
81
|
kind: string;
|
|
@@ -420,6 +422,8 @@ export async function defaultWhereDeps(): Promise<WhereDeps> {
|
|
|
420
422
|
try {
|
|
421
423
|
return {
|
|
422
424
|
launchd: c.hostLaunchd,
|
|
425
|
+
pid: c.hostPid,
|
|
426
|
+
startedAt: c.hostStartedAt || undefined,
|
|
423
427
|
access: await c.access().catch(() => null),
|
|
424
428
|
};
|
|
425
429
|
} finally {
|
|
@@ -438,6 +442,24 @@ export async function defaultWhereDeps(): Promise<WhereDeps> {
|
|
|
438
442
|
return null;
|
|
439
443
|
}
|
|
440
444
|
},
|
|
445
|
+
hostJob: () => {
|
|
446
|
+
try {
|
|
447
|
+
const { label } = readWebServiceConfig(
|
|
448
|
+
cfg(),
|
|
449
|
+
getDistRoot(),
|
|
450
|
+
os.homedir(),
|
|
451
|
+
);
|
|
452
|
+
const host = hostLabel(label);
|
|
453
|
+
const job = serviceJobState(
|
|
454
|
+
host,
|
|
455
|
+
os.homedir(),
|
|
456
|
+
process.getuid?.() ?? 0,
|
|
457
|
+
);
|
|
458
|
+
return job?.state === "loaded" ? launchdJobPid(job.domain, host) : null;
|
|
459
|
+
} catch {
|
|
460
|
+
return null;
|
|
461
|
+
}
|
|
462
|
+
},
|
|
441
463
|
};
|
|
442
464
|
}
|
|
443
465
|
|
|
@@ -445,7 +467,8 @@ export async function defaultWhereDeps(): Promise<WhereDeps> {
|
|
|
445
467
|
export function whereCheck(r: WhereRow): OnboardCheck {
|
|
446
468
|
return {
|
|
447
469
|
id: `where:${r.what.replace(" ", "-")}`,
|
|
448
|
-
|
|
470
|
+
// info is as chosen: not a problem.
|
|
471
|
+
status: r.status === "info" ? "ok" : r.status,
|
|
449
472
|
detail: `${r.session}${r.detail ? `: ${r.detail}` : ""}`,
|
|
450
473
|
};
|
|
451
474
|
}
|
|
@@ -41,15 +41,22 @@ export interface WhereDeps {
|
|
|
41
41
|
/** The running session host's; null when none runs. */
|
|
42
42
|
host: () => Promise<{
|
|
43
43
|
launchd: string | null;
|
|
44
|
+
/** The pid that answers on the socket. */
|
|
45
|
+
pid?: number;
|
|
46
|
+
/** When it started (ISO). */
|
|
47
|
+
startedAt?: string;
|
|
44
48
|
access?: { exe: string; fullDiskAccess: boolean | null } | null;
|
|
45
49
|
} | null>;
|
|
46
50
|
service: () => ServiceJob | null;
|
|
51
|
+
/** The service's own host job's pid (it may stand by), or null. */
|
|
52
|
+
hostJob?: () => number | null;
|
|
47
53
|
}
|
|
48
54
|
|
|
49
55
|
export interface WhereRow {
|
|
50
56
|
what: "server" | "session host" | "full disk access" | "service";
|
|
51
57
|
session: string;
|
|
52
|
-
|
|
58
|
+
/** info: as chosen, worth knowing (login mode ends at logout). */
|
|
59
|
+
status: "ok" | "info" | "warn";
|
|
53
60
|
detail?: string;
|
|
54
61
|
}
|
|
55
62
|
|
|
@@ -57,12 +64,15 @@ function sessionRow(
|
|
|
57
64
|
what: "server" | "session host",
|
|
58
65
|
launchd: string | null | undefined,
|
|
59
66
|
cli: string,
|
|
67
|
+
loginChosen = false,
|
|
60
68
|
): WhereRow {
|
|
61
69
|
const session = launchd || "unknown";
|
|
62
70
|
return {
|
|
63
71
|
what,
|
|
64
72
|
session,
|
|
65
|
-
|
|
73
|
+
// The service installed in login mode: ending at logout is what was
|
|
74
|
+
// chosen (the default), so it is said, not warned about.
|
|
75
|
+
status: endsAtLogout(launchd) ? (loginChosen ? "info" : "warn") : "ok",
|
|
66
76
|
...(sessionWarning(launchd, cli, what)
|
|
67
77
|
? { detail: sessionWarning(launchd, cli, what)! }
|
|
68
78
|
: {}),
|
|
@@ -72,10 +82,12 @@ function sessionRow(
|
|
|
72
82
|
export async function whereRows(d: WhereDeps): Promise<WhereRow[]> {
|
|
73
83
|
if (d.platform !== "darwin") return [];
|
|
74
84
|
const rows: WhereRow[] = [];
|
|
85
|
+
const svc = d.service();
|
|
86
|
+
const loginChosen = svc?.mode === "login" && svc.state !== "not-loaded";
|
|
75
87
|
const server = await d.server().catch(() => null);
|
|
76
88
|
rows.push(
|
|
77
89
|
server
|
|
78
|
-
? sessionRow("server", server.launchd, d.cli)
|
|
90
|
+
? sessionRow("server", server.launchd, d.cli, loginChosen)
|
|
79
91
|
: {
|
|
80
92
|
// Not started yet is not a problem (a fresh install).
|
|
81
93
|
what: "server",
|
|
@@ -85,15 +97,38 @@ export async function whereRows(d: WhereDeps): Promise<WhereRow[]> {
|
|
|
85
97
|
},
|
|
86
98
|
);
|
|
87
99
|
const host = await d.host().catch(() => null);
|
|
100
|
+
const jobPid = host ? (d.hostJob?.() ?? null) : null;
|
|
101
|
+
// The service's host stands by while another host holds the socket, and
|
|
102
|
+
// takes over when that one exits: nothing to do.
|
|
103
|
+
const standsBy =
|
|
104
|
+
jobPid !== null && host?.pid !== undefined && jobPid !== host.pid;
|
|
105
|
+
const since = host?.startedAt ? `, since ${host.startedAt.slice(0, 10)}` : "";
|
|
106
|
+
const older = `an older session host (pid ${host?.pid ?? "?"}${since})`;
|
|
107
|
+
const switchNow = `To switch now (ends running terminals and agents): ${d.cli} sessions host --stop`;
|
|
88
108
|
rows.push(
|
|
89
|
-
host
|
|
90
|
-
?
|
|
91
|
-
: {
|
|
109
|
+
host && standsBy
|
|
110
|
+
? {
|
|
92
111
|
what: "session host",
|
|
93
|
-
session:
|
|
94
|
-
status: "
|
|
95
|
-
detail:
|
|
96
|
-
}
|
|
112
|
+
session: host.launchd || `older host (pid ${host.pid})`,
|
|
113
|
+
status: "info",
|
|
114
|
+
detail: `${older} is serving; the service's host (pid ${jobPid}) takes over when it exits. Nothing to do. ${switchNow}`,
|
|
115
|
+
}
|
|
116
|
+
: host && !host.launchd
|
|
117
|
+
? {
|
|
118
|
+
// An older host and no service host behind it: what it lacks.
|
|
119
|
+
what: "session host",
|
|
120
|
+
session: `unknown (older host${host.pid ? `, pid ${host.pid}` : ""})`,
|
|
121
|
+
status: "warn",
|
|
122
|
+
detail: `${older} can't say where it runs, so whether its terminals and agents stop at logout is unknown. It is replaced the next time it exits. ${switchNow}`,
|
|
123
|
+
}
|
|
124
|
+
: host
|
|
125
|
+
? sessionRow("session host", host.launchd, d.cli, loginChosen)
|
|
126
|
+
: {
|
|
127
|
+
what: "session host",
|
|
128
|
+
session: "not running",
|
|
129
|
+
status: "ok",
|
|
130
|
+
detail: "it starts when the first terminal or agent does",
|
|
131
|
+
},
|
|
97
132
|
);
|
|
98
133
|
const fda = host?.access;
|
|
99
134
|
if (fda && fda.fullDiskAccess !== null) {
|
|
@@ -116,7 +151,6 @@ export async function whereRows(d: WhereDeps): Promise<WhereRow[]> {
|
|
|
116
151
|
},
|
|
117
152
|
);
|
|
118
153
|
}
|
|
119
|
-
const svc = d.service();
|
|
120
154
|
if (!svc)
|
|
121
155
|
rows.push({
|
|
122
156
|
what: "service",
|
|
@@ -145,6 +179,17 @@ export async function whereRows(d: WhereDeps): Promise<WhereRow[]> {
|
|
|
145
179
|
return rows;
|
|
146
180
|
}
|
|
147
181
|
|
|
182
|
+
/** The pid launchd runs `<domain>/<label>` as, or null (launchctl print). */
|
|
183
|
+
export function launchdJobPid(domain: string, label: string): number | null {
|
|
184
|
+
const r = spawnSync("launchctl", ["print", `${domain}/${label}`], {
|
|
185
|
+
encoding: "utf-8",
|
|
186
|
+
timeout: 3000,
|
|
187
|
+
});
|
|
188
|
+
if (r.status !== 0) return null;
|
|
189
|
+
const m = /^\s*pid = (\d+)/m.exec(r.stdout ?? "");
|
|
190
|
+
return m ? Number(m[1]) : null;
|
|
191
|
+
}
|
|
192
|
+
|
|
148
193
|
export interface JobProbe {
|
|
149
194
|
exists: (p: string) => boolean;
|
|
150
195
|
/** `launchctl print <domain>/<label>`'s exit status (127: no launchctl). */
|
|
@@ -195,9 +240,10 @@ export function formatWhere(rows: WhereRow[], title: string): string[] {
|
|
|
195
240
|
const lines = ["", `${title} doctor: where it runs`, ""];
|
|
196
241
|
const w = Math.max(...rows.map((r) => r.session.length));
|
|
197
242
|
for (const r of rows) {
|
|
198
|
-
const mark = r.status === "ok" ? "✓" : "⚠";
|
|
243
|
+
const mark = r.status === "ok" ? "✓" : r.status === "info" ? "ℹ" : "⚠";
|
|
199
244
|
lines.push(` ${r.what.padEnd(16)} ${r.session.padEnd(w)} ${mark}`);
|
|
200
|
-
if (r.detail)
|
|
245
|
+
if (r.detail)
|
|
246
|
+
lines.push(` ${r.status === "warn" ? "⚠ " : ""}${r.detail}`);
|
|
201
247
|
}
|
|
202
248
|
return lines;
|
|
203
249
|
}
|
|
@@ -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));
|
|
@@ -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
|
-
|
|
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 =
|
|
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
|
+
}
|