@monster-orange/den 0.4.21 → 0.4.22

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.
@@ -0,0 +1,92 @@
1
+ /**
2
+ * D20: the launchd session a process runs in, as `launchctl managername`
3
+ * names it. It decides what survives a logout:
4
+ *
5
+ * Aqua, LoginWindow the desktop login session (gui/<uid>): ends when the
6
+ * person logs out, with every process in it.
7
+ * Background outside it (an ssh shell, user/<uid>): keeps running
8
+ * after logout, but has no login keychain and no GUI
9
+ * (headed apps, WebKit on macOS).
10
+ * System a LaunchDaemon: as Background.
11
+ *
12
+ * A child inherits its parent's session, so a session host started from
13
+ * the desktop dies at logout with every agent in it. Off macOS there is no
14
+ * such thing (null).
15
+ */
16
+ import { spawnSync } from "child_process";
17
+
18
+ /**
19
+ * `service install --mode always` is opt-in: it is meant to keep the
20
+ * server, the host and every agent running across a logout, but no logout
21
+ * has proven it yet. Every place that offers it says so.
22
+ */
23
+ export const ALWAYS_UNPROVEN = "not yet proven across a logout";
24
+
25
+ export type LaunchctlRunner = () => { status: number | null; stdout: string };
26
+
27
+ const defaultRunner: LaunchctlRunner = () => {
28
+ const r = spawnSync("launchctl", ["managername"], {
29
+ encoding: "utf-8",
30
+ timeout: 2000,
31
+ });
32
+ return { status: r.status, stdout: r.stdout ?? "" };
33
+ };
34
+
35
+ /** `launchctl managername`, or "unknown"; null off macOS. */
36
+ export function readLaunchdSession(
37
+ run: LaunchctlRunner = defaultRunner,
38
+ platform: NodeJS.Platform = process.platform,
39
+ ): string | null {
40
+ if (platform !== "darwin") return null;
41
+ try {
42
+ const r = run();
43
+ const name = r.status === 0 ? r.stdout.trim() : "";
44
+ // Only a plain word: it is shown and stored as-is.
45
+ return /^[A-Za-z]{1,32}$/.test(name) ? name : "unknown";
46
+ } catch {
47
+ return "unknown";
48
+ }
49
+ }
50
+
51
+ let cached: string | null | undefined;
52
+
53
+ /** This process's launchd session (it never changes, so asked once). */
54
+ export function launchdSession(): string | null {
55
+ if (cached === undefined) cached = readLaunchdSession();
56
+ return cached;
57
+ }
58
+
59
+ /**
60
+ * D20: `web up` from a shell outside the desktop session (an ssh shell:
61
+ * Background) starts a server there; say what that means and the right
62
+ * way. null in the desktop session or off macOS.
63
+ */
64
+ export function webUpWarning(
65
+ session: string | null | undefined,
66
+ cli: string,
67
+ ): string | null {
68
+ if (session !== "Background" && session !== "System") return null;
69
+ return `this shell runs outside the desktop session (${session}, e.g. ssh): a server started here keeps running after logout but has no login keychain and no GUI, and its agents run here too. To run it as a service: ${cli} service install --mode always (opt-in, ${ALWAYS_UNPROVEN}) or --mode login`;
70
+ }
71
+
72
+ /** Whether a process in this session ends when the person logs out. */
73
+ export function endsAtLogout(session: string | null | undefined): boolean {
74
+ return session === "Aqua" || session === "LoginWindow";
75
+ }
76
+
77
+ /**
78
+ * The line doctor and the agent page show for `what` (the server, the
79
+ * session host, an agent) running in `session`: the fix when it ends at
80
+ * logout, what it lacks when it doesn't. null when there is nothing to say.
81
+ */
82
+ export function sessionWarning(
83
+ session: string | null | undefined,
84
+ cli: string,
85
+ what: string,
86
+ ): string | null {
87
+ if (endsAtLogout(session))
88
+ return `the ${what} runs in the desktop login session (${session}): it stops when you log out. To keep it running: ${cli} service install --mode always (opt-in, ${ALWAYS_UNPROVEN})`;
89
+ if (session === "Background" || session === "System")
90
+ return `the ${what} keeps running after logout (${session}); it has no login keychain and no GUI, so it uses the 1Password service-account token, headless browsers and Full Disk Access instead`;
91
+ return null;
92
+ }
@@ -45,6 +45,8 @@ export class HostClient extends EventEmitter {
45
45
  private readonly attachments = new Map<string, AttachHandlers>();
46
46
  hostPid = 0;
47
47
  hostStartedAt = "";
48
+ /** D20: the host's launchd session (null off macOS or an older host). */
49
+ hostLaunchd: string | null = null;
48
50
 
49
51
  constructor(private readonly socket: net.Socket) {
50
52
  super();
@@ -117,10 +119,32 @@ export class HostClient extends EventEmitter {
117
119
  this.request(op, body).catch(() => undefined);
118
120
  }
119
121
 
122
+ /**
123
+ * D18: the host's executable and whether it has Full Disk Access (null:
124
+ * cannot tell). null from a host older than 0.4.22.
125
+ */
126
+ async access(): Promise<{
127
+ exe: string;
128
+ fullDiskAccess: boolean | null;
129
+ } | null> {
130
+ try {
131
+ const r = await this.request("access");
132
+ return {
133
+ exe: String(r.exe ?? ""),
134
+ fullDiskAccess:
135
+ typeof r.fullDiskAccess === "boolean" ? r.fullDiskAccess : null,
136
+ };
137
+ } catch (e) {
138
+ if (e instanceof HostError && e.code === "bad-request") return null;
139
+ throw e;
140
+ }
141
+ }
142
+
120
143
  async hello(): Promise<void> {
121
144
  const r = await this.request("hello");
122
145
  this.hostPid = Number(r.pid);
123
146
  this.hostStartedAt = String(r.startedAt ?? "");
147
+ this.hostLaunchd = typeof r.launchd === "string" ? r.launchd : null;
124
148
  }
125
149
 
126
150
  async start(
@@ -29,6 +29,7 @@ import {
29
29
  type Request,
30
30
  } from "./protocol.js";
31
31
  import type { PtyLoad, PtyProcess } from "./pty.js";
32
+ import { launchdSession } from "../launchd-session.js";
32
33
  import {
33
34
  SESSION_POOLS,
34
35
  SESSION_SIGNALS,
@@ -54,6 +55,14 @@ export interface SessionHostOptions {
54
55
  log?: (line: string) => void;
55
56
  /** Called after a `shutdown` request has closed the host. */
56
57
  onShutdown?: () => void;
58
+ /** D20: the launchd session it runs in (default: asked; null off macOS). */
59
+ launchdSession?: string | null;
60
+ /**
61
+ * D18: whether this process has Full Disk Access (default: it can list
62
+ * macOS's TCC folder, which only such a process can; null off macOS).
63
+ * What it starts inherits it, so the agents have it too.
64
+ */
65
+ fullDiskAccess?: () => boolean | null;
57
66
  }
58
67
 
59
68
  interface Session {
@@ -169,6 +178,27 @@ function printableActor(v: unknown): string {
169
178
 
170
179
  const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
171
180
 
181
+ /**
182
+ * D18: Full Disk Access, as macOS grants it to this executable: only such
183
+ * a process may list ~/Library/Application Support/com.apple.TCC. null off
184
+ * macOS, or when the folder is not there to ask.
185
+ */
186
+ export function hasFullDiskAccess(
187
+ home: string = process.env.HOME ?? "",
188
+ platform: NodeJS.Platform = process.platform,
189
+ ): boolean | null {
190
+ if (platform !== "darwin" || !home) return null;
191
+ try {
192
+ fs.readdirSync(
193
+ path.join(home, "Library", "Application Support", "com.apple.TCC"),
194
+ );
195
+ return true;
196
+ } catch (e) {
197
+ const code = (e as NodeJS.ErrnoException).code;
198
+ return code === "EPERM" || code === "EACCES" ? false : null;
199
+ }
200
+ }
201
+
172
202
  export class SessionHost {
173
203
  readonly paths: SessionPaths;
174
204
  private readonly sessions = new Map<string, Session>();
@@ -176,9 +206,17 @@ export class SessionHost {
176
206
  private server: net.Server | null = null;
177
207
  private readonly startedAt = new Date().toISOString();
178
208
  private closed = false;
209
+ /** D20: where it runs; stamped on every session it starts. */
210
+ private readonly launchd: string | null;
179
211
 
180
212
  constructor(private readonly opts: SessionHostOptions) {
181
213
  this.paths = sessionPaths(opts.stateDir);
214
+ const where =
215
+ opts.launchdSession !== undefined
216
+ ? opts.launchdSession
217
+ : launchdSession();
218
+ // "unknown" (launchctl said nothing) is no stamp at all.
219
+ this.launchd = where === "unknown" ? null : where;
182
220
  }
183
221
 
184
222
  private log(line: string): void {
@@ -189,6 +227,34 @@ export class SessionHost {
189
227
  return this.opts.scrollbackBytes ?? DEFAULT_SCROLLBACK_BYTES;
190
228
  }
191
229
 
230
+ /**
231
+ * Calls `onGone` once its state dir no longer exists (checked every
232
+ * `everyMs`): nothing it owns can be kept any more, so the host should
233
+ * end. A test that deletes its temp state dir no longer leaves a host
234
+ * running; a real install never deletes its state dir under a running
235
+ * host. Returns a stop function.
236
+ */
237
+ watchStateDir(everyMs: number, onGone: () => void): () => void {
238
+ const t = setInterval(() => {
239
+ if (fs.existsSync(this.opts.stateDir)) return;
240
+ clearInterval(t);
241
+ onGone();
242
+ }, everyMs);
243
+ t.unref();
244
+ return () => clearInterval(t);
245
+ }
246
+
247
+ /**
248
+ * D18: serve once no other host answers on the socket, checking every
249
+ * `everyMs`; resolves once it listens.
250
+ */
251
+ async listenWhenFree(everyMs: number): Promise<"listening"> {
252
+ for (;;) {
253
+ if ((await this.listen()) === "listening") return "listening";
254
+ await sleep(everyMs);
255
+ }
256
+ }
257
+
192
258
  /** Serve on the socket, or stand down when a host already answers there. */
193
259
  async listen(): Promise<"listening" | "already-running"> {
194
260
  const { socket } = this.paths;
@@ -293,6 +359,7 @@ export class SessionHost {
293
359
  exited?: boolean;
294
360
  exitCode?: number;
295
361
  prompted?: boolean;
362
+ launchd?: unknown;
296
363
  };
297
364
  if (typeof rec.id !== "string" || typeof rec.name !== "string")
298
365
  continue;
@@ -308,6 +375,10 @@ export class SessionHost {
308
375
  ? { exitCode: rec.exitCode }
309
376
  : {}),
310
377
  ...(rec.prompted === true ? { prompted: true } : {}),
378
+ ...(typeof rec.launchd === "string" &&
379
+ /^[A-Za-z]{1,32}$/.test(rec.launchd)
380
+ ? { launchd: rec.launchd }
381
+ : {}),
311
382
  },
312
383
  cwd: rec.cwd,
313
384
  pty: null,
@@ -338,6 +409,7 @@ export class SessionHost {
338
409
  ...(s.info.exited ? { exited: true } : {}),
339
410
  ...(s.info.exitCode !== undefined ? { exitCode: s.info.exitCode } : {}),
340
411
  ...(s.info.prompted ? { prompted: true } : {}),
412
+ ...(s.info.launchd ? { launchd: s.info.launchd } : {}),
341
413
  };
342
414
  try {
343
415
  fs.writeFileSync(
@@ -419,6 +491,7 @@ export class SessionHost {
419
491
  v: PROTOCOL_VERSION,
420
492
  pid: process.pid,
421
493
  startedAt: this.startedAt,
494
+ launchd: this.launchd,
422
495
  };
423
496
  case "list":
424
497
  return {
@@ -498,6 +571,12 @@ export class SessionHost {
498
571
  this.forget(s);
499
572
  return {};
500
573
  }
574
+ case "access":
575
+ // D18: what this host (and every agent in it) may reach.
576
+ return {
577
+ exe: process.execPath,
578
+ fullDiskAccess: (this.opts.fullDiskAccess ?? hasFullDiskAccess)(),
579
+ };
501
580
  default:
502
581
  throw new Refusal("bad-request", `unknown op "${String(req.op)}"`);
503
582
  }
@@ -607,6 +686,7 @@ export class SessionHost {
607
686
  name,
608
687
  pid: pty.pid,
609
688
  startedAt: new Date().toISOString(),
689
+ ...(this.launchd ? { launchd: this.launchd } : {}),
610
690
  },
611
691
  cwd,
612
692
  pty,
@@ -712,7 +792,19 @@ export class SessionHost {
712
792
  * already running; 1 when it cannot listen.
713
793
  */
714
794
  export async function runSessionHost(
715
- opts: Omit<SessionHostOptions, "onShutdown">,
795
+ opts: Omit<SessionHostOptions, "onShutdown"> & {
796
+ /**
797
+ * D18: the launchd job's host. While another host answers on the
798
+ * socket (one started before the job, from a terminal or the web
799
+ * server), wait for it to end, then serve, instead of exiting at once
800
+ * (launchd would restart it every few seconds). Checks every
801
+ * standByMs (default 5 s).
802
+ */
803
+ standBy?: boolean;
804
+ standByMs?: number;
805
+ /** How often it checks its state dir still exists (default 10 s). */
806
+ stateDirCheckMs?: number;
807
+ },
716
808
  ): Promise<number> {
717
809
  const log =
718
810
  opts.log ??
@@ -729,15 +821,32 @@ export async function runSessionHost(
729
821
  log(`session host could not start: ${(e as Error).message}`);
730
822
  return 1;
731
823
  }
732
- if (state === "already-running") {
824
+ const stop = () => void host.close().then(() => process.exit(0));
825
+ if (state === "already-running" && opts.standBy) {
826
+ process.on("SIGTERM", stop);
827
+ process.on("SIGINT", stop);
828
+ log(
829
+ `a session host is already running on ${host.paths.socket}: standing by until it ends (its sessions keep running there)`,
830
+ );
831
+ try {
832
+ state = await host.listenWhenFree(opts.standByMs ?? 5000);
833
+ } catch (e) {
834
+ log(`session host could not start: ${(e as Error).message}`);
835
+ return 1;
836
+ }
837
+ } else if (state === "already-running") {
733
838
  log(`a session host is already running on ${host.paths.socket}`);
734
839
  return 0;
840
+ } else {
841
+ process.on("SIGTERM", stop);
842
+ process.on("SIGINT", stop);
735
843
  }
736
- const stop = () => void host.close().then(() => process.exit(0));
737
- process.on("SIGTERM", stop);
738
- process.on("SIGINT", stop);
739
844
  // No terminal to hang up on: it was started detached.
740
845
  process.on("SIGHUP", () => undefined);
741
846
  log(`session host ${process.pid} listening on ${host.paths.socket}`);
847
+ host.watchStateDir(opts.stateDirCheckMs ?? 10_000, () => {
848
+ log(`its state dir ${opts.stateDir} is gone: stopping`);
849
+ stop();
850
+ });
742
851
  return 0;
743
852
  }
@@ -30,6 +30,11 @@ export interface SessionInfo {
30
30
  * starts afresh instead. Never what was typed.
31
31
  */
32
32
  prompted?: boolean;
33
+ /**
34
+ * D20: the launchd session its host ran in (`launchctl managername`):
35
+ * Aqua ends at logout, Background survives it. Absent off macOS.
36
+ */
37
+ launchd?: string;
33
38
  }
34
39
 
35
40
  /**
@@ -71,7 +71,10 @@ import {
71
71
  type CatalogEntry,
72
72
  } from "../../shared/agents/catalog.js";
73
73
  import { checkRuntimeBin } from "../../shared/agents/catalog-runtime.js";
74
- import { agentSessionName } from "../../shared/agents/session-name.js";
74
+ import {
75
+ agentSessionName,
76
+ peerSessionName,
77
+ } from "../../shared/agents/session-name.js";
75
78
  import { cliEntrypoint } from "../../shared/engine.js";
76
79
  import { getDistRoot } from "../../shared/paths.js";
77
80
  import { envKey } from "../../shared/product.js";
@@ -1637,11 +1640,17 @@ export async function startAgent(
1637
1640
  );
1638
1641
  ptyOrRefuse(d);
1639
1642
  const allow = allowedFor(d, persona, ch.extras);
1643
+ // D24: the name peers see it by, from the name it will have (below).
1644
+ const peerName = peerSessionName(d.cli, ws, {
1645
+ id,
1646
+ displayName: given ?? prev?.displayName ?? persona.displayName,
1647
+ });
1640
1648
  const input = {
1641
1649
  ...launchInput(d, cfg, persona, ws, id, model, det.path, undefined, {
1642
1650
  ...(ch.permission ? { permission: ch.permission } : {}),
1643
1651
  ...(rt.permissions ? { allow } : {}),
1644
1652
  }),
1653
+ sessionLabel: peerName,
1645
1654
  ...(more.firstPrompt ? { firstPrompt: more.firstPrompt } : {}),
1646
1655
  // 0.4.18: Start agent here: in a folder of the workspace's.
1647
1656
  ...(req.folder !== undefined && !more.bringIn
@@ -1699,6 +1708,7 @@ export async function startAgent(
1699
1708
  workspace: ws,
1700
1709
  cwd: input.worktree,
1701
1710
  sessionName: sessionName(d.dist, ws, id),
1711
+ peerName,
1702
1712
  // AG-4b: a name given to the agent is the agent's, not its
1703
1713
  // conversation's: Start fresh and Relaunch keep it. AG-10.8:
1704
1714
  // else its persona's name ("Rita").
@@ -2175,8 +2185,11 @@ export async function resumeAgent(
2175
2185
  },
2176
2186
  );
2177
2187
  // Resume in the cwd the agent had, wherever the workspace points now.
2188
+ // D24 (and D25: a rename since) the name peers see it by.
2189
+ const peerName = peerSessionName(d.cli, rec.workspace, rec);
2178
2190
  const at = {
2179
2191
  ...input,
2192
+ sessionLabel: peerName,
2180
2193
  worktree: rec.cwd,
2181
2194
  ...(rec.resumeToken ? { resumeToken: rec.resumeToken } : {}),
2182
2195
  };
@@ -2211,7 +2224,12 @@ export async function resumeAgent(
2211
2224
  d,
2212
2225
  c,
2213
2226
  {
2214
- rec: { ...base, cwd: rec.cwd, ...(token ? { launched: true } : {}) },
2227
+ rec: {
2228
+ ...base,
2229
+ cwd: rec.cwd,
2230
+ peerName,
2231
+ ...(token ? { launched: true } : {}),
2232
+ },
2215
2233
  argv: out.argv,
2216
2234
  env: out.env,
2217
2235
  passthrough: rt.envPassthrough,
@@ -87,6 +87,7 @@ import {
87
87
  relaunchAgent,
88
88
  removeAgent,
89
89
  removeStopped,
90
+ renameAgent,
90
91
  resolveAgentsDeps,
91
92
  resumeAgent,
92
93
  resumeInterrupted,
@@ -99,6 +100,7 @@ import {
99
100
  type RuntimeChoice,
100
101
  type Started,
101
102
  } from "./agent-ops.js";
103
+ import { peerSessionName } from "../../shared/agents/session-name.js";
102
104
  import {
103
105
  columns,
104
106
  guarded,
@@ -119,7 +121,7 @@ export {
119
121
 
120
122
  export const AGENTS_HELP = {
121
123
  usage:
122
- "agents <list|start|stop|resume|relaunch|import|persona|rm|personas|doctor|handback-check|handoff-check> ...",
124
+ "agents <list|start|stop|resume|relaunch|import|persona|rename|rm|personas|doctor|handback-check|handoff-check> ...",
123
125
  lines: [
124
126
  "Agents: a persona and a workspace, run by a runtime in a session that",
125
127
  "outlives the web server (see `{cli} sessions`).",
@@ -156,6 +158,11 @@ export const AGENTS_HELP = {
156
158
  " persona's role, permissions and commands; id, name, folder and model stay.",
157
159
  " Mid-turn it asks: --after-turn waits for the turn to end, --now stops it.",
158
160
  " A person's only (refused in an agent's session)",
161
+ "{cli} agents rename <id> <name> [-w <ws>]",
162
+ "{cli} agents rename <id> --clear [-w <ws>]",
163
+ " name an agent (its tab, row and pane header); the id never changes.",
164
+ " Other sessions see it as {cli}-<ws>:<name> from its next start or resume.",
165
+ " --clear: back to its id",
159
166
  "{cli} agents rm <id> [-w <ws>]",
160
167
  "{cli} agents rm --stopped [-w <ws>]",
161
168
  " forget a stopped, interrupted or exited agent (never a running one);",
@@ -513,6 +520,28 @@ async function resumeAll(args: string[], d: AgentsResolved): Promise<number> {
513
520
  }
514
521
 
515
522
  /** `agents rm <id> [-w <ws>]`, `agents rm --stopped [-w <ws>]` (AG-14.1b). */
523
+ /** D25: `agents rename <id> <name>|--clear [-w <ws>]`. */
524
+ function rename(args: string[], d: AgentsResolved): number {
525
+ const where = "agents rename";
526
+ const p = parseArgs(where, args, { "--workspace": true, "--clear": false });
527
+ const [id, ...words] = p.positional;
528
+ if (!id) throw new UsageError(`${where} needs <id> and a name, or --clear`);
529
+ const name = words.join(" ").trim();
530
+ if (p.flags["--clear"] && name)
531
+ throw new UsageError(`${where}: give a name or --clear, not both`);
532
+ if (!p.flags["--clear"] && !name)
533
+ throw new UsageError(`${where} needs a name (or --clear)`);
534
+ const ws =
535
+ workspaceFlag(where, p) ?? readRecord(d.stateDir(), id)?.workspace ?? "";
536
+ const rec = renameAgent(d, id, ws, p.flags["--clear"] ? null : name);
537
+ d.out(
538
+ rec.displayName
539
+ ? `renamed agent ${id} to "${rec.displayName}"; others see it as ${peerSessionName(d.cli, rec.workspace, rec)} from its next start or resume`
540
+ : `agent ${id} has no name now; others see it as ${peerSessionName(d.cli, rec.workspace, rec)} from its next start or resume`,
541
+ );
542
+ return 0;
543
+ }
544
+
516
545
  async function rm(args: string[], d: AgentsResolved): Promise<number> {
517
546
  const where = "agents rm";
518
547
  const p = parseArgs(where, args, { "--workspace": true, "--stopped": false });
@@ -767,6 +796,8 @@ export async function cmdAgents(
767
796
  case "persona":
768
797
  await loadAgentWorkspaces(deps);
769
798
  return await persona(rest, d);
799
+ case "rename":
800
+ return rename(rest, d);
770
801
  case "rm":
771
802
  await loadAgentWorkspaces(deps);
772
803
  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) {