@alfe.ai/gateway 0.3.2 → 0.4.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 { S as SOCKET_PATH, a as installService, c as uninstallService, i as checkExistingDaemon, n as queryDaemonHealth, r as startDaemon, s as stopExistingDaemon, t as formatHealthReport } 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
@@ -5366,14 +5366,30 @@ 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";
5369
5377
  /** Surface → Sentry project: `cli` and `agent-daemon` respectively. */
5370
5378
  const SURFACE_DSNS = {
5371
- cli: "https://82dde4631336561f2fcc89d7531623de@o4511008239452160.ingest.us.sentry.io/4511679411191808",
5379
+ cli: CLI_SENTRY_DSN,
5372
5380
  daemon: AGENT_DAEMON_SENTRY_DSN
5373
5381
  };
5374
5382
  /** Held after a successful init so the capture helpers can run synchronously. */
5375
5383
  let sentry = null;
5376
5384
  /**
5385
+ * Second client for the `agent-runtime` project (daemon surface only). The
5386
+ * default client keeps the daemon's own DSN; runtime child-process events are
5387
+ * routed here via an explicitly-bound Scope — the documented multi-client
5388
+ * pattern. `null` until `initAgentSentry({ surface: "daemon" })` succeeds.
5389
+ */
5390
+ let runtimeClient = null;
5391
+ let runtimeScope = null;
5392
+ /**
5377
5393
  * Map the configured API URL to a coarse environment tag. Best-effort — returns
5378
5394
  * `"unknown"` when no config is present yet (e.g. `alfe login` pre-setup).
5379
5395
  */
@@ -5404,12 +5420,13 @@ function errorReportingDisabled() {
5404
5420
  } catch {}
5405
5421
  return false;
5406
5422
  }
5407
- const KV_SECRET_RE = /\b(authorization|api[_-]?key|apikey|token|secret|password|passwd|bearer)\b(\s*[:=]\s*|\s+)("?)([^\s"']+)\3/gi;
5423
+ 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;
5424
+ const URL_QUERY_SECRET_RE = /([?&](?:[A-Za-z0-9_-]*(?:token|secret|key|signature|credential|password)|sig|code|x-amz-[a-z-]+)=)[^&\s"']+/gi;
5408
5425
  const MIN_BARE_SECRET_LEN = 16;
5409
5426
  const ALFE_TOKEN_RE = /alfe_(?:dev|test|demo|live)_[A-Za-z0-9._-]+/g;
5410
5427
  const BEARER_RE = /\bbearer\s+[A-Za-z0-9._\-+/=]+/gi;
5411
5428
  function scrubString(value) {
5412
- return value.replace(BEARER_RE, "Bearer [REDACTED]").replace(KV_SECRET_RE, (match, label, sep, _q, secret) => {
5429
+ return value.replace(BEARER_RE, "Bearer [REDACTED]").replace(URL_QUERY_SECRET_RE, "$1[REDACTED]").replace(KV_SECRET_RE, (match, label, sep, _q, secret) => {
5413
5430
  if (!/[:=]/.test(sep) && secret.length < MIN_BARE_SECRET_LEN) return match;
5414
5431
  return `${label}${sep}[REDACTED]`;
5415
5432
  }).replace(ALFE_TOKEN_RE, "alfe_[REDACTED]");
@@ -5453,6 +5470,33 @@ async function initAgentSentry(options) {
5453
5470
  const dsn = SURFACE_DSNS[options.surface].trim();
5454
5471
  if (!dsn) return;
5455
5472
  const mod = await import("@sentry/node");
5473
+ if (options.surface === "daemon" && !runtimeScope) try {
5474
+ const dsn = AGENT_RUNTIME_SENTRY_DSN.trim();
5475
+ {
5476
+ const client = new mod.NodeClient({
5477
+ dsn,
5478
+ environment: deriveEnvironment(),
5479
+ ...options.release ? { release: options.release } : {},
5480
+ sampleRate: 1,
5481
+ tracesSampleRate: 0,
5482
+ sendDefaultPii: false,
5483
+ transport: mod.makeNodeTransport,
5484
+ stackParser: mod.defaultStackParser,
5485
+ integrations: [],
5486
+ beforeSend: (event) => scrubEvent(event)
5487
+ });
5488
+ client.init();
5489
+ const scope = new mod.Scope();
5490
+ scope.setClient(client);
5491
+ const runtime = deriveRuntime();
5492
+ if (runtime) scope.setTag("runtime", runtime);
5493
+ runtimeClient = client;
5494
+ runtimeScope = scope;
5495
+ }
5496
+ } catch {
5497
+ runtimeClient = null;
5498
+ runtimeScope = null;
5499
+ }
5456
5500
  if (mod.getClient()) {
5457
5501
  sentry = mod;
5458
5502
  return;
@@ -5464,6 +5508,7 @@ async function initAgentSentry(options) {
5464
5508
  sampleRate: 1,
5465
5509
  tracesSampleRate: 0,
5466
5510
  sendDefaultPii: false,
5511
+ ...options.surface === "daemon" ? { integrations: (defaults) => defaults.filter((i) => i.name !== "OnUncaughtException" && i.name !== "OnUnhandledRejection") } : {},
5467
5512
  beforeSend: (event) => scrubEvent(event),
5468
5513
  beforeBreadcrumb: (breadcrumb) => scrubBreadcrumb(breadcrumb)
5469
5514
  });
@@ -5484,6 +5529,10 @@ function setAgentContext(context) {
5484
5529
  try {
5485
5530
  if (context.agentId) sentry.setTag("agentId", context.agentId);
5486
5531
  if (context.runtime) sentry.setTag("runtime", context.runtime);
5532
+ if (runtimeScope) {
5533
+ if (context.agentId) runtimeScope.setTag("agentId", context.agentId);
5534
+ if (context.runtime) runtimeScope.setTag("runtime", context.runtime);
5535
+ }
5487
5536
  } catch {}
5488
5537
  }
5489
5538
  /**
@@ -5528,8 +5577,75 @@ function captureCliFailure(context, cause) {
5528
5577
  });
5529
5578
  } catch {}
5530
5579
  }
5580
+ /**
5581
+ * Capture a runtime child-process crash (non-graceful exit) or spawn failure
5582
+ * into the dedicated `agent-runtime` project. Recent output travels as
5583
+ * breadcrumbs — NEVER `extra`, which `scrubEvent` (the runtime client's
5584
+ * `beforeSend`) deletes. Throttling is the caller's job (`CaptureThrottle` in
5585
+ * `runtime-output-monitor.ts`). No-op when the runtime client is not
5586
+ * initialised. Never throws.
5587
+ */
5588
+ function captureRuntimeCrash(opts) {
5589
+ if (!runtimeScope) return;
5590
+ try {
5591
+ const scope = runtimeScope.clone();
5592
+ for (const { stream, line } of opts.recentOutput) scope.addBreadcrumb({
5593
+ category: `runtime.${stream}`,
5594
+ level: stream === "stderr" ? "warning" : "info",
5595
+ message: line
5596
+ });
5597
+ const crashKey = opts.spawnError ? `spawn:${opts.spawnError.code ?? "error"}` : String(opts.code ?? opts.signal ?? "unknown");
5598
+ scope.setFingerprint([
5599
+ "runtime-crash",
5600
+ opts.runtime,
5601
+ crashKey
5602
+ ]);
5603
+ scope.setTags({
5604
+ runtime: opts.runtime,
5605
+ exitCode: String(opts.code),
5606
+ signal: String(opts.signal),
5607
+ crashesSuppressed: String(opts.crashesSuppressed ?? 0)
5608
+ });
5609
+ if (opts.spawnError) scope.captureException(opts.spawnError);
5610
+ else scope.captureMessage(`Runtime ${opts.runtime} crashed (code=${String(opts.code)}, signal=${String(opts.signal)})`, "error");
5611
+ } catch {}
5612
+ }
5613
+ /**
5614
+ * Capture an error-looking block of runtime output (stack trace, Python
5615
+ * traceback, or ERROR-level log line) detected while the child is running.
5616
+ * The block lines travel as breadcrumbs; the head line is the message.
5617
+ * Throttling/dedupe is the caller's job. No-op when Sentry is not initialised.
5618
+ * Never throws.
5619
+ */
5620
+ function captureRuntimeErrorOutput(opts) {
5621
+ if (!runtimeScope) return;
5622
+ try {
5623
+ const scope = runtimeScope.clone();
5624
+ for (const line of opts.lines) scope.addBreadcrumb({
5625
+ category: `runtime.${opts.stream}`,
5626
+ level: "error",
5627
+ message: line
5628
+ });
5629
+ scope.setFingerprint([
5630
+ "runtime-output",
5631
+ opts.runtime,
5632
+ opts.kind,
5633
+ opts.fingerprintKey
5634
+ ]);
5635
+ scope.setTags({
5636
+ runtime: opts.runtime,
5637
+ stream: opts.stream,
5638
+ kind: opts.kind,
5639
+ suppressedCount: String(opts.suppressedCount ?? 0)
5640
+ });
5641
+ scope.captureMessage(opts.lines[0].slice(0, 300), "error");
5642
+ } catch {}
5643
+ }
5531
5644
  /** Flush queued events (small timeout) so short-lived commands deliver them. */
5532
5645
  async function flushSentry(timeoutMs = 2e3) {
5646
+ try {
5647
+ if (runtimeClient) await runtimeClient.flush(timeoutMs);
5648
+ } catch {}
5533
5649
  if (!sentry) return;
5534
5650
  try {
5535
5651
  await sentry.flush(timeoutMs);
@@ -21980,6 +22096,177 @@ function createLogger(component, additionalData) {
21980
22096
  component
21981
22097
  });
21982
22098
  }
22099
+ const ANSI_RE = /\x1b\[[0-9;]*[A-Za-z]/g;
22100
+ function stripAnsi(line) {
22101
+ return line.replace(ANSI_RE, "");
22102
+ }
22103
+ /** Rolling window of the child's most recent output lines (both streams). */
22104
+ var OutputRingBuffer = class {
22105
+ lines = [];
22106
+ push(line) {
22107
+ this.lines.push({
22108
+ stream: line.stream,
22109
+ line: line.line.length > 500 ? line.line.slice(0, 500) : line.line
22110
+ });
22111
+ if (this.lines.length > 50) this.lines.shift();
22112
+ }
22113
+ /** Oldest → newest copy. */
22114
+ snapshot() {
22115
+ return this.lines.map((l) => ({ ...l }));
22116
+ }
22117
+ };
22118
+ const PY_TRACEBACK_START = "Traceback (most recent call last):";
22119
+ const NODE_ERROR_HEAD = /^\s*(?:Uncaught\s+|Unhandled\w*\s+)?[A-Z][A-Za-z0-9_]*(?:Error|Exception)\b\s*[:(]/;
22120
+ const LOG_ERROR_LEVEL = /^.{0,40}(?:\[(?:ERROR|FATAL|CRITICAL)\]|\b(?:ERROR|FATAL|CRITICAL)\b\s*[:|])/;
22121
+ const JSON_ERROR_LEVEL = /"level"\s*:\s*(?:"(?:error|fatal)"|(?:50|60)\b)/;
22122
+ const NODE_FRAME = /^\s+at\s/;
22123
+ const NODE_CAUSE = /^\s*caused by:?/i;
22124
+ const NODE_CHAINED = /^\s*[A-Z][A-Za-z0-9_]*(?:Error|Exception)\b\s*:/;
22125
+ const PY_FILE = /^\s*File "/;
22126
+ const PY_INDENTED = /^\s{2,}\S/;
22127
+ const PY_TERMINAL = /^[A-Za-z_][\w.]*(?:Error|Exception|Interrupt)?:/;
22128
+ /** Digits/hex-stripped, lowercased head line — stable across timestamps/ids. */
22129
+ function toFingerprintKey(head) {
22130
+ return head.toLowerCase().replace(/\b0x[0-9a-f]+\b/g, "<hex>").replace(/\d+/g, "<n>").trim().slice(0, 120);
22131
+ }
22132
+ function compact(blocks) {
22133
+ return blocks.filter((b) => b !== null);
22134
+ }
22135
+ /**
22136
+ * Stateful multi-line error detector. Feed lines (per stream) as they arrive;
22137
+ * returns the blocks completed by that line (usually zero or one — two when a
22138
+ * line closes a pending block AND is itself a single-line error-log block).
22139
+ * Call `flush()` on quiet periods / process exit to complete a trailing block.
22140
+ */
22141
+ var ErrorLineDetector = class {
22142
+ pending = null;
22143
+ get isCollecting() {
22144
+ return this.pending !== null;
22145
+ }
22146
+ feed(stream, rawLine) {
22147
+ const line = stripAnsi(rawLine);
22148
+ if (this.pending) {
22149
+ if (stream === this.pending.stream && this.isContinuation(line)) {
22150
+ this.pending.lines.push(line);
22151
+ if (NODE_FRAME.test(line)) this.pending.sawFrame = true;
22152
+ if (this.pending.kind === "python-traceback" && PY_TERMINAL.test(line) && !PY_FILE.test(line) || this.pending.lines.length >= 40) return compact([this.finish()]);
22153
+ return [];
22154
+ }
22155
+ return compact([this.finish(), this.startOrDetect(stream, line)]);
22156
+ }
22157
+ return compact([this.startOrDetect(stream, line)]);
22158
+ }
22159
+ /** Force-complete a pending block (debounce timeout / process exit). */
22160
+ flush() {
22161
+ return this.finish();
22162
+ }
22163
+ startOrDetect(stream, line) {
22164
+ if (line.trim() === "") return null;
22165
+ if (line.startsWith(PY_TRACEBACK_START)) {
22166
+ this.pending = {
22167
+ kind: "python-traceback",
22168
+ stream,
22169
+ lines: [line],
22170
+ sawFrame: true
22171
+ };
22172
+ return null;
22173
+ }
22174
+ if (LOG_ERROR_LEVEL.test(line) || JSON_ERROR_LEVEL.test(line)) return {
22175
+ kind: "error-log",
22176
+ stream,
22177
+ lines: [line.slice(0, 500)],
22178
+ fingerprintKey: toFingerprintKey(line)
22179
+ };
22180
+ if (NODE_ERROR_HEAD.test(line)) {
22181
+ this.pending = {
22182
+ kind: "node-stack",
22183
+ stream,
22184
+ lines: [line],
22185
+ sawFrame: false
22186
+ };
22187
+ return null;
22188
+ }
22189
+ return null;
22190
+ }
22191
+ isContinuation(line) {
22192
+ if (!this.pending) return false;
22193
+ if (this.pending.kind === "python-traceback") return PY_FILE.test(line) || PY_INDENTED.test(line) || PY_TERMINAL.test(line);
22194
+ return NODE_FRAME.test(line) || NODE_CAUSE.test(line) || NODE_CHAINED.test(line);
22195
+ }
22196
+ finish() {
22197
+ const pending = this.pending;
22198
+ this.pending = null;
22199
+ if (!pending) return null;
22200
+ if (pending.kind === "node-stack" && !pending.sawFrame) return null;
22201
+ return {
22202
+ kind: pending.kind,
22203
+ stream: pending.stream,
22204
+ lines: pending.lines.map((l) => l.slice(0, 500)),
22205
+ fingerprintKey: toFingerprintKey(pending.lines[0])
22206
+ };
22207
+ }
22208
+ };
22209
+ /**
22210
+ * Bounds Sentry quota per RuntimeProcess. Worst case per agent:
22211
+ * ≤ `ERROR_CAPTURES_MAX` output events + ≤ 1 crash event per 10-minute window.
22212
+ */
22213
+ var CaptureThrottle = class {
22214
+ windowStart = 0;
22215
+ windowCount = 0;
22216
+ errorsSuppressed = 0;
22217
+ lastSentByKey = /* @__PURE__ */ new Map();
22218
+ lastCrashCaptureAt = 0;
22219
+ hasCapturedCrash = false;
22220
+ crashesSuppressed = 0;
22221
+ constructor(stableUptimeMs) {
22222
+ this.stableUptimeMs = stableUptimeMs;
22223
+ }
22224
+ allowErrorCapture(fingerprintKey, now = Date.now()) {
22225
+ if (now - this.windowStart >= 6e5) {
22226
+ this.windowStart = now;
22227
+ this.windowCount = 0;
22228
+ }
22229
+ const lastSent = this.lastSentByKey.get(fingerprintKey);
22230
+ if (lastSent !== void 0 && now - lastSent < 6e5 || this.windowCount >= 5) {
22231
+ this.errorsSuppressed++;
22232
+ return {
22233
+ allow: false,
22234
+ suppressedCount: this.errorsSuppressed
22235
+ };
22236
+ }
22237
+ this.windowCount++;
22238
+ this.lastSentByKey.set(fingerprintKey, now);
22239
+ if (this.lastSentByKey.size > 50) {
22240
+ const oldest = this.lastSentByKey.keys().next().value;
22241
+ if (oldest !== void 0) this.lastSentByKey.delete(oldest);
22242
+ }
22243
+ const suppressed = this.errorsSuppressed;
22244
+ this.errorsSuppressed = 0;
22245
+ return {
22246
+ allow: true,
22247
+ suppressedCount: suppressed
22248
+ };
22249
+ }
22250
+ allowCrashCapture(uptimeMs, now = Date.now()) {
22251
+ const stable = uptimeMs >= this.stableUptimeMs;
22252
+ const throttled = this.hasCapturedCrash && now - this.lastCrashCaptureAt < 6e5;
22253
+ if (!stable && throttled) {
22254
+ this.crashesSuppressed++;
22255
+ return {
22256
+ allow: false,
22257
+ suppressedCount: this.crashesSuppressed
22258
+ };
22259
+ }
22260
+ this.hasCapturedCrash = true;
22261
+ this.lastCrashCaptureAt = now;
22262
+ const suppressed = this.crashesSuppressed;
22263
+ this.crashesSuppressed = 0;
22264
+ return {
22265
+ allow: true,
22266
+ suppressedCount: suppressed
22267
+ };
22268
+ }
22269
+ };
21983
22270
  //#endregion
21984
22271
  //#region src/runtime-process.ts
21985
22272
  /**
@@ -21989,14 +22276,22 @@ function createLogger(component, additionalData) {
21989
22276
  * and restarts on crash with exponential backoff.
21990
22277
  */
21991
22278
  const log$1 = createLogger("RuntimeProcess");
22279
+ /** Quiet-period flush for a pending multi-line error block (e.g. a traceback
22280
+ * followed by silence — no closing line ever arrives to complete it). */
22281
+ const DETECTOR_FLUSH_DEBOUNCE_MS = 1500;
21992
22282
  const BACKOFF_INITIAL_MS = 1e3;
21993
22283
  const BACKOFF_MAX_MS = 3e4;
22284
+ const STABLE_UPTIME_MS = 6e4;
21994
22285
  var RuntimeProcess = class {
21995
22286
  child = null;
21996
22287
  stopped = false;
21997
22288
  backoffMs = BACKOFF_INITIAL_MS;
21998
22289
  lastStartTime = 0;
21999
22290
  restartTimer = null;
22291
+ ringBuffer = new OutputRingBuffer();
22292
+ detector = new ErrorLineDetector();
22293
+ throttle = new CaptureThrottle(STABLE_UPTIME_MS);
22294
+ detectorFlushTimer = null;
22000
22295
  constructor(options) {
22001
22296
  this.options = options;
22002
22297
  }
@@ -22047,17 +22342,23 @@ var RuntimeProcess = class {
22047
22342
  });
22048
22343
  this.child.stdout?.on("data", (data) => {
22049
22344
  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);
22345
+ for (const line of lines) {
22346
+ log$1.info({
22347
+ runtime: this.options.runtime,
22348
+ stream: "stdout"
22349
+ }, line);
22350
+ this.observeLine("stdout", line);
22351
+ }
22054
22352
  });
22055
22353
  this.child.stderr?.on("data", (data) => {
22056
22354
  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);
22355
+ for (const line of lines) {
22356
+ log$1.warn({
22357
+ runtime: this.options.runtime,
22358
+ stream: "stderr"
22359
+ }, line);
22360
+ this.observeLine("stderr", line);
22361
+ }
22061
22362
  });
22062
22363
  this.child.on("exit", (code, signal) => {
22063
22364
  this.child = null;
@@ -22086,7 +22387,26 @@ var RuntimeProcess = class {
22086
22387
  signal,
22087
22388
  backoffMs: this.backoffMs
22088
22389
  }, "Runtime crashed — scheduling restart with backoff");
22089
- if (Date.now() - this.lastStartTime >= 6e4) this.backoffMs = BACKOFF_INITIAL_MS;
22390
+ const uptime = Date.now() - this.lastStartTime;
22391
+ if (this.detectorFlushTimer) {
22392
+ clearTimeout(this.detectorFlushTimer);
22393
+ this.detectorFlushTimer = null;
22394
+ }
22395
+ this.emitErrorBlock(this.detector.flush());
22396
+ const crash = this.throttle.allowCrashCapture(uptime);
22397
+ if (crash.allow) captureRuntimeCrash({
22398
+ runtime: this.options.runtime,
22399
+ code,
22400
+ signal,
22401
+ uptimeMs: uptime,
22402
+ recentOutput: this.ringBuffer.snapshot(),
22403
+ crashesSuppressed: crash.suppressedCount
22404
+ });
22405
+ else log$1.debug({
22406
+ runtime: this.options.runtime,
22407
+ suppressed: crash.suppressedCount
22408
+ }, "Crash capture suppressed by throttle");
22409
+ if (uptime >= 6e4) this.backoffMs = BACKOFF_INITIAL_MS;
22090
22410
  this.restartTimer = setTimeout(() => {
22091
22411
  this.restartTimer = null;
22092
22412
  this.start();
@@ -22098,6 +22418,66 @@ var RuntimeProcess = class {
22098
22418
  runtime: this.options.runtime,
22099
22419
  err: err.message
22100
22420
  }, "Runtime process error");
22421
+ if (this.stopped) return;
22422
+ const crash = this.throttle.allowCrashCapture(Date.now() - this.lastStartTime);
22423
+ if (crash.allow) captureRuntimeCrash({
22424
+ runtime: this.options.runtime,
22425
+ code: null,
22426
+ signal: null,
22427
+ uptimeMs: Date.now() - this.lastStartTime,
22428
+ recentOutput: this.ringBuffer.snapshot(),
22429
+ crashesSuppressed: crash.suppressedCount,
22430
+ spawnError: err
22431
+ });
22432
+ });
22433
+ }
22434
+ /**
22435
+ * Track a child output line for error capture: ring-buffer it (crash
22436
+ * context) and run it through the error-block detector. A pending
22437
+ * multi-line block is flushed after a quiet period so a trailing traceback
22438
+ * isn't held forever.
22439
+ */
22440
+ observeLine(stream, line) {
22441
+ try {
22442
+ this.ringBuffer.push({
22443
+ stream,
22444
+ line
22445
+ });
22446
+ for (const block of this.detector.feed(stream, line)) this.emitErrorBlock(block);
22447
+ if (this.detectorFlushTimer) {
22448
+ clearTimeout(this.detectorFlushTimer);
22449
+ this.detectorFlushTimer = null;
22450
+ }
22451
+ if (this.detector.isCollecting) this.detectorFlushTimer = setTimeout(() => {
22452
+ this.detectorFlushTimer = null;
22453
+ this.emitErrorBlock(this.detector.flush());
22454
+ }, DETECTOR_FLUSH_DEBOUNCE_MS);
22455
+ } catch (err) {
22456
+ log$1.debug({
22457
+ runtime: this.options.runtime,
22458
+ err: err instanceof Error ? err.message : String(err)
22459
+ }, "Runtime output observation failed");
22460
+ }
22461
+ }
22462
+ /** Report a completed error block to Sentry, throttle permitting. */
22463
+ emitErrorBlock(block) {
22464
+ if (!block) return;
22465
+ const { allow, suppressedCount } = this.throttle.allowErrorCapture(block.fingerprintKey);
22466
+ if (!allow) {
22467
+ log$1.debug({
22468
+ runtime: this.options.runtime,
22469
+ kind: block.kind,
22470
+ suppressed: suppressedCount
22471
+ }, "Runtime error-output capture suppressed by throttle");
22472
+ return;
22473
+ }
22474
+ captureRuntimeErrorOutput({
22475
+ runtime: this.options.runtime,
22476
+ stream: block.stream,
22477
+ kind: block.kind,
22478
+ lines: block.lines,
22479
+ fingerprintKey: block.fingerprintKey,
22480
+ suppressedCount
22101
22481
  });
22102
22482
  }
22103
22483
  /**
@@ -22109,6 +22489,11 @@ var RuntimeProcess = class {
22109
22489
  clearTimeout(this.restartTimer);
22110
22490
  this.restartTimer = null;
22111
22491
  }
22492
+ if (this.detectorFlushTimer) {
22493
+ clearTimeout(this.detectorFlushTimer);
22494
+ this.detectorFlushTimer = null;
22495
+ }
22496
+ this.emitErrorBlock(this.detector.flush());
22112
22497
  const child = this.child;
22113
22498
  if (!child) return;
22114
22499
  return new Promise((resolve) => {
@@ -22820,6 +23205,21 @@ async function startDaemon() {
22820
23205
  surface: "daemon",
22821
23206
  release: await getCliVersion()
22822
23207
  });
23208
+ let fatalExiting = false;
23209
+ process.on("uncaughtException", (err) => {
23210
+ if (fatalExiting) process.exit(1);
23211
+ fatalExiting = true;
23212
+ logger$1.error({
23213
+ err: err.message,
23214
+ stack: err.stack
23215
+ }, "Uncaught exception — daemon exiting");
23216
+ captureFatal(err);
23217
+ flushAndExit(1);
23218
+ });
23219
+ process.on("unhandledRejection", (reason) => {
23220
+ logger$1.error({ reason: reason instanceof Error ? reason.message : String(reason) }, "Unhandled promise rejection");
23221
+ captureFatal(reason instanceof Error ? reason : /* @__PURE__ */ new Error(`Unhandled rejection: ${String(reason)}`));
23222
+ });
22823
23223
  if (!managed) {
22824
23224
  await mkdir(join(homedir(), ".alfe"), { recursive: true });
22825
23225
  const existingPid = await checkExistingDaemon();
@@ -23807,4 +24207,4 @@ function formatDuration(ms) {
23807
24207
  return `${String(Math.round(seconds / 3600))}h`;
23808
24208
  }
23809
24209
  //#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 };
24210
+ export { fetchAgentConfig as C, PINNED_OPENCLAW_VERSION as E, SOCKET_PATH as S, resolveAgentIdentity as T, initAgentSentry as _, installService as a, ALFE_DIR as b, uninstallService as c, captureCliFailure as d, captureFatal as f, flushSentry as g, captureRuntimeErrorOutput as h, checkExistingDaemon as i, AGENT_DAEMON_SENTRY_DSN as l, captureRuntimeCrash as m, queryDaemonHealth as n, startService as o, captureIntegrationFailure as p, startDaemon as r, stopExistingDaemon as s, formatHealthReport as t, AGENT_RUNTIME_SENTRY_DSN as u, setAgentContext as v, loadDaemonConfig as w, PID_PATH as x, PROTOCOL_VERSION as y };
@@ -74,6 +74,13 @@ 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";
77
84
  /** Which surface initialised Sentry — tagged on every event. */
78
85
  type SentrySurface = "cli" | "daemon";
79
86
  interface InitAgentSentryOptions {
@@ -112,6 +119,44 @@ declare function captureIntegrationFailure(integration: string, phase: string, c
112
119
  * Sentry is not initialised. Never throws.
113
120
  */
114
121
  declare function captureCliFailure(context: string, cause?: unknown): void;
122
+ /** A line of runtime child-process output attached to a crash event. */
123
+ interface RuntimeOutputLine {
124
+ stream: "stdout" | "stderr";
125
+ line: string;
126
+ }
127
+ /**
128
+ * Capture a runtime child-process crash (non-graceful exit) or spawn failure
129
+ * into the dedicated `agent-runtime` project. Recent output travels as
130
+ * breadcrumbs — NEVER `extra`, which `scrubEvent` (the runtime client's
131
+ * `beforeSend`) deletes. Throttling is the caller's job (`CaptureThrottle` in
132
+ * `runtime-output-monitor.ts`). No-op when the runtime client is not
133
+ * initialised. Never throws.
134
+ */
135
+ declare function captureRuntimeCrash(opts: {
136
+ runtime: string;
137
+ code: number | null;
138
+ signal: string | null;
139
+ uptimeMs: number;
140
+ recentOutput: readonly RuntimeOutputLine[];
141
+ crashesSuppressed?: number;
142
+ /** Set for the child `error` event path (e.g. ENOENT — binary missing). */
143
+ spawnError?: Error;
144
+ }): void;
145
+ /**
146
+ * Capture an error-looking block of runtime output (stack trace, Python
147
+ * traceback, or ERROR-level log line) detected while the child is running.
148
+ * The block lines travel as breadcrumbs; the head line is the message.
149
+ * Throttling/dedupe is the caller's job. No-op when Sentry is not initialised.
150
+ * Never throws.
151
+ */
152
+ declare function captureRuntimeErrorOutput(opts: {
153
+ runtime: string;
154
+ stream: "stdout" | "stderr";
155
+ kind: "node-stack" | "python-traceback" | "error-log";
156
+ lines: readonly string[];
157
+ fingerprintKey: string;
158
+ suppressedCount?: number;
159
+ }): void;
115
160
  /** Flush queued events (small timeout) so short-lived commands deliver them. */
116
161
  declare function flushSentry(timeoutMs?: number): Promise<void>;
117
162
  /**
@@ -293,4 +338,4 @@ declare function checkExistingDaemon(): Promise<number | null>;
293
338
  */
294
339
  declare function stopExistingDaemon(): Promise<boolean>;
295
340
  //#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 };
341
+ export { AGENT_DAEMON_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, 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 fetchAgentConfig, E as PINNED_OPENCLAW_VERSION, S as SOCKET_PATH, T as resolveAgentIdentity, _ as initAgentSentry, a as installService, b as ALFE_DIR, c as uninstallService, d as captureCliFailure, f as captureFatal, g as flushSentry, h as captureRuntimeErrorOutput, i as checkExistingDaemon, l as AGENT_DAEMON_SENTRY_DSN, m as captureRuntimeCrash, n as queryDaemonHealth, o as startService, p as captureIntegrationFailure, r as startDaemon, s as stopExistingDaemon, t as formatHealthReport, u as AGENT_RUNTIME_SENTRY_DSN, v as setAgentContext, w as loadDaemonConfig, x as PID_PATH, y as PROTOCOL_VERSION } 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_RUNTIME_SENTRY_DSN, ALFE_DIR, PID_PATH, PINNED_OPENCLAW_VERSION, PROTOCOL_VERSION, SOCKET_PATH, captureCliFailure, captureFatal, captureIntegrationFailure, 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.4.0",
4
4
  "description": "Alfe local gateway daemon — persistent control plane for agent integrations",
5
5
  "type": "module",
6
6
  "bin": {
@@ -23,7 +23,7 @@
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.7.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",