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

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
  ];
@@ -672,26 +672,29 @@ class DaemonProcessManager {
672
672
  state.snapshot.errorReason = null;
673
673
  state.snapshot.fixHint = null;
674
674
  }
675
- const crashed = !state.stopRequested && code !== 0;
675
+ const unexpectedExit = !state.stopRequested;
676
+ const crashed = unexpectedExit && code !== 0;
676
677
  const now = this.currentTimeMs();
677
678
  const startedAt = state.snapshot.startedAt ? Date.parse(state.snapshot.startedAt) : now;
678
679
  const runDuration = Math.max(0, now - startedAt);
679
680
  (0, runtime_1.emitNervesEvent)({
680
- level: crashed ? "warn" : "info",
681
+ level: unexpectedExit ? "warn" : "info",
681
682
  component: "daemon",
682
683
  event: "daemon.agent_exit",
683
684
  message: "managed agent process exited",
684
- meta: { agent: state.config.name, code, signal, crashed, runDurationMs: runDuration },
685
+ meta: { agent: state.config.name, code, signal, crashed, unexpectedExit, runDurationMs: runDuration },
685
686
  });
686
- if (!crashed) {
687
+ if (runDuration >= this.stabilityThresholdMs) {
688
+ state.snapshot.backoffMs = this.initialBackoffMs;
689
+ }
690
+ if (!unexpectedExit) {
687
691
  state.snapshot.status = "stopped";
688
- if (runDuration >= this.stabilityThresholdMs) {
689
- state.snapshot.backoffMs = this.initialBackoffMs;
690
- }
691
692
  this.notifySnapshotChange(state.snapshot);
692
693
  return;
693
694
  }
694
- state.snapshot.lastCrashAt = new Date(now).toISOString();
695
+ if (crashed) {
696
+ state.snapshot.lastCrashAt = new Date(now).toISOString();
697
+ }
695
698
  if (state.config.autoStart === false) {
696
699
  state.snapshot.status = "stopped";
697
700
  state.snapshot.errorReason = "non-autostart worker exited unexpectedly; not respawning";
@@ -699,46 +702,45 @@ class DaemonProcessManager {
699
702
  this.notifySnapshotChange(state.snapshot);
700
703
  return;
701
704
  }
702
- // Fast-crash detection: if the agent dies within 5 seconds of starting, it's likely
703
- // a configuration issue (missing credentials, bad provider, etc.) not a transient failure.
704
- // After 3 consecutive fast crashes, stop retrying and mark as config-failed.
705
+ // Fast-exit detection: repeated early exits, including clean exit-code-0
706
+ // terminations, indicate a startup/configuration or lifecycle fault rather
707
+ // than a stable long-lived worker. Bound them without claiming a crash.
705
708
  const FAST_CRASH_THRESHOLD_MS = 5000;
706
709
  const FAST_CRASH_MAX = 3;
707
710
  if (runDuration < FAST_CRASH_THRESHOLD_MS) {
708
711
  state.fastCrashCount = state.fastCrashCount + 1;
709
712
  if (state.fastCrashCount >= FAST_CRASH_MAX) {
710
713
  state.snapshot.status = "crashed";
711
- // Capture the fast-crash diagnosis on the snapshot so it surfaces
712
- // via the pulse. The error message is prescriptive: it tells the
713
- // user (and their sibling agents) exactly what to do.
714
- state.snapshot.errorReason = `agent crashed ${FAST_CRASH_MAX} times within ${FAST_CRASH_THRESHOLD_MS}ms of startup — likely a configuration issue (missing credentials, bad provider).`;
715
- state.snapshot.fixHint = `Fix the config and run \`ouro up\` to restart, or check daemon logs for the underlying error.`;
714
+ // Capture the fast-exit diagnosis on the snapshot so it surfaces via
715
+ // the pulse with a prescriptive, cause-neutral repair path.
716
+ state.snapshot.errorReason = `agent exited unexpectedly ${FAST_CRASH_MAX} times within ${FAST_CRASH_THRESHOLD_MS}ms of startup — likely a startup/configuration or lifecycle issue.`;
717
+ state.snapshot.fixHint = `Inspect daemon logs and lifecycle/configuration, then run \`ouro up\` to restart.`;
716
718
  (0, runtime_1.emitNervesEvent)({
717
719
  level: "error",
718
720
  component: "daemon",
719
721
  event: "daemon.agent_config_failure",
720
- message: `agent crashed ${FAST_CRASH_MAX} times within ${FAST_CRASH_THRESHOLD_MS}ms of startup — likely a configuration issue (missing credentials, bad provider). Not retrying. Fix the config and run \`ouro up\` to restart.`,
721
- meta: { agent: state.config.name, fastCrashCount: state.fastCrashCount, avgRunDurationMs: runDuration },
722
+ message: `agent exited unexpectedly ${FAST_CRASH_MAX} times within ${FAST_CRASH_THRESHOLD_MS}ms of startup. Not retrying. Inspect lifecycle/configuration and run \`ouro up\` to restart.`,
723
+ meta: { agent: state.config.name, fastUnexpectedExitCount: state.fastCrashCount, avgRunDurationMs: runDuration },
722
724
  });
723
725
  this.notifySnapshotChange(state.snapshot);
724
726
  return; // Don't schedule cooldown recovery — this needs human/agent intervention
725
727
  }
726
728
  }
727
729
  else {
728
- // Reset fast-crash counter on a stable run
730
+ // Reset the fast unexpected-exit counter on a stable run.
729
731
  state.fastCrashCount = 0;
730
732
  }
731
733
  state.crashTimestamps = state.crashTimestamps.filter((crashTs) => crashTs >= startOfHour(now));
732
734
  state.crashTimestamps.push(now);
733
735
  if (state.crashTimestamps.length > this.maxRestartsPerHour) {
734
736
  state.snapshot.status = "crashed";
735
- state.snapshot.errorReason = `agent exceeded restart limit (${this.maxRestartsPerHour}/hr) — entering cooldown`;
736
- state.snapshot.fixHint = "investigate why the agent keeps crashing; cooldown will retry shortly";
737
+ state.snapshot.errorReason = `agent exceeded unexpected-exit restart limit (${this.maxRestartsPerHour}/hr) — entering cooldown`;
738
+ state.snapshot.fixHint = "investigate why the agent keeps exiting unexpectedly; cooldown will retry shortly";
737
739
  (0, runtime_1.emitNervesEvent)({
738
740
  level: "error",
739
741
  component: "daemon",
740
742
  event: "daemon.agent_restart_exhausted",
741
- message: "managed agent exceeded restart limit and is marked crashed",
743
+ message: "managed agent exceeded unexpected-exit restart limit and is marked crashed",
742
744
  meta: { agent: state.config.name, maxRestartsPerHour: this.maxRestartsPerHour },
743
745
  });
744
746
  this.notifySnapshotChange(state.snapshot);
@@ -488,7 +488,7 @@ function readBlueBubblesRuntimeFacts(agent, bundlesRoot, snapshot) {
488
488
  ...proofFacts,
489
489
  lastFailure: "listener process is not running",
490
490
  failureLayer: "listener",
491
- recoveryAction: "daemon health monitor will restart the BlueBubbles listener when its probe fails",
491
+ recoveryAction: "daemon process manager will restart the BlueBubbles listener after an unsolicited exit",
492
492
  };
493
493
  }
494
494
  if (state.upstreamStatus === "error") {