@alfe.ai/gateway 0.3.1 → 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
@@ -111,6 +111,7 @@ const ID_PREFIXES = {
111
111
  directGrant: "dgr",
112
112
  revision: "rev",
113
113
  fact: "kfc",
114
+ changeRequest: "chr",
114
115
  oauthConnection: "con",
115
116
  channel: "chn",
116
117
  oauthConnectionRequest: "crq",
@@ -4757,6 +4758,13 @@ enumValues({
4757
4758
  InProgress: "in-progress",
4758
4759
  Done: "done"
4759
4760
  });
4761
+ enumValues({
4762
+ Open: "open",
4763
+ Approved: "approved",
4764
+ Rejected: "rejected",
4765
+ Withdrawn: "withdrawn",
4766
+ Superseded: "superseded"
4767
+ });
4760
4768
  enumValues({ Flex: "flex" });
4761
4769
  enumValues({
4762
4770
  Provisioning: "provisioning",
@@ -5358,14 +5366,30 @@ var NoopRuntimeGate = class {
5358
5366
  * both places.
5359
5367
  */
5360
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";
5361
5377
  /** Surface → Sentry project: `cli` and `agent-daemon` respectively. */
5362
5378
  const SURFACE_DSNS = {
5363
- cli: "https://82dde4631336561f2fcc89d7531623de@o4511008239452160.ingest.us.sentry.io/4511679411191808",
5379
+ cli: CLI_SENTRY_DSN,
5364
5380
  daemon: AGENT_DAEMON_SENTRY_DSN
5365
5381
  };
5366
5382
  /** Held after a successful init so the capture helpers can run synchronously. */
5367
5383
  let sentry = null;
5368
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
+ /**
5369
5393
  * Map the configured API URL to a coarse environment tag. Best-effort — returns
5370
5394
  * `"unknown"` when no config is present yet (e.g. `alfe login` pre-setup).
5371
5395
  */
@@ -5396,12 +5420,13 @@ function errorReportingDisabled() {
5396
5420
  } catch {}
5397
5421
  return false;
5398
5422
  }
5399
- 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;
5400
5425
  const MIN_BARE_SECRET_LEN = 16;
5401
5426
  const ALFE_TOKEN_RE = /alfe_(?:dev|test|demo|live)_[A-Za-z0-9._-]+/g;
5402
5427
  const BEARER_RE = /\bbearer\s+[A-Za-z0-9._\-+/=]+/gi;
5403
5428
  function scrubString(value) {
5404
- 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) => {
5405
5430
  if (!/[:=]/.test(sep) && secret.length < MIN_BARE_SECRET_LEN) return match;
5406
5431
  return `${label}${sep}[REDACTED]`;
5407
5432
  }).replace(ALFE_TOKEN_RE, "alfe_[REDACTED]");
@@ -5445,6 +5470,33 @@ async function initAgentSentry(options) {
5445
5470
  const dsn = SURFACE_DSNS[options.surface].trim();
5446
5471
  if (!dsn) return;
5447
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
+ }
5448
5500
  if (mod.getClient()) {
5449
5501
  sentry = mod;
5450
5502
  return;
@@ -5456,6 +5508,7 @@ async function initAgentSentry(options) {
5456
5508
  sampleRate: 1,
5457
5509
  tracesSampleRate: 0,
5458
5510
  sendDefaultPii: false,
5511
+ ...options.surface === "daemon" ? { integrations: (defaults) => defaults.filter((i) => i.name !== "OnUncaughtException" && i.name !== "OnUnhandledRejection") } : {},
5459
5512
  beforeSend: (event) => scrubEvent(event),
5460
5513
  beforeBreadcrumb: (breadcrumb) => scrubBreadcrumb(breadcrumb)
5461
5514
  });
@@ -5476,6 +5529,10 @@ function setAgentContext(context) {
5476
5529
  try {
5477
5530
  if (context.agentId) sentry.setTag("agentId", context.agentId);
5478
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
+ }
5479
5536
  } catch {}
5480
5537
  }
5481
5538
  /**
@@ -5520,8 +5577,75 @@ function captureCliFailure(context, cause) {
5520
5577
  });
5521
5578
  } catch {}
5522
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
+ }
5523
5644
  /** Flush queued events (small timeout) so short-lived commands deliver them. */
5524
5645
  async function flushSentry(timeoutMs = 2e3) {
5646
+ try {
5647
+ if (runtimeClient) await runtimeClient.flush(timeoutMs);
5648
+ } catch {}
5525
5649
  if (!sentry) return;
5526
5650
  try {
5527
5651
  await sentry.flush(timeoutMs);
@@ -21972,6 +22096,177 @@ function createLogger(component, additionalData) {
21972
22096
  component
21973
22097
  });
21974
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
+ };
21975
22270
  //#endregion
21976
22271
  //#region src/runtime-process.ts
21977
22272
  /**
@@ -21981,14 +22276,22 @@ function createLogger(component, additionalData) {
21981
22276
  * and restarts on crash with exponential backoff.
21982
22277
  */
21983
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;
21984
22282
  const BACKOFF_INITIAL_MS = 1e3;
21985
22283
  const BACKOFF_MAX_MS = 3e4;
22284
+ const STABLE_UPTIME_MS = 6e4;
21986
22285
  var RuntimeProcess = class {
21987
22286
  child = null;
21988
22287
  stopped = false;
21989
22288
  backoffMs = BACKOFF_INITIAL_MS;
21990
22289
  lastStartTime = 0;
21991
22290
  restartTimer = null;
22291
+ ringBuffer = new OutputRingBuffer();
22292
+ detector = new ErrorLineDetector();
22293
+ throttle = new CaptureThrottle(STABLE_UPTIME_MS);
22294
+ detectorFlushTimer = null;
21992
22295
  constructor(options) {
21993
22296
  this.options = options;
21994
22297
  }
@@ -22039,17 +22342,23 @@ var RuntimeProcess = class {
22039
22342
  });
22040
22343
  this.child.stdout?.on("data", (data) => {
22041
22344
  const lines = data.toString().trim().split("\n");
22042
- for (const line of lines) log$1.info({
22043
- runtime: this.options.runtime,
22044
- stream: "stdout"
22045
- }, 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
+ }
22046
22352
  });
22047
22353
  this.child.stderr?.on("data", (data) => {
22048
22354
  const lines = data.toString().trim().split("\n");
22049
- for (const line of lines) log$1.warn({
22050
- runtime: this.options.runtime,
22051
- stream: "stderr"
22052
- }, 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
+ }
22053
22362
  });
22054
22363
  this.child.on("exit", (code, signal) => {
22055
22364
  this.child = null;
@@ -22078,7 +22387,26 @@ var RuntimeProcess = class {
22078
22387
  signal,
22079
22388
  backoffMs: this.backoffMs
22080
22389
  }, "Runtime crashed — scheduling restart with backoff");
22081
- 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;
22082
22410
  this.restartTimer = setTimeout(() => {
22083
22411
  this.restartTimer = null;
22084
22412
  this.start();
@@ -22090,6 +22418,66 @@ var RuntimeProcess = class {
22090
22418
  runtime: this.options.runtime,
22091
22419
  err: err.message
22092
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
22093
22481
  });
22094
22482
  }
22095
22483
  /**
@@ -22101,6 +22489,11 @@ var RuntimeProcess = class {
22101
22489
  clearTimeout(this.restartTimer);
22102
22490
  this.restartTimer = null;
22103
22491
  }
22492
+ if (this.detectorFlushTimer) {
22493
+ clearTimeout(this.detectorFlushTimer);
22494
+ this.detectorFlushTimer = null;
22495
+ }
22496
+ this.emitErrorBlock(this.detector.flush());
22104
22497
  const child = this.child;
22105
22498
  if (!child) return;
22106
22499
  return new Promise((resolve) => {
@@ -22812,6 +23205,21 @@ async function startDaemon() {
22812
23205
  surface: "daemon",
22813
23206
  release: await getCliVersion()
22814
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
+ });
22815
23223
  if (!managed) {
22816
23224
  await mkdir(join(homedir(), ".alfe"), { recursive: true });
22817
23225
  const existingPid = await checkExistingDaemon();
@@ -23799,4 +24207,4 @@ function formatDuration(ms) {
23799
24207
  return `${String(Math.round(seconds / 3600))}h`;
23800
24208
  }
23801
24209
  //#endregion
23802
- 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.1",
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,11 +23,11 @@
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.5.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",
30
- "@alfe.ai/integrations": "^0.2.7",
30
+ "@alfe.ai/integrations": "^0.2.8",
31
31
  "@alfe.ai/mcp-bundler": "^0.2.2"
32
32
  },
33
33
  "license": "UNLICENSED",