@alfe.ai/gateway 0.3.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { a as installService, c as uninstallService, i as checkExistingDaemon, n as queryDaemonHealth, r as startDaemon, s as stopExistingDaemon, t as formatHealthReport, y as SOCKET_PATH } from "../health.js";
2
+ import { a as installService, c as uninstallService, i as checkExistingDaemon, n as queryDaemonHealth, r as startDaemon, s as stopExistingDaemon, t as formatHealthReport, w as SOCKET_PATH } from "../health.js";
3
3
  import { t as LOG_FILE } from "../logger.js";
4
4
  import { spawn } from "node:child_process";
5
5
  //#region bin/gateway.ts
package/dist/health.js CHANGED
@@ -5266,7 +5266,7 @@ function isIPCResponse(msg) {
5266
5266
  const PROTOCOL_VERSION = 1;
5267
5267
  //#endregion
5268
5268
  //#region src/runtime-gate.ts
5269
- const log$3 = logger$1.child({ component: "RuntimeGate" });
5269
+ const log$4 = logger$1.child({ component: "RuntimeGate" });
5270
5270
  var RuntimeGate = class {
5271
5271
  depth = 0;
5272
5272
  wasRunning = false;
@@ -5284,7 +5284,7 @@ var RuntimeGate = class {
5284
5284
  const rp = this.getRuntime();
5285
5285
  this.wasRunning = rp?.isRunning ?? false;
5286
5286
  if (rp && this.wasRunning) {
5287
- log$3.info("Suspending runtime for mutating reconcile (avoid concurrent SQLite writers)");
5287
+ log$4.info("Suspending runtime for mutating reconcile (avoid concurrent SQLite writers)");
5288
5288
  await rp.stop();
5289
5289
  }
5290
5290
  }
@@ -5299,7 +5299,7 @@ var RuntimeGate = class {
5299
5299
  if (this.depth < 0) this.depth = 0;
5300
5300
  const rp = this.getRuntime();
5301
5301
  if (rp && this.wasRunning) {
5302
- log$3.info("Resuming runtime after mutating reconcile");
5302
+ log$4.info("Resuming runtime after mutating reconcile");
5303
5303
  rp.resume();
5304
5304
  }
5305
5305
  this.wasRunning = false;
@@ -5314,12 +5314,12 @@ var RuntimeGate = class {
5314
5314
  */
5315
5315
  requestRestart() {
5316
5316
  if (this.depth > 0) {
5317
- log$3.info("Runtime restart requested while suspended — deferring to pending resume");
5317
+ log$4.info("Runtime restart requested while suspended — deferring to pending resume");
5318
5318
  return;
5319
5319
  }
5320
5320
  const rp = this.getRuntime();
5321
5321
  if (rp) rp.restart().catch((err) => {
5322
- log$3.error({ err: err instanceof Error ? err.message : String(err) }, "Failed to restart runtime");
5322
+ log$4.error({ err: err instanceof Error ? err.message : String(err) }, "Failed to restart runtime");
5323
5323
  });
5324
5324
  }
5325
5325
  };
@@ -5366,14 +5366,40 @@ var NoopRuntimeGate = class {
5366
5366
  * both places.
5367
5367
  */
5368
5368
  const AGENT_DAEMON_SENTRY_DSN = "https://a47f010d8d39fb4350f913492189f283@o4511008239452160.ingest.us.sentry.io/4511679448547328";
5369
+ const CLI_SENTRY_DSN = "https://82dde4631336561f2fcc89d7531623de@o4511008239452160.ingest.us.sentry.io/4511679411191808";
5370
+ /**
5371
+ * Dedicated project for errors emitted BY the agent runtime child process
5372
+ * (OpenClaw/Hermes) — crashes, spawn failures, and error-looking output. Kept
5373
+ * separate from `agent-daemon` so runtime noise never drowns daemon issues.
5374
+ * Mirrors `SENTRY_DSNS.agentRuntime` in `config/config.ts`.
5375
+ */
5376
+ const AGENT_RUNTIME_SENTRY_DSN = "https://feb7fb2e3b8e723aec518e74715bfa6d@o4511008239452160.ingest.us.sentry.io/4511683667558400";
5377
+ /**
5378
+ * MCP tool/server failures on agent VMs — reported by the daemon-hosted MCP
5379
+ * bundler (tool errors, child crashes, stderr output). This is the SAME
5380
+ * project as the cloud MCP Fly service (`SENTRY_DSNS.mcp`); agent-side events
5381
+ * are distinguished by `source: agent-daemon` + `agentId` tags.
5382
+ */
5383
+ const AGENT_MCP_SENTRY_DSN = "https://638309eac96146a945f46b578f10c806@o4511008239452160.ingest.us.sentry.io/4511146364174336";
5369
5384
  /** Surface → Sentry project: `cli` and `agent-daemon` respectively. */
5370
5385
  const SURFACE_DSNS = {
5371
- cli: "https://82dde4631336561f2fcc89d7531623de@o4511008239452160.ingest.us.sentry.io/4511679411191808",
5386
+ cli: CLI_SENTRY_DSN,
5372
5387
  daemon: AGENT_DAEMON_SENTRY_DSN
5373
5388
  };
5374
5389
  /** Held after a successful init so the capture helpers can run synchronously. */
5375
5390
  let sentry = null;
5376
5391
  /**
5392
+ * Additional bound clients (daemon surface only). The default client keeps the
5393
+ * daemon's own DSN; runtime child-process events and MCP failures are routed
5394
+ * to their own projects via explicitly-bound Scopes — the documented
5395
+ * multi-client pattern. `null` until `initAgentSentry({ surface: "daemon" })`
5396
+ * succeeds.
5397
+ */
5398
+ let runtimeClient = null;
5399
+ let runtimeScope = null;
5400
+ let mcpClient = null;
5401
+ let mcpScope = null;
5402
+ /**
5377
5403
  * Map the configured API URL to a coarse environment tag. Best-effort — returns
5378
5404
  * `"unknown"` when no config is present yet (e.g. `alfe login` pre-setup).
5379
5405
  */
@@ -5404,12 +5430,13 @@ function errorReportingDisabled() {
5404
5430
  } catch {}
5405
5431
  return false;
5406
5432
  }
5407
- const KV_SECRET_RE = /\b(authorization|api[_-]?key|apikey|token|secret|password|passwd|bearer)\b(\s*[:=]\s*|\s+)("?)([^\s"']+)\3/gi;
5433
+ const KV_SECRET_RE = /(?<![A-Za-z0-9])([A-Za-z0-9_-]*(?:authorization|api[_-]?key|apikey|token|secret|password|passwd|bearer|credential)s?)(\s*[:=]\s*|\s+)("?)([^\s"']+)\3/gi;
5434
+ const URL_QUERY_SECRET_RE = /([?&](?:[A-Za-z0-9_-]*(?:token|secret|key|signature|credential|password)|sig|code|x-amz-[a-z-]+)=)[^&\s"']+/gi;
5408
5435
  const MIN_BARE_SECRET_LEN = 16;
5409
5436
  const ALFE_TOKEN_RE = /alfe_(?:dev|test|demo|live)_[A-Za-z0-9._-]+/g;
5410
5437
  const BEARER_RE = /\bbearer\s+[A-Za-z0-9._\-+/=]+/gi;
5411
5438
  function scrubString(value) {
5412
- return value.replace(BEARER_RE, "Bearer [REDACTED]").replace(KV_SECRET_RE, (match, label, sep, _q, secret) => {
5439
+ return value.replace(BEARER_RE, "Bearer [REDACTED]").replace(URL_QUERY_SECRET_RE, "$1[REDACTED]").replace(KV_SECRET_RE, (match, label, sep, _q, secret) => {
5413
5440
  if (!/[:=]/.test(sep) && secret.length < MIN_BARE_SECRET_LEN) return match;
5414
5441
  return `${label}${sep}[REDACTED]`;
5415
5442
  }).replace(ALFE_TOKEN_RE, "alfe_[REDACTED]");
@@ -5453,6 +5480,47 @@ async function initAgentSentry(options) {
5453
5480
  const dsn = SURFACE_DSNS[options.surface].trim();
5454
5481
  if (!dsn) return;
5455
5482
  const mod = await import("@sentry/node");
5483
+ if (options.surface === "daemon") {
5484
+ const buildBoundScope = (dsn, extraTags) => {
5485
+ try {
5486
+ if (!dsn.trim()) return null;
5487
+ const client = new mod.NodeClient({
5488
+ dsn: dsn.trim(),
5489
+ environment: deriveEnvironment(),
5490
+ ...options.release ? { release: options.release } : {},
5491
+ sampleRate: 1,
5492
+ tracesSampleRate: 0,
5493
+ sendDefaultPii: false,
5494
+ transport: mod.makeNodeTransport,
5495
+ stackParser: mod.defaultStackParser,
5496
+ integrations: [],
5497
+ beforeSend: (event) => scrubEvent(event)
5498
+ });
5499
+ client.init();
5500
+ const scope = new mod.Scope();
5501
+ scope.setClient(client);
5502
+ const runtime = deriveRuntime();
5503
+ if (runtime) scope.setTag("runtime", runtime);
5504
+ for (const [k, v] of Object.entries(extraTags)) scope.setTag(k, v);
5505
+ return {
5506
+ client,
5507
+ scope
5508
+ };
5509
+ } catch {
5510
+ return null;
5511
+ }
5512
+ };
5513
+ if (!runtimeScope) {
5514
+ const bound = buildBoundScope(AGENT_RUNTIME_SENTRY_DSN, {});
5515
+ runtimeClient = bound?.client ?? null;
5516
+ runtimeScope = bound?.scope ?? null;
5517
+ }
5518
+ if (!mcpScope) {
5519
+ const bound = buildBoundScope(AGENT_MCP_SENTRY_DSN, { source: "agent-daemon" });
5520
+ mcpClient = bound?.client ?? null;
5521
+ mcpScope = bound?.scope ?? null;
5522
+ }
5523
+ }
5456
5524
  if (mod.getClient()) {
5457
5525
  sentry = mod;
5458
5526
  return;
@@ -5464,6 +5532,7 @@ async function initAgentSentry(options) {
5464
5532
  sampleRate: 1,
5465
5533
  tracesSampleRate: 0,
5466
5534
  sendDefaultPii: false,
5535
+ ...options.surface === "daemon" ? { integrations: (defaults) => defaults.filter((i) => i.name !== "OnUncaughtException" && i.name !== "OnUnhandledRejection") } : {},
5467
5536
  beforeSend: (event) => scrubEvent(event),
5468
5537
  beforeBreadcrumb: (breadcrumb) => scrubBreadcrumb(breadcrumb)
5469
5538
  });
@@ -5482,8 +5551,15 @@ async function initAgentSentry(options) {
5482
5551
  function setAgentContext(context) {
5483
5552
  if (!sentry) return;
5484
5553
  try {
5485
- if (context.agentId) sentry.setTag("agentId", context.agentId);
5486
- if (context.runtime) sentry.setTag("runtime", context.runtime);
5554
+ for (const scope of [
5555
+ sentry,
5556
+ runtimeScope,
5557
+ mcpScope
5558
+ ]) {
5559
+ if (!scope) continue;
5560
+ if (context.agentId) scope.setTag("agentId", context.agentId);
5561
+ if (context.runtime) scope.setTag("runtime", context.runtime);
5562
+ }
5487
5563
  } catch {}
5488
5564
  }
5489
5565
  /**
@@ -5528,8 +5604,105 @@ function captureCliFailure(context, cause) {
5528
5604
  });
5529
5605
  } catch {}
5530
5606
  }
5607
+ /**
5608
+ * Capture a runtime child-process crash (non-graceful exit) or spawn failure
5609
+ * into the dedicated `agent-runtime` project. Recent output travels as
5610
+ * breadcrumbs — NEVER `extra`, which `scrubEvent` (the runtime client's
5611
+ * `beforeSend`) deletes. Throttling is the caller's job (`CaptureThrottle` in
5612
+ * `runtime-output-monitor.ts`). No-op when the runtime client is not
5613
+ * initialised. Never throws.
5614
+ */
5615
+ function captureRuntimeCrash(opts) {
5616
+ if (!runtimeScope) return;
5617
+ try {
5618
+ const scope = runtimeScope.clone();
5619
+ for (const { stream, line } of opts.recentOutput) scope.addBreadcrumb({
5620
+ category: `runtime.${stream}`,
5621
+ level: stream === "stderr" ? "warning" : "info",
5622
+ message: line
5623
+ });
5624
+ const crashKey = opts.spawnError ? `spawn:${opts.spawnError.code ?? "error"}` : String(opts.code ?? opts.signal ?? "unknown");
5625
+ scope.setFingerprint([
5626
+ "runtime-crash",
5627
+ opts.runtime,
5628
+ crashKey
5629
+ ]);
5630
+ scope.setTags({
5631
+ runtime: opts.runtime,
5632
+ exitCode: String(opts.code),
5633
+ signal: String(opts.signal),
5634
+ crashesSuppressed: String(opts.crashesSuppressed ?? 0)
5635
+ });
5636
+ if (opts.spawnError) scope.captureException(opts.spawnError);
5637
+ else scope.captureMessage(`Runtime ${opts.runtime} crashed (code=${String(opts.code)}, signal=${String(opts.signal)})`, "error");
5638
+ } catch {}
5639
+ }
5640
+ /**
5641
+ * Capture an error-looking block of runtime output (stack trace, Python
5642
+ * traceback, or ERROR-level log line) detected while the child is running.
5643
+ * The block lines travel as breadcrumbs; the head line is the message.
5644
+ * Throttling/dedupe is the caller's job. No-op when Sentry is not initialised.
5645
+ * Never throws.
5646
+ */
5647
+ function captureRuntimeErrorOutput(opts) {
5648
+ if (!runtimeScope) return;
5649
+ try {
5650
+ const scope = runtimeScope.clone();
5651
+ for (const line of opts.lines) scope.addBreadcrumb({
5652
+ category: `runtime.${opts.stream}`,
5653
+ level: "error",
5654
+ message: line
5655
+ });
5656
+ scope.setFingerprint([
5657
+ "runtime-output",
5658
+ opts.runtime,
5659
+ opts.kind,
5660
+ opts.fingerprintKey
5661
+ ]);
5662
+ scope.setTags({
5663
+ runtime: opts.runtime,
5664
+ stream: opts.stream,
5665
+ kind: opts.kind,
5666
+ suppressedCount: String(opts.suppressedCount ?? 0)
5667
+ });
5668
+ scope.captureMessage(opts.lines[0].slice(0, 300), "error");
5669
+ } catch {}
5670
+ }
5671
+ /**
5672
+ * Capture an MCP failure (tool error, server crash, or error-looking stderr
5673
+ * output) into the `mcp` project via the daemon's bound MCP client. Throttling
5674
+ * is the caller's job (`mcp-error-capture.ts`). No-op when the MCP client is
5675
+ * not initialised. Never throws.
5676
+ */
5677
+ function captureMcpFailure(opts) {
5678
+ if (!mcpScope) return;
5679
+ try {
5680
+ const scope = mcpScope.clone();
5681
+ for (const line of opts.lines ?? []) scope.addBreadcrumb({
5682
+ category: `mcp.${opts.server}`,
5683
+ level: "error",
5684
+ message: line
5685
+ });
5686
+ scope.setFingerprint([
5687
+ "agent-mcp",
5688
+ opts.server,
5689
+ opts.kind,
5690
+ ...opts.tool ? [opts.tool] : []
5691
+ ]);
5692
+ scope.setTags({
5693
+ server: opts.server,
5694
+ kind: opts.kind,
5695
+ ...opts.tool ? { tool: opts.tool } : {},
5696
+ suppressedCount: String(opts.suppressedCount ?? 0)
5697
+ });
5698
+ scope.captureMessage(`MCP ${opts.server}${opts.tool ? `.${opts.tool}` : ""} ${opts.kind}: ${opts.message.slice(0, 300)}`, "error");
5699
+ } catch {}
5700
+ }
5531
5701
  /** Flush queued events (small timeout) so short-lived commands deliver them. */
5532
5702
  async function flushSentry(timeoutMs = 2e3) {
5703
+ for (const client of [runtimeClient, mcpClient]) try {
5704
+ if (client) await client.flush(timeoutMs);
5705
+ } catch {}
5533
5706
  if (!sentry) return;
5534
5707
  try {
5535
5708
  await sentry.flush(timeoutMs);
@@ -5548,7 +5721,7 @@ function captureFatal(cause) {
5548
5721
  }
5549
5722
  //#endregion
5550
5723
  //#region src/reconciliation.ts
5551
- const log$2 = logger$1.child({ component: "Reconciliation" });
5724
+ const log$3 = logger$1.child({ component: "Reconciliation" });
5552
5725
  /**
5553
5726
  * How many times reconcile will re-attempt activation of an intact integration
5554
5727
  * stuck in `error` (with no reinstall requested) before giving up and waiting
@@ -5618,7 +5791,7 @@ var ReconciliationEngine = class {
5618
5791
  try {
5619
5792
  localIntegrations = await this.manager.getInstalledIntegrations();
5620
5793
  } catch (err) {
5621
- log$2.error({ err }, "Failed to get local integrations");
5794
+ log$3.error({ err }, "Failed to get local integrations");
5622
5795
  localIntegrations = [];
5623
5796
  }
5624
5797
  const localMap = new Map(localIntegrations.map((i) => [i.id, i]));
@@ -5637,10 +5810,10 @@ var ReconciliationEngine = class {
5637
5810
  try {
5638
5811
  if (!local) {
5639
5812
  await this.ensureSuspended();
5640
- log$2.info(`Installing ${id}@${desired.version}`);
5813
+ log$3.info(`Installing ${id}@${desired.version}`);
5641
5814
  await this.manager.install(id, desired.version, desired.config, desired.customSource);
5642
5815
  report.installed.push(id);
5643
- log$2.info(`Activating ${id}`);
5816
+ log$3.info(`Activating ${id}`);
5644
5817
  if ((await this.manager.activate(id)).configApplied) report.configApplied = true;
5645
5818
  report.activated.push(id);
5646
5819
  report.results.push({
@@ -5652,7 +5825,7 @@ var ReconciliationEngine = class {
5652
5825
  }
5653
5826
  if (local.version !== desired.version && desired.version !== "" && local.version !== "unknown") {
5654
5827
  await this.ensureSuspended();
5655
- log$2.info(`Upgrading ${id}: ${local.version} → ${desired.version}`);
5828
+ log$3.info(`Upgrading ${id}: ${local.version} → ${desired.version}`);
5656
5829
  if ((await this.manager.reinstall(id, desired.version, desired.config, desired.customSource)).configApplied) report.configApplied = true;
5657
5830
  report.installed.push(id);
5658
5831
  report.activated.push(id);
@@ -5666,7 +5839,7 @@ var ReconciliationEngine = class {
5666
5839
  if (local.status === "error") {
5667
5840
  if (desired.reinstallRequestedAt && local.installedAt && desired.reinstallRequestedAt > local.installedAt) {
5668
5841
  await this.ensureSuspended();
5669
- log$2.info(`Reinstalling ${id} from error state (requested: ${desired.reinstallRequestedAt}, installed: ${local.installedAt})`);
5842
+ log$3.info(`Reinstalling ${id} from error state (requested: ${desired.reinstallRequestedAt}, installed: ${local.installedAt})`);
5670
5843
  this.manager.resetReinstallAttempts(id);
5671
5844
  this.activateAttempts.delete(id);
5672
5845
  if ((await this.manager.reinstall(id, desired.version, desired.config, desired.customSource)).configApplied) report.configApplied = true;
@@ -5682,7 +5855,7 @@ var ReconciliationEngine = class {
5682
5855
  if (!await this.manager.isInstallIntact(id)) {
5683
5856
  const attempts = this.manager.getReinstallAttempts(id);
5684
5857
  if (attempts >= 3) {
5685
- log$2.error(`Auto-reinstall blocked for ${id} — ${String(attempts)} consecutive failures. Manual reinstall required.`);
5858
+ log$3.error(`Auto-reinstall blocked for ${id} — ${String(attempts)} consecutive failures. Manual reinstall required.`);
5686
5859
  captureIntegrationFailure(id, "reinstall", `Max auto-reinstall attempts (${String(attempts)}) reached — manual reinstall required`);
5687
5860
  report.errors.push({
5688
5861
  integrationId: id,
@@ -5697,7 +5870,7 @@ var ReconciliationEngine = class {
5697
5870
  return;
5698
5871
  }
5699
5872
  await this.ensureSuspended();
5700
- log$2.info(`Auto-reinstalling ${id} — install directory is corrupted or missing (attempt ${String(attempts + 1)}/3)`);
5873
+ log$3.info(`Auto-reinstalling ${id} — install directory is corrupted or missing (attempt ${String(attempts + 1)}/3)`);
5701
5874
  this.manager.incrementReinstallAttempts(id);
5702
5875
  try {
5703
5876
  if ((await this.manager.reinstall(id, desired.version, desired.config, desired.customSource)).configApplied) report.configApplied = true;
@@ -5712,7 +5885,7 @@ var ReconciliationEngine = class {
5712
5885
  });
5713
5886
  } catch (reinstallErr) {
5714
5887
  const reinstallMsg = reinstallErr instanceof Error ? reinstallErr.message : String(reinstallErr);
5715
- log$2.error({ err: reinstallErr }, `Auto-reinstall failed for ${id}`);
5888
+ log$3.error({ err: reinstallErr }, `Auto-reinstall failed for ${id}`);
5716
5889
  captureIntegrationFailure(id, "reinstall", reinstallErr);
5717
5890
  report.errors.push({
5718
5891
  integrationId: id,
@@ -5731,7 +5904,7 @@ var ReconciliationEngine = class {
5731
5904
  if (activateAttempts < MAX_ERROR_ACTIVATE_ATTEMPTS) {
5732
5905
  this.activateAttempts.set(id, activateAttempts + 1);
5733
5906
  await this.ensureSuspended();
5734
- log$2.info(`Re-activating ${id} from error state (attempt ${String(activateAttempts + 1)}/${String(MAX_ERROR_ACTIVATE_ATTEMPTS)})`);
5907
+ log$3.info(`Re-activating ${id} from error state (attempt ${String(activateAttempts + 1)}/${String(MAX_ERROR_ACTIVATE_ATTEMPTS)})`);
5735
5908
  try {
5736
5909
  if ((await this.manager.activate(id)).configApplied) report.configApplied = true;
5737
5910
  this.activateAttempts.delete(id);
@@ -5744,7 +5917,7 @@ var ReconciliationEngine = class {
5744
5917
  return;
5745
5918
  } catch (reactivateErr) {
5746
5919
  const reactivateMsg = reactivateErr instanceof Error ? reactivateErr.message : String(reactivateErr);
5747
- log$2.warn(`Re-activation of ${id} from error state failed (attempt ${String(activateAttempts + 1)}/${String(MAX_ERROR_ACTIVATE_ATTEMPTS)}): ${reactivateMsg}`);
5920
+ log$3.warn(`Re-activation of ${id} from error state failed (attempt ${String(activateAttempts + 1)}/${String(MAX_ERROR_ACTIVATE_ATTEMPTS)}): ${reactivateMsg}`);
5748
5921
  captureIntegrationFailure(id, "reactivate", reactivateErr);
5749
5922
  report.errors.push({
5750
5923
  integrationId: id,
@@ -5759,7 +5932,7 @@ var ReconciliationEngine = class {
5759
5932
  return;
5760
5933
  }
5761
5934
  }
5762
- log$2.warn(`Integration ${id} is in error state — re-activation exhausted, waiting for reinstall request`);
5935
+ log$3.warn(`Integration ${id} is in error state — re-activation exhausted, waiting for reinstall request`);
5763
5936
  captureIntegrationFailure(id, "reactivate", `Integration ${id} stuck in error state — re-activation attempts exhausted`);
5764
5937
  report.errors.push({
5765
5938
  integrationId: id,
@@ -5775,7 +5948,7 @@ var ReconciliationEngine = class {
5775
5948
  }
5776
5949
  if (local.status !== "active") {
5777
5950
  await this.ensureSuspended();
5778
- log$2.info(`Activating ${id}`);
5951
+ log$3.info(`Activating ${id}`);
5779
5952
  if ((await this.manager.activate(id)).configApplied) report.configApplied = true;
5780
5953
  report.activated.push(id);
5781
5954
  report.results.push({
@@ -5787,7 +5960,7 @@ var ReconciliationEngine = class {
5787
5960
  }
5788
5961
  if (desired.reinstallRequestedAt && local.installedAt && desired.reinstallRequestedAt > local.installedAt) {
5789
5962
  await this.ensureSuspended();
5790
- log$2.info(`Reinstall requested for ${id} (requested: ${desired.reinstallRequestedAt}, installed: ${local.installedAt})`);
5963
+ log$3.info(`Reinstall requested for ${id} (requested: ${desired.reinstallRequestedAt}, installed: ${local.installedAt})`);
5791
5964
  this.manager.resetReinstallAttempts(id);
5792
5965
  this.activateAttempts.delete(id);
5793
5966
  if ((await this.manager.reinstall(id, desired.version, desired.config, desired.customSource)).configApplied) report.configApplied = true;
@@ -5808,7 +5981,7 @@ var ReconciliationEngine = class {
5808
5981
  });
5809
5982
  } catch (err) {
5810
5983
  const message = err instanceof Error ? err.message : String(err);
5811
- log$2.error({ err }, `Error reconciling ${id}`);
5984
+ log$3.error({ err }, `Error reconciling ${id}`);
5812
5985
  captureIntegrationFailure(id, "reconcile_active", err);
5813
5986
  report.errors.push({
5814
5987
  integrationId: id,
@@ -5833,7 +6006,7 @@ var ReconciliationEngine = class {
5833
6006
  }
5834
6007
  try {
5835
6008
  await this.ensureSuspended();
5836
- log$2.info(`Removing ${id}`);
6009
+ log$3.info(`Removing ${id}`);
5837
6010
  if (local.status === "active") {
5838
6011
  await this.manager.deactivate(id);
5839
6012
  report.deactivated.push(id);
@@ -5847,7 +6020,7 @@ var ReconciliationEngine = class {
5847
6020
  });
5848
6021
  } catch (err) {
5849
6022
  const message = err instanceof Error ? err.message : String(err);
5850
- log$2.error({ err }, `Error removing ${id}`);
6023
+ log$3.error({ err }, `Error removing ${id}`);
5851
6024
  captureIntegrationFailure(id, "reconcile_removed", err);
5852
6025
  report.errors.push({
5853
6026
  integrationId: id,
@@ -21980,6 +22153,177 @@ function createLogger(component, additionalData) {
21980
22153
  component
21981
22154
  });
21982
22155
  }
22156
+ const ANSI_RE = /\x1b\[[0-9;]*[A-Za-z]/g;
22157
+ function stripAnsi(line) {
22158
+ return line.replace(ANSI_RE, "");
22159
+ }
22160
+ /** Rolling window of the child's most recent output lines (both streams). */
22161
+ var OutputRingBuffer = class {
22162
+ lines = [];
22163
+ push(line) {
22164
+ this.lines.push({
22165
+ stream: line.stream,
22166
+ line: line.line.length > 500 ? line.line.slice(0, 500) : line.line
22167
+ });
22168
+ if (this.lines.length > 50) this.lines.shift();
22169
+ }
22170
+ /** Oldest → newest copy. */
22171
+ snapshot() {
22172
+ return this.lines.map((l) => ({ ...l }));
22173
+ }
22174
+ };
22175
+ const PY_TRACEBACK_START = "Traceback (most recent call last):";
22176
+ const NODE_ERROR_HEAD = /^\s*(?:Uncaught\s+|Unhandled\w*\s+)?[A-Z][A-Za-z0-9_]*(?:Error|Exception)\b\s*[:(]/;
22177
+ const LOG_ERROR_LEVEL = /^.{0,40}(?:\[(?:ERROR|FATAL|CRITICAL)\]|\b(?:ERROR|FATAL|CRITICAL)\b\s*[:|])/;
22178
+ const JSON_ERROR_LEVEL = /"level"\s*:\s*(?:"(?:error|fatal)"|(?:50|60)\b)/;
22179
+ const NODE_FRAME = /^\s+at\s/;
22180
+ const NODE_CAUSE = /^\s*caused by:?/i;
22181
+ const NODE_CHAINED = /^\s*[A-Z][A-Za-z0-9_]*(?:Error|Exception)\b\s*:/;
22182
+ const PY_FILE = /^\s*File "/;
22183
+ const PY_INDENTED = /^\s{2,}\S/;
22184
+ const PY_TERMINAL = /^[A-Za-z_][\w.]*(?:Error|Exception|Interrupt)?:/;
22185
+ /** Digits/hex-stripped, lowercased head line — stable across timestamps/ids. */
22186
+ function toFingerprintKey(head) {
22187
+ return head.toLowerCase().replace(/\b0x[0-9a-f]+\b/g, "<hex>").replace(/\d+/g, "<n>").trim().slice(0, 120);
22188
+ }
22189
+ function compact(blocks) {
22190
+ return blocks.filter((b) => b !== null);
22191
+ }
22192
+ /**
22193
+ * Stateful multi-line error detector. Feed lines (per stream) as they arrive;
22194
+ * returns the blocks completed by that line (usually zero or one — two when a
22195
+ * line closes a pending block AND is itself a single-line error-log block).
22196
+ * Call `flush()` on quiet periods / process exit to complete a trailing block.
22197
+ */
22198
+ var ErrorLineDetector = class {
22199
+ pending = null;
22200
+ get isCollecting() {
22201
+ return this.pending !== null;
22202
+ }
22203
+ feed(stream, rawLine) {
22204
+ const line = stripAnsi(rawLine);
22205
+ if (this.pending) {
22206
+ if (stream === this.pending.stream && this.isContinuation(line)) {
22207
+ this.pending.lines.push(line);
22208
+ if (NODE_FRAME.test(line)) this.pending.sawFrame = true;
22209
+ if (this.pending.kind === "python-traceback" && PY_TERMINAL.test(line) && !PY_FILE.test(line) || this.pending.lines.length >= 40) return compact([this.finish()]);
22210
+ return [];
22211
+ }
22212
+ return compact([this.finish(), this.startOrDetect(stream, line)]);
22213
+ }
22214
+ return compact([this.startOrDetect(stream, line)]);
22215
+ }
22216
+ /** Force-complete a pending block (debounce timeout / process exit). */
22217
+ flush() {
22218
+ return this.finish();
22219
+ }
22220
+ startOrDetect(stream, line) {
22221
+ if (line.trim() === "") return null;
22222
+ if (line.startsWith(PY_TRACEBACK_START)) {
22223
+ this.pending = {
22224
+ kind: "python-traceback",
22225
+ stream,
22226
+ lines: [line],
22227
+ sawFrame: true
22228
+ };
22229
+ return null;
22230
+ }
22231
+ if (LOG_ERROR_LEVEL.test(line) || JSON_ERROR_LEVEL.test(line)) return {
22232
+ kind: "error-log",
22233
+ stream,
22234
+ lines: [line.slice(0, 500)],
22235
+ fingerprintKey: toFingerprintKey(line)
22236
+ };
22237
+ if (NODE_ERROR_HEAD.test(line)) {
22238
+ this.pending = {
22239
+ kind: "node-stack",
22240
+ stream,
22241
+ lines: [line],
22242
+ sawFrame: false
22243
+ };
22244
+ return null;
22245
+ }
22246
+ return null;
22247
+ }
22248
+ isContinuation(line) {
22249
+ if (!this.pending) return false;
22250
+ if (this.pending.kind === "python-traceback") return PY_FILE.test(line) || PY_INDENTED.test(line) || PY_TERMINAL.test(line);
22251
+ return NODE_FRAME.test(line) || NODE_CAUSE.test(line) || NODE_CHAINED.test(line);
22252
+ }
22253
+ finish() {
22254
+ const pending = this.pending;
22255
+ this.pending = null;
22256
+ if (!pending) return null;
22257
+ if (pending.kind === "node-stack" && !pending.sawFrame) return null;
22258
+ return {
22259
+ kind: pending.kind,
22260
+ stream: pending.stream,
22261
+ lines: pending.lines.map((l) => l.slice(0, 500)),
22262
+ fingerprintKey: toFingerprintKey(pending.lines[0])
22263
+ };
22264
+ }
22265
+ };
22266
+ /**
22267
+ * Bounds Sentry quota per RuntimeProcess. Worst case per agent:
22268
+ * ≤ `ERROR_CAPTURES_MAX` output events + ≤ 1 crash event per 10-minute window.
22269
+ */
22270
+ var CaptureThrottle = class {
22271
+ windowStart = 0;
22272
+ windowCount = 0;
22273
+ errorsSuppressed = 0;
22274
+ lastSentByKey = /* @__PURE__ */ new Map();
22275
+ lastCrashCaptureAt = 0;
22276
+ hasCapturedCrash = false;
22277
+ crashesSuppressed = 0;
22278
+ constructor(stableUptimeMs) {
22279
+ this.stableUptimeMs = stableUptimeMs;
22280
+ }
22281
+ allowErrorCapture(fingerprintKey, now = Date.now()) {
22282
+ if (now - this.windowStart >= 6e5) {
22283
+ this.windowStart = now;
22284
+ this.windowCount = 0;
22285
+ }
22286
+ const lastSent = this.lastSentByKey.get(fingerprintKey);
22287
+ if (lastSent !== void 0 && now - lastSent < 6e5 || this.windowCount >= 5) {
22288
+ this.errorsSuppressed++;
22289
+ return {
22290
+ allow: false,
22291
+ suppressedCount: this.errorsSuppressed
22292
+ };
22293
+ }
22294
+ this.windowCount++;
22295
+ this.lastSentByKey.set(fingerprintKey, now);
22296
+ if (this.lastSentByKey.size > 50) {
22297
+ const oldest = this.lastSentByKey.keys().next().value;
22298
+ if (oldest !== void 0) this.lastSentByKey.delete(oldest);
22299
+ }
22300
+ const suppressed = this.errorsSuppressed;
22301
+ this.errorsSuppressed = 0;
22302
+ return {
22303
+ allow: true,
22304
+ suppressedCount: suppressed
22305
+ };
22306
+ }
22307
+ allowCrashCapture(uptimeMs, now = Date.now()) {
22308
+ const stable = uptimeMs >= this.stableUptimeMs;
22309
+ const throttled = this.hasCapturedCrash && now - this.lastCrashCaptureAt < 6e5;
22310
+ if (!stable && throttled) {
22311
+ this.crashesSuppressed++;
22312
+ return {
22313
+ allow: false,
22314
+ suppressedCount: this.crashesSuppressed
22315
+ };
22316
+ }
22317
+ this.hasCapturedCrash = true;
22318
+ this.lastCrashCaptureAt = now;
22319
+ const suppressed = this.crashesSuppressed;
22320
+ this.crashesSuppressed = 0;
22321
+ return {
22322
+ allow: true,
22323
+ suppressedCount: suppressed
22324
+ };
22325
+ }
22326
+ };
21983
22327
  //#endregion
21984
22328
  //#region src/runtime-process.ts
21985
22329
  /**
@@ -21988,15 +22332,23 @@ function createLogger(component, additionalData) {
21988
22332
  * Spawns the runtime binary, pipes stdout/stderr to the daemon logger,
21989
22333
  * and restarts on crash with exponential backoff.
21990
22334
  */
21991
- const log$1 = createLogger("RuntimeProcess");
22335
+ const log$2 = createLogger("RuntimeProcess");
22336
+ /** Quiet-period flush for a pending multi-line error block (e.g. a traceback
22337
+ * followed by silence — no closing line ever arrives to complete it). */
22338
+ const DETECTOR_FLUSH_DEBOUNCE_MS = 1500;
21992
22339
  const BACKOFF_INITIAL_MS = 1e3;
21993
22340
  const BACKOFF_MAX_MS = 3e4;
22341
+ const STABLE_UPTIME_MS = 6e4;
21994
22342
  var RuntimeProcess = class {
21995
22343
  child = null;
21996
22344
  stopped = false;
21997
22345
  backoffMs = BACKOFF_INITIAL_MS;
21998
22346
  lastStartTime = 0;
21999
22347
  restartTimer = null;
22348
+ ringBuffer = new OutputRingBuffer();
22349
+ detector = new ErrorLineDetector();
22350
+ throttle = new CaptureThrottle(STABLE_UPTIME_MS);
22351
+ detectorFlushTimer = null;
22000
22352
  constructor(options) {
22001
22353
  this.options = options;
22002
22354
  }
@@ -22027,7 +22379,7 @@ var RuntimeProcess = class {
22027
22379
  if (this.stopped) return;
22028
22380
  const { command, args } = this.resolveCommand();
22029
22381
  this.lastStartTime = Date.now();
22030
- log$1.info({
22382
+ log$2.info({
22031
22383
  runtime: this.options.runtime,
22032
22384
  command,
22033
22385
  args,
@@ -22047,22 +22399,28 @@ var RuntimeProcess = class {
22047
22399
  });
22048
22400
  this.child.stdout?.on("data", (data) => {
22049
22401
  const lines = data.toString().trim().split("\n");
22050
- for (const line of lines) log$1.info({
22051
- runtime: this.options.runtime,
22052
- stream: "stdout"
22053
- }, line);
22402
+ for (const line of lines) {
22403
+ log$2.info({
22404
+ runtime: this.options.runtime,
22405
+ stream: "stdout"
22406
+ }, line);
22407
+ this.observeLine("stdout", line);
22408
+ }
22054
22409
  });
22055
22410
  this.child.stderr?.on("data", (data) => {
22056
22411
  const lines = data.toString().trim().split("\n");
22057
- for (const line of lines) log$1.warn({
22058
- runtime: this.options.runtime,
22059
- stream: "stderr"
22060
- }, line);
22412
+ for (const line of lines) {
22413
+ log$2.warn({
22414
+ runtime: this.options.runtime,
22415
+ stream: "stderr"
22416
+ }, line);
22417
+ this.observeLine("stderr", line);
22418
+ }
22061
22419
  });
22062
22420
  this.child.on("exit", (code, signal) => {
22063
22421
  this.child = null;
22064
22422
  if (this.stopped) {
22065
- log$1.info({
22423
+ log$2.info({
22066
22424
  runtime: this.options.runtime,
22067
22425
  code,
22068
22426
  signal
@@ -22070,7 +22428,7 @@ var RuntimeProcess = class {
22070
22428
  return;
22071
22429
  }
22072
22430
  if (code === 0 && signal == null) {
22073
- log$1.info({
22431
+ log$2.info({
22074
22432
  runtime: this.options.runtime,
22075
22433
  code
22076
22434
  }, "Runtime exited gracefully — restarting");
@@ -22080,13 +22438,32 @@ var RuntimeProcess = class {
22080
22438
  }, 500);
22081
22439
  return;
22082
22440
  }
22083
- log$1.warn({
22441
+ log$2.warn({
22084
22442
  runtime: this.options.runtime,
22085
22443
  code,
22086
22444
  signal,
22087
22445
  backoffMs: this.backoffMs
22088
22446
  }, "Runtime crashed — scheduling restart with backoff");
22089
- if (Date.now() - this.lastStartTime >= 6e4) this.backoffMs = BACKOFF_INITIAL_MS;
22447
+ const uptime = Date.now() - this.lastStartTime;
22448
+ if (this.detectorFlushTimer) {
22449
+ clearTimeout(this.detectorFlushTimer);
22450
+ this.detectorFlushTimer = null;
22451
+ }
22452
+ this.emitErrorBlock(this.detector.flush());
22453
+ const crash = this.throttle.allowCrashCapture(uptime);
22454
+ if (crash.allow) captureRuntimeCrash({
22455
+ runtime: this.options.runtime,
22456
+ code,
22457
+ signal,
22458
+ uptimeMs: uptime,
22459
+ recentOutput: this.ringBuffer.snapshot(),
22460
+ crashesSuppressed: crash.suppressedCount
22461
+ });
22462
+ else log$2.debug({
22463
+ runtime: this.options.runtime,
22464
+ suppressed: crash.suppressedCount
22465
+ }, "Crash capture suppressed by throttle");
22466
+ if (uptime >= 6e4) this.backoffMs = BACKOFF_INITIAL_MS;
22090
22467
  this.restartTimer = setTimeout(() => {
22091
22468
  this.restartTimer = null;
22092
22469
  this.start();
@@ -22094,10 +22471,70 @@ var RuntimeProcess = class {
22094
22471
  this.backoffMs = Math.min(this.backoffMs * 2, BACKOFF_MAX_MS);
22095
22472
  });
22096
22473
  this.child.on("error", (err) => {
22097
- log$1.error({
22474
+ log$2.error({
22098
22475
  runtime: this.options.runtime,
22099
22476
  err: err.message
22100
22477
  }, "Runtime process error");
22478
+ if (this.stopped) return;
22479
+ const crash = this.throttle.allowCrashCapture(Date.now() - this.lastStartTime);
22480
+ if (crash.allow) captureRuntimeCrash({
22481
+ runtime: this.options.runtime,
22482
+ code: null,
22483
+ signal: null,
22484
+ uptimeMs: Date.now() - this.lastStartTime,
22485
+ recentOutput: this.ringBuffer.snapshot(),
22486
+ crashesSuppressed: crash.suppressedCount,
22487
+ spawnError: err
22488
+ });
22489
+ });
22490
+ }
22491
+ /**
22492
+ * Track a child output line for error capture: ring-buffer it (crash
22493
+ * context) and run it through the error-block detector. A pending
22494
+ * multi-line block is flushed after a quiet period so a trailing traceback
22495
+ * isn't held forever.
22496
+ */
22497
+ observeLine(stream, line) {
22498
+ try {
22499
+ this.ringBuffer.push({
22500
+ stream,
22501
+ line
22502
+ });
22503
+ for (const block of this.detector.feed(stream, line)) this.emitErrorBlock(block);
22504
+ if (this.detectorFlushTimer) {
22505
+ clearTimeout(this.detectorFlushTimer);
22506
+ this.detectorFlushTimer = null;
22507
+ }
22508
+ if (this.detector.isCollecting) this.detectorFlushTimer = setTimeout(() => {
22509
+ this.detectorFlushTimer = null;
22510
+ this.emitErrorBlock(this.detector.flush());
22511
+ }, DETECTOR_FLUSH_DEBOUNCE_MS);
22512
+ } catch (err) {
22513
+ log$2.debug({
22514
+ runtime: this.options.runtime,
22515
+ err: err instanceof Error ? err.message : String(err)
22516
+ }, "Runtime output observation failed");
22517
+ }
22518
+ }
22519
+ /** Report a completed error block to Sentry, throttle permitting. */
22520
+ emitErrorBlock(block) {
22521
+ if (!block) return;
22522
+ const { allow, suppressedCount } = this.throttle.allowErrorCapture(block.fingerprintKey);
22523
+ if (!allow) {
22524
+ log$2.debug({
22525
+ runtime: this.options.runtime,
22526
+ kind: block.kind,
22527
+ suppressed: suppressedCount
22528
+ }, "Runtime error-output capture suppressed by throttle");
22529
+ return;
22530
+ }
22531
+ captureRuntimeErrorOutput({
22532
+ runtime: this.options.runtime,
22533
+ stream: block.stream,
22534
+ kind: block.kind,
22535
+ lines: block.lines,
22536
+ fingerprintKey: block.fingerprintKey,
22537
+ suppressedCount
22101
22538
  });
22102
22539
  }
22103
22540
  /**
@@ -22109,11 +22546,16 @@ var RuntimeProcess = class {
22109
22546
  clearTimeout(this.restartTimer);
22110
22547
  this.restartTimer = null;
22111
22548
  }
22549
+ if (this.detectorFlushTimer) {
22550
+ clearTimeout(this.detectorFlushTimer);
22551
+ this.detectorFlushTimer = null;
22552
+ }
22553
+ this.emitErrorBlock(this.detector.flush());
22112
22554
  const child = this.child;
22113
22555
  if (!child) return;
22114
22556
  return new Promise((resolve) => {
22115
22557
  const killTimer = setTimeout(() => {
22116
- log$1.warn({ runtime: this.options.runtime }, "Runtime did not exit in time — sending SIGKILL");
22558
+ log$2.warn({ runtime: this.options.runtime }, "Runtime did not exit in time — sending SIGKILL");
22117
22559
  child.kill("SIGKILL");
22118
22560
  }, 5e3);
22119
22561
  child.on("exit", () => {
@@ -22138,7 +22580,7 @@ var RuntimeProcess = class {
22138
22580
  * Restart the runtime process (stop then start with fresh backoff).
22139
22581
  */
22140
22582
  async restart() {
22141
- log$1.info({ runtime: this.options.runtime }, "Restarting runtime...");
22583
+ log$2.info({ runtime: this.options.runtime }, "Restarting runtime...");
22142
22584
  await this.stop();
22143
22585
  this.stopped = false;
22144
22586
  this.backoffMs = BACKOFF_INITIAL_MS;
@@ -22160,7 +22602,7 @@ var RuntimeProcess = class {
22160
22602
  * this registry from active integration manifests. Handler files are loaded
22161
22603
  * via ESM dynamic import on first use and cached until the registry is cleared.
22162
22604
  */
22163
- const log = createLogger("CommandRegistry");
22605
+ const log$1 = createLogger("CommandRegistry");
22164
22606
  var CommandRegistry = class {
22165
22607
  commands = /* @__PURE__ */ new Map();
22166
22608
  version = 0;
@@ -22171,7 +22613,7 @@ var CommandRegistry = class {
22171
22613
  register(integrationId, name, handlerPath, method = "handle", timeoutMs = 3e4) {
22172
22614
  const existing = this.commands.get(name);
22173
22615
  if (existing) {
22174
- log.warn({
22616
+ log$1.warn({
22175
22617
  command: name,
22176
22618
  existing: existing.integrationId,
22177
22619
  attempted: integrationId
@@ -22179,7 +22621,7 @@ var CommandRegistry = class {
22179
22621
  return;
22180
22622
  }
22181
22623
  if (!existsSync(handlerPath)) {
22182
- log.warn({
22624
+ log$1.warn({
22183
22625
  command: name,
22184
22626
  handlerPath,
22185
22627
  integrationId
@@ -22194,7 +22636,7 @@ var CommandRegistry = class {
22194
22636
  timeoutMs,
22195
22637
  handler: null
22196
22638
  });
22197
- log.info({
22639
+ log$1.info({
22198
22640
  command: name,
22199
22641
  integrationId,
22200
22642
  handlerPath,
@@ -22208,7 +22650,7 @@ var CommandRegistry = class {
22208
22650
  unregisterAll(integrationId) {
22209
22651
  for (const [name, entry] of this.commands) if (entry.integrationId === integrationId) {
22210
22652
  this.commands.delete(name);
22211
- log.info({
22653
+ log$1.info({
22212
22654
  command: name,
22213
22655
  integrationId
22214
22656
  }, "Unregistered command");
@@ -22246,7 +22688,7 @@ var CommandRegistry = class {
22246
22688
  entry.handler = fn;
22247
22689
  } catch (err) {
22248
22690
  const message = err instanceof Error ? err.message : String(err);
22249
- log.error({
22691
+ log$1.error({
22250
22692
  err: message,
22251
22693
  command: name,
22252
22694
  handlerPath: entry.handlerPath
@@ -22267,7 +22709,7 @@ var CommandRegistry = class {
22267
22709
  })]);
22268
22710
  } catch (err) {
22269
22711
  const message = err instanceof Error ? err.message : String(err);
22270
- log.error({
22712
+ log$1.error({
22271
22713
  err: message,
22272
22714
  command: name
22273
22715
  }, "Command execution failed");
@@ -22296,7 +22738,7 @@ var CommandRegistry = class {
22296
22738
  clear() {
22297
22739
  this.commands.clear();
22298
22740
  this.version++;
22299
- log.info({ version: this.version }, "Command registry cleared");
22741
+ log$1.info({ version: this.version }, "Command registry cleared");
22300
22742
  }
22301
22743
  };
22302
22744
  //#endregion
@@ -22585,6 +23027,93 @@ function writeSentinel(path) {
22585
23027
  } catch {}
22586
23028
  }
22587
23029
  //#endregion
23030
+ //#region src/mcp-error-capture.ts
23031
+ const log = createLogger("McpErrorCapture");
23032
+ /** Quiet-period flush for a pending multi-line stderr block. */
23033
+ const MCP_STDERR_FLUSH_DEBOUNCE_MS = 1500;
23034
+ function createMcpErrorHooks() {
23035
+ const resultErrorThrottle = new CaptureThrottle(6e4);
23036
+ const errorThrottle = new CaptureThrottle(6e4);
23037
+ const crashThrottle = new CaptureThrottle(6e4);
23038
+ const throttleFor = (kind) => kind === "tool-result-error" ? resultErrorThrottle : kind === "server-crash" ? crashThrottle : errorThrottle;
23039
+ const detectors = /* @__PURE__ */ new Map();
23040
+ const flushTimers = /* @__PURE__ */ new Map();
23041
+ const capture = (opts) => {
23042
+ const { allow, suppressedCount } = throttleFor(opts.kind).allowErrorCapture(opts.fingerprintKey);
23043
+ if (!allow) {
23044
+ log.debug({
23045
+ server: opts.server,
23046
+ kind: opts.kind,
23047
+ suppressed: suppressedCount
23048
+ }, "MCP failure capture suppressed by throttle");
23049
+ return;
23050
+ }
23051
+ captureMcpFailure({
23052
+ ...opts,
23053
+ suppressedCount
23054
+ });
23055
+ };
23056
+ const emitBlock = (server, block) => {
23057
+ if (!block) return;
23058
+ capture({
23059
+ server,
23060
+ kind: "stderr-output",
23061
+ message: block.lines[0],
23062
+ lines: block.lines,
23063
+ fingerprintKey: `stderr:${server}:${block.fingerprintKey}`
23064
+ });
23065
+ };
23066
+ return {
23067
+ onToolError: (info) => {
23068
+ capture({
23069
+ server: info.server,
23070
+ tool: info.tool,
23071
+ kind: info.kind === "thrown" ? "tool-thrown" : "tool-result-error",
23072
+ message: info.message,
23073
+ fingerprintKey: `tool:${info.server}:${info.tool}:${toFingerprintKey(info.message)}`
23074
+ });
23075
+ },
23076
+ onServerCrash: (server) => {
23077
+ const timer = flushTimers.get(server);
23078
+ if (timer) {
23079
+ clearTimeout(timer);
23080
+ flushTimers.delete(server);
23081
+ }
23082
+ emitBlock(server, detectors.get(server)?.flush() ?? null);
23083
+ capture({
23084
+ server,
23085
+ kind: "server-crash",
23086
+ message: "MCP server connection closed unexpectedly",
23087
+ fingerprintKey: `crash:${server}`
23088
+ });
23089
+ },
23090
+ onServerStderr: (server, line) => {
23091
+ let detector = detectors.get(server);
23092
+ if (!detector) {
23093
+ detector = new ErrorLineDetector();
23094
+ detectors.set(server, detector);
23095
+ }
23096
+ for (const block of detector.feed("stderr", line)) emitBlock(server, block);
23097
+ const existing = flushTimers.get(server);
23098
+ if (existing) {
23099
+ clearTimeout(existing);
23100
+ flushTimers.delete(server);
23101
+ }
23102
+ if (detector.isCollecting) {
23103
+ const target = detector;
23104
+ flushTimers.set(server, setTimeout(() => {
23105
+ flushTimers.delete(server);
23106
+ emitBlock(server, target.flush());
23107
+ }, MCP_STDERR_FLUSH_DEBOUNCE_MS));
23108
+ }
23109
+ },
23110
+ dispose: () => {
23111
+ for (const timer of flushTimers.values()) clearTimeout(timer);
23112
+ flushTimers.clear();
23113
+ }
23114
+ };
23115
+ }
23116
+ //#endregion
22588
23117
  //#region src/daemon.ts
22589
23118
  /**
22590
23119
  * Alfe Gateway Daemon — main entry point.
@@ -22820,6 +23349,21 @@ async function startDaemon() {
22820
23349
  surface: "daemon",
22821
23350
  release: await getCliVersion()
22822
23351
  });
23352
+ let fatalExiting = false;
23353
+ process.on("uncaughtException", (err) => {
23354
+ if (fatalExiting) process.exit(1);
23355
+ fatalExiting = true;
23356
+ logger$1.error({
23357
+ err: err.message,
23358
+ stack: err.stack
23359
+ }, "Uncaught exception — daemon exiting");
23360
+ captureFatal(err);
23361
+ flushAndExit(1);
23362
+ });
23363
+ process.on("unhandledRejection", (reason) => {
23364
+ logger$1.error({ reason: reason instanceof Error ? reason.message : String(reason) }, "Unhandled promise rejection");
23365
+ captureFatal(reason instanceof Error ? reason : /* @__PURE__ */ new Error(`Unhandled rejection: ${String(reason)}`));
23366
+ });
22823
23367
  if (!managed) {
22824
23368
  await mkdir(join(homedir(), ".alfe"), { recursive: true });
22825
23369
  const existingPid = await checkExistingDaemon();
@@ -22954,9 +23498,11 @@ async function startDaemon() {
22954
23498
  });
22955
23499
  const mcpManager = new Manager({ logger: logger$1 });
22956
23500
  mcpManagerRef = mcpManager;
23501
+ const mcpErrorHooks = createMcpErrorHooks();
22957
23502
  mcpBundler = new McpBundler({
22958
23503
  logger: logger$1,
22959
- idleTtlMs: 0
23504
+ idleTtlMs: 0,
23505
+ ...mcpErrorHooks
22960
23506
  }, { connect: defaultConnect });
22961
23507
  if (config.runtime === "hermes") logger$1.info("Hermes runtime — daemon MCP bundler left idle (store mirrored to config.yaml by HermesMcpSync)");
22962
23508
  else {
@@ -23083,6 +23629,7 @@ async function startDaemon() {
23083
23629
  mcpBundler = null;
23084
23630
  logger$1.debug("MCP bundler stopped");
23085
23631
  }
23632
+ mcpErrorHooks.dispose();
23086
23633
  logger$1.debug("Stopping MCP bundler manager...");
23087
23634
  await mcpManager.dispose();
23088
23635
  mcpManagerRef = null;
@@ -23807,4 +24354,4 @@ function formatDuration(ms) {
23807
24354
  return `${String(Math.round(seconds / 3600))}h`;
23808
24355
  }
23809
24356
  //#endregion
23810
- export { PINNED_OPENCLAW_VERSION as C, resolveAgentIdentity as S, ALFE_DIR as _, installService as a, fetchAgentConfig as b, uninstallService as c, captureFatal as d, captureIntegrationFailure as f, PROTOCOL_VERSION as g, setAgentContext as h, checkExistingDaemon as i, AGENT_DAEMON_SENTRY_DSN as l, initAgentSentry as m, queryDaemonHealth as n, startService as o, flushSentry as p, startDaemon as r, stopExistingDaemon as s, formatHealthReport as t, captureCliFailure as u, PID_PATH as v, loadDaemonConfig as x, SOCKET_PATH as y };
24357
+ export { PID_PATH as C, resolveAgentIdentity as D, loadDaemonConfig as E, PINNED_OPENCLAW_VERSION as O, ALFE_DIR as S, fetchAgentConfig as T, captureRuntimeErrorOutput as _, installService as a, setAgentContext as b, uninstallService as c, AGENT_RUNTIME_SENTRY_DSN as d, captureCliFailure as f, captureRuntimeCrash as g, captureMcpFailure as h, checkExistingDaemon as i, AGENT_DAEMON_SENTRY_DSN as l, captureIntegrationFailure as m, queryDaemonHealth as n, startService as o, captureFatal as p, startDaemon as r, stopExistingDaemon as s, formatHealthReport as t, AGENT_MCP_SENTRY_DSN as u, flushSentry as v, SOCKET_PATH as w, PROTOCOL_VERSION as x, initAgentSentry as y };
@@ -74,6 +74,20 @@ declare const PINNED_OPENCLAW_VERSION = "2026.6.8";
74
74
  * both places.
75
75
  */
76
76
  declare const AGENT_DAEMON_SENTRY_DSN = "https://a47f010d8d39fb4350f913492189f283@o4511008239452160.ingest.us.sentry.io/4511679448547328";
77
+ /**
78
+ * Dedicated project for errors emitted BY the agent runtime child process
79
+ * (OpenClaw/Hermes) — crashes, spawn failures, and error-looking output. Kept
80
+ * separate from `agent-daemon` so runtime noise never drowns daemon issues.
81
+ * Mirrors `SENTRY_DSNS.agentRuntime` in `config/config.ts`.
82
+ */
83
+ declare const AGENT_RUNTIME_SENTRY_DSN = "https://feb7fb2e3b8e723aec518e74715bfa6d@o4511008239452160.ingest.us.sentry.io/4511683667558400";
84
+ /**
85
+ * MCP tool/server failures on agent VMs — reported by the daemon-hosted MCP
86
+ * bundler (tool errors, child crashes, stderr output). This is the SAME
87
+ * project as the cloud MCP Fly service (`SENTRY_DSNS.mcp`); agent-side events
88
+ * are distinguished by `source: agent-daemon` + `agentId` tags.
89
+ */
90
+ declare const AGENT_MCP_SENTRY_DSN = "https://638309eac96146a945f46b578f10c806@o4511008239452160.ingest.us.sentry.io/4511146364174336";
77
91
  /** Which surface initialised Sentry — tagged on every event. */
78
92
  type SentrySurface = "cli" | "daemon";
79
93
  interface InitAgentSentryOptions {
@@ -112,6 +126,59 @@ declare function captureIntegrationFailure(integration: string, phase: string, c
112
126
  * Sentry is not initialised. Never throws.
113
127
  */
114
128
  declare function captureCliFailure(context: string, cause?: unknown): void;
129
+ /** A line of runtime child-process output attached to a crash event. */
130
+ interface RuntimeOutputLine {
131
+ stream: "stdout" | "stderr";
132
+ line: string;
133
+ }
134
+ /**
135
+ * Capture a runtime child-process crash (non-graceful exit) or spawn failure
136
+ * into the dedicated `agent-runtime` project. Recent output travels as
137
+ * breadcrumbs — NEVER `extra`, which `scrubEvent` (the runtime client's
138
+ * `beforeSend`) deletes. Throttling is the caller's job (`CaptureThrottle` in
139
+ * `runtime-output-monitor.ts`). No-op when the runtime client is not
140
+ * initialised. Never throws.
141
+ */
142
+ declare function captureRuntimeCrash(opts: {
143
+ runtime: string;
144
+ code: number | null;
145
+ signal: string | null;
146
+ uptimeMs: number;
147
+ recentOutput: readonly RuntimeOutputLine[];
148
+ crashesSuppressed?: number;
149
+ /** Set for the child `error` event path (e.g. ENOENT — binary missing). */
150
+ spawnError?: Error;
151
+ }): void;
152
+ /**
153
+ * Capture an error-looking block of runtime output (stack trace, Python
154
+ * traceback, or ERROR-level log line) detected while the child is running.
155
+ * The block lines travel as breadcrumbs; the head line is the message.
156
+ * Throttling/dedupe is the caller's job. No-op when Sentry is not initialised.
157
+ * Never throws.
158
+ */
159
+ declare function captureRuntimeErrorOutput(opts: {
160
+ runtime: string;
161
+ stream: "stdout" | "stderr";
162
+ kind: "node-stack" | "python-traceback" | "error-log";
163
+ lines: readonly string[];
164
+ fingerprintKey: string;
165
+ suppressedCount?: number;
166
+ }): void;
167
+ /**
168
+ * Capture an MCP failure (tool error, server crash, or error-looking stderr
169
+ * output) into the `mcp` project via the daemon's bound MCP client. Throttling
170
+ * is the caller's job (`mcp-error-capture.ts`). No-op when the MCP client is
171
+ * not initialised. Never throws.
172
+ */
173
+ declare function captureMcpFailure(opts: {
174
+ server: string;
175
+ tool?: string;
176
+ kind: "tool-thrown" | "tool-result-error" | "server-crash" | "stderr-output";
177
+ message: string;
178
+ /** Optional context lines (stderr block) — attached as breadcrumbs. */
179
+ lines?: readonly string[];
180
+ suppressedCount?: number;
181
+ }): void;
115
182
  /** Flush queued events (small timeout) so short-lived commands deliver them. */
116
183
  declare function flushSentry(timeoutMs?: number): Promise<void>;
117
184
  /**
@@ -293,4 +360,4 @@ declare function checkExistingDaemon(): Promise<number | null>;
293
360
  */
294
361
  declare function stopExistingDaemon(): Promise<boolean>;
295
362
  //#endregion
296
- export { AGENT_DAEMON_SENTRY_DSN, ALFE_DIR, type AgentIdentity, type AgentWorkspaceConfig, type DaemonConfig, type DaemonHealth, type IPCEvent, type IPCRequest, type IPCResponse, type InitAgentSentryOptions, PID_PATH, PINNED_OPENCLAW_VERSION, PROTOCOL_VERSION, SOCKET_PATH, type SentrySurface, captureCliFailure, captureFatal, captureIntegrationFailure, checkExistingDaemon, fetchAgentConfig, flushSentry, formatHealthReport, initAgentSentry, installService, loadDaemonConfig, logger, queryDaemonHealth, resolveAgentIdentity, setAgentContext, startDaemon, startService, stopExistingDaemon, uninstallService };
363
+ export { AGENT_DAEMON_SENTRY_DSN, AGENT_MCP_SENTRY_DSN, AGENT_RUNTIME_SENTRY_DSN, ALFE_DIR, type AgentIdentity, type AgentWorkspaceConfig, type DaemonConfig, type DaemonHealth, type IPCEvent, type IPCRequest, type IPCResponse, type InitAgentSentryOptions, PID_PATH, PINNED_OPENCLAW_VERSION, PROTOCOL_VERSION, type RuntimeOutputLine, SOCKET_PATH, type SentrySurface, captureCliFailure, captureFatal, captureIntegrationFailure, captureMcpFailure, captureRuntimeCrash, captureRuntimeErrorOutput, checkExistingDaemon, fetchAgentConfig, flushSentry, formatHealthReport, initAgentSentry, installService, loadDaemonConfig, logger, queryDaemonHealth, resolveAgentIdentity, setAgentContext, startDaemon, startService, stopExistingDaemon, uninstallService };
package/dist/src/index.js CHANGED
@@ -1,3 +1,3 @@
1
- import { C as PINNED_OPENCLAW_VERSION, S as resolveAgentIdentity, _ as ALFE_DIR, a as installService, b as fetchAgentConfig, c as uninstallService, d as captureFatal, f as captureIntegrationFailure, g as PROTOCOL_VERSION, h as setAgentContext, i as checkExistingDaemon, l as AGENT_DAEMON_SENTRY_DSN, m as initAgentSentry, n as queryDaemonHealth, o as startService, p as flushSentry, r as startDaemon, s as stopExistingDaemon, t as formatHealthReport, u as captureCliFailure, v as PID_PATH, x as loadDaemonConfig, y as SOCKET_PATH } from "../health.js";
1
+ import { C as PID_PATH, D as resolveAgentIdentity, E as loadDaemonConfig, O as PINNED_OPENCLAW_VERSION, S as ALFE_DIR, T as fetchAgentConfig, _ as captureRuntimeErrorOutput, a as installService, b as setAgentContext, c as uninstallService, d as AGENT_RUNTIME_SENTRY_DSN, f as captureCliFailure, g as captureRuntimeCrash, h as captureMcpFailure, i as checkExistingDaemon, l as AGENT_DAEMON_SENTRY_DSN, m as captureIntegrationFailure, n as queryDaemonHealth, o as startService, p as captureFatal, r as startDaemon, s as stopExistingDaemon, t as formatHealthReport, u as AGENT_MCP_SENTRY_DSN, v as flushSentry, w as SOCKET_PATH, x as PROTOCOL_VERSION, y as initAgentSentry } from "../health.js";
2
2
  import { n as logger } from "../logger.js";
3
- export { AGENT_DAEMON_SENTRY_DSN, ALFE_DIR, PID_PATH, PINNED_OPENCLAW_VERSION, PROTOCOL_VERSION, SOCKET_PATH, captureCliFailure, captureFatal, captureIntegrationFailure, checkExistingDaemon, fetchAgentConfig, flushSentry, formatHealthReport, initAgentSentry, installService, loadDaemonConfig, logger, queryDaemonHealth, resolveAgentIdentity, setAgentContext, startDaemon, startService, stopExistingDaemon, uninstallService };
3
+ export { AGENT_DAEMON_SENTRY_DSN, AGENT_MCP_SENTRY_DSN, AGENT_RUNTIME_SENTRY_DSN, ALFE_DIR, PID_PATH, PINNED_OPENCLAW_VERSION, PROTOCOL_VERSION, SOCKET_PATH, captureCliFailure, captureFatal, captureIntegrationFailure, captureMcpFailure, captureRuntimeCrash, captureRuntimeErrorOutput, checkExistingDaemon, fetchAgentConfig, flushSentry, formatHealthReport, initAgentSentry, installService, loadDaemonConfig, logger, queryDaemonHealth, resolveAgentIdentity, setAgentContext, startDaemon, startService, stopExistingDaemon, uninstallService };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alfe.ai/gateway",
3
- "version": "0.3.2",
3
+ "version": "0.5.0",
4
4
  "description": "Alfe local gateway daemon — persistent control plane for agent integrations",
5
5
  "type": "module",
6
6
  "bin": {
@@ -23,12 +23,12 @@
23
23
  "pino-roll": "^1.2.0",
24
24
  "smol-toml": ">=1.6.1",
25
25
  "ws": "^8.18.0",
26
- "@alfe.ai/agent-api-client": "^0.6.0",
26
+ "@alfe.ai/agent-api-client": "^0.8.0",
27
27
  "@alfe.ai/ai-proxy-local": "^0.0.13",
28
28
  "@alfe.ai/config": "^0.3.0",
29
29
  "@alfe.ai/integration-manifest": "^0.3.1",
30
- "@alfe.ai/integrations": "^0.2.8",
31
- "@alfe.ai/mcp-bundler": "^0.2.2"
30
+ "@alfe.ai/integrations": "^0.2.10",
31
+ "@alfe.ai/mcp-bundler": "^0.3.0"
32
32
  },
33
33
  "license": "UNLICENSED",
34
34
  "homepage": "https://alfe.ai",