@interactive-inc/claude-funnel 0.57.0 → 0.58.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.
@@ -360,7 +360,7 @@ const funnelEventSchema = z.object({
360
360
  */
361
361
  var FunnelEventLog = class {};
362
362
  //#endregion
363
- //#region lib/logger/leuco-logger-sqlite-sink.ts
363
+ //#region lib/logger/funnel-log-sqlite-sink.ts
364
364
  /** Conservative whitelist for column names interpolated into SQL. */
365
365
  const COLUMN_NAME_RE = /^[a-z_][a-z0-9_]*$/;
366
366
  /** How many inserts between on-disk size checks (see insertsSinceByteCheck). */
@@ -380,9 +380,14 @@ const RESERVED_COLUMNS = new Set([
380
380
  * they are configuration, not schema evolution.
381
381
  */
382
382
  const MIGRATIONS = [[
383
- "CREATE TABLE IF NOT EXISTS leuco_log (seq INTEGER PRIMARY KEY, ts INTEGER NOT NULL, type TEXT, event TEXT NOT NULL)",
384
- "CREATE INDEX IF NOT EXISTS idx_leuco_log_ts ON leuco_log (ts)",
385
- "CREATE INDEX IF NOT EXISTS idx_leuco_log_type ON leuco_log (type)"
383
+ "CREATE TABLE IF NOT EXISTS logs (seq INTEGER PRIMARY KEY, ts INTEGER NOT NULL, type TEXT, event TEXT NOT NULL)",
384
+ "CREATE INDEX IF NOT EXISTS idx_logs_ts ON logs (ts)",
385
+ "CREATE INDEX IF NOT EXISTS idx_logs_type ON logs (type)"
386
+ ], [
387
+ "DROP TABLE IF EXISTS leuco_log",
388
+ "CREATE TABLE IF NOT EXISTS logs (seq INTEGER PRIMARY KEY, ts INTEGER NOT NULL, type TEXT, event TEXT NOT NULL)",
389
+ "CREATE INDEX IF NOT EXISTS idx_logs_ts ON logs (ts)",
390
+ "CREATE INDEX IF NOT EXISTS idx_logs_type ON logs (type)"
386
391
  ]];
387
392
  /**
388
393
  * SQLite-backed sink built on `bun:sqlite`. Implements both primary and
@@ -391,7 +396,7 @@ const MIGRATIONS = [[
391
396
  * from a backup stream).
392
397
  *
393
398
  * Concurrency model: seq is `INTEGER PRIMARY KEY`, so SQLite assigns it
394
- * atomically via `lastInsertRowid`. Two `LeucoLogger` instances pointed
399
+ * atomically via `lastInsertRowid`. Two `FunnelLog` instances pointed
395
400
  * at the same database file therefore see one monotonically increasing
396
401
  * seq stream without any bus-level coordination — the database itself is
397
402
  * the synchronization point.
@@ -403,7 +408,7 @@ const MIGRATIONS = [[
403
408
  * a new index to an existing database is a no-downtime operation.
404
409
  *
405
410
  * Type safety: the second generic parameter `I` is the literal tuple of
406
- * index column names. `extractIndexes` and `getRecords({ where })` are
411
+ * index column names. `extractIndexes` and `query({ where })` are
407
412
  * both type-checked against this tuple, so a typo at the call site is a
408
413
  * compile-time error rather than a silent miss at runtime.
409
414
  *
@@ -414,7 +419,7 @@ const MIGRATIONS = [[
414
419
  * for ~10–100x throughput at the cost of one fsync per batch instead of
415
420
  * one per row.
416
421
  */
417
- var LeucoLoggerSqliteSink = class {
422
+ var FunnelLogSqliteSink = class {
418
423
  db;
419
424
  maxRows;
420
425
  maxAgeMs;
@@ -457,15 +462,15 @@ var LeucoLoggerSqliteSink = class {
457
462
  ...this.indexes
458
463
  ];
459
464
  const placeholders = cols.map(() => "?").join(", ");
460
- this.insertStmt = this.db.prepare(`INSERT INTO leuco_log (${cols.join(", ")}) VALUES (${placeholders})`);
465
+ this.insertStmt = this.db.prepare(`INSERT INTO logs (${cols.join(", ")}) VALUES (${placeholders})`);
461
466
  const colsWithSeq = ["seq", ...cols];
462
467
  const placeholdersWithSeq = colsWithSeq.map(() => "?").join(", ");
463
- this.insertWithSeqStmt = this.db.prepare(`INSERT INTO leuco_log (${colsWithSeq.join(", ")}) VALUES (${placeholdersWithSeq})`);
464
- this.maxSeqStmt = this.db.prepare("SELECT COALESCE(MAX(seq), 0) AS max FROM leuco_log");
465
- this.countStmt = this.db.prepare("SELECT COUNT(*) AS n FROM leuco_log");
466
- this.trimRowsStmt = this.db.prepare("DELETE FROM leuco_log WHERE seq <= (SELECT seq FROM leuco_log ORDER BY seq DESC LIMIT 1 OFFSET ?)");
467
- this.trimAgeStmt = this.db.prepare("DELETE FROM leuco_log WHERE ts < ?");
468
- this.trimOldestStmt = this.db.prepare("DELETE FROM leuco_log WHERE seq IN (SELECT seq FROM leuco_log ORDER BY seq ASC LIMIT ?)");
468
+ this.insertWithSeqStmt = this.db.prepare(`INSERT INTO logs (${colsWithSeq.join(", ")}) VALUES (${placeholdersWithSeq})`);
469
+ this.maxSeqStmt = this.db.prepare("SELECT COALESCE(MAX(seq), 0) AS max FROM logs");
470
+ this.countStmt = this.db.prepare("SELECT COUNT(*) AS n FROM logs");
471
+ this.trimRowsStmt = this.db.prepare("DELETE FROM logs WHERE seq <= (SELECT seq FROM logs ORDER BY seq DESC LIMIT 1 OFFSET ?)");
472
+ this.trimAgeStmt = this.db.prepare("DELETE FROM logs WHERE ts < ?");
473
+ this.trimOldestStmt = this.db.prepare("DELETE FROM logs WHERE seq IN (SELECT seq FROM logs ORDER BY seq ASC LIMIT ?)");
469
474
  }
470
475
  insert(input) {
471
476
  try {
@@ -516,7 +521,7 @@ var LeucoLoggerSqliteSink = class {
516
521
  const row = this.maxSeqStmt.get();
517
522
  return row ? row.max : 0;
518
523
  }
519
- getRecords(props = {}) {
524
+ query(props = {}) {
520
525
  const conditions = ["seq > ?"];
521
526
  const params = [props.sinceSeq ?? 0];
522
527
  if (typeof props.type === "string") {
@@ -527,7 +532,7 @@ var LeucoLoggerSqliteSink = class {
527
532
  const limit = props.limit ?? 1e3;
528
533
  params.push(limit);
529
534
  const dir = props.order === "desc" ? "DESC" : "ASC";
530
- const sql = `SELECT seq, ts, type, event FROM leuco_log WHERE ${conditions.join(" AND ")} ORDER BY seq ${dir} LIMIT ?`;
535
+ const sql = `SELECT seq, ts, type, event FROM logs WHERE ${conditions.join(" AND ")} ORDER BY seq ${dir} LIMIT ?`;
531
536
  const rows = this.db.prepare(sql).all(...params);
532
537
  if (dir === "DESC") rows.reverse();
533
538
  return rows.map(toRecord);
@@ -607,15 +612,15 @@ var LeucoLoggerSqliteSink = class {
607
612
  }
608
613
  /** Drop every row and reclaim the file space. Used by `<log>.clear()`. */
609
614
  clear() {
610
- this.db.run("DELETE FROM leuco_log");
615
+ this.db.run("DELETE FROM logs");
611
616
  this.db.run("VACUUM");
612
617
  this.insertsSinceByteCheck = 0;
613
618
  }
614
619
  syncIndexColumns() {
615
- const existing = new Set(this.db.prepare("PRAGMA table_info(leuco_log)").all().map((r) => r.name));
620
+ const existing = new Set(this.db.prepare("PRAGMA table_info(logs)").all().map((r) => r.name));
616
621
  for (const col of this.indexes) {
617
- if (!existing.has(col)) this.db.run(`ALTER TABLE leuco_log ADD COLUMN ${col} TEXT`);
618
- this.db.run(`CREATE INDEX IF NOT EXISTS idx_leuco_log_${col} ON leuco_log (${col})`);
622
+ if (!existing.has(col)) this.db.run(`ALTER TABLE logs ADD COLUMN ${col} TEXT`);
623
+ this.db.run(`CREATE INDEX IF NOT EXISTS idx_logs_${col} ON logs (${col})`);
619
624
  }
620
625
  }
621
626
  migrate() {
@@ -679,7 +684,7 @@ var SqliteFunnelEventLog = class extends FunnelEventLog {
679
684
  super();
680
685
  this.now = props.now ?? (() => Date.now());
681
686
  this.logger = props.logger;
682
- this.sink = new LeucoLoggerSqliteSink({
687
+ this.sink = new FunnelLogSqliteSink({
683
688
  path: props.path,
684
689
  indexes: ["channel_id", "connector_id"],
685
690
  extractIndexes: (event) => ({
@@ -722,7 +727,7 @@ var SqliteFunnelEventLog = class extends FunnelEventLog {
722
727
  * so this returns the full slice and lets the caller filter.
723
728
  */
724
729
  loadSince(since) {
725
- const records = this.sink.getRecords({ sinceSeq: since });
730
+ const records = this.sink.query({ sinceSeq: since });
726
731
  const out = [];
727
732
  for (const record of records) out.push({
728
733
  content: record.event.content,
@@ -739,7 +744,7 @@ var SqliteFunnelEventLog = class extends FunnelEventLog {
739
744
  loadForChannel(props) {
740
745
  const where = { channel_id: props.channelId };
741
746
  if (props.connectorId !== void 0) where.connector_id = props.connectorId;
742
- const records = this.sink.getRecords({
747
+ const records = this.sink.query({
743
748
  where,
744
749
  ...props.sinceSeq !== void 0 ? { sinceSeq: props.sinceSeq } : {},
745
750
  ...props.limit !== void 0 ? { limit: props.limit } : {}
@@ -771,7 +776,8 @@ function truncate(content) {
771
776
  const defaultOnError$1 = () => {};
772
777
  const DEFAULT_HEALTH_INTERVAL_MS = 3e4;
773
778
  const DEFAULT_MAX_BACKOFF_MS = 6e4;
774
- const defaultSleep = (ms) => new Promise((r) => {
779
+ const DEFAULT_START_TIMEOUT_MS = 3e4;
780
+ const defaultSleep$1 = (ms) => new Promise((r) => {
775
781
  setTimeout(r, ms);
776
782
  });
777
783
  /**
@@ -796,10 +802,13 @@ var FunnelListenerSupervisor = class FunnelListenerSupervisor {
796
802
  stats = /* @__PURE__ */ new Map();
797
803
  healthCheckIntervalMs;
798
804
  maxBackoffMs;
805
+ startTimeoutMs;
799
806
  sleep;
800
807
  now;
801
808
  healthCheckTimer = null;
802
809
  healthCheckInFlight = false;
810
+ /** Connectors that failed initial start — retried by the health check. */
811
+ pendingRetry = /* @__PURE__ */ new Map();
803
812
  constructor(deps) {
804
813
  this.channels = deps.channels;
805
814
  this.notify = deps.notify;
@@ -807,7 +816,8 @@ var FunnelListenerSupervisor = class FunnelListenerSupervisor {
807
816
  this.onError = deps.onError ?? defaultOnError$1;
808
817
  this.healthCheckIntervalMs = deps.healthCheckIntervalMs ?? DEFAULT_HEALTH_INTERVAL_MS;
809
818
  this.maxBackoffMs = deps.maxBackoffMs ?? DEFAULT_MAX_BACKOFF_MS;
810
- this.sleep = deps.sleep ?? defaultSleep;
819
+ this.startTimeoutMs = deps.startTimeoutMs ?? DEFAULT_START_TIMEOUT_MS;
820
+ this.sleep = deps.sleep ?? defaultSleep$1;
811
821
  this.now = deps.now ?? (() => Date.now());
812
822
  }
813
823
  static keyOf(channelName, connectorName) {
@@ -853,13 +863,16 @@ var FunnelListenerSupervisor = class FunnelListenerSupervisor {
853
863
  }
854
864
  };
855
865
  try {
856
- await created.listener.start(bind);
866
+ await Promise.race([created.listener.start(bind), this.sleep(this.startTimeoutMs).then(() => {
867
+ throw new Error(`listener start timed out after ${this.startTimeoutMs}ms`);
868
+ })]);
857
869
  this.running.set(key, {
858
870
  config: created.config,
859
871
  channelName,
860
872
  channelId: created.channelId,
861
873
  listener: created.listener
862
874
  });
875
+ this.pendingRetry.delete(key);
863
876
  this.ensureStats(key);
864
877
  this.logger?.info(`${created.config.type} listener started`, {
865
878
  channel: channelName,
@@ -894,8 +907,6 @@ var FunnelListenerSupervisor = class FunnelListenerSupervisor {
894
907
  };
895
908
  try {
896
909
  await entry.listener.stop();
897
- this.running.delete(key);
898
- this.failureCounts.delete(key);
899
910
  this.logger?.info(`${entry.config.type} listener stopped`, {
900
911
  channel: channelName,
901
912
  connector: connectorName
@@ -918,6 +929,9 @@ var FunnelListenerSupervisor = class FunnelListenerSupervisor {
918
929
  ok: false,
919
930
  reason: err.message
920
931
  };
932
+ } finally {
933
+ this.running.delete(key);
934
+ this.failureCounts.delete(key);
921
935
  }
922
936
  }
923
937
  async restart(channelName, connectorName) {
@@ -927,11 +941,23 @@ var FunnelListenerSupervisor = class FunnelListenerSupervisor {
927
941
  }
928
942
  async startAll() {
929
943
  const all = this.channels.listAllConnectors();
930
- for (const view of all) await this.start(view.channelName, view.name);
944
+ const results = await Promise.allSettled(all.map((view) => this.start(view.channelName, view.name)));
945
+ for (let i = 0; i < results.length; i++) {
946
+ const result = results[i];
947
+ const view = all[i];
948
+ if (result.status === "rejected" || result.status === "fulfilled" && !result.value.ok) {
949
+ const key = FunnelListenerSupervisor.keyOf(view.channelName, view.name);
950
+ this.pendingRetry.set(key, {
951
+ channelName: view.channelName,
952
+ connectorName: view.name
953
+ });
954
+ }
955
+ }
931
956
  this.startHealthCheck();
932
957
  }
933
958
  async stopAll() {
934
959
  this.stopHealthCheck();
960
+ this.pendingRetry.clear();
935
961
  for (const [, entry] of [...this.running.entries()]) await this.stop(entry.channelName, entry.config.name);
936
962
  }
937
963
  ensureStats(key) {
@@ -966,6 +992,10 @@ var FunnelListenerSupervisor = class FunnelListenerSupervisor {
966
992
  clearInterval(this.healthCheckTimer);
967
993
  this.healthCheckTimer = null;
968
994
  }
995
+ /** Run one health-check pass synchronously. Test-only seam. */
996
+ async runHealthCheckForTest() {
997
+ await this.runHealthCheck();
998
+ }
969
999
  async runHealthCheck() {
970
1000
  if (this.healthCheckInFlight) return;
971
1001
  this.healthCheckInFlight = true;
@@ -977,6 +1007,23 @@ var FunnelListenerSupervisor = class FunnelListenerSupervisor {
977
1007
  }
978
1008
  await this.recoverDead(entry.channelName, entry.config.name, entry.config.type);
979
1009
  }
1010
+ for (const [key, pending] of [...this.pendingRetry.entries()]) {
1011
+ if (this.running.has(key)) {
1012
+ this.pendingRetry.delete(key);
1013
+ continue;
1014
+ }
1015
+ this.logger?.info("retrying failed listener", {
1016
+ channel: pending.channelName,
1017
+ connector: pending.connectorName
1018
+ });
1019
+ const failureCount = this.failureCounts.get(key) ?? 0;
1020
+ const backoffMs = Math.min(1e3 * 2 ** failureCount, this.maxBackoffMs);
1021
+ await this.sleep(backoffMs);
1022
+ if ((await this.start(pending.channelName, pending.connectorName)).ok) {
1023
+ this.pendingRetry.delete(key);
1024
+ this.failureCounts.delete(key);
1025
+ } else this.failureCounts.set(key, failureCount + 1);
1026
+ }
980
1027
  } finally {
981
1028
  this.healthCheckInFlight = false;
982
1029
  }
@@ -1005,7 +1052,27 @@ var FunnelListenerSupervisor = class FunnelListenerSupervisor {
1005
1052
  //#endregion
1006
1053
  //#region lib/gateway/kill-competing-slack-gateways.ts
1007
1054
  const defaultProcess = new NodeFunnelProcessRunner();
1055
+ const SIGTERM_GRACE_MS = 3e3;
1056
+ const POLL_INTERVAL_MS = 100;
1057
+ const SIGKILL_GRACE_MS = 200;
1058
+ const defaultSleep = (ms) => new Promise((resolve) => {
1059
+ setTimeout(resolve, ms);
1060
+ });
1008
1061
  const titleFor = (dir) => `funnel-gateway[${dir}]`;
1062
+ const waitForExit = async (props) => {
1063
+ const deadline = props.now() + SIGTERM_GRACE_MS;
1064
+ while (props.now() < deadline) {
1065
+ if (props.pids.every((pid) => !props.runner.isAlive(pid))) return;
1066
+ await props.sleep(POLL_INTERVAL_MS);
1067
+ }
1068
+ for (const pid of props.pids) {
1069
+ if (!props.runner.isAlive(pid)) continue;
1070
+ try {
1071
+ props.runner.kill(pid, "SIGKILL");
1072
+ } catch {}
1073
+ }
1074
+ await props.sleep(SIGKILL_GRACE_MS);
1075
+ };
1009
1076
  /**
1010
1077
  * Kills other funnel daemon processes that share the SAME funnel home dir,
1011
1078
  * which is the only situation that causes a real conflict (duplicate Slack
@@ -1015,6 +1082,10 @@ const titleFor = (dir) => `funnel-gateway[${dir}]`;
1015
1082
  * `funnel-gateway[<dir>]` marker appended to argv (also assigned to
1016
1083
  * `process.title` on POSIX). `FunnelProcessRunner.listProcessesContaining`
1017
1084
  * absorbs the POSIX/Windows enumeration difference behind the marker match.
1085
+ *
1086
+ * Waits for the killed daemons to actually exit before returning, so the caller
1087
+ * can bind the port and open a fresh Socket Mode connection without overlapping
1088
+ * the old one (the overlap is what makes Slack split inbound events).
1018
1089
  */
1019
1090
  const killCompetingSlackGateways = async (props) => {
1020
1091
  const runner = props.process ?? defaultProcess;
@@ -1031,6 +1102,13 @@ const killCompetingSlackGateways = async (props) => {
1031
1102
  args: snapshot.command.slice(0, 160)
1032
1103
  });
1033
1104
  }
1105
+ if (killed.length === 0) return killed;
1106
+ await waitForExit({
1107
+ runner,
1108
+ pids: killed,
1109
+ sleep: props.sleep ?? defaultSleep,
1110
+ now: props.now ?? (() => Date.now())
1111
+ });
1034
1112
  return killed;
1035
1113
  };
1036
1114
  //#endregion
@@ -1290,6 +1368,7 @@ const healthHandler = factory.createHandlers((c) => {
1290
1368
  return c.json({
1291
1369
  ok: true,
1292
1370
  pid: deps.selfPid,
1371
+ funnelDir: deps.dir,
1293
1372
  clients: deps.broadcaster.getClientCount(),
1294
1373
  listeners: deps.supervisor.list()
1295
1374
  });
@@ -1341,6 +1420,7 @@ const statusHandler = factory.createHandlers((c) => {
1341
1420
  return c.json({
1342
1421
  ok: true,
1343
1422
  pid: deps.selfPid,
1423
+ funnelDir: deps.dir,
1344
1424
  uptimeMs: deps.uptimeMs(),
1345
1425
  clients: deps.broadcaster.listChannels(),
1346
1426
  listeners: deps.supervisor.list(),
@@ -1450,6 +1530,7 @@ var FunnelGatewayServer = class {
1450
1530
  if (this.server) return this.server;
1451
1531
  if (!this.token && !LOOPBACK_HOSTS.has(this.hostname) && !this.allowInsecureHost) throw new Error(`refusing to start gateway: hostname "${this.hostname}" is reachable off-box but no token is set. Set a token, bind to loopback (127.0.0.1), or pass allowInsecureHost: true.`);
1452
1532
  const app = this.buildApp();
1533
+ await this.killCompetingSlackIfNeeded();
1453
1534
  this.startedAt = this.nowMs();
1454
1535
  this.server = Bun.serve({
1455
1536
  port: this.port,
@@ -1504,6 +1585,7 @@ var FunnelGatewayServer = class {
1504
1585
  if (this.token && !this.tokenMatchesUpgrade(request)) return new Response("unauthorized", { status: 401 });
1505
1586
  const requestedChannel = url.searchParams.get("channel") ?? "";
1506
1587
  const channel = requestedChannel ? this.resolveChannel(requestedChannel) : null;
1588
+ if (requestedChannel && !channel) return new Response(`unknown channel "${requestedChannel}"`, { status: 404 });
1507
1589
  const channelId = channel?.id ?? requestedChannel;
1508
1590
  const channelName = channel?.name ?? null;
1509
1591
  const connectors = channel?.connectors ?? [];
@@ -1567,6 +1649,7 @@ var FunnelGatewayServer = class {
1567
1649
  base.use((c, next) => {
1568
1650
  c.set("deps", {
1569
1651
  selfPid: this.selfPid,
1652
+ dir: this.dir,
1570
1653
  broadcaster: this.broadcaster,
1571
1654
  supervisor: this.supervisor,
1572
1655
  channels: this.channels,
@@ -1606,21 +1689,22 @@ var FunnelGatewayServer = class {
1606
1689
  delivery: channel.delivery
1607
1690
  };
1608
1691
  }
1692
+ async killCompetingSlackIfNeeded() {
1693
+ if (!this.killCompetingSlack) return;
1694
+ if (!this.channels.listAllConnectors().some((c) => c.type === "slack")) return;
1695
+ const killed = await killCompetingSlackGateways({
1696
+ selfPid: this.selfPid,
1697
+ dir: this.dir,
1698
+ process: this.process,
1699
+ logger: this.logger
1700
+ });
1701
+ if (killed.length > 0) this.logger?.info("killed competing Slack gateway processes", {
1702
+ event_type: "system",
1703
+ action: "kill_competing",
1704
+ pids: killed.join(",")
1705
+ });
1706
+ }
1609
1707
  async bootListeners() {
1610
- const allConnectors = this.channels.listAllConnectors();
1611
- if (this.killCompetingSlack && allConnectors.some((c) => c.type === "slack")) {
1612
- const killed = await killCompetingSlackGateways({
1613
- selfPid: this.selfPid,
1614
- dir: this.dir,
1615
- process: this.process,
1616
- logger: this.logger
1617
- });
1618
- if (killed.length > 0) this.logger?.info("killed competing Slack gateway processes", {
1619
- event_type: "system",
1620
- action: "kill_competing",
1621
- pids: killed.join(",")
1622
- });
1623
- }
1624
1708
  await this.supervisor.startAll();
1625
1709
  for (const entry of this.supervisor.list()) this.logger?.info(`${entry.type} listener started: ${entry.name}`, {
1626
1710
  event_type: "system",
@@ -1885,7 +1969,7 @@ var ConnectorDiagnosticLog = class {};
1885
1969
  */
1886
1970
  const RAW_PAYLOAD_CAP = 256 * 1024;
1887
1971
  /**
1888
- * Default `ConnectorDiagnosticLog`: three independent `LeucoLoggerSqliteSink`s, one
1972
+ * Default `ConnectorDiagnosticLog`: three independent `FunnelLogSqliteSink`s, one
1889
1973
  * per table (raw / processed / connection), in separate files. Each sink
1890
1974
  * indexes the columns its queries filter on — `event_id` / `connector_id` /
1891
1975
  * `channel_id` for raw, plus `outcome` for processed and `status` for
@@ -1920,7 +2004,7 @@ var SqliteConnectorDiagnosticLog = class extends ConnectorDiagnosticLog {
1920
2004
  ...ageCap,
1921
2005
  ...rawMax !== void 0 ? { maxRows: rawMax } : {}
1922
2006
  };
1923
- this.raw = new LeucoLoggerSqliteSink({
2007
+ this.raw = new FunnelLogSqliteSink({
1924
2008
  path: props.rawPath,
1925
2009
  indexes: [
1926
2010
  "event_id",
@@ -1934,7 +2018,7 @@ var SqliteConnectorDiagnosticLog = class extends ConnectorDiagnosticLog {
1934
2018
  }),
1935
2019
  ...rawCap
1936
2020
  });
1937
- this.processed = new LeucoLoggerSqliteSink({
2021
+ this.processed = new FunnelLogSqliteSink({
1938
2022
  path: props.processedPath,
1939
2023
  indexes: [
1940
2024
  "event_id",
@@ -1950,7 +2034,7 @@ var SqliteConnectorDiagnosticLog = class extends ConnectorDiagnosticLog {
1950
2034
  }),
1951
2035
  ...verdictCap
1952
2036
  });
1953
- this.connection = new LeucoLoggerSqliteSink({
2037
+ this.connection = new FunnelLogSqliteSink({
1954
2038
  path: props.connectionPath,
1955
2039
  indexes: [
1956
2040
  "connector_id",
@@ -2016,7 +2100,7 @@ var SqliteConnectorDiagnosticLog = class extends ConnectorDiagnosticLog {
2016
2100
  });
2017
2101
  }
2018
2102
  queryRaw(query) {
2019
- return this.raw.getRecords({
2103
+ return this.raw.query({
2020
2104
  ...query.type !== void 0 ? { type: query.type } : {},
2021
2105
  ...query.limit !== void 0 ? { limit: query.limit } : {},
2022
2106
  where: buildWhere(query),
@@ -2034,7 +2118,7 @@ var SqliteConnectorDiagnosticLog = class extends ConnectorDiagnosticLog {
2034
2118
  queryProcessed(query) {
2035
2119
  const where = buildWhere(query);
2036
2120
  if (query.outcome !== void 0) where.outcome = query.outcome;
2037
- return this.processed.getRecords({
2121
+ return this.processed.query({
2038
2122
  ...query.type !== void 0 ? { type: query.type } : {},
2039
2123
  ...query.limit !== void 0 ? { limit: query.limit } : {},
2040
2124
  where,
@@ -2053,7 +2137,7 @@ var SqliteConnectorDiagnosticLog = class extends ConnectorDiagnosticLog {
2053
2137
  queryConnection(query) {
2054
2138
  const where = buildWhere(query);
2055
2139
  if (query.status !== void 0) where.status = query.status;
2056
- return this.connection.getRecords({
2140
+ return this.connection.query({
2057
2141
  ...query.type !== void 0 ? { type: query.type } : {},
2058
2142
  ...query.limit !== void 0 ? { limit: query.limit } : {},
2059
2143
  where,
@@ -1,5 +1,5 @@
1
1
  import { n as FunnelFileSystem } from "./file-system-Wub9Nto4.js";
2
- import { i as FunnelTokenPrompter } from "./local-config-sync-E_t5_fjw.js";
2
+ import { i as FunnelTokenPrompter } from "./local-config-sync-BY20ixEV.js";
3
3
 
4
4
  //#region lib/services/local-config/local-config-json-schema.d.ts
5
5
  /**
@@ -3,7 +3,7 @@ import { t as resolveConnectorToken } from "./resolve-connector-token-CczqG_Ig.j
3
3
  import { t as FunnelConnectorListener } from "./connector-listener-CpHBecCj.js";
4
4
  import { z } from "zod";
5
5
  import { WebClient } from "@slack/web-api";
6
- import { App, LogLevel } from "@slack/bolt";
6
+ import { App, LogLevel, SocketModeReceiver } from "@slack/bolt";
7
7
  //#region lib/engine/connectors/slack-adapter.ts
8
8
  const toRecord = (value) => {
9
9
  const result = {};
@@ -272,6 +272,7 @@ var FunnelSlackListener = class extends FunnelConnectorListener {
272
272
  onAppCreated;
273
273
  preprocessEvent;
274
274
  app = null;
275
+ connected = false;
275
276
  constructor(deps) {
276
277
  super();
277
278
  this.config = deps.config;
@@ -290,15 +291,25 @@ var FunnelSlackListener = class extends FunnelConnectorListener {
290
291
  env: this.env,
291
292
  label: `${this.config.name}.botToken`
292
293
  });
293
- const app = new App({
294
- token: botToken,
294
+ const receiver = new SocketModeReceiver({
295
295
  appToken: resolveConnectorToken({
296
296
  literal: this.config.appToken,
297
297
  envVar: this.config.appTokenEnv,
298
298
  env: this.env,
299
299
  label: `${this.config.name}.appToken`
300
300
  }),
301
- socketMode: true,
301
+ logLevel: LogLevel.ERROR,
302
+ autoReconnectEnabled: false
303
+ });
304
+ receiver.client.on("connected", () => {
305
+ this.connected = true;
306
+ });
307
+ receiver.client.on("disconnected", () => {
308
+ this.connected = false;
309
+ });
310
+ const app = new App({
311
+ token: botToken,
312
+ receiver,
302
313
  logLevel: LogLevel.ERROR
303
314
  });
304
315
  let authResult;
@@ -362,6 +373,7 @@ var FunnelSlackListener = class extends FunnelConnectorListener {
362
373
  throw error;
363
374
  }
364
375
  this.app = app;
376
+ this.connected = true;
365
377
  this.recordConnection("connected", "");
366
378
  }
367
379
  async stop() {
@@ -374,11 +386,12 @@ var FunnelSlackListener = class extends FunnelConnectorListener {
374
386
  this.logger?.error("Slack stop error", { error: messageOf(error) });
375
387
  } finally {
376
388
  this.app = null;
389
+ this.connected = false;
377
390
  this.recordConnection("stopped", "");
378
391
  }
379
392
  }
380
393
  isAlive() {
381
- return this.app !== null;
394
+ return this.app !== null && this.connected;
382
395
  }
383
396
  recordRaw(eventId, event) {
384
397
  this.diagnosticLog?.recordRaw({
@@ -123,6 +123,7 @@ declare class FunnelSlackListener extends FunnelConnectorListener {
123
123
  private readonly onAppCreated;
124
124
  private readonly preprocessEvent;
125
125
  private app;
126
+ private connected;
126
127
  constructor(deps: Deps);
127
128
  start(notify: NotifyFn): Promise<void>;
128
129
  stop(): Promise<void>;
@@ -43,7 +43,7 @@ var FunnelClaude = class {
43
43
  }
44
44
  if (!this.gateway.isRunning()) {
45
45
  this.logger?.info(`starting gateway automatically`);
46
- await this.gateway.start();
46
+ if (!await this.gateway.start()) throw new Error(`gateway failed to start on port ${resolveFunnelPort()}; another funnel daemon (a different repo/scope) may already hold it. See 'fnl gateway logs' and 'ps -o pid,args= | grep funnel-gateway'.`);
47
47
  }
48
48
  if (options.profileId) this.guard.acquire(options.profileId);
49
49
  const session = (options.resume ?? false) && options.profileId ? this.resolveSession(options.profileId, cwd, options.userArgs ?? [], options.env ?? {}) : null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@interactive-inc/claude-funnel",
3
- "version": "0.57.0",
3
+ "version": "0.58.0",
4
4
  "description": "Hub CLI that routes external events (Slack / GitHub / Discord) to Claude Code agents through subscription channels over MCP.",
5
5
  "keywords": [
6
6
  "bun",
@@ -102,6 +102,11 @@
102
102
  "import": "./dist/connectors/schedule.js",
103
103
  "default": "./dist/connectors/schedule.js"
104
104
  },
105
+ "./logger": {
106
+ "types": "./dist/logger.d.ts",
107
+ "import": "./dist/logger.js",
108
+ "default": "./dist/logger.js"
109
+ },
105
110
  "./bin": "./dist/bin.js",
106
111
  "./package.json": "./package.json"
107
112
  },