@ouro.bot/cli 0.1.0-alpha.727 → 0.1.0-alpha.728

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.
@@ -118,6 +118,7 @@ function usage() {
118
118
  " ouro link <agent> --friend <id> --provider <provider> --external-id <external-id>",
119
119
  " ouro bluebubbles replay [--agent <name>] --message-guid <guid> [--event-type new-message|updated-message] [--json]",
120
120
  " ouro bluebubbles context-smoke [--agent <name>] --message-guid <guid> [--persist] [--json]",
121
+ " ouro bluebubbles host <install|status|repair|remove|collect> [--username <name> --uid <uid> --home <path>] [--request-id <id>] [--json]",
121
122
  " ouro rsvp <doctor|incident|cutover|legacy-render|replay|config|habit|import-legacy|refresh|compare|smoke> ...",
122
123
  " ouro friend list [--agent <name>]",
123
124
  " ouro friend show <id> [--agent <name>]",
@@ -1543,6 +1544,7 @@ function parseMcpCommand(args) {
1543
1544
  if (sub === "doctor") {
1544
1545
  let socketOverride;
1545
1546
  let json = false;
1547
+ let hostStallObserved = false;
1546
1548
  for (let i = 0; i < rest.length; i++) {
1547
1549
  if (rest[i] === "--socket") {
1548
1550
  if (!rest[i + 1])
@@ -1554,6 +1556,10 @@ function parseMcpCommand(args) {
1554
1556
  json = true;
1555
1557
  continue;
1556
1558
  }
1559
+ if (rest[i] === "--host-stall-observed") {
1560
+ hostStallObserved = true;
1561
+ continue;
1562
+ }
1557
1563
  throw new Error(`Unknown mcp doctor flag: ${rest[i]}`);
1558
1564
  }
1559
1565
  if (!agent)
@@ -1563,6 +1569,7 @@ function parseMcpCommand(args) {
1563
1569
  agent,
1564
1570
  ...(socketOverride ? { socketOverride } : {}),
1565
1571
  ...(json ? { json: true } : {}),
1572
+ ...(hostStallObserved ? { hostStallObserved: true } : {}),
1566
1573
  };
1567
1574
  }
1568
1575
  throw new Error(`Usage\n${usage()}`);
@@ -1751,6 +1758,62 @@ function parseMigrateToDeskCommand(args) {
1751
1758
  }
1752
1759
  function parseBlueBubblesCommand(args) {
1753
1760
  const subcommand = args[0];
1761
+ if (subcommand === "host") {
1762
+ const action = args[1];
1763
+ const usageText = "Usage: ouro bluebubbles host <install|status|repair|remove|collect> [--username <name> --uid <uid> --home <path>] [--request-id <id>] [--json]";
1764
+ if (action === "collect") {
1765
+ let requestId;
1766
+ let json = false;
1767
+ for (let index = 2; index < args.length; index += 1) {
1768
+ if (args[index] === "--request-id" && args[index + 1]) {
1769
+ requestId = args[++index];
1770
+ }
1771
+ else if (args[index] === "--json") {
1772
+ json = true;
1773
+ }
1774
+ else {
1775
+ throw new Error(usageText);
1776
+ }
1777
+ }
1778
+ if (!requestId)
1779
+ throw new Error(`${usageText}\ncollect requires --request-id <id>`);
1780
+ return { kind: "bluebubbles.host.collect", requestId, ...(json ? { json: true } : {}) };
1781
+ }
1782
+ if (action !== "install" && action !== "status" && action !== "repair" && action !== "remove") {
1783
+ throw new Error(usageText);
1784
+ }
1785
+ let username;
1786
+ let uid;
1787
+ let homeDir;
1788
+ let json = false;
1789
+ for (let index = 2; index < args.length; index += 1) {
1790
+ const token = args[index];
1791
+ if (token === "--username" && args[index + 1])
1792
+ username = args[++index];
1793
+ else if (token === "--uid" && args[index + 1]) {
1794
+ const value = Number(args[++index]);
1795
+ if (!Number.isInteger(value))
1796
+ throw new Error(`${usageText}\n--uid must be an integer`);
1797
+ uid = value;
1798
+ }
1799
+ else if (token === "--home" && args[index + 1])
1800
+ homeDir = args[++index];
1801
+ else if (token === "--json")
1802
+ json = true;
1803
+ else
1804
+ throw new Error(usageText);
1805
+ }
1806
+ const targetFields = [username, uid, homeDir].filter((value) => value !== undefined).length;
1807
+ if (targetFields !== 0 && targetFields !== 3) {
1808
+ throw new Error(`${usageText}\ncross-user setup requires --username, --uid, and --home together`);
1809
+ }
1810
+ return {
1811
+ kind: "bluebubbles.host",
1812
+ action,
1813
+ ...(targetFields === 3 ? { target: { username: username, uid: uid, homeDir: homeDir } } : {}),
1814
+ ...(json ? { json: true } : {}),
1815
+ };
1816
+ }
1754
1817
  if (subcommand !== "replay" && subcommand !== "context-smoke") {
1755
1818
  throw new Error(`Usage\n${usage()}`);
1756
1819
  }
@@ -2442,7 +2505,12 @@ function parseOuroCommand(args) {
2442
2505
  }
2443
2506
  if (head === "up") {
2444
2507
  const noRepair = args.includes("--no-repair");
2445
- return noRepair ? { kind: "daemon.up", noRepair: true } : { kind: "daemon.up" };
2508
+ const latest = args.includes("--latest");
2509
+ return {
2510
+ kind: "daemon.up",
2511
+ ...(noRepair ? { noRepair: true } : {}),
2512
+ ...(latest ? { latest: true } : {}),
2513
+ };
2446
2514
  }
2447
2515
  if (head === "dev") {
2448
2516
  const devArgs = args.slice(1);
@@ -48,6 +48,7 @@ exports.buildStoppedStatusPayload = buildStoppedStatusPayload;
48
48
  exports.renderRollupStatusLine = renderRollupStatusLine;
49
49
  exports.daemonUnavailableStatusOutput = daemonUnavailableStatusOutput;
50
50
  exports.isDaemonUnavailableError = isDaemonUnavailableError;
51
+ exports.isDaemonTimeoutError = isDaemonTimeoutError;
51
52
  exports.formatMcpResponse = formatMcpResponse;
52
53
  const path = __importStar(require("path"));
53
54
  const runtime_metadata_1 = require("./runtime-metadata");
@@ -746,6 +747,12 @@ function isDaemonUnavailableError(error) {
746
747
  : "";
747
748
  return code === "ENOENT" || code === "ECONNREFUSED";
748
749
  }
750
+ function isDaemonTimeoutError(error) {
751
+ const code = typeof error === "object" && error !== null && "code" in error
752
+ ? String(error.code ?? "")
753
+ : "";
754
+ return code === "ETIMEDOUT";
755
+ }
749
756
  function formatMcpResponse(command, response) {
750
757
  if (command.kind === "mcp.list") {
751
758
  const allTools = response.data;
@@ -24,6 +24,7 @@ exports.checkLifecycle = checkLifecycle;
24
24
  exports.runDoctorChecks = runDoctorChecks;
25
25
  const runtime_1 = require("../../nerves/runtime");
26
26
  const bluebubbles_health_diagnostics_1 = require("./bluebubbles-health-diagnostics");
27
+ const webhook_registration_1 = require("../../senses/bluebubbles/webhook-registration");
27
28
  const freshness_1 = require("./freshness");
28
29
  const ouro_path_installer_1 = require("../versioning/ouro-path-installer");
29
30
  const runtime_credentials_1 = require("../runtime-credentials");
@@ -35,6 +36,8 @@ const config_1 = require("../../rsvp/config");
35
36
  const cutover_1 = require("../../rsvp/cutover");
36
37
  const diagnostics_1 = require("../../rsvp/diagnostics");
37
38
  const DEFAULT_BLUEBUBBLES_REQUEST_TIMEOUT_MS = 30_000;
39
+ const DEFAULT_BLUEBUBBLES_PORT = 18_790;
40
+ const DEFAULT_BLUEBUBBLES_WEBHOOK_PATH = "/bluebubbles-webhook";
38
41
  // ── Category checkers ──
39
42
  function checkCliPath(deps) {
40
43
  const resolution = (0, ouro_path_installer_1.diagnoseOuroPath)({
@@ -412,10 +415,11 @@ async function checkSenses(deps) {
412
415
  });
413
416
  let upstreamContext = "upstream probe not run in this pass";
414
417
  if (deps.fetchImpl) {
418
+ const requestTimeoutMs = numberField(bluebubblesChannel, "requestTimeoutMs", DEFAULT_BLUEBUBBLES_REQUEST_TIMEOUT_MS);
415
419
  const probe = await (0, bluebubbles_health_diagnostics_1.probeBlueBubblesHealth)({
416
420
  serverUrl,
417
421
  password,
418
- requestTimeoutMs: numberField(bluebubblesChannel, "requestTimeoutMs", DEFAULT_BLUEBUBBLES_REQUEST_TIMEOUT_MS),
422
+ requestTimeoutMs,
419
423
  fetchImpl: deps.fetchImpl,
420
424
  });
421
425
  checks.push({
@@ -426,6 +430,22 @@ async function checkSenses(deps) {
426
430
  upstreamContext = probe.ok
427
431
  ? "upstream probe reachable — which does not prove inbound delivery"
428
432
  : "upstream probe failing";
433
+ const webhook = await (0, webhook_registration_1.inspectBlueBubblesWebhookRegistration)({
434
+ serverUrl,
435
+ password,
436
+ callbackPort: numberField(bluebubblesChannel, "port", DEFAULT_BLUEBUBBLES_PORT),
437
+ callbackPath: textField(bluebubblesChannel, "webhookPath") || DEFAULT_BLUEBUBBLES_WEBHOOK_PATH,
438
+ agentName,
439
+ machineId,
440
+ requestTimeoutMs,
441
+ listenerReady: true,
442
+ }, { fetchImpl: deps.fetchImpl });
443
+ checks.push({
444
+ id: "senses.bluebubbles.webhook",
445
+ label: `${agentDir} bluebubbles webhook`,
446
+ status: webhook.ok ? "pass" : "fail",
447
+ detail: webhook.detail,
448
+ });
429
449
  }
430
450
  const bluebubblesStateRoot = `${deps.bundlesRoot}/${agentDir}/state/senses/bluebubbles`;
431
451
  checks.push(senseInboundDeliveryCheck({
@@ -33,7 +33,10 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.DEFAULT_CANARY_TIMEOUT_MS = void 0;
36
+ exports.SETUP_CANARY_TIMEOUT_MS = exports.DEFAULT_CANARY_TIMEOUT_MS = void 0;
37
+ exports.classifyMcpBoundary = classifyMcpBoundary;
38
+ exports.sanitizeMcpCanaryText = sanitizeMcpCanaryText;
39
+ exports.sanitizeMcpCanaryArgs = sanitizeMcpCanaryArgs;
37
40
  exports.buildMcpBridgeRepairGuidance = buildMcpBridgeRepairGuidance;
38
41
  exports.formatMcpBridgeRepairDetails = formatMcpBridgeRepairDetails;
39
42
  exports.buildMcpDoctorNextSteps = buildMcpDoctorNextSteps;
@@ -47,7 +50,40 @@ const child_process_1 = require("child_process");
47
50
  const path = __importStar(require("path"));
48
51
  const runtime_1 = require("../../nerves/runtime");
49
52
  exports.DEFAULT_CANARY_TIMEOUT_MS = 60_000;
53
+ exports.SETUP_CANARY_TIMEOUT_MS = 10_000;
50
54
  const MCP_PROTOCOL_VERSION = "2024-11-05";
55
+ function classifyMcpBoundary(input) {
56
+ if (!input.bridgeHealthy)
57
+ return "ouro-bridge-failed";
58
+ return input.hostStallObserved ? "host-stall-unexplained" : "ouro-bridge-healthy-at-capture";
59
+ }
60
+ function sanitizeMcpCanaryText(text) {
61
+ return text
62
+ .replace(/(https?:\/\/[^\s?]+)\?[^\s]*/g, "$1?[redacted]")
63
+ .replace(/"(?:password|token|secret|api[_-]?key)"\s*:\s*"[^"]*"/gi, '"credential":"[redacted]"')
64
+ .replace(/\bBearer\s+[^\s]+/gi, "Bearer [redacted]")
65
+ .replace(/--(?:password|token|secret|api[_-]?key)(\s+|=)[^\s]+/gi, "--credential$1[redacted]")
66
+ .replace(/\b(?:password|token|secret|api[_-]?key)=([^\s]+)/gi, "credential=[redacted]")
67
+ .trim();
68
+ }
69
+ function sanitizeMcpCanaryArgs(args) {
70
+ const sanitized = [];
71
+ let redactNext = false;
72
+ for (const arg of args) {
73
+ if (redactNext) {
74
+ sanitized.push("[redacted]");
75
+ redactNext = false;
76
+ continue;
77
+ }
78
+ if (/^--(?:password|token|secret|api[_-]?key)$/i.test(arg)) {
79
+ sanitized.push("--credential");
80
+ redactNext = true;
81
+ continue;
82
+ }
83
+ sanitized.push(sanitizeMcpCanaryText(arg));
84
+ }
85
+ return sanitized;
86
+ }
51
87
  function defaultCommandArgs(agent, socketPath) {
52
88
  const entryPath = path.join(__dirname, "ouro-bot-entry.js");
53
89
  return [
@@ -58,18 +94,32 @@ function defaultCommandArgs(agent, socketPath) {
58
94
  ...(socketPath ? ["--socket", socketPath] : []),
59
95
  ];
60
96
  }
61
- function responseText(response) {
62
- const result = response.result;
63
- if (!result || typeof result !== "object" || Array.isArray(result))
64
- return JSON.stringify(response);
65
- const content = result.content;
66
- if (!Array.isArray(content))
67
- return JSON.stringify(response);
68
- const first = content[0];
69
- if (!first || typeof first !== "object" || Array.isArray(first))
70
- return JSON.stringify(response);
71
- const text = first.text;
72
- return typeof text === "string" ? text : JSON.stringify(response);
97
+ function isRecord(value) {
98
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
99
+ }
100
+ function requireInitializeResult(response) {
101
+ if (!isRecord(response.result)) {
102
+ throw new Error("MCP canary received invalid initialize result");
103
+ }
104
+ }
105
+ function requireStatusText(response) {
106
+ if (!isRecord(response.result)) {
107
+ throw new Error("MCP canary received invalid status result");
108
+ }
109
+ if (response.result.isError === true) {
110
+ const errorContent = response.result.content;
111
+ const errorFirst = Array.isArray(errorContent) ? errorContent[0] : undefined;
112
+ const errorText = isRecord(errorFirst) && typeof errorFirst.text === "string"
113
+ ? errorFirst.text
114
+ : "MCP status tool returned an error";
115
+ throw new Error(errorText);
116
+ }
117
+ const content = response.result.content;
118
+ const first = Array.isArray(content) ? content[0] : undefined;
119
+ if (!isRecord(first) || typeof first.text !== "string") {
120
+ throw new Error("MCP canary received invalid status result");
121
+ }
122
+ return first.text;
73
123
  }
74
124
  function parseFields(line) {
75
125
  const parsed = {};
@@ -214,7 +264,23 @@ function validateMcpStatus(parsed, requiredSenses, options = {}) {
214
264
  };
215
265
  }
216
266
  async function runMcpStatusCanary(options) {
267
+ const startedAt = Date.now();
217
268
  const timeoutMs = options.timeoutMs ?? exports.DEFAULT_CANARY_TIMEOUT_MS;
269
+ const deadlineAt = startedAt + timeoutMs;
270
+ const evidence = {
271
+ capturedAt: new Date(startedAt).toISOString(),
272
+ durationMs: 0,
273
+ childPid: null,
274
+ phase: "spawn",
275
+ exitCode: null,
276
+ exitSignal: null,
277
+ stderr: "",
278
+ };
279
+ const decorate = (result, bridgeHealthy = false) => ({
280
+ ...result,
281
+ classification: classifyMcpBoundary({ bridgeHealthy, hostStallObserved: options.hostStallObserved === true }),
282
+ evidence,
283
+ });
218
284
  /* v8 ignore next -- default spawn is exercised by live canaries, while unit tests inject a fake child @preserve */
219
285
  const spawnImpl = options.spawnImpl ?? child_process_1.spawn;
220
286
  const command = options.command ?? process.execPath;
@@ -227,17 +293,38 @@ async function runMcpStatusCanary(options) {
227
293
  meta: {
228
294
  agent: options.agent,
229
295
  command,
230
- commandArgs,
296
+ commandArgs: sanitizeMcpCanaryArgs(commandArgs),
231
297
  timeoutMs,
232
298
  requiredSenses,
233
299
  ignoreOverviewHealth: options.ignoreOverviewHealth === true,
234
300
  ignoreSenseHealth: options.ignoreSenseHealth === true,
235
301
  },
236
302
  });
237
- const child = spawnImpl(command, commandArgs, { stdio: ["pipe", "pipe", "pipe"] });
303
+ let child;
304
+ try {
305
+ child = spawnImpl(command, commandArgs, { stdio: ["pipe", "pipe", "pipe"] });
306
+ evidence.childPid = child.pid ?? null;
307
+ }
308
+ catch (error) {
309
+ const reason = sanitizeMcpCanaryText(error instanceof Error ? error.message : String(error));
310
+ evidence.durationMs = Date.now() - startedAt;
311
+ (0, runtime_1.emitNervesEvent)({
312
+ component: "daemon",
313
+ event: "daemon.mcp_canary_error",
314
+ level: "error",
315
+ message: "MCP status canary failed to spawn",
316
+ meta: { agent: options.agent, reason },
317
+ });
318
+ return decorate({ ok: false, summary: `mcp canary failed: ${reason}`, details: [reason] });
319
+ }
238
320
  let buffer = "";
239
321
  let stderr = "";
240
322
  const pending = new Map();
323
+ let resolveExit;
324
+ const exitObserved = new Promise((resolve) => { resolveExit = resolve; });
325
+ function safeStderr() {
326
+ return sanitizeMcpCanaryText(stderr);
327
+ }
241
328
  function cleanup() {
242
329
  for (const [, request] of pending) {
243
330
  clearTimeout(request.timer);
@@ -268,14 +355,19 @@ async function runMcpStatusCanary(options) {
268
355
  buffer = buffer.slice(idx + 1);
269
356
  if (!line)
270
357
  continue;
271
- let response;
358
+ let parsedResponse;
272
359
  try {
273
- response = JSON.parse(line);
360
+ parsedResponse = JSON.parse(line);
274
361
  }
275
362
  catch {
276
363
  failAll(new Error(`MCP canary received malformed JSON: ${line}`));
277
364
  return;
278
365
  }
366
+ if (!isRecord(parsedResponse)) {
367
+ failAll(new Error("MCP canary received malformed JSON-RPC response"));
368
+ return;
369
+ }
370
+ const response = parsedResponse;
279
371
  const id = typeof response.id === "number" ? response.id : null;
280
372
  if (id === null)
281
373
  continue;
@@ -284,14 +376,27 @@ async function runMcpStatusCanary(options) {
284
376
  continue;
285
377
  pending.delete(id);
286
378
  clearTimeout(request.timer);
379
+ const hasResult = Object.prototype.hasOwnProperty.call(response, "result");
380
+ const hasError = Object.prototype.hasOwnProperty.call(response, "error");
381
+ if (response.jsonrpc !== "2.0" || (!hasResult && !hasError)) {
382
+ request.reject(new Error("MCP canary received malformed JSON-RPC response"));
383
+ continue;
384
+ }
385
+ if (hasError) {
386
+ request.reject(new Error(`MCP canary received JSON-RPC error: ${JSON.stringify(response.error)}`));
387
+ continue;
388
+ }
287
389
  request.resolve(response);
288
390
  }
289
391
  });
290
392
  child.on("error", (error) => failAll(error));
291
393
  child.on("close", (code, signal) => {
394
+ evidence.exitCode = code;
395
+ evidence.exitSignal = signal;
396
+ resolveExit();
292
397
  if (pending.size === 0)
293
398
  return;
294
- failAll(new Error(`MCP canary process closed before response code=${code} signal=${signal ?? "none"} stderr=${stderr.trim()}`));
399
+ failAll(new Error(`MCP canary process closed before response code=${code} signal=${signal ?? "none"} stderr=${safeStderr()}`));
295
400
  });
296
401
  let nextId = 1;
297
402
  function request(method, params) {
@@ -300,36 +405,43 @@ async function runMcpStatusCanary(options) {
300
405
  reject(new Error("MCP canary stdin is not writable"));
301
406
  return;
302
407
  }
408
+ const remainingMs = deadlineAt - Date.now();
409
+ if (remainingMs <= 0) {
410
+ reject(new Error(`MCP canary timed out waiting for ${method}; stderr=${safeStderr()}`));
411
+ return;
412
+ }
303
413
  const id = nextId++;
304
414
  const timer = setTimeout(() => {
305
415
  pending.delete(id);
306
- reject(new Error(`MCP canary timed out waiting for ${method}; stderr=${stderr.trim()}`));
307
- }, timeoutMs);
416
+ reject(new Error(`MCP canary timed out waiting for ${method}; stderr=${safeStderr()}`));
417
+ }, remainingMs);
308
418
  pending.set(id, { resolve, reject, timer });
309
419
  child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n");
310
420
  });
311
421
  }
312
422
  try {
313
- await request("initialize", {
423
+ evidence.phase = "initialize";
424
+ const initializeResponse = await request("initialize", {
314
425
  protocolVersion: MCP_PROTOCOL_VERSION,
315
426
  capabilities: {},
316
427
  clientInfo: { name: "ouro-mcp-canary", version: "1.0" },
317
428
  });
429
+ requireInitializeResult(initializeResponse);
318
430
  child.stdin?.write(JSON.stringify({ jsonrpc: "2.0", method: "initialized" }) + "\n");
431
+ evidence.phase = "status";
319
432
  const statusResponse = await request("tools/call", {
320
433
  name: "status",
321
434
  arguments: {},
322
435
  });
323
- const result = statusResponse.result;
324
- if (result && typeof result === "object" && !Array.isArray(result) && result.isError === true) {
325
- throw new Error(responseText(statusResponse));
326
- }
327
- const parsed = parseMcpStatusText(responseText(statusResponse));
436
+ const statusText = requireStatusText(statusResponse);
437
+ const bridgeHealthy = true;
438
+ const parsed = parseMcpStatusText(sanitizeMcpCanaryText(statusText));
328
439
  const canary = validateMcpStatus(parsed, requiredSenses, {
329
440
  agent: options.agent,
330
441
  ignoreOverviewHealth: options.ignoreOverviewHealth,
331
442
  ignoreSenseHealth: options.ignoreSenseHealth,
332
443
  });
444
+ evidence.phase = "complete";
333
445
  (0, runtime_1.emitNervesEvent)({
334
446
  component: "daemon",
335
447
  event: canary.ok ? "daemon.mcp_canary_end" : "daemon.mcp_canary_error",
@@ -337,10 +449,10 @@ async function runMcpStatusCanary(options) {
337
449
  message: canary.summary,
338
450
  meta: { agent: options.agent, ok: canary.ok },
339
451
  });
340
- return canary;
452
+ return decorate(canary, bridgeHealthy);
341
453
  }
342
454
  catch (error) {
343
- const reason = error instanceof Error ? error.message : String(error);
455
+ const reason = sanitizeMcpCanaryText(error instanceof Error ? error.message : String(error));
344
456
  (0, runtime_1.emitNervesEvent)({
345
457
  component: "daemon",
346
458
  event: "daemon.mcp_canary_error",
@@ -348,16 +460,30 @@ async function runMcpStatusCanary(options) {
348
460
  message: "MCP status canary failed",
349
461
  meta: { agent: options.agent, reason },
350
462
  });
351
- return { ok: false, summary: `mcp canary failed: ${reason}`, details: [reason] };
463
+ return decorate({ ok: false, summary: `mcp canary failed: ${reason}`, details: [reason] });
352
464
  }
353
465
  finally {
354
466
  child.stdin?.end();
355
467
  cleanup();
468
+ let exitTimer;
469
+ const remainingExitWaitMs = Math.min(100, Math.max(0, deadlineAt - Date.now()));
470
+ await Promise.race([
471
+ exitObserved,
472
+ new Promise((resolve) => { exitTimer = setTimeout(resolve, remainingExitWaitMs); }),
473
+ ]);
474
+ clearTimeout(exitTimer);
475
+ evidence.stderr = safeStderr();
476
+ evidence.durationMs = Date.now() - startedAt;
356
477
  }
357
478
  }
358
479
  function formatMcpStatusCanaryResult(result) {
359
480
  return [
360
481
  result.ok ? "mcp canary: ok" : "mcp canary: failed",
482
+ ...(result.classification ? [`classification: ${result.classification}`] : []),
483
+ ...(result.evidence ? [
484
+ `captured=${result.evidence.capturedAt} durationMs=${result.evidence.durationMs} childPid=${result.evidence.childPid ?? "none"} phase=${result.evidence.phase} exitCode=${result.evidence.exitCode ?? "none"} exitSignal=${result.evidence.exitSignal ?? "none"}`,
485
+ ...(result.evidence.stderr ? [`stderr=${result.evidence.stderr}`] : []),
486
+ ] : []),
361
487
  result.summary,
362
488
  ...result.details.map((line) => ` ${line}`),
363
489
  ].join("\n");
@@ -368,6 +494,11 @@ function formatMcpStatusDoctorResult(result, agent) {
368
494
  && !line.startsWith("verify command="));
369
495
  const lines = [
370
496
  result.ok ? "mcp doctor: ok" : "mcp doctor: failed",
497
+ ...(result.classification ? [`classification: ${result.classification}`] : []),
498
+ ...(result.evidence ? [
499
+ `captured=${result.evidence.capturedAt} durationMs=${result.evidence.durationMs} childPid=${result.evidence.childPid ?? "none"} phase=${result.evidence.phase} exitCode=${result.evidence.exitCode ?? "none"} exitSignal=${result.evidence.exitSignal ?? "none"}`,
500
+ ...(result.evidence.stderr ? [`stderr=${result.evidence.stderr}`] : []),
501
+ ] : []),
371
502
  result.summary,
372
503
  ...rawDetails.map((line) => ` ${line}`),
373
504
  ];
@@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.DEFAULT_DAEMON_COMMAND_TIMEOUT_MS = exports.DEFAULT_DAEMON_SOCKET_PATH = void 0;
36
+ exports.DEFAULT_DAEMON_STATUS_TIMEOUT_MS = exports.DEFAULT_DAEMON_COMMAND_TIMEOUT_MS = exports.DEFAULT_DAEMON_SOCKET_PATH = void 0;
37
37
  exports.__bypassVitestGuardForTests = __bypassVitestGuardForTests;
38
38
  exports.sendDaemonCommand = sendDaemonCommand;
39
39
  exports.checkDaemonSocketAlive = checkDaemonSocketAlive;
@@ -44,6 +44,7 @@ const net = __importStar(require("net"));
44
44
  const runtime_1 = require("../../nerves/runtime");
45
45
  exports.DEFAULT_DAEMON_SOCKET_PATH = "/tmp/ouroboros-daemon.sock";
46
46
  exports.DEFAULT_DAEMON_COMMAND_TIMEOUT_MS = 10 * 60 * 1000;
47
+ exports.DEFAULT_DAEMON_STATUS_TIMEOUT_MS = 5_000;
47
48
  /**
48
49
  * Defense-in-depth: detect if we're running under vitest. Tests that forget
49
50
  * to `vi.mock("../../heart/daemon/socket-client")` would otherwise leak real
@@ -130,11 +131,16 @@ function sendDaemonCommand(socketPath, command, options = {}) {
130
131
  let raw = "";
131
132
  let settled = false;
132
133
  const timeoutMs = options.timeoutMs ?? exports.DEFAULT_DAEMON_COMMAND_TIMEOUT_MS;
134
+ let deadlineTimer;
135
+ const clearDeadline = () => {
136
+ clearTimeout(deadlineTimer);
137
+ };
133
138
  const resolveOnce = (response) => {
134
139
  /* v8 ignore next -- duplicate settlement guard; callers also guard event handlers before reaching this helper @preserve */
135
140
  if (settled)
136
141
  return;
137
142
  settled = true;
143
+ clearDeadline();
138
144
  resolve(response);
139
145
  };
140
146
  const rejectOnce = (error) => {
@@ -142,27 +148,30 @@ function sendDaemonCommand(socketPath, command, options = {}) {
142
148
  if (settled)
143
149
  return;
144
150
  settled = true;
151
+ clearDeadline();
145
152
  reject(error);
146
153
  };
147
- if ("setTimeout" in client && typeof client.setTimeout === "function") {
148
- client.setTimeout(timeoutMs, () => {
149
- const error = new Error(`Daemon command ${command.kind} timed out after ${timeoutMs}ms waiting for a response.`);
150
- (0, runtime_1.emitNervesEvent)({
151
- level: "error",
152
- component: "daemon",
153
- event: "daemon.socket_command_timeout",
154
- message: "daemon socket command timed out",
155
- meta: {
156
- socketPath,
157
- kind: command.kind,
158
- timeoutMs,
159
- error: error.message,
160
- },
161
- });
162
- client.destroy();
163
- rejectOnce(error);
154
+ deadlineTimer = setTimeout(() => {
155
+ /* v8 ignore next -- terminal socket events clear this timer; guard protects a queued timer callback race @preserve */
156
+ if (settled)
157
+ return;
158
+ const error = new Error(`Daemon command ${command.kind} timed out after ${timeoutMs}ms waiting for a response.`);
159
+ error.code = "ETIMEDOUT";
160
+ (0, runtime_1.emitNervesEvent)({
161
+ level: "error",
162
+ component: "daemon",
163
+ event: "daemon.socket_command_timeout",
164
+ message: "daemon socket command timed out",
165
+ meta: {
166
+ socketPath,
167
+ kind: command.kind,
168
+ timeoutMs,
169
+ error: error.message,
170
+ },
164
171
  });
165
- }
172
+ client.destroy();
173
+ rejectOnce(error);
174
+ }, timeoutMs);
166
175
  client.on("connect", () => {
167
176
  // Write the command + newline delimiter. DO NOT call client.end()
168
177
  // afterwards — the server closes the connection once it has written