@ouro.bot/cli 0.1.0-alpha.530 → 0.1.0-alpha.532

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/changelog.json CHANGED
@@ -1,6 +1,22 @@
1
1
  {
2
2
  "_note": "This changelog is maintained as part of the PR/version-bump workflow. Agent-curated, not auto-generated. Agents read this file directly via read_file to understand what changed between versions.",
3
3
  "versions": [
4
+ {
5
+ "version": "0.1.0-alpha.532",
6
+ "changes": [
7
+ "MCP clients now classify dead transports, reconnect stale servers before tool calls, retry one transport-level failure, and expose live refresh canaries so closed MCP pipes recover instead of poisoning long-running sessions.",
8
+ "Adds `ouro mcp canary --agent <name>` plus daemon health-monitor wiring, surfacing fresh MCP status failures in `ouro status` and validating daemon/MCP version parity and required sense health.",
9
+ "Explicit `daemon.stop` now writes a last-known down health state instead of creating a misleading unexpected-clean-exit tombstone, daemon background update checks no longer self-restart the live runtime, and `ouro msg` wakes the target agent before IPC delivery.",
10
+ "Release publishing now tags prereleases with their prerelease channel and release smoke verifies exact wrapper versions, keeping npm dist-tags from driving surprise prerelease churn through the daemon updater."
11
+ ]
12
+ },
13
+ {
14
+ "version": "0.1.0-alpha.531",
15
+ "changes": [
16
+ "`ouro doctor` now recognizes the current nested trip ledger schema (`ledger.ledgerId`) as healthy while keeping backward compatibility with older top-level `ledgerId` ledgers.",
17
+ "Adds regression coverage so valid trip ledgers no longer surface as doctor warnings during runtime reliability checks."
18
+ ]
19
+ },
4
20
  {
5
21
  "version": "0.1.0-alpha.530",
6
22
  "changes": [
@@ -116,6 +116,7 @@ const boot_sync_probe_1 = require("./boot-sync-probe");
116
116
  const connect_bay_1 = require("./connect-bay");
117
117
  const runtime_capability_check_1 = require("../runtime-capability-check");
118
118
  const vault_items_1 = require("./vault-items");
119
+ const mcp_canary_1 = require("./mcp-canary");
119
120
  // ── ensureDaemonRunning ──
120
121
  const DEFAULT_DAEMON_STARTUP_TIMEOUT_MS = 60_000;
121
122
  const DEFAULT_DAEMON_STARTUP_POLL_INTERVAL_MS = 500;
@@ -6516,6 +6517,28 @@ async function runOuroCli(args, deps = (0, cli_defaults_1.createDefaultOuroCliDe
6516
6517
  return message;
6517
6518
  }
6518
6519
  }
6520
+ if (command.kind === "mcp.canary") {
6521
+ const canarySocketPath = command.socketOverride ?? deps.socketPath;
6522
+ const result = await (0, mcp_canary_1.runMcpStatusCanary)({
6523
+ agent: command.agent,
6524
+ socketPath: canarySocketPath,
6525
+ command: process.execPath,
6526
+ commandArgs: [
6527
+ path.join(__dirname, "ouro-bot-entry.js"),
6528
+ "mcp-serve",
6529
+ "--agent",
6530
+ command.agent,
6531
+ "--socket",
6532
+ canarySocketPath,
6533
+ ],
6534
+ requiredSenses: command.requiredSenses ?? [],
6535
+ });
6536
+ if (!result.ok)
6537
+ deps.setExitCode?.(1);
6538
+ const message = command.json ? JSON.stringify(result, null, 2) : (0, mcp_canary_1.formatMcpStatusCanaryResult)(result);
6539
+ deps.writeStdout(message);
6540
+ return message;
6541
+ }
6519
6542
  /* v8 ignore start — mcp-serve block binds to process.stdin/stdout; tested via mcp-server unit tests */
6520
6543
  // ── mcp-serve: start MCP server in-process on stdin/stdout ──
6521
6544
  if (command.kind === "mcp-serve") {
@@ -253,8 +253,8 @@ exports.COMMAND_REGISTRY = {
253
253
  category: "System",
254
254
  description: "Interact with MCP servers",
255
255
  usage: "ouro mcp <subcommand>",
256
- example: "ouro mcp list",
257
- subcommands: ["list", "call"],
256
+ example: "ouro mcp canary --agent ouroboros",
257
+ subcommands: ["list", "call", "canary"],
258
258
  },
259
259
  "mcp-serve": {
260
260
  category: "System",
@@ -1223,7 +1223,8 @@ function parseConfigCommand(args) {
1223
1223
  throw new Error(`Usage\n${usage()}`);
1224
1224
  }
1225
1225
  function parseMcpCommand(args) {
1226
- const [sub, ...rest] = args;
1226
+ const { agent, rest: cleaned } = extractAgentFlag(args);
1227
+ const [sub, ...rest] = cleaned;
1227
1228
  if (!sub)
1228
1229
  throw new Error(`Usage\n${usage()}`);
1229
1230
  if (sub === "list")
@@ -1237,6 +1238,34 @@ function parseMcpCommand(args) {
1237
1238
  const mcpArgs = argsIdx !== -1 && rest[argsIdx + 1] ? rest[argsIdx + 1] : undefined;
1238
1239
  return { kind: "mcp.call", server, tool, ...(mcpArgs ? { args: mcpArgs } : {}) };
1239
1240
  }
1241
+ if (sub === "canary") {
1242
+ let socketOverride;
1243
+ let json = false;
1244
+ const requiredSenses = [];
1245
+ for (let i = 0; i < rest.length; i++) {
1246
+ if (rest[i] === "--socket" && rest[i + 1]) {
1247
+ socketOverride = rest[++i];
1248
+ continue;
1249
+ }
1250
+ if (rest[i] === "--require-sense" && rest[i + 1]) {
1251
+ requiredSenses.push(rest[++i]);
1252
+ continue;
1253
+ }
1254
+ if (rest[i] === "--json") {
1255
+ json = true;
1256
+ continue;
1257
+ }
1258
+ }
1259
+ if (!agent)
1260
+ throw new Error("mcp canary requires --agent <name>");
1261
+ return {
1262
+ kind: "mcp.canary",
1263
+ agent,
1264
+ ...(socketOverride ? { socketOverride } : {}),
1265
+ ...(requiredSenses.length > 0 ? { requiredSenses } : {}),
1266
+ ...(json ? { json: true } : {}),
1267
+ };
1268
+ }
1240
1269
  throw new Error(`Usage\n${usage()}`);
1241
1270
  }
1242
1271
  function inferAgentNameFromRemote(remote) {
@@ -121,6 +121,7 @@ function parseStatusPayload(data) {
121
121
  const sync = raw.sync;
122
122
  const agents = raw.agents;
123
123
  const providers = raw.providers;
124
+ const healthChecks = raw.healthChecks;
124
125
  if (!overview || typeof overview !== "object" || Array.isArray(overview))
125
126
  return null;
126
127
  if (!Array.isArray(senses) || !Array.isArray(workers))
@@ -132,6 +133,8 @@ function parseStatusPayload(data) {
132
133
  return null;
133
134
  if (providers !== undefined && !Array.isArray(providers))
134
135
  return null;
136
+ if (healthChecks !== undefined && !Array.isArray(healthChecks))
137
+ return null;
135
138
  const parsedOverview = {
136
139
  daemon: stringField(overview.daemon) ?? "unknown",
137
140
  health: stringField(overview.health) ?? "unknown",
@@ -281,16 +284,29 @@ function parseStatusPayload(data) {
281
284
  parsed.detail = detail;
282
285
  return parsed;
283
286
  });
287
+ const parsedHealthChecks = (healthChecks ?? []).map((entry) => {
288
+ if (!entry || typeof entry !== "object" || Array.isArray(entry))
289
+ return null;
290
+ const row = entry;
291
+ const name = stringField(row.name);
292
+ const status = stringField(row.status);
293
+ const message = stringField(row.message);
294
+ if (!name || !status || !message)
295
+ return null;
296
+ return { name, status, message };
297
+ });
284
298
  if (parsedSenses.some((row) => row === null) ||
285
299
  parsedWorkers.some((row) => row === null) ||
286
300
  parsedSync.some((row) => row === null) ||
287
301
  parsedAgents.some((row) => row === null) ||
288
- parsedProviders.some((row) => row === null))
302
+ parsedProviders.some((row) => row === null) ||
303
+ parsedHealthChecks.some((row) => row === null))
289
304
  return null;
290
305
  return {
291
306
  overview: parsedOverview,
292
307
  senses: parsedSenses,
293
308
  workers: parsedWorkers,
309
+ healthChecks: parsedHealthChecks,
294
310
  sync: parsedSync,
295
311
  agents: parsedAgents,
296
312
  providers: parsedProviders,
@@ -482,6 +498,16 @@ function formatDaemonStatusOutput(response, fallback) {
482
498
  }
483
499
  lines.push("");
484
500
  }
501
+ // ── Health Checks ──
502
+ if (payload.healthChecks.length > 0) {
503
+ lines.push(` ${teal("──")} ${bold("Health Checks")} ${teal("─".repeat(29))}`);
504
+ const nameWidth = Math.max(16, ...payload.healthChecks.map((r) => r.name.length));
505
+ const statusWidth = Math.max(8, ...payload.healthChecks.map((r) => r.status.length));
506
+ for (const row of payload.healthChecks) {
507
+ lines.push(` ${row.name.padEnd(nameWidth)} ${statusDot(row.status)} ${row.status.padEnd(statusWidth)} ${dim(row.message)}`);
508
+ }
509
+ lines.push("");
510
+ }
485
511
  // ── Git Sync (per agent) ──
486
512
  if (payload.sync.length > 0) {
487
513
  lines.push(` ${teal("──")} ${bold("Git Sync")} ${teal("─".repeat(35))}`);
@@ -547,6 +573,7 @@ function buildStoppedStatusPayload(socketPath, syncRows = [], agentRows = []) {
547
573
  },
548
574
  senses: [],
549
575
  workers: [],
576
+ healthChecks: [],
550
577
  sync: syncRows,
551
578
  agents: agentRows,
552
579
  providers: [],
@@ -60,6 +60,7 @@ const drift_detection_1 = require("./drift-detection");
60
60
  const pulse_1 = require("./pulse");
61
61
  const socket_client_1 = require("./socket-client");
62
62
  const bundle_manifest_1 = require("../../mind/bundle-manifest");
63
+ const mcp_canary_1 = require("./mcp-canary");
63
64
  function parseSocketPath(argv) {
64
65
  const socketIndex = argv.indexOf("--socket");
65
66
  if (socketIndex >= 0) {
@@ -131,7 +132,22 @@ const senseManager = new sense_manager_1.DaemonSenseManager({
131
132
  const healthMonitor = new health_monitor_1.HealthMonitor({
132
133
  processManager,
133
134
  scheduler,
134
- senseProbeProvider: () => senseManager.listHealthProbes(),
135
+ senseProbeProvider: () => [
136
+ ...senseManager.listHealthProbes(),
137
+ ...managedAgents.map((agent) => (0, mcp_canary_1.createMcpStatusCanaryProbe)({
138
+ agent,
139
+ socketPath,
140
+ command: process.execPath,
141
+ commandArgs: [
142
+ path.join(__dirname, "ouro-bot-entry.js"),
143
+ "mcp-serve",
144
+ "--agent",
145
+ agent,
146
+ "--socket",
147
+ socketPath,
148
+ ],
149
+ })),
150
+ ],
135
151
  alertSink: (message) => {
136
152
  (0, runtime_1.emitNervesEvent)({
137
153
  level: "error",
@@ -173,6 +189,10 @@ function scheduleCleanProcessExitAfterStopCommand() {
173
189
  if (stopCommandExitScheduled)
174
190
  return;
175
191
  stopCommandExitScheduled = true;
192
+ // Account for the explicit daemon.stop path so the process exit catch-all
193
+ // does not mislabel an operator-requested stop as an unexpected clean exit.
194
+ _tombstoneWritten = true;
195
+ writeStopCommandHealthState();
176
196
  setTimeout(() => process.exit(0), 100);
177
197
  }
178
198
  const daemon = new daemon_1.OuroDaemon({
@@ -328,6 +348,18 @@ const healthWriter = new daemon_health_1.DaemonHealthWriter((0, daemon_health_1.
328
348
  const healthSink = (0, daemon_health_1.createHealthNervesSink)(healthWriter, buildDaemonHealthState);
329
349
  (0, index_1.registerGlobalLogSink)(healthSink);
330
350
  /* v8 ignore stop */
351
+ function writeStopCommandHealthState() {
352
+ try {
353
+ healthWriter.writeHealth({
354
+ ...buildDaemonHealthState(),
355
+ status: "down",
356
+ uptimeSeconds: Math.floor(process.uptime()),
357
+ });
358
+ }
359
+ catch {
360
+ // Health writes are best-effort during shutdown.
361
+ }
362
+ }
331
363
  /* v8 ignore start -- habit wiring: lambdas delegate to processManager/fs; tested via HabitScheduler unit tests @preserve */
332
364
  void daemon.start().then(() => {
333
365
  const bundlesRoot = (0, identity_1.getAgentBundlesRoot)();
@@ -54,8 +54,6 @@ const bundle_meta_1 = require("./hooks/bundle-meta");
54
54
  const agent_config_v2_1 = require("./hooks/agent-config-v2");
55
55
  const bundle_manifest_1 = require("../../mind/bundle-manifest");
56
56
  const update_checker_1 = require("../versioning/update-checker");
57
- const staged_restart_1 = require("../versioning/staged-restart");
58
- const ouro_version_manager_1 = require("../versioning/ouro-version-manager");
59
57
  const child_process_1 = require("child_process");
60
58
  const pending_1 = require("../../mind/pending");
61
59
  const agent_service_1 = require("./agent-service");
@@ -356,15 +354,20 @@ function unhealthySenseRows(senses) {
356
354
  return true;
357
355
  });
358
356
  }
359
- function overviewHealth(workers, senses) {
357
+ function unhealthyHealthChecks(healthChecks) {
358
+ return healthChecks.filter((row) => row.status !== "ok");
359
+ }
360
+ function overviewHealth(workers, senses, healthChecks = []) {
360
361
  if (!workers.every((worker) => worker.status === "running"))
361
362
  return "warn";
362
363
  if (unhealthySenseRows(senses).length > 0)
363
364
  return "warn";
365
+ if (unhealthyHealthChecks(healthChecks).length > 0)
366
+ return "warn";
364
367
  return "ok";
365
368
  }
366
369
  function formatStatusSummary(payload) {
367
- if (payload.overview.workerCount === 0 && payload.overview.senseCount === 0) {
370
+ if (payload.overview.workerCount === 0 && payload.overview.senseCount === 0 && (payload.healthChecks ?? []).length === 0) {
368
371
  return "no managed agents";
369
372
  }
370
373
  const degraded = [
@@ -373,6 +376,9 @@ function formatStatusSummary(payload) {
373
376
  .map((row) => `worker:${row.agent}/${row.worker}:${row.status}`),
374
377
  ...unhealthySenseRows(payload.senses)
375
378
  .map((row) => `sense:${row.agent}/${row.sense}:${row.status}`),
379
+ ...(payload.healthChecks ?? [])
380
+ .filter((row) => row.status !== "ok")
381
+ .map((row) => `health-check:${row.name}:${row.status}`),
376
382
  ];
377
383
  const detail = degraded.length > 0 ? `\tdegraded=${degraded.join(",")}` : "";
378
384
  if (!detail) {
@@ -498,6 +504,7 @@ class OuroDaemon {
498
504
  const snapshots = this.processManager.listAgentSnapshots();
499
505
  const workers = buildWorkerRows(snapshots);
500
506
  const senses = this.senseManager?.listSenseRows() ?? [];
507
+ const healthChecks = this.healthMonitor.getLastResults?.() ?? [];
501
508
  const repoRoot = (0, identity_1.getRepoRoot)();
502
509
  const sync = (0, agent_discovery_1.listBundleSyncRows)({ bundlesRoot: this.bundlesRoot });
503
510
  const agents = (0, agent_discovery_1.listAllBundleAgents)({ bundlesRoot: this.bundlesRoot });
@@ -509,7 +516,7 @@ class OuroDaemon {
509
516
  return {
510
517
  overview: {
511
518
  daemon: "running",
512
- health: overviewHealth(workers, senses),
519
+ health: overviewHealth(workers, senses, healthChecks),
513
520
  socketPath: this.socketPath,
514
521
  mailboxUrl,
515
522
  outlookUrl: mailboxUrl,
@@ -521,6 +528,7 @@ class OuroDaemon {
521
528
  },
522
529
  workers,
523
530
  senses,
531
+ ...(healthChecks.length > 0 ? { healthChecks } : {}),
524
532
  sync,
525
533
  agents,
526
534
  ...(providers.length > 0 ? { providers } : {}),
@@ -562,8 +570,6 @@ class OuroDaemon {
562
570
  await (0, update_hooks_1.applyPendingUpdates)(this.bundlesRoot, currentVersion);
563
571
  // Start periodic update checker (polls npm registry every 30 minutes)
564
572
  // Skip in dev mode — dev builds should not auto-update from npm
565
- const bundlesRoot = this.bundlesRoot;
566
- const daemonSocketPath = this.socketPath;
567
573
  if (this.mode === "dev") {
568
574
  (0, runtime_1.emitNervesEvent)({
569
575
  component: "daemon",
@@ -573,7 +579,6 @@ class OuroDaemon {
573
579
  });
574
580
  }
575
581
  else {
576
- const daemon = this;
577
582
  (0, update_checker_1.startUpdateChecker)({
578
583
  currentVersion,
579
584
  deps: {
@@ -583,55 +588,6 @@ class OuroDaemon {
583
588
  return res.json();
584
589
  },
585
590
  },
586
- onUpdate: /* v8 ignore start -- integration: real npm install + process spawn @preserve */ async (result) => {
587
- if (!result.latestVersion)
588
- return;
589
- // Install via the version manager (NOT `npm install -g`). The
590
- // global install path doesn't end up on the daemon process's
591
- // NODE_PATH, so the previous `require.resolve('@ouro.bot/cli')`
592
- // -based path lookup always returned null and the staged restart
593
- // never actually completed. Verified live on 2026-04-08:
594
- // alpha.268 daemon detected alpha.270 was available, ran the
595
- // staged restart, and bailed at `staged_restart_path_failed` —
596
- // meaning the daemon could never auto-update itself and required
597
- // manual `ouro up` to pick up new versions.
598
- //
599
- // Switch to the version-managed layout the CLI itself uses:
600
- // installVersion(version) puts files at
601
- // ~/.ouro-cli/versions/{version}/node_modules/@ouro.bot/cli
602
- // which is a known path we can compute deterministically.
603
- // Then activateVersion(version) flips the CurrentVersion symlink
604
- // so the next `ouro up` from the user sees the same version
605
- // the daemon is running.
606
- const cliHome = (0, ouro_version_manager_1.getOuroCliHome)();
607
- await (0, staged_restart_1.performStagedRestart)(result.latestVersion, {
608
- execSync: (cmd) => (0, child_process_1.execSync)(cmd, { stdio: "inherit" }),
609
- spawnSync: child_process_1.spawnSync,
610
- installNewVersion: (version) => {
611
- (0, ouro_version_manager_1.installVersion)(version, {});
612
- (0, ouro_version_manager_1.activateVersion)(version, {});
613
- },
614
- resolveNewCodePath: (version) => {
615
- const versionPath = path.join(cliHome, "versions", version, "node_modules", "@ouro.bot", "cli");
616
- return fs.existsSync(versionPath) ? versionPath : null;
617
- },
618
- gracefulShutdown: () => daemon.stop(),
619
- spawnNewDaemon: (entryPath, sock) => {
620
- const outFd = fs.openSync(os.devNull, "w");
621
- const errFd = fs.openSync(os.devNull, "w");
622
- const child = (0, child_process_1.spawn)(process.execPath, [entryPath, "--socket", sock], {
623
- detached: true,
624
- stdio: ["ignore", outFd, errFd],
625
- });
626
- child.unref();
627
- return { pid: child.pid ?? null };
628
- },
629
- nodePath: process.execPath,
630
- bundlesRoot,
631
- socketPath: daemonSocketPath,
632
- });
633
- },
634
- /* v8 ignore stop */
635
591
  });
636
592
  }
637
593
  // MCP connections are lazily initialized per-agent during senseTurn
@@ -1136,6 +1092,7 @@ class OuroDaemon {
1136
1092
  sessionId: command.sessionId,
1137
1093
  taskRef: command.taskRef,
1138
1094
  });
1095
+ await this.processManager.startAgent(command.to);
1139
1096
  this.processManager.sendToAgent?.(command.to, { type: "message" });
1140
1097
  return { ok: true, message: `queued message ${receipt.id}`, data: receipt };
1141
1098
  }
@@ -420,7 +420,14 @@ function checkTrips(deps) {
420
420
  checks.push({ label: `${agentDir} trip ledger`, status: "fail", detail: "ledger.json is not valid JSON" });
421
421
  continue;
422
422
  }
423
- const ledgerId = typeof parsed.ledgerId === "string" ? parsed.ledgerId : null;
423
+ const nestedLedger = parsed.ledger && typeof parsed.ledger === "object"
424
+ ? parsed.ledger
425
+ : null;
426
+ const ledgerId = typeof parsed.ledgerId === "string"
427
+ ? parsed.ledgerId
428
+ : typeof nestedLedger?.ledgerId === "string"
429
+ ? nestedLedger.ledgerId
430
+ : null;
424
431
  const hasPrivateKey = typeof parsed.privateKeyPem === "string" && parsed.privateKeyPem.includes("BEGIN");
425
432
  if (!ledgerId) {
426
433
  checks.push({ label: `${agentDir} trip ledger`, status: "warn", detail: "ledger.json missing ledgerId field" });
@@ -12,6 +12,7 @@ class HealthMonitor {
12
12
  senseProbes;
13
13
  senseProbeProvider;
14
14
  intervalHandle = null;
15
+ lastResults = [];
15
16
  constructor(options) {
16
17
  this.processManager = options.processManager;
17
18
  this.scheduler = options.scheduler;
@@ -177,6 +178,7 @@ class HealthMonitor {
177
178
  });
178
179
  }
179
180
  }
181
+ this.lastResults = results.map((result) => ({ ...result }));
180
182
  for (const result of results) {
181
183
  (0, runtime_1.emitNervesEvent)({
182
184
  level: result.status === "critical" ? "error" : result.status === "warn" ? "warn" : "info",
@@ -191,5 +193,8 @@ class HealthMonitor {
191
193
  }
192
194
  return results;
193
195
  }
196
+ getLastResults() {
197
+ return this.lastResults.map((result) => ({ ...result }));
198
+ }
194
199
  }
195
200
  exports.HealthMonitor = HealthMonitor;
@@ -0,0 +1,288 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.parseMcpStatusText = parseMcpStatusText;
37
+ exports.runMcpStatusCanary = runMcpStatusCanary;
38
+ exports.formatMcpStatusCanaryResult = formatMcpStatusCanaryResult;
39
+ exports.createMcpStatusCanaryProbe = createMcpStatusCanaryProbe;
40
+ const child_process_1 = require("child_process");
41
+ const path = __importStar(require("path"));
42
+ const runtime_1 = require("../../nerves/runtime");
43
+ const DEFAULT_CANARY_TIMEOUT_MS = 10_000;
44
+ const MCP_PROTOCOL_VERSION = "2024-11-05";
45
+ function defaultCommandArgs(agent, socketPath) {
46
+ const entryPath = path.join(__dirname, "ouro-bot-entry.js");
47
+ return [
48
+ entryPath,
49
+ "mcp-serve",
50
+ "--agent",
51
+ agent,
52
+ ...(socketPath ? ["--socket", socketPath] : []),
53
+ ];
54
+ }
55
+ function responseText(response) {
56
+ const result = response.result;
57
+ if (!result || typeof result !== "object" || Array.isArray(result))
58
+ return JSON.stringify(response);
59
+ const content = result.content;
60
+ if (!Array.isArray(content))
61
+ return JSON.stringify(response);
62
+ const first = content[0];
63
+ if (!first || typeof first !== "object" || Array.isArray(first))
64
+ return JSON.stringify(response);
65
+ const text = first.text;
66
+ return typeof text === "string" ? text : JSON.stringify(response);
67
+ }
68
+ function parseFields(line) {
69
+ const parsed = {};
70
+ for (const segment of line.split("\t")) {
71
+ const idx = segment.indexOf("=");
72
+ if (idx <= 0)
73
+ continue;
74
+ parsed[segment.slice(0, idx)] = segment.slice(idx + 1);
75
+ }
76
+ return parsed;
77
+ }
78
+ function parseMcpStatusText(text) {
79
+ const daemon = {};
80
+ const senses = {};
81
+ for (const line of text.split(/\r?\n/)) {
82
+ const trimmed = line.trim();
83
+ if (!trimmed)
84
+ continue;
85
+ if (trimmed.startsWith("daemon=")) {
86
+ Object.assign(daemon, parseFields(trimmed));
87
+ continue;
88
+ }
89
+ if (!trimmed.startsWith("sense="))
90
+ continue;
91
+ const fields = parseFields(trimmed);
92
+ const sense = fields.sense;
93
+ if (!sense)
94
+ continue;
95
+ const [name, status = "unknown"] = sense.split(":");
96
+ senses[name] = { ...fields, name, status };
97
+ }
98
+ return { daemon, senses, raw: text };
99
+ }
100
+ function validateMcpStatus(parsed, requiredSenses) {
101
+ const failures = [];
102
+ if (parsed.daemon.daemon !== "running") {
103
+ failures.push(`daemon=${parsed.daemon.daemon ?? "missing"}`);
104
+ }
105
+ if (parsed.daemon.health !== "ok") {
106
+ failures.push(`health=${parsed.daemon.health ?? "missing"}`);
107
+ }
108
+ if (parsed.daemon.daemonVersion &&
109
+ parsed.daemon.mcpVersion &&
110
+ parsed.daemon.daemonVersion !== parsed.daemon.mcpVersion) {
111
+ failures.push(`version mismatch daemon=${parsed.daemon.daemonVersion} mcp=${parsed.daemon.mcpVersion}`);
112
+ }
113
+ for (const [sense, row] of Object.entries(parsed.senses)) {
114
+ if (row.status === "disabled")
115
+ continue;
116
+ if (row.status === "running" || row.status === "interactive")
117
+ continue;
118
+ failures.push(`sense=${sense}:${row.status}`);
119
+ }
120
+ for (const sense of requiredSenses) {
121
+ const row = parsed.senses[sense];
122
+ if (!row) {
123
+ failures.push(`required sense missing: ${sense}`);
124
+ continue;
125
+ }
126
+ if (row.status !== "running" && row.status !== "interactive") {
127
+ failures.push(`required sense unhealthy: ${sense}:${row.status}`);
128
+ }
129
+ }
130
+ const senseSummary = Object.values(parsed.senses)
131
+ .map((row) => `${row.name}:${row.status}`)
132
+ .join(",");
133
+ const summary = failures.length === 0
134
+ ? `mcp canary ok: daemon=${parsed.daemon.daemon} health=${parsed.daemon.health} senses=${senseSummary}`
135
+ : `mcp canary failed: ${failures.join("; ")}`;
136
+ return {
137
+ ok: failures.length === 0,
138
+ summary,
139
+ details: failures.length === 0 ? [parsed.raw] : [...failures, parsed.raw],
140
+ parsed,
141
+ };
142
+ }
143
+ async function runMcpStatusCanary(options) {
144
+ const timeoutMs = options.timeoutMs ?? DEFAULT_CANARY_TIMEOUT_MS;
145
+ /* v8 ignore next -- default spawn is exercised by live canaries, while unit tests inject a fake child @preserve */
146
+ const spawnImpl = options.spawnImpl ?? child_process_1.spawn;
147
+ const command = options.command ?? process.execPath;
148
+ const commandArgs = options.commandArgs ?? defaultCommandArgs(options.agent, options.socketPath);
149
+ const requiredSenses = options.requiredSenses ?? [];
150
+ (0, runtime_1.emitNervesEvent)({
151
+ component: "daemon",
152
+ event: "daemon.mcp_canary_start",
153
+ message: "starting MCP status canary",
154
+ meta: { agent: options.agent, command, commandArgs, timeoutMs, requiredSenses },
155
+ });
156
+ const child = spawnImpl(command, commandArgs, { stdio: ["pipe", "pipe", "pipe"] });
157
+ let buffer = "";
158
+ let stderr = "";
159
+ const pending = new Map();
160
+ function cleanup() {
161
+ for (const [, request] of pending) {
162
+ clearTimeout(request.timer);
163
+ }
164
+ pending.clear();
165
+ if (!child.killed)
166
+ child.kill();
167
+ }
168
+ function failAll(error) {
169
+ for (const [, request] of pending) {
170
+ clearTimeout(request.timer);
171
+ request.reject(error);
172
+ }
173
+ pending.clear();
174
+ }
175
+ child.stderr?.setEncoding("utf8");
176
+ child.stderr?.on("data", (chunk) => {
177
+ stderr += chunk.toString();
178
+ });
179
+ child.stdout?.setEncoding("utf8");
180
+ child.stdout?.on("data", (chunk) => {
181
+ buffer += chunk.toString();
182
+ for (;;) {
183
+ const idx = buffer.indexOf("\n");
184
+ if (idx === -1)
185
+ break;
186
+ const line = buffer.slice(0, idx).trim();
187
+ buffer = buffer.slice(idx + 1);
188
+ if (!line)
189
+ continue;
190
+ let response;
191
+ try {
192
+ response = JSON.parse(line);
193
+ }
194
+ catch {
195
+ failAll(new Error(`MCP canary received malformed JSON: ${line}`));
196
+ return;
197
+ }
198
+ const id = typeof response.id === "number" ? response.id : null;
199
+ if (id === null)
200
+ continue;
201
+ const request = pending.get(id);
202
+ if (!request)
203
+ continue;
204
+ pending.delete(id);
205
+ clearTimeout(request.timer);
206
+ request.resolve(response);
207
+ }
208
+ });
209
+ child.on("error", (error) => failAll(error));
210
+ child.on("close", (code, signal) => {
211
+ if (pending.size === 0)
212
+ return;
213
+ failAll(new Error(`MCP canary process closed before response code=${code} signal=${signal ?? "none"} stderr=${stderr.trim()}`));
214
+ });
215
+ let nextId = 1;
216
+ function request(method, params) {
217
+ return new Promise((resolve, reject) => {
218
+ if (!child.stdin?.writable) {
219
+ reject(new Error("MCP canary stdin is not writable"));
220
+ return;
221
+ }
222
+ const id = nextId++;
223
+ const timer = setTimeout(() => {
224
+ pending.delete(id);
225
+ reject(new Error(`MCP canary timed out waiting for ${method}; stderr=${stderr.trim()}`));
226
+ }, timeoutMs);
227
+ pending.set(id, { resolve, reject, timer });
228
+ child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n");
229
+ });
230
+ }
231
+ try {
232
+ await request("initialize", {
233
+ protocolVersion: MCP_PROTOCOL_VERSION,
234
+ capabilities: {},
235
+ clientInfo: { name: "ouro-mcp-canary", version: "1.0" },
236
+ });
237
+ child.stdin?.write(JSON.stringify({ jsonrpc: "2.0", method: "initialized" }) + "\n");
238
+ const statusResponse = await request("tools/call", {
239
+ name: "status",
240
+ arguments: {},
241
+ });
242
+ const result = statusResponse.result;
243
+ if (result && typeof result === "object" && !Array.isArray(result) && result.isError === true) {
244
+ throw new Error(responseText(statusResponse));
245
+ }
246
+ const parsed = parseMcpStatusText(responseText(statusResponse));
247
+ const canary = validateMcpStatus(parsed, requiredSenses);
248
+ (0, runtime_1.emitNervesEvent)({
249
+ component: "daemon",
250
+ event: canary.ok ? "daemon.mcp_canary_end" : "daemon.mcp_canary_error",
251
+ level: canary.ok ? "info" : "error",
252
+ message: canary.summary,
253
+ meta: { agent: options.agent, ok: canary.ok },
254
+ });
255
+ return canary;
256
+ }
257
+ catch (error) {
258
+ const reason = error instanceof Error ? error.message : String(error);
259
+ (0, runtime_1.emitNervesEvent)({
260
+ component: "daemon",
261
+ event: "daemon.mcp_canary_error",
262
+ level: "error",
263
+ message: "MCP status canary failed",
264
+ meta: { agent: options.agent, reason },
265
+ });
266
+ return { ok: false, summary: `mcp canary failed: ${reason}`, details: [reason] };
267
+ }
268
+ finally {
269
+ child.stdin?.end();
270
+ cleanup();
271
+ }
272
+ }
273
+ function formatMcpStatusCanaryResult(result) {
274
+ return [
275
+ result.ok ? "mcp canary: ok" : "mcp canary: failed",
276
+ result.summary,
277
+ ...result.details.map((line) => ` ${line}`),
278
+ ].join("\n");
279
+ }
280
+ function createMcpStatusCanaryProbe(options) {
281
+ return {
282
+ name: `mcp-canary:${options.agent}`,
283
+ check: async () => {
284
+ const result = await runMcpStatusCanary(options);
285
+ return { ok: result.ok, detail: result.summary };
286
+ },
287
+ };
288
+ }
@@ -1,11 +1,26 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.McpClient = void 0;
4
+ exports.isMcpTransportError = isMcpTransportError;
4
5
  const child_process_1 = require("child_process");
5
6
  const readline_1 = require("readline");
6
7
  const runtime_1 = require("../nerves/runtime");
7
8
  const MCP_PROTOCOL_VERSION = "2024-11-05";
9
+ const DEFAULT_REQUEST_TIMEOUT = 10_000;
8
10
  const DEFAULT_TOOL_CALL_TIMEOUT = 30_000;
11
+ function isMcpTransportError(error) {
12
+ const message = error instanceof Error ? error.message : String(error);
13
+ const normalized = message.toLowerCase();
14
+ return normalized.includes("disconnected")
15
+ || normalized.includes("transport")
16
+ || normalized.includes("closed")
17
+ || normalized.includes("econnreset")
18
+ || normalized.includes("econnrefused")
19
+ || normalized.includes("enoent")
20
+ || normalized.includes("epipe")
21
+ || normalized.includes("broken pipe")
22
+ || normalized.includes("not writable");
23
+ }
9
24
  class McpClient {
10
25
  config;
11
26
  process = null;
@@ -18,6 +33,9 @@ class McpClient {
18
33
  this.config = config;
19
34
  }
20
35
  async connect() {
36
+ if (this.connected)
37
+ return;
38
+ this.shutdownProcessOnly();
21
39
  (0, runtime_1.emitNervesEvent)({
22
40
  event: "mcp.connect_start",
23
41
  component: "repertoire",
@@ -44,6 +62,7 @@ class McpClient {
44
62
  }
45
63
  catch (error) {
46
64
  this.connected = false;
65
+ this.shutdownProcessOnly();
47
66
  (0, runtime_1.emitNervesEvent)({
48
67
  level: "error",
49
68
  event: "mcp.connect_error",
@@ -76,6 +95,10 @@ class McpClient {
76
95
  this.cachedTools = allTools;
77
96
  return allTools;
78
97
  }
98
+ async refreshTools() {
99
+ this.cachedTools = null;
100
+ return this.listTools();
101
+ }
79
102
  async callTool(name, args, timeout = DEFAULT_TOOL_CALL_TIMEOUT) {
80
103
  (0, runtime_1.emitNervesEvent)({
81
104
  event: "mcp.tool_call_start",
@@ -139,7 +162,7 @@ class McpClient {
139
162
  });
140
163
  return result;
141
164
  }
142
- sendRequest(method, params, timeout) {
165
+ sendRequest(method, params, timeout = DEFAULT_REQUEST_TIMEOUT) {
143
166
  return new Promise((resolve, reject) => {
144
167
  if (!this.process || !this.connected && method !== "initialize") {
145
168
  reject(new Error("MCP client is disconnected"));
@@ -160,14 +183,21 @@ class McpClient {
160
183
  method,
161
184
  params,
162
185
  };
163
- this.writeMessage(request);
186
+ if (!this.writeMessage(request)) {
187
+ this.pending.delete(id);
188
+ if (pending.timer) {
189
+ clearTimeout(pending.timer);
190
+ }
191
+ reject(new Error(`MCP transport is not writable for request: ${method}`));
192
+ }
164
193
  });
165
194
  }
166
195
  writeMessage(message) {
167
- /* v8 ignore next -- defensive: stdin always writable during active connection @preserve */
168
196
  if (this.process?.stdin?.writable) {
169
197
  this.process.stdin.write(JSON.stringify(message) + "\n");
198
+ return true;
170
199
  }
200
+ return false;
171
201
  }
172
202
  setupLineReader() {
173
203
  /* v8 ignore next -- defensive: stdout always exists after spawn @preserve */
@@ -251,5 +281,13 @@ class McpClient {
251
281
  this.pending.delete(id);
252
282
  }
253
283
  }
284
+ shutdownProcessOnly() {
285
+ this.rejectAllPending(new Error("MCP transport closed during reconnect"));
286
+ /* v8 ignore next -- defensive: process may already be absent @preserve */
287
+ if (this.process && !this.process.killed) {
288
+ this.process.kill();
289
+ }
290
+ this.process = null;
291
+ }
254
292
  }
255
293
  exports.McpClient = McpClient;
@@ -33,14 +33,59 @@ class McpManager {
33
33
  return result;
34
34
  }
35
35
  async callTool(server, tool, args) {
36
- const entry = this.servers.get(server);
36
+ let entry = this.servers.get(server);
37
37
  if (!entry) {
38
38
  throw new Error(`Unknown server: ${server}`);
39
39
  }
40
40
  if (!entry.client.isConnected()) {
41
- throw new Error(`Server "${server}" is disconnected`);
41
+ await this.recoverStaleTransport(server, "pre-call disconnected");
42
+ entry = this.servers.get(server);
43
+ if (!entry?.client.isConnected()) {
44
+ throw new Error(`Server "${server}" is disconnected`);
45
+ }
46
+ }
47
+ try {
48
+ return await entry.client.callTool(tool, args);
49
+ }
50
+ catch (error) {
51
+ if (!(0, mcp_client_1.isMcpTransportError)(error)) {
52
+ throw error;
53
+ }
54
+ const reason = error instanceof Error ? error.message : String(error);
55
+ await this.recoverStaleTransport(server, reason);
56
+ const recovered = this.servers.get(server);
57
+ if (!recovered?.client.isConnected()) {
58
+ throw new Error(`Server "${server}" is disconnected after recovery: ${reason}`);
59
+ }
60
+ return recovered.client.callTool(tool, args);
61
+ }
62
+ }
63
+ async runCanaries() {
64
+ const results = [];
65
+ for (const [server, entry] of [...this.servers]) {
66
+ try {
67
+ if (!entry.client.isConnected()) {
68
+ await this.recoverStaleTransport(server, "canary disconnected");
69
+ }
70
+ const current = this.servers.get(server);
71
+ if (!current?.client.isConnected()) {
72
+ results.push({ server, ok: false, detail: "disconnected after recovery attempt" });
73
+ continue;
74
+ }
75
+ const tools = await current.client.refreshTools();
76
+ current.cachedTools = tools;
77
+ current.consecutiveFailures = 0;
78
+ results.push({ server, ok: true, detail: `${tools.length} tools listed` });
79
+ }
80
+ catch (error) {
81
+ const reason = error instanceof Error ? error.message : String(error);
82
+ if ((0, mcp_client_1.isMcpTransportError)(error)) {
83
+ await this.recoverStaleTransport(server, reason);
84
+ }
85
+ results.push({ server, ok: false, detail: reason });
86
+ }
42
87
  }
43
- return entry.client.callTool(tool, args);
88
+ return results;
44
89
  }
45
90
  /* v8 ignore start — reconcile: dynamic MCP server management, tested via integration @preserve */
46
91
  /** Re-read agent config and connect new servers / disconnect removed ones. */
@@ -232,6 +277,7 @@ class McpManager {
232
277
  return;
233
278
  // Remove old entry and reconnect
234
279
  this.servers.delete(name);
280
+ entry.client.shutdown();
235
281
  await this.connectServer(name, entry.config);
236
282
  // Preserve failure count
237
283
  const newEntry = this.servers.get(name);
@@ -239,6 +285,17 @@ class McpManager {
239
285
  newEntry.consecutiveFailures = entry.consecutiveFailures;
240
286
  }
241
287
  }
288
+ /* v8 ignore stop */
289
+ async recoverStaleTransport(name, reason) {
290
+ (0, runtime_1.emitNervesEvent)({
291
+ level: "warn",
292
+ event: "mcp.transport_recovery",
293
+ component: "repertoire",
294
+ message: `recovering stale MCP transport: ${name}`,
295
+ meta: { server: name, reason },
296
+ });
297
+ await this.restartServer(name);
298
+ }
242
299
  }
243
300
  exports.McpManager = McpManager;
244
301
  let _sharedManager = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.530",
3
+ "version": "0.1.0-alpha.532",
4
4
  "main": "dist/heart/daemon/ouro-entry.js",
5
5
  "bin": {
6
6
  "cli": "dist/heart/daemon/ouro-bot-entry.js",