@ddtcorex/dsh-maestro-supervisor 0.6.7 → 0.7.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/cli.d.ts CHANGED
@@ -1 +1,14 @@
1
+ /**
2
+ * Copy a chosen LKG snapshot back into the DSH home. Recent snapshots are
3
+ * tried newest-first, skipping any that still carry a failing plugin so a
4
+ * broken bundle is not restored. `sessions/` is deliberately NOT restored —
5
+ * session logs are append-only truth and rolling them back to a snapshot
6
+ * would drop every turn recorded after that snapshot.
7
+ * @returns the rolled-back snapshot id.
8
+ */
9
+ export declare function rollbackLKG(opts: {
10
+ dshHome: string;
11
+ lkgRoot: string;
12
+ failingPlugin?: string;
13
+ }): Promise<string>;
1
14
  export declare function runCli(args: string[]): Promise<void>;
package/lib/cli.js CHANGED
@@ -5,7 +5,76 @@ import * as fs from 'node:fs';
5
5
  import * as path from 'node:path';
6
6
  import * as os from 'node:os';
7
7
  import { resolveHarnessRoot, resolveDeepseekHarnessDir } from './paths.js';
8
- import { buildKillStalePortsCommand, isSelfCopyError, checkPlannedRestart, writePlannedRestart } from './restart-guards.js';
8
+ import { buildKillStalePortsCommand, isSelfCopyError, checkPlannedRestart, writePlannedRestart, readRestartRequest, clearPlannedRestart } from './restart-guards.js';
9
+ /**
10
+ * Copy a chosen LKG snapshot back into the DSH home. Recent snapshots are
11
+ * tried newest-first, skipping any that still carry a failing plugin so a
12
+ * broken bundle is not restored. `sessions/` is deliberately NOT restored —
13
+ * session logs are append-only truth and rolling them back to a snapshot
14
+ * would drop every turn recorded after that snapshot.
15
+ * @returns the rolled-back snapshot id.
16
+ */
17
+ export async function rollbackLKG(opts) {
18
+ const { dshHome, lkgRoot, failingPlugin } = opts;
19
+ const entries = fs.existsSync(lkgRoot) ? fs.readdirSync(lkgRoot).sort() : [];
20
+ if (!entries.length)
21
+ throw new Error('no LKG to rollback to');
22
+ const candidates = [...entries].reverse().slice(0, 3);
23
+ let chosen;
24
+ for (const cand of candidates) {
25
+ if (!failingPlugin) {
26
+ chosen = cand;
27
+ break;
28
+ }
29
+ try {
30
+ const pkgPath = path.join(lkgRoot, cand, 'profiles/web/package.json');
31
+ if (!fs.existsSync(pkgPath)) {
32
+ chosen = cand;
33
+ break;
34
+ }
35
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
36
+ const bundles = pkg?.dsh?.profile?.bundles ?? [];
37
+ const deps = pkg?.dependencies ?? {};
38
+ const hasFailing = bundles.some((b) => b.includes(failingPlugin)) || Object.keys(deps).some(k => k.includes(failingPlugin));
39
+ if (!hasFailing) {
40
+ chosen = cand;
41
+ break;
42
+ }
43
+ console.log(`[supervisor] skipping LKG ${cand} still contains failing plugin ${failingPlugin}`);
44
+ }
45
+ catch {
46
+ chosen = cand;
47
+ break;
48
+ }
49
+ }
50
+ const target = chosen ?? entries[entries.length - 1];
51
+ const src = path.join(lkgRoot, target);
52
+ for (const entry of fs.readdirSync(src)) {
53
+ if (entry === 'manifest.json')
54
+ continue;
55
+ if (entry === 'sessions') {
56
+ console.log('[supervisor] rollback keeps live sessions (append-only truth) — skipping sessions/');
57
+ continue;
58
+ }
59
+ const srcPath = path.join(src, entry);
60
+ const destPath = path.join(dshHome, entry);
61
+ try {
62
+ // Skip if src and dest are the same file (e.g. symlink to same target like ~/.dsh/AGENTS.md)
63
+ try {
64
+ if (fs.existsSync(srcPath) && fs.existsSync(destPath) && fs.realpathSync(srcPath) === fs.realpathSync(destPath))
65
+ continue;
66
+ }
67
+ catch { }
68
+ fs.cpSync(srcPath, destPath, { recursive: true, force: true });
69
+ }
70
+ catch (e) {
71
+ if (isSelfCopyError(String(e?.message ?? '')))
72
+ continue;
73
+ throw e;
74
+ }
75
+ }
76
+ return target;
77
+ }
9
78
  export async function runCli(args) {
10
79
  const cmd = args[2] ?? '--help';
11
80
  if (cmd === '--help' || cmd === '-h' || cmd === 'help') {
@@ -108,10 +177,6 @@ Commands:
108
177
  },
109
178
  rollback: async () => {
110
179
  const { execSync } = await import('node:child_process');
111
- const entries = fs.existsSync(lkgRoot) ? fs.readdirSync(lkgRoot).sort() : [];
112
- if (!entries.length)
113
- throw new Error('no LKG to rollback to');
114
- // Try newest to oldest (up to 3) to find a clean LKG for plugin failures
115
180
  // Extract failing plugin from current log tail if possible
116
181
  let failingPlugin;
117
182
  try {
@@ -121,56 +186,7 @@ Commands:
121
186
  failingPlugin = m[0].replace(/^@ddtcorex\//, '');
122
187
  }
123
188
  catch { }
124
- const candidates = [...entries].reverse().slice(0, 3);
125
- let chosen;
126
- for (const cand of candidates) {
127
- if (!failingPlugin) {
128
- chosen = cand;
129
- break;
130
- }
131
- try {
132
- const pkgPath = path.join(lkgRoot, cand, 'profiles/web/package.json');
133
- if (!fs.existsSync(pkgPath)) {
134
- chosen = cand;
135
- break;
136
- }
137
- const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
138
- const bundles = pkg?.dsh?.profile?.bundles ?? [];
139
- const deps = pkg?.dependencies ?? {};
140
- const hasFailing = bundles.some((b) => b.includes(failingPlugin)) || Object.keys(deps).some(k => k.includes(failingPlugin));
141
- if (!hasFailing) {
142
- chosen = cand;
143
- break;
144
- }
145
- console.log(`[supervisor] skipping LKG ${cand} still contains failing plugin ${failingPlugin}`);
146
- }
147
- catch {
148
- chosen = cand;
149
- break;
150
- }
151
- }
152
- const target = chosen ?? entries[entries.length - 1];
153
- const src = path.join(lkgRoot, target);
154
- for (const entry of fs.readdirSync(src)) {
155
- if (entry === 'manifest.json')
156
- continue;
157
- const srcPath = path.join(src, entry);
158
- const destPath = path.join(dshHome, entry);
159
- try {
160
- // Skip if src and dest are the same file (e.g. symlink to same target like ~/.dsh/AGENTS.md)
161
- try {
162
- if (fs.existsSync(srcPath) && fs.existsSync(destPath) && fs.realpathSync(srcPath) === fs.realpathSync(destPath))
163
- continue;
164
- }
165
- catch { }
166
- fs.cpSync(srcPath, destPath, { recursive: true, force: true });
167
- }
168
- catch (e) {
169
- if (isSelfCopyError(String(e?.message ?? '')))
170
- continue;
171
- throw e;
172
- }
173
- }
189
+ const target = await rollbackLKG({ dshHome, lkgRoot, failingPlugin });
174
190
  console.log(`[supervisor] rolled back to ${target}${failingPlugin ? ` (avoiding ${failingPlugin})` : ''}`);
175
191
  // Reconcile node_modules from restored package.json (critical for link: deps)
176
192
  try {
@@ -229,6 +245,22 @@ Commands:
229
245
  },
230
246
  notify: async (msg) => console.log(`[notify] ${msg}`),
231
247
  isPlannedRestartActive: () => checkPlannedRestart(),
248
+ // dsh_web_restart marker ownership: the daemon acts on the marker. After
249
+ // the restart, scan recent session logs for torn tails (an in-flight
250
+ // session truncated by the restart) and report; the marker is cleared in
251
+ // the supervisor's own finally block and again here as a safety net.
252
+ readRestartRequest: () => readRestartRequest(),
253
+ onRestartRequestHandled: async () => {
254
+ const { scanSessions } = await import('./scan.js');
255
+ const res = await scanSessions(path.join(os.homedir(), '.dsh'), { withinMs: 10 * 60 * 1000 }).catch(() => ({ scanned: 0, torn: [] }));
256
+ if (res.torn.length) {
257
+ console.log(`[supervisor] post-self-restart scan: ${res.torn.length} torn session log(s)`);
258
+ }
259
+ else {
260
+ console.log('[supervisor] post-self-restart scan: clean');
261
+ }
262
+ void clearPlannedRestart();
263
+ },
232
264
  });
233
265
  await supervisor.start();
234
266
  // keep process alive
@@ -268,6 +268,7 @@ async function defaultFetchLLM(prompt) {
268
268
  body: JSON.stringify({
269
269
  model: cfg.model,
270
270
  temperature: 0.2,
271
+ ...(cfg.reasoningEffort ? { reasoning_effort: cfg.reasoningEffort, reasoningEffort: cfg.reasoningEffort } : {}),
271
272
  messages: [
272
273
  { role: 'system', content: 'You are a systematic-debugging agent for DSH Web resilience. Follow the 4 phases: root cause, pattern analysis, hypothesis, implementation. Always propose minimal single-file fix.' },
273
274
  { role: 'user', content: prompt },
@@ -312,7 +313,7 @@ async function defaultFetchLLM(prompt) {
312
313
  { role: 'user', content: prompt },
313
314
  ],
314
315
  max_output_tokens: 4000,
315
- reasoning: { effort: 'low' },
316
+ ...(cfg.reasoningEffort ? { reasoning: { effort: cfg.reasoningEffort } } : { reasoning: { effort: 'low' } }),
316
317
  }),
317
318
  });
318
319
  if (!res2.ok)
@@ -339,7 +340,9 @@ async function resolveLLMConfig() {
339
340
  // Model: AI_MODEL / DEEPSEEK_MODEL / settings.json domains.supervisor.model -> domains.review.model / default
340
341
  // Supervisor has its own picker; falls back to review model for backward compat, then DSH default.
341
342
  let model = process.env.AI_MODEL ?? process.env.DEEPSEEK_MODEL ?? process.env.OPENAI_MODEL ?? null;
342
- if (!model) {
343
+ // reasoningEffort: env first, then supervisor -> review, then settings.yaml fallback
344
+ let reasoningEffort = (process.env.AI_REASONING_EFFORT ?? process.env.REASONING_EFFORT ?? null) ?? undefined;
345
+ if (!model || !reasoningEffort) {
343
346
  try {
344
347
  const { readFileSync } = await import('node:fs');
345
348
  const { homedir } = await import('node:os');
@@ -347,24 +350,57 @@ async function resolveLLMConfig() {
347
350
  const raw = readFileSync(settingsPath, 'utf-8');
348
351
  const j = JSON.parse(raw);
349
352
  // Prefer supervisor model, fall back to review model (so old installs keep working)
350
- const sup = j?.domains?.supervisor?.model?.model ?? j?.domains?.supervisor?.model;
351
- const rev = j?.domains?.review?.model?.model ?? j?.domains?.review?.model;
352
- let m = null;
353
- if (typeof sup === 'string')
354
- m = sup;
355
- else if (sup?.model && typeof sup.model === 'string')
356
- m = sup.model;
357
- else if (typeof rev === 'string')
358
- m = rev;
359
- else if (rev?.model && typeof rev.model === 'string')
360
- m = rev.model;
361
- if (typeof m === 'string' && m.trim() !== '')
362
- model = m;
353
+ if (!model) {
354
+ const sup = j?.domains?.supervisor?.model?.model ?? j?.domains?.supervisor?.model;
355
+ const rev = j?.domains?.review?.model?.model ?? j?.domains?.review?.model;
356
+ let m = null;
357
+ if (typeof sup === 'string')
358
+ m = sup;
359
+ else if (sup?.model && typeof sup.model === 'string')
360
+ m = sup.model;
361
+ else if (typeof rev === 'string')
362
+ m = rev;
363
+ else if (rev?.model && typeof rev.model === 'string')
364
+ m = rev.model;
365
+ if (typeof m === 'string' && m.trim() !== '')
366
+ model = m;
367
+ }
368
+ if (!reasoningEffort) {
369
+ // config-lib validator: model is {provider, model, reasoningEffort?} inside domains.supervisor.model / domains.review.model
370
+ // Support both nested object and flat legacy fallback for future compat
371
+ const supObj = j?.domains?.supervisor?.model;
372
+ const revObj = j?.domains?.review?.model;
373
+ const supEff = typeof supObj === 'object' && supObj !== null ? supObj.reasoningEffort : undefined;
374
+ const supFlat = j?.domains?.supervisor?.reasoningEffort;
375
+ const revEff = typeof revObj === 'object' && revObj !== null ? revObj.reasoningEffort : undefined;
376
+ const revFlat = j?.domains?.review?.reasoningEffort;
377
+ const candidate = supEff ?? supFlat ?? revEff ?? revFlat;
378
+ if (typeof candidate === 'string' && candidate.trim() !== '')
379
+ reasoningEffort = candidate.trim();
380
+ }
363
381
  }
364
382
  catch { }
365
383
  }
366
384
  if (!model)
367
385
  model = 'deepseek-chat';
386
+ // Also try settings.yaml for reasoningEffort if still missing (e.g., llm-pi-ai default reasoning)
387
+ if (!reasoningEffort) {
388
+ try {
389
+ const { readFileSync } = await import('node:fs');
390
+ const { homedir } = await import('node:os');
391
+ const yamlPath = `${homedir()}/.dsh/settings.yaml`;
392
+ const yaml = readFileSync(yamlPath, 'utf-8');
393
+ // Look for reasoningEffort near the model id in provider blocks (future-proof; currently not in yaml)
394
+ const re = new RegExp(`id:\\s*${model}[\\s\\S]{0,200}reasoningEffort:\\s*(\\S+)`, 'm');
395
+ const m = yaml.match(re);
396
+ if (m) {
397
+ const v = m[1].trim().replace(/^["']|["']$/g, '');
398
+ if (v)
399
+ reasoningEffort = v;
400
+ }
401
+ }
402
+ catch { }
403
+ }
368
404
  // If url not set via env, try to resolve from ~/.dsh/settings.yaml llm-pi-ai providers (DeepSeek suggested setup)
369
405
  if (!url) {
370
406
  try {
@@ -432,7 +468,7 @@ async function resolveLLMConfig() {
432
468
  if (!url.includes('/v1/') && !url.includes('/chat/completions')) {
433
469
  finalUrl = url.replace(/\/$/, '') + '/v1/chat/completions';
434
470
  }
435
- return { key, url: finalUrl, model };
471
+ return { key, url: finalUrl, model, ...(reasoningEffort ? { reasoningEffort } : {}) };
436
472
  }
437
473
  async function resolveApiKey() {
438
474
  const cfg = await resolveLLMConfig();
@@ -50,6 +50,10 @@ function isRecentlyStarted(opts, wallMs) {
50
50
  return true;
51
51
  return false;
52
52
  }
53
+ // Specific parse/boot-failure markers only. Bare 'JSON'/'YAML' were removed
54
+ // (2026-08-31): they matched any line whose payload merely *contained* those
55
+ // substrings — e.g. maestro-sync's status JSON listing session.jsonl.zstd /
56
+ // settings.json paths — turning a healthy 401 into a rollback + restart.
53
57
  const ERROR_PATTERNS = [
54
58
  'ERR_MODULE_NOT_FOUND',
55
59
  'ERR_PNPM',
@@ -58,8 +62,6 @@ const ERROR_PATTERNS = [
58
62
  'SyntaxError',
59
63
  'YAMLParseError',
60
64
  'ParseError',
61
- 'YAML',
62
- 'JSON',
63
65
  'corrupted',
64
66
  'allowBuilds',
65
67
  'Cannot find module',
package/lib/plugin.d.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  * and web restart; this plugin handles the in-process resume.
7
7
  */
8
8
  import { findInterrupted as defaultFindInterrupted, findDanglingOpenTurns as defaultFindDanglingOpenTurns } from './resume.js';
9
- export declare const inject: readonly ["sessions", "agents", "connection"];
9
+ export declare const inject: readonly ["sessions", "agents", "connection", "skills"];
10
10
  export interface SupervisorPluginConfig {
11
11
  autoResumeWithin?: number | string;
12
12
  autoResumeEnabled?: boolean;
package/lib/plugin.js CHANGED
@@ -8,8 +8,13 @@
8
8
  import * as fs from 'node:fs';
9
9
  import * as path from 'node:path';
10
10
  import * as os from 'node:os';
11
+ import { fileURLToPath } from 'node:url';
11
12
  import { findInterrupted as defaultFindInterrupted, findDanglingOpenTurns as defaultFindDanglingOpenTurns } from './resume.js';
12
- export const inject = ['sessions', 'agents', 'connection'];
13
+ import { makeSkillProvider } from './skill-provider.js';
14
+ import { registerRestartTool } from './restart-tool.js';
15
+ import { makePreExecuteGuard } from './self-kill-guard.js';
16
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
17
+ export const inject = ['sessions', 'agents', 'connection', 'skills'];
13
18
  function parseDuration(s) {
14
19
  if (!s)
15
20
  return undefined;
@@ -280,6 +285,25 @@ export function createResumeRpcHandler(ctx, opts = {}) {
280
285
  return { ok: true, value: { resumed: await resume(ctx, ids) } };
281
286
  };
282
287
  }
288
+ /** Resolve the package-root skills/ dir regardless of module layout. The built
289
+ * host lib is flat (lib/plugin.js → ../skills), but under vitest the same
290
+ * module loads from src/host/ (→ ../../skills). Walking to the nearest
291
+ * package.json yields the same package-root skills/ in both layouts. */
292
+ function resolveSkillsDir(fromDir) {
293
+ let dir = fromDir;
294
+ for (let i = 0; i < 6; i++) {
295
+ try {
296
+ if (fs.existsSync(path.join(dir, 'package.json')))
297
+ return path.join(dir, 'skills');
298
+ }
299
+ catch { }
300
+ const parent = path.dirname(dir);
301
+ if (parent === dir)
302
+ break;
303
+ dir = parent;
304
+ }
305
+ return path.join(fromDir, '..', 'skills');
306
+ }
283
307
  function ensureSystemdKeepalive(ctx) {
284
308
  // Best-effort: ensure dsh-web-keepalive.service exists and is enabled, and linger is on.
285
309
  // This is the user-level auto-fix for the 11:42:58 crash where manager session 97
@@ -365,6 +389,36 @@ export function apply(ctx, config = {}) {
365
389
  ensureSystemdKeepalive(ctx);
366
390
  }
367
391
  catch { }
392
+ try {
393
+ const skills = ctx.get?.('skills');
394
+ if (skills?.registerProvider) {
395
+ ctx.effect(() => {
396
+ let unregister;
397
+ try {
398
+ // Package-root skills/ is resolved at runtime by walking to the
399
+ // nearest package.json (robust to lib/ vs src/host/ layouts).
400
+ unregister = skills.registerProvider(() => makeSkillProvider(resolveSkillsDir(__dirname)));
401
+ }
402
+ catch (e) {
403
+ ctx.logger?.warn?.(`[supervisor] skill provider failed: ${e?.message ?? String(e)}`);
404
+ }
405
+ return () => { try {
406
+ unregister?.();
407
+ }
408
+ catch { } };
409
+ }, 'supervisor:skill');
410
+ }
411
+ }
412
+ catch { }
413
+ try {
414
+ ctx.effect(() => registerRestartTool(ctx), 'supervisor:restart-tool');
415
+ }
416
+ catch (e) {
417
+ try {
418
+ ctx.logger?.warn?.(`[supervisor] restart tool effect failed: ${e?.message ?? String(e)}`);
419
+ }
420
+ catch { }
421
+ }
368
422
  ctx.effect(() => {
369
423
  let disposed = false;
370
424
  let timer = null;
@@ -400,6 +454,24 @@ export function apply(ctx, config = {}) {
400
454
  }
401
455
  };
402
456
  }, 'supervisor:auto-resume');
457
+ try {
458
+ // Deny bash/shell self-kill commands in-tree; the safe restart path is
459
+ // dsh_web_restart (supervisor daemon owns the actual restart).
460
+ const guard = makePreExecuteGuard();
461
+ ctx.effect(() => {
462
+ const un = ctx.on?.('tools/pre-execute', guard) ?? null;
463
+ return () => { try {
464
+ un?.();
465
+ }
466
+ catch { } };
467
+ }, 'supervisor:self-kill-guard');
468
+ }
469
+ catch (e) {
470
+ try {
471
+ ctx.logger?.warn?.(`[supervisor] self-kill guard effect failed: ${e?.message ?? String(e)}`);
472
+ }
473
+ catch { }
474
+ }
403
475
  }
404
476
  catch (e) {
405
477
  try {
@@ -6,3 +6,14 @@ export declare function plannedRestartPath(): string;
6
6
  export declare function writePlannedRestart(ttlMs?: number): void;
7
7
  export declare function checkPlannedRestart(markerPath?: string): boolean;
8
8
  export declare function clearPlannedRestart(): void;
9
+ export interface RestartRequest {
10
+ ts: number;
11
+ ttl: number;
12
+ callerSessionId?: string;
13
+ reason?: string;
14
+ }
15
+ export declare function writeRestartRequest(caller: {
16
+ callerSessionId?: string;
17
+ reason?: string;
18
+ }, ttlMs?: number): void;
19
+ export declare function readRestartRequest(): RestartRequest | undefined;
@@ -92,3 +92,26 @@ export function clearPlannedRestart() {
92
92
  }
93
93
  catch { }
94
94
  }
95
+ export function writeRestartRequest(caller, ttlMs = 180_000) {
96
+ const p = plannedRestartPath();
97
+ fs.mkdirSync(path.dirname(p), { recursive: true });
98
+ const body = { ts: Date.now(), ttl: ttlMs, ...caller };
99
+ fs.writeFileSync(p, JSON.stringify(body), { mode: 0o600 });
100
+ try {
101
+ fs.chmodSync(p, 0o600);
102
+ }
103
+ catch { }
104
+ }
105
+ export function readRestartRequest() {
106
+ try {
107
+ const raw = fs.readFileSync(plannedRestartPath(), 'utf8');
108
+ const j = JSON.parse(raw);
109
+ if (typeof j.ts === 'number' && typeof j.ttl === 'number') {
110
+ if (Date.now() - j.ts >= j.ttl)
111
+ return undefined;
112
+ return { ts: j.ts, ttl: j.ttl, callerSessionId: j.callerSessionId, reason: j.reason };
113
+ }
114
+ }
115
+ catch { }
116
+ return undefined;
117
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * dsh_web_restart tool — the safe restart path for the model running inside
3
+ * dsh web. Scheduling a restart through this tool instead of a raw kill keeps
4
+ * the restart inside the supervisor's ownership loop:
5
+ *
6
+ * 1. dry-boot gate — if the plugin tree changed since the latest LKG, boot a
7
+ * copy of the live profile on an ephemeral DSH_HOME first and only
8
+ * schedule when that boot serves HTTP.
9
+ * 2. intent sidecar — record the caller session + reason under
10
+ * ~/.dsh/.supervisor/intents/ for attribution.
11
+ * 3. hand-off — write the restart-request marker (planned-restart.json with
12
+ * callerSessionId) that the supervisor daemon owns and acts on
13
+ * (out-of-band). This tool NEVER restarts the host in-tree.
14
+ */
15
+ import { writeRestartRequest } from './restart-guards.js';
16
+ /**
17
+ * Boot a copy of the live web profile on an isolated DSH_HOME and verify the
18
+ * plugin tree loads and serves. Returns ok + a one-line detail for the tool
19
+ * message. The spawned tree is killed (best-effort) and the temp home removed.
20
+ * Unit tests mock this (never spawn a real node boot in tests).
21
+ *
22
+ * NOTE (bin.ts finding): the `web` alias already implies `--profile web`, and
23
+ * the web app's own commander program (no allowUnknownOption) rejects a stray
24
+ * `--profile` in its inner args — so the spawn passes only `web --no-open
25
+ * --port <port>`.
26
+ */
27
+ export declare function dryBootVerify(harnessRoot: string, opts?: {
28
+ timeoutMs?: number;
29
+ }): Promise<{
30
+ ok: boolean;
31
+ detail: string;
32
+ }>;
33
+ /** Minimal file metadata the drift check reads; injectable for deterministic tests. */
34
+ export interface FileStat {
35
+ mtimeMs: number;
36
+ }
37
+ /**
38
+ * Whether the live plugin tree differs from the latest LKG snapshot. Two
39
+ * signals are combined:
40
+ *
41
+ * 1. manifest drift — the live profile's web `package.json` text vs baseline;
42
+ * 2. plugin-lib drift — any `@ddtcorex` plugin `lib/` file newer than the
43
+ * snapshot moment. Link-installed plugins resolve to the same workspace
44
+ * files in both live and LKG, so the stored copies cannot be compared
45
+ * byte-wise; the snapshot itself is the meaningful baseline and a rebuilt
46
+ * `lib/` bumps a file past it even when the manifest text is unchanged.
47
+ * writeLKG writes `manifest.json` LAST, so its FILE mtime is the
48
+ * authoritative snapshot moment; the snapshot dir mtime is only a
49
+ * fallback for legacy snapshots without a manifest.
50
+ *
51
+ * `statFile` (default `statSync`) reads the metadata so tests can inject a
52
+ * controlled reader instead of relying on filesystem utimes (which CI runners
53
+ * do not reliably reflect). No LKG baseline, a missing file on either side, or
54
+ * any stat/read error means "changed" — the caller falls back to the dry-boot
55
+ * gate.
56
+ */
57
+ export declare function isPluginTreeChanged(harnessRoot: string, lkgDir?: string, opts?: {
58
+ statFile?: (p: string) => FileStat;
59
+ }): boolean;
60
+ /**
61
+ * Register the dsh_web_restart tool. Registration is fail-safe (warns, never
62
+ * throws) and the returned function disposes the registration. `deps` are
63
+ * injectable for tests.
64
+ */
65
+ export declare function registerRestartTool(ctx: any, deps?: {
66
+ sessionIdOf?: (exec: any) => string | undefined;
67
+ dryBoot?: typeof dryBootVerify;
68
+ writeRestartRequest?: typeof writeRestartRequest;
69
+ harnessRoot?: string;
70
+ }): () => void;