@ddtcorex/dsh-maestro-supervisor 0.7.9 → 0.8.0

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/lib/intents.d.ts CHANGED
@@ -13,3 +13,20 @@ export declare function intentsDir(): string;
13
13
  export declare function intentPath(sessionId: string): string;
14
14
  export declare function readIntent(sessionId: string): RestartIntent | undefined;
15
15
  export declare function consumeIntent(sessionId: string): void;
16
+ /**
17
+ * Post-swap outcome written by the supervisor daemon after acting on a
18
+ * restart request (`<sessionId>.outcome.json`, mode 600, same dir). Read by
19
+ * `dsh_web_restart_status` so callers learn the new PID + HTTP status without
20
+ * hand-probing `ss`/`curl`.
21
+ */
22
+ export interface RestartOutcome {
23
+ state: 'ok' | 'failed';
24
+ oldPid?: number;
25
+ newPid?: number;
26
+ httpStatus?: number;
27
+ swappedAt: number;
28
+ error?: string;
29
+ }
30
+ export declare function outcomePath(sessionId: string): string;
31
+ export declare function writeRestartOutcome(sessionId: string, outcome: RestartOutcome): void;
32
+ export declare function readRestartOutcome(sessionId: string): RestartOutcome | undefined;
package/lib/intents.js CHANGED
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync, unlinkSync } from 'node:fs';
1
+ import { existsSync, readFileSync, unlinkSync, mkdirSync, writeFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
4
  export function intentsDir() {
@@ -25,3 +25,25 @@ export function consumeIntent(sessionId) {
25
25
  }
26
26
  catch { }
27
27
  }
28
+ export function outcomePath(sessionId) {
29
+ const safe = sessionId.replace(/[^A-Za-z0-9._-]/g, '_');
30
+ return join(intentsDir(), `${safe}.outcome.json`);
31
+ }
32
+ export function writeRestartOutcome(sessionId, outcome) {
33
+ try {
34
+ mkdirSync(intentsDir(), { recursive: true });
35
+ writeFileSync(outcomePath(sessionId), JSON.stringify(outcome), { encoding: 'utf8', mode: 0o600 });
36
+ }
37
+ catch { }
38
+ }
39
+ export function readRestartOutcome(sessionId) {
40
+ try {
41
+ const p = outcomePath(sessionId);
42
+ if (!existsSync(p))
43
+ return undefined;
44
+ return JSON.parse(readFileSync(p, 'utf8'));
45
+ }
46
+ catch {
47
+ return undefined;
48
+ }
49
+ }
@@ -11,9 +11,11 @@ export interface RestartRequest {
11
11
  ttl: number;
12
12
  callerSessionId?: string;
13
13
  reason?: string;
14
+ oldPid?: number;
14
15
  }
15
16
  export declare function writeRestartRequest(caller: {
16
17
  callerSessionId?: string;
17
18
  reason?: string;
19
+ oldPid?: number;
18
20
  }, ttlMs?: number): void;
19
21
  export declare function readRestartRequest(): RestartRequest | undefined;
@@ -109,7 +109,7 @@ export function readRestartRequest() {
109
109
  if (typeof j.ts === 'number' && typeof j.ttl === 'number') {
110
110
  if (Date.now() - j.ts >= j.ttl)
111
111
  return undefined;
112
- return { ts: j.ts, ttl: j.ttl, callerSessionId: j.callerSessionId, reason: j.reason };
112
+ return { ts: j.ts, ttl: j.ttl, callerSessionId: j.callerSessionId, reason: j.reason, oldPid: j.oldPid };
113
113
  }
114
114
  }
115
115
  catch { }
@@ -13,6 +13,42 @@
13
13
  * (out-of-band). This tool NEVER restarts the host in-tree.
14
14
  */
15
15
  import { writeRestartRequest } from './restart-guards.js';
16
+ /**
17
+ * Copy a live profile tree for an isolated dry-boot. A naive recursive copy
18
+ * breaks `link:` installs: their node_modules entries are relative symlinks
19
+ * (e.g. `../../../shared/pkg`) that resolve against the copy location and
20
+ * dangle. Every symlink left dangling by the copy is rewritten to the
21
+ * absolute live target it pointed at, so the dry-boot loads the same code
22
+ * the live tree loads. Links already broken in the live tree are left alone
23
+ * (the dry-boot must stay faithful, not fix the live tree).
24
+ */
25
+ export declare function copyProfileForDryBoot(srcDir: string, destDir: string): void;
26
+ /**
27
+ * A dry-boot orphan candidate: a `dsh web` process rooted at a temp DSH_HOME
28
+ * with an ephemeral-port listener. Ports 3080/3081/3082 are the live tree and
29
+ * can never be candidates.
30
+ */
31
+ export interface DryBootCandidate {
32
+ pid: number;
33
+ port: number;
34
+ dshHome: string;
35
+ }
36
+ export interface GcReaders {
37
+ readProc?: () => Array<{
38
+ pid: number;
39
+ cmd: string;
40
+ env: string;
41
+ }>;
42
+ ssPortsOf?: (pid: number) => number[];
43
+ selfPid?: number;
44
+ }
45
+ /**
46
+ * List dry-boot orphans: `dsh web` processes on a temp DSH_HOME holding an
47
+ * ephemeral 9000-9999 listener. Conjunctive fingerprint + absolute exclusions
48
+ * (self PID, live ports, real-home DSH_HOME) — a process is returned only when
49
+ * every signal agrees it is a disposable dry-boot.
50
+ */
51
+ export declare function listDryBootCandidates(readers?: GcReaders): DryBootCandidate[];
16
52
  /**
17
53
  * Boot a copy of the live web profile on an isolated DSH_HOME and verify the
18
54
  * plugin tree loads and serves. Returns ok + a one-line detail for the tool
@@ -80,4 +116,6 @@ export declare function registerRestartTool(ctx: any, deps?: {
80
116
  dryBoot?: typeof dryBootVerify;
81
117
  writeRestartRequest?: typeof writeRestartRequest;
82
118
  harnessRoot?: string;
119
+ gcReaders?: GcReaders;
120
+ killPid?: (pid: number, sig: string) => void;
83
121
  }): () => void;
@@ -12,12 +12,113 @@
12
12
  * callerSessionId) that the supervisor daemon owns and acts on
13
13
  * (out-of-band). This tool NEVER restarts the host in-tree.
14
14
  */
15
- import { join } from 'node:path';
16
- import { mkdtempSync, rmSync, cpSync, existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
15
+ import { join, dirname, resolve } from 'node:path';
16
+ import { mkdtempSync, rmSync, cpSync, existsSync, readFileSync, readdirSync, statSync, lstatSync, readlinkSync, symlinkSync, unlinkSync } from 'node:fs';
17
17
  import { tmpdir, homedir } from 'node:os';
18
- import { spawn } from 'node:child_process';
18
+ import { spawn, execFileSync } from 'node:child_process';
19
19
  import { createRequire } from 'node:module';
20
20
  import { writeRestartRequest } from './restart-guards.js';
21
+ import { intentPath, readIntent, readRestartOutcome } from './intents.js';
22
+ /**
23
+ * Copy a live profile tree for an isolated dry-boot. A naive recursive copy
24
+ * breaks `link:` installs: their node_modules entries are relative symlinks
25
+ * (e.g. `../../../shared/pkg`) that resolve against the copy location and
26
+ * dangle. Every symlink left dangling by the copy is rewritten to the
27
+ * absolute live target it pointed at, so the dry-boot loads the same code
28
+ * the live tree loads. Links already broken in the live tree are left alone
29
+ * (the dry-boot must stay faithful, not fix the live tree).
30
+ */
31
+ export function copyProfileForDryBoot(srcDir, destDir) {
32
+ cpSync(srcDir, destDir, { recursive: true, preserveTimestamps: true });
33
+ const repair = (dir, rel) => {
34
+ for (const name of readdirSync(dir)) {
35
+ const p = join(dir, name);
36
+ const r = rel ? `${rel}/${name}` : name;
37
+ const st = lstatSync(p);
38
+ if (st.isSymbolicLink()) {
39
+ if (!existsSync(p)) {
40
+ const liveTarget = resolve(srcDir, dirname(r), readlinkSync(p));
41
+ if (existsSync(liveTarget)) {
42
+ unlinkSync(p);
43
+ symlinkSync(liveTarget, p);
44
+ }
45
+ }
46
+ }
47
+ else if (st.isDirectory()) {
48
+ repair(p, r);
49
+ }
50
+ }
51
+ };
52
+ repair(destDir, '');
53
+ }
54
+ const LIVE_PORTS = new Set([3080, 3081, 3082]);
55
+ function defaultReadProc() {
56
+ const out = [];
57
+ let names = [];
58
+ try {
59
+ names = readdirSync('/proc');
60
+ }
61
+ catch {
62
+ return out;
63
+ }
64
+ for (const name of names) {
65
+ if (!/^\d+$/.test(name))
66
+ continue;
67
+ try {
68
+ const cmd = readFileSync(`/proc/${name}/cmdline`, 'utf8').replace(/\0/g, ' ');
69
+ const env = readFileSync(`/proc/${name}/environ`, 'utf8');
70
+ out.push({ pid: Number(name), cmd, env });
71
+ }
72
+ catch { /* process exited mid-scan — ignore */ }
73
+ }
74
+ return out;
75
+ }
76
+ function defaultSsPortsOf(pid) {
77
+ try {
78
+ const out = execFileSync('ss', ['-tlnp'], { encoding: 'utf8' });
79
+ const ports = [];
80
+ for (const line of out.split('\n')) {
81
+ if (!line.includes(`pid=${pid},`))
82
+ continue;
83
+ const m = /:(\d+)\s/.exec(line);
84
+ if (m)
85
+ ports.push(Number(m[1]));
86
+ }
87
+ return ports;
88
+ }
89
+ catch {
90
+ return [];
91
+ }
92
+ }
93
+ /**
94
+ * List dry-boot orphans: `dsh web` processes on a temp DSH_HOME holding an
95
+ * ephemeral 9000-9999 listener. Conjunctive fingerprint + absolute exclusions
96
+ * (self PID, live ports, real-home DSH_HOME) — a process is returned only when
97
+ * every signal agrees it is a disposable dry-boot.
98
+ */
99
+ export function listDryBootCandidates(readers = {}) {
100
+ const readProc = readers.readProc ?? defaultReadProc;
101
+ const ssPortsOf = readers.ssPortsOf ?? defaultSsPortsOf;
102
+ const selfPid = readers.selfPid ?? process.pid;
103
+ const out = [];
104
+ for (const p of readProc()) {
105
+ if (p.pid === selfPid)
106
+ continue;
107
+ if (!/bin\.ts web/.test(p.cmd))
108
+ continue;
109
+ const home = /^DSH_HOME=([^\0]*)/m.exec(p.env)?.[1] ?? '';
110
+ if (!home.startsWith(join(tmpdir(), 'dsh-dryboot-')))
111
+ continue;
112
+ const ports = ssPortsOf(p.pid);
113
+ if (ports.some(port => LIVE_PORTS.has(port)))
114
+ continue;
115
+ const eph = ports.filter(port => port >= 9000 && port <= 9999);
116
+ if (eph.length === 0)
117
+ continue;
118
+ out.push({ pid: p.pid, port: eph[0], dshHome: home });
119
+ }
120
+ return out;
121
+ }
21
122
  /**
22
123
  * Boot a copy of the live web profile on an isolated DSH_HOME and verify the
23
124
  * plugin tree loads and serves. Returns ok + a one-line detail for the tool
@@ -39,7 +140,7 @@ export async function dryBootVerify(harnessRoot, opts = {}) {
39
140
  const logs = [];
40
141
  let child = null;
41
142
  try {
42
- cpSync(liveProfile, join(tmpHome, 'profiles', 'web'), { recursive: true, preserveTimestamps: true });
143
+ copyProfileForDryBoot(liveProfile, join(tmpHome, 'profiles', 'web'));
43
144
  const port = String(9000 + Math.floor(Math.random() * 1000));
44
145
  const url = `http://127.0.0.1:${port}/`;
45
146
  child = spawn('node', ['--import', 'tsx/esm', 'apps/cli/src/bin.ts', 'web', '--no-open', '--port', port], {
@@ -207,6 +308,9 @@ export function registerRestartTool(ctx, deps = {}) {
207
308
  const doWrite = deps.writeRestartRequest ?? writeRestartRequest;
208
309
  const doSessionId = deps.sessionIdOf ?? currentSessionId;
209
310
  let dispose;
311
+ let disposeDryboot;
312
+ let disposeGc;
313
+ let disposeStatus;
210
314
  try {
211
315
  dispose = ctx.tools.register({
212
316
  name: 'dsh_web_restart',
@@ -238,9 +342,9 @@ export function registerRestartTool(ctx, deps = {}) {
238
342
  // marker would be written but no restart would ever be supervised.
239
343
  return { ok: false, detail: 'cannot identify the calling session — restart not scheduled' };
240
344
  }
241
- doWrite({ callerSessionId, reason: typeof args.reason === 'string' ? args.reason : undefined }, 180_000);
345
+ doWrite({ callerSessionId, reason: typeof args.reason === 'string' ? args.reason : undefined, oldPid: process.pid }, 180_000);
242
346
  writeIntentSidecar(callerSessionId, args.reason);
243
- return { ok: true, detail: `restart scheduled (≈30s) — caller ${callerSessionId}` };
347
+ return { ok: true, detail: `restart scheduled (≈30s) — caller ${callerSessionId}`, oldPid: process.pid, intentPath: intentPath(callerSessionId) };
244
348
  },
245
349
  });
246
350
  }
@@ -250,11 +354,135 @@ export function registerRestartTool(ctx, deps = {}) {
250
354
  }
251
355
  catch { }
252
356
  }
253
- return () => { try {
254
- if (typeof dispose === 'function')
255
- dispose();
357
+ try {
358
+ disposeDryboot = ctx.tools.register({
359
+ name: 'dsh_web_dryboot',
360
+ description: 'Validate the plugin tree by booting a copy of the live profile on an ephemeral port. Never schedules or performs a restart; temp home removed afterwards.',
361
+ parameters: {
362
+ type: 'object',
363
+ properties: {
364
+ timeoutMs: { type: 'number', description: 'Gate timeout in ms (default 60000).' },
365
+ },
366
+ additionalProperties: false,
367
+ },
368
+ output: {
369
+ schema: { type: 'object', additionalProperties: true, properties: { ok: { type: 'boolean' }, detail: { type: 'string' } } },
370
+ render: (_args, value) => [{ type: 'text', text: value.detail }],
371
+ },
372
+ execute: async (args) => {
373
+ const harnessRoot = deps.harnessRoot ?? (await import('./paths.js')).resolveDeepseekHarnessDir();
374
+ const gate = await doDryBoot(harnessRoot, typeof args.timeoutMs === 'number' ? { timeoutMs: args.timeoutMs } : undefined);
375
+ return { ok: gate.ok, detail: gate.detail };
376
+ },
377
+ });
256
378
  }
257
- catch { } };
379
+ catch (e) {
380
+ try {
381
+ ctx.logger?.warn?.(`[supervisor] dsh_web_dryboot tool failed: ${e?.message ?? String(e)}`);
382
+ }
383
+ catch { }
384
+ }
385
+ try {
386
+ const doKill = deps.killPid ?? ((pid, sig) => process.kill(pid, sig));
387
+ disposeGc = ctx.tools.register({
388
+ name: 'dsh_web_gc',
389
+ description: 'Reap orphaned dry-boot dsh web processes (temp DSH_HOME + ephemeral port). Preview-first: returns candidates without killing unless confirm:true.',
390
+ parameters: {
391
+ type: 'object',
392
+ properties: {
393
+ confirm: { type: 'boolean', description: 'Actually SIGKILL the candidates and verify they are gone.' },
394
+ },
395
+ additionalProperties: false,
396
+ },
397
+ output: {
398
+ schema: { type: 'object', additionalProperties: true, properties: { killed: { type: 'array' }, candidates: { type: 'array' } } },
399
+ render: (_args, value) => [{ type: 'text', text: JSON.stringify(value) }],
400
+ },
401
+ execute: async (args) => {
402
+ // INVARIANT: kill only conjunctive-fingerprint dry-boots (temp DSH_HOME
403
+ // + ephemeral listener), never self / live ports / real-home processes.
404
+ // Preview is the default; killing requires explicit confirm:true.
405
+ const found = listDryBootCandidates(deps.gcReaders);
406
+ if (args.confirm !== true)
407
+ return { killed: [], candidates: found };
408
+ const killed = [];
409
+ for (const c of found) {
410
+ try {
411
+ doKill(c.pid, 'SIGKILL');
412
+ }
413
+ catch { }
414
+ }
415
+ const remaining = listDryBootCandidates(deps.gcReaders);
416
+ const alive = new Set(remaining.map(c => c.pid));
417
+ for (const c of found) {
418
+ if (!alive.has(c.pid))
419
+ killed.push(c.pid);
420
+ }
421
+ return { killed, candidates: remaining };
422
+ },
423
+ });
424
+ }
425
+ catch (e) {
426
+ try {
427
+ ctx.logger?.warn?.(`[supervisor] dsh_web_gc tool failed: ${e?.message ?? String(e)}`);
428
+ }
429
+ catch { }
430
+ }
431
+ try {
432
+ disposeStatus = ctx.tools.register({
433
+ name: 'dsh_web_restart_status',
434
+ description: 'Read the outcome of the calling session\u2019s latest scheduled dsh web restart (pending until the daemon swaps and health-checks).',
435
+ parameters: {
436
+ type: 'object',
437
+ properties: {},
438
+ additionalProperties: false,
439
+ },
440
+ output: {
441
+ schema: { type: 'object', additionalProperties: true },
442
+ render: (_args, value) => [{ type: 'text', text: JSON.stringify(value) }],
443
+ },
444
+ execute: async (_args, exec) => {
445
+ const callerSessionId = doSessionId(exec);
446
+ if (!callerSessionId)
447
+ return { state: 'none', detail: 'cannot identify the calling session' };
448
+ const outcome = readRestartOutcome(callerSessionId);
449
+ if (outcome)
450
+ return { ...outcome };
451
+ const intent = readIntent(callerSessionId);
452
+ if (intent)
453
+ return { state: 'pending', detail: 'restart scheduled, daemon has not reported back yet' };
454
+ return { state: 'none', detail: 'no restart scheduled for this session' };
455
+ },
456
+ });
457
+ }
458
+ catch (e) {
459
+ try {
460
+ ctx.logger?.warn?.(`[supervisor] dsh_web_restart_status tool failed: ${e?.message ?? String(e)}`);
461
+ }
462
+ catch { }
463
+ }
464
+ return () => {
465
+ try {
466
+ if (typeof dispose === 'function')
467
+ dispose();
468
+ }
469
+ catch { }
470
+ try {
471
+ if (typeof disposeDryboot === 'function')
472
+ disposeDryboot();
473
+ }
474
+ catch { }
475
+ try {
476
+ if (typeof disposeGc === 'function')
477
+ disposeGc();
478
+ }
479
+ catch { }
480
+ try {
481
+ if (typeof disposeStatus === 'function')
482
+ disposeStatus();
483
+ }
484
+ catch { }
485
+ };
258
486
  }
259
487
  function writeIntentSidecar(sessionId, reason) {
260
488
  try {
@@ -1,5 +1,6 @@
1
1
  import type { HealthState } from './health-poller.js';
2
2
  import type { RestartRequest } from './restart-guards.js';
3
+ import { type RestartOutcome } from './intents.js';
3
4
  import { type MintCookieOpts } from './dsh-session.js';
4
5
  export interface SupervisorDeps {
5
6
  pollHealth: () => Promise<HealthState>;
@@ -45,7 +46,11 @@ export interface SupervisorDeps {
45
46
  }>;
46
47
  readRestartRequest?: () => RestartRequest | undefined;
47
48
  onRestartRequestHandled?: (req: RestartRequest) => void;
49
+ writeOutcome?: (sessionId: string, outcome: RestartOutcome) => void;
50
+ listenerPid?: (port: number) => number | undefined;
48
51
  }
52
+ /** PID holding a 127.0.0.1 listener on the port, or undefined. Never throws. */
53
+ export declare function defaultListenerPid(port: number): number | undefined;
49
54
  export declare function resumeViaRpc(ids: string[], fetchFn?: (url: string, init: RequestInit) => Promise<Response>, extraHeaders?: Record<string, string>): Promise<{
50
55
  resumed: string[];
51
56
  }>;
package/lib/supervisor.js CHANGED
@@ -6,8 +6,23 @@ import * as os from 'node:os';
6
6
  import { resolveHarnessRoot } from './paths.js';
7
7
  import { readSupervisorConfig } from './config.js';
8
8
  import { writePlannedRestart as defaultWritePlannedRestart, checkPlannedRestart as defaultCheckPlannedRestart, clearPlannedRestart, PLANNED_RESTART_TTL_MS } from './restart-guards.js';
9
+ import { writeRestartOutcome as defaultWriteOutcome } from './intents.js';
10
+ import { execFileSync } from 'node:child_process';
9
11
  import { mintDshSessionCookie } from './dsh-session.js';
10
12
  import { buildKillStalePortsCommand } from './restart-guards.js';
13
+ /** PID holding a 127.0.0.1 listener on the port, or undefined. Never throws. */
14
+ export function defaultListenerPid(port) {
15
+ try {
16
+ const out = execFileSync('ss', ['-tlnp'], { encoding: 'utf8' });
17
+ for (const line of out.split('\n')) {
18
+ const m = new RegExp(`127\\.0\\.0\\.1:${port}\\s[^]*?pid=(\\d+)`).exec(line);
19
+ if (m)
20
+ return Number(m[1]);
21
+ }
22
+ }
23
+ catch { }
24
+ return undefined;
25
+ }
11
26
  export async function resumeViaRpc(ids, fetchFn = globalThis.fetch, extraHeaders = {}) {
12
27
  const rpcId = crypto.randomUUID();
13
28
  const response = await fetchFn('http://127.0.0.1:3080/dsh-maestro-supervisor-resume/resume', {
@@ -359,6 +374,18 @@ export class Supervisor {
359
374
  }
360
375
  catch (e) {
361
376
  await this.deps.notify(`self-restart dsh-web failed: ${e?.message ?? String(e)}`).catch(() => { });
377
+ if (restartReq.callerSessionId) {
378
+ try {
379
+ ;
380
+ (this.deps.writeOutcome ?? defaultWriteOutcome)(restartReq.callerSessionId, {
381
+ state: 'failed',
382
+ oldPid: restartReq.oldPid,
383
+ swappedAt: this.deps.getTime ? this.deps.getTime() : Date.now(),
384
+ error: e?.message ?? String(e),
385
+ });
386
+ }
387
+ catch { }
388
+ }
362
389
  }
363
390
  finally {
364
391
  // Hold the marker and latch until health.up: clearing here would
@@ -506,6 +533,19 @@ export class Supervisor {
506
533
  this.deps.onRestartRequestHandled?.(req);
507
534
  }
508
535
  catch { }
536
+ if (req.callerSessionId) {
537
+ try {
538
+ const listen = this.deps.listenerPid ?? defaultListenerPid;
539
+ (this.deps.writeOutcome ?? defaultWriteOutcome)(req.callerSessionId, {
540
+ state: 'ok',
541
+ oldPid: req.oldPid,
542
+ newPid: listen(3082),
543
+ httpStatus: health?.httpCode ?? 200,
544
+ swappedAt: this.deps.getTime ? this.deps.getTime() : Date.now(),
545
+ });
546
+ }
547
+ catch { }
548
+ }
509
549
  }
510
550
  }
511
551
  // Throttle LKG writes to at most once per 5 minutes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ddtcorex/dsh-maestro-supervisor",
3
- "version": "0.7.9",
3
+ "version": "0.8.0",
4
4
  "description": "Supervisor daemon for DSH Web resilience — auto-detect crashes, rollback to LKG, report",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -62,6 +62,36 @@ the append-only log destination. The helper refuses a real swap without
62
62
  and refuses to launch if ports are still occupied. `--dry-run` never runs
63
63
  `kill` or `setsid`.
64
64
 
65
+ ## In-session validation without restart
66
+
67
+ In-session agents have three tools (same row as `dsh_web_restart`) that never
68
+ touch the live process:
69
+
70
+ - `dsh_web_dryboot` (`{ timeoutMs? }`) — boots a copy of the live profile on an
71
+ ephemeral port with an isolated `DSH_HOME` and returns `{ ok, detail }`.
72
+ Prefer this over hand-rolled `cp -r` + spawn: the copy repairs relative
73
+ `link:` symlinks and the child is always killed with the temp home removed.
74
+ - `dsh_web_gc` (`{ confirm? }`) — lists orphaned dry-boot processes
75
+ (`DSH_HOME=/tmp/dsh-dryboot-*` + ephemeral listener, never self / live ports
76
+ / real-home processes). Default returns the preview list; `confirm:true`
77
+ SIGKILLs and verifies absence.
78
+ - `dsh_web_restart_status` (no params) — reads the calling session's restart
79
+ outcome: `pending` until the daemon swaps, then `ok`/`failed` with
80
+ `oldPid`/`newPid`/`httpStatus`. `dsh_web_restart` itself now returns
81
+ `{ ok, detail, oldPid, intentPath }` — quote all four when reporting.
82
+
83
+ Settings-staging rule: config-lib memoizes settings per process, so an
84
+ out-of-band settings edit is invisible to the host until restart. Stage ALL
85
+ such edits, then restart ONCE. Prefer in-host Settings UI saves — they are
86
+ visible immediately with no restart at all.
87
+
88
+ TLS note for plugin authors: Node/undici reads `NODE_EXTRA_CA_CERTS` from the
89
+ birth environment only; assigning it at runtime is silently ignored. The
90
+ durable path for a local CA (e.g. a Govard/Caddy dev CA) is a systemd user
91
+ drop-in (`~/.config/systemd/user/dsh-web.service.d/*.conf` with
92
+ `Environment=NODE_EXTRA_CA_CERTS=<path>`), followed by a daemon reload and a
93
+ host restart.
94
+
65
95
  ## Post-swap checks
66
96
 
67
97
  Do not read the top of an old append-only log as liveness evidence. Instead: