@interactive-inc/claude-funnel 0.22.0 → 0.23.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.
package/dist/index.d.ts CHANGED
@@ -405,10 +405,15 @@ type Deps$13 = {
405
405
  /**
406
406
  * Per-channel persistent Claude Code session IDs, keyed by the cwd the
407
407
  * channel was launched from. The whole point is to give each (channel, cwd)
408
- * its own stable conversation: relaunching from the same path picks up the
409
- * previous claude session via `--session-id <uuid>`, while a different cwd
410
- * (or a different channel) gets an independent one — so sessions never
411
- * silently bleed across workspaces the way claude's `-c` does.
408
+ * its own stable conversation: relaunching from the same path resumes the
409
+ * previous claude session via `--resume <uuid>`, while a different cwd (or
410
+ * a different channel) gets an independent one — so sessions never silently
411
+ * bleed across workspaces the way claude's `-c` does.
412
+ *
413
+ * `get` and `create` are intentionally separate: claude's `--session-id`
414
+ * only accepts a fresh UUID (it errors if the session jsonl already
415
+ * exists), so callers must check `get` first and fall back to `create`
416
+ * only when there is nothing to resume.
412
417
  *
413
418
  * Storage lives under `<dir>/channels/<channel-id>/sessions.json` (channel
414
419
  * id, not name, so renames don't lose history). The file is a flat
@@ -419,10 +424,10 @@ declare class FunnelSessions {
419
424
  private readonly idGenerator;
420
425
  private readonly dir;
421
426
  constructor(deps: Deps$13);
422
- /** Returns the existing session id for (channelId, cwd) or generates and persists a new one. */
423
- getOrCreate(channelId: string, cwd: string): string;
424
427
  /** Returns the existing session id for (channelId, cwd) or null. */
425
428
  get(channelId: string, cwd: string): string | null;
429
+ /** Generates a new session id for (channelId, cwd) and persists it, overwriting any prior entry. */
430
+ create(channelId: string, cwd: string): string;
426
431
  /** Drops the recorded session id for (channelId, cwd). No-op if absent. */
427
432
  clear(channelId: string, cwd: string): void;
428
433
  /** Drops the whole session map for the channel (e.g. when the channel is deleted). */
@@ -484,14 +489,27 @@ declare class FunnelClaude {
484
489
  private isProcessAlive;
485
490
  private buildArgs;
486
491
  /**
487
- * Decides whether funnel should inject `--session-id`. We back off when
488
- * the user already passed a session-shaping flag, since combining them
489
- * would either confuse claude or override the explicit user intent.
492
+ * Decides whether funnel should resume an existing claude session or start
493
+ * a freshly minted one. Backs off when the user already passed a
494
+ * session-shaping flag, since combining them would either confuse claude
495
+ * or override the explicit user intent.
490
496
  */
491
- private resolveSessionId;
497
+ private resolveSession;
492
498
  private buildEnv;
493
499
  }
494
500
  //#endregion
501
+ //#region lib/engine/error/on-funnel-error.d.ts
502
+ /**
503
+ * Host integration hook called when Funnel catches an exception that would
504
+ * otherwise be silently swallowed (subscriber throw, listener start failure,
505
+ * MCP forward failure, etc.). Pass `Sentry.captureException` from the host to
506
+ * pipe these into your error reporter. Defaults to a no-op when omitted.
507
+ *
508
+ * `context` carries the component name and any extra metadata the caller had
509
+ * at the catch site (channel / connector / subscriber id when available).
510
+ */
511
+ type OnFunnelError = (error: Error, context?: Record<string, unknown>) => void;
512
+ //#endregion
495
513
  //#region lib/engine/local-config/dotenv-reader.d.ts
496
514
  type Deps$11 = {
497
515
  fs: FunnelFileSystem;
@@ -787,7 +805,8 @@ type ReplaySource = {
787
805
  loadSince(since: number): ReplayableEvent[];
788
806
  };
789
807
  type Deps$6 = {
790
- logger?: FunnelLogger;
808
+ logger?: FunnelLogger; /** Host hook for surfacing subscriber-throw exceptions. Defaults to no-op. */
809
+ onError?: OnFunnelError;
791
810
  maxBufferedBytes?: number;
792
811
  now?: () => number; /** Number of recent events kept in the in-memory replay buffer. */
793
812
  replayBufferSize?: number; /** Hard byte cap on replay buffer payloads. Older events are evicted FIFO until under this cap. */
@@ -825,6 +844,7 @@ declare class FunnelBroadcaster {
825
844
  private readonly clients;
826
845
  private readonly subscribers;
827
846
  private readonly logger;
847
+ private readonly onError;
828
848
  private readonly maxBufferedBytes;
829
849
  private readonly now;
830
850
  private readonly replayBufferSize;
@@ -886,7 +906,8 @@ type SupervisorNotify = (channelName: string, connectorName: string, content: st
886
906
  type Deps$5 = {
887
907
  channels: ConnectorRegistry;
888
908
  notify: SupervisorNotify;
889
- logger?: FunnelLogger;
909
+ logger?: FunnelLogger; /** Host hook for surfacing listener lifecycle exceptions. Defaults to no-op. */
910
+ onError?: OnFunnelError;
890
911
  healthCheckIntervalMs?: number;
891
912
  maxBackoffMs?: number;
892
913
  sleep?: (ms: number) => Promise<void>;
@@ -919,6 +940,7 @@ declare class FunnelListenerSupervisor {
919
940
  private readonly channels;
920
941
  private readonly notify;
921
942
  private readonly logger;
943
+ private readonly onError;
922
944
  private readonly running;
923
945
  private readonly failureCounts;
924
946
  private readonly stats;
@@ -1119,7 +1141,8 @@ type Deps$3 = {
1119
1141
  dbPath?: string;
1120
1142
  process?: FunnelProcessRunner;
1121
1143
  clock?: FunnelClock;
1122
- logger?: FunnelLogger;
1144
+ logger?: FunnelLogger; /** Host hook for surfacing internal exceptions (broadcaster / supervisor). Defaults to no-op. */
1145
+ onError?: OnFunnelError;
1123
1146
  selfPid?: number; /** Funnel home dir, used to scope kill-competing to daemons rooted at the same dir. Defaults to FUNNEL_DIR. */
1124
1147
  dir?: string;
1125
1148
  killCompetingSlack?: boolean; /** Bearer token required for `/listeners*`, `/status`, and `/ws`. Empty string disables auth (tests only). */
@@ -1157,6 +1180,7 @@ declare class FunnelGatewayServer {
1157
1180
  private readonly dbPath;
1158
1181
  private readonly process?;
1159
1182
  private readonly logger;
1183
+ private readonly onError;
1160
1184
  private readonly selfPid;
1161
1185
  private readonly dir;
1162
1186
  private readonly killCompetingSlack;
@@ -1321,6 +1345,12 @@ type Props$5 = {
1321
1345
  * each successful fire, useful for dropping one-shot entries.
1322
1346
  */
1323
1347
  scheduleListenerOptions?: ScheduleListenerOptions;
1348
+ /**
1349
+ * Called when Funnel catches an exception that would otherwise be silently
1350
+ * swallowed (subscriber throw, listener start/stop failure, etc.). Pass
1351
+ * `Sentry.captureException` from the host to surface these. Defaults to no-op.
1352
+ */
1353
+ onError?: OnFunnelError;
1324
1354
  };
1325
1355
  /**
1326
1356
  * Facade exposing every funnel facet as a getter.
@@ -1365,6 +1395,11 @@ declare class Funnel {
1365
1395
  get logger(): FunnelLogger;
1366
1396
  /** Clock boundary. Defaults to NodeFunnelClock. */
1367
1397
  get clock(): FunnelClock;
1398
+ /**
1399
+ * Error hook. Forwards Funnel-internal exceptions that would otherwise be
1400
+ * swallowed. Defaults to a no-op when no host hook was passed.
1401
+ */
1402
+ get onError(): OnFunnelError;
1368
1403
  /** ID generator boundary. Defaults to NodeFunnelIdGenerator. */
1369
1404
  get idGenerator(): FunnelIdGenerator;
1370
1405
  /** Settings reader. If not injected, a FunnelSettingsStore rooted at `dir` is created. */
@@ -4275,4 +4310,4 @@ ${string}`;
4275
4310
  //#region lib/tui/tui.d.ts
4276
4311
  declare function launchTui(funnel: Funnel): Promise<void>;
4277
4312
  //#endregion
4278
- export { AliveStub, AttachOptions, BroadcastEvent, BroadcastSubscriber, ChannelConfig, ChannelConnectorView, ChannelDeliveryMode, ChannelServerOptions, ChannelSpec, ConnectorConfig, ConnectorSpec, ConnectorSyncOutcome, ConnectorType, DEFAULT_GATEWAY_TOKEN_PATH, DetachOptions, DiscordConnectorConfig, Env, FUNNEL_DIR, FUNNEL_MCP_COMMAND, FUNNEL_MCP_NAME, FileStat, Funnel, FunnelBroadcaster, FunnelChannelPublisher, FunnelChannels, FunnelClaude, FunnelClock, FunnelConnectorFactory, FunnelConnectorListener, FunnelDotenvReader, FunnelEvent, FunnelEventStore, FunnelFileSystem, FunnelGateway, FunnelGatewayServer, FunnelGatewayToken, FunnelIdGenerator, FunnelListenerSupervisor, FunnelListenersClient, FunnelLocalConfig, FunnelLocalConfigSync, FunnelLogger, FunnelMcp, FunnelProcessRunner, FunnelProfiles, FunnelSessions, FunnelSettingsReader, FunnelSettingsStore, FunnelSlackEventProcessor, FunnelTokenPrompter, type GatewayEmitInput, type GatewayRouteDeps, type Env$1 as GatewayServerEnv, GhConnectorConfig, LOCAL_CONFIG_FILENAME, LOCAL_ENV_FILENAME, LaunchOptions, ListListenersResult, ListenerEntry, ListenerOpResult, LocalConfig, LocalConfigSyncResult, LogEntry, MemoryFunnelClock, MemoryFunnelFileSystem, MemoryFunnelIdGenerator, MemoryFunnelLogger, MemoryFunnelProcessRunner, MemoryFunnelTokenPrompter, MemoryProcessCall, MemoryProcessHandler, MemoryProcessResponse, MemoryProcessSyncHandler, MockFunnelSettingsReader, NodeFunnelClock, NodeFunnelFileSystem, NodeFunnelIdGenerator, NodeFunnelLogger, NodeFunnelProcessRunner, NodeFunnelTokenPrompter, NoopFunnelLogger, NotifyFn, ProcessListStub, ProcessSnapshot, ProfileConfig, PublishRequest, PublishResponse, PublishResult, ReplayableEvent, RunOptions, RunResult, SETTINGS_PATH, SETTINGS_VERSION, ScheduleCatchupPolicy, ScheduleConnectorConfig, ScheduleEntry, ScheduleListenerOptions, Settings, SlackConnectorConfig, SlackListenerOptions, SlackProcessed, SlackProcessedEmit, SlackProcessedSkip, SlackRawEvent, channelConfigSchema, channelDeliveryModeSchema, channelSpecSchema, app as cliApp, connectorConfigSchema, connectorSpecSchema, createCliApp, createSettings, discordConnectorSchema, factory, funnelEventSchema, funnelJsonSchema, ghConnectorSchema, launchTui, localConfigSchema, profileConfigSchema, publishRequestSchema, publishResponseSchema, queryToCliArgs, scheduleCatchupPolicySchema, scheduleConnectorSchema, scheduleEntrySchema, settingsSchema, slackConnectorSchema, startChannelServer, toRequest };
4313
+ export { AliveStub, AttachOptions, BroadcastEvent, BroadcastSubscriber, ChannelConfig, ChannelConnectorView, ChannelDeliveryMode, ChannelServerOptions, ChannelSpec, ConnectorConfig, ConnectorSpec, ConnectorSyncOutcome, ConnectorType, DEFAULT_GATEWAY_TOKEN_PATH, DetachOptions, DiscordConnectorConfig, Env, FUNNEL_DIR, FUNNEL_MCP_COMMAND, FUNNEL_MCP_NAME, FileStat, Funnel, FunnelBroadcaster, FunnelChannelPublisher, FunnelChannels, FunnelClaude, FunnelClock, FunnelConnectorFactory, FunnelConnectorListener, FunnelDotenvReader, FunnelEvent, FunnelEventStore, FunnelFileSystem, FunnelGateway, FunnelGatewayServer, FunnelGatewayToken, FunnelIdGenerator, FunnelListenerSupervisor, FunnelListenersClient, FunnelLocalConfig, FunnelLocalConfigSync, FunnelLogger, FunnelMcp, FunnelProcessRunner, FunnelProfiles, FunnelSessions, FunnelSettingsReader, FunnelSettingsStore, FunnelSlackEventProcessor, FunnelTokenPrompter, type GatewayEmitInput, type GatewayRouteDeps, type Env$1 as GatewayServerEnv, GhConnectorConfig, LOCAL_CONFIG_FILENAME, LOCAL_ENV_FILENAME, LaunchOptions, ListListenersResult, ListenerEntry, ListenerOpResult, LocalConfig, LocalConfigSyncResult, LogEntry, MemoryFunnelClock, MemoryFunnelFileSystem, MemoryFunnelIdGenerator, MemoryFunnelLogger, MemoryFunnelProcessRunner, MemoryFunnelTokenPrompter, MemoryProcessCall, MemoryProcessHandler, MemoryProcessResponse, MemoryProcessSyncHandler, MockFunnelSettingsReader, NodeFunnelClock, NodeFunnelFileSystem, NodeFunnelIdGenerator, NodeFunnelLogger, NodeFunnelProcessRunner, NodeFunnelTokenPrompter, NoopFunnelLogger, NotifyFn, OnFunnelError, ProcessListStub, ProcessSnapshot, ProfileConfig, PublishRequest, PublishResponse, PublishResult, ReplayableEvent, RunOptions, RunResult, SETTINGS_PATH, SETTINGS_VERSION, ScheduleCatchupPolicy, ScheduleConnectorConfig, ScheduleEntry, ScheduleListenerOptions, Settings, SlackConnectorConfig, SlackListenerOptions, SlackProcessed, SlackProcessedEmit, SlackProcessedSkip, SlackRawEvent, channelConfigSchema, channelDeliveryModeSchema, channelSpecSchema, app as cliApp, connectorConfigSchema, connectorSpecSchema, createCliApp, createSettings, discordConnectorSchema, factory, funnelEventSchema, funnelJsonSchema, ghConnectorSchema, launchTui, localConfigSchema, profileConfigSchema, publishRequestSchema, publishResponseSchema, queryToCliArgs, scheduleCatchupPolicySchema, scheduleConnectorSchema, scheduleEntrySchema, settingsSchema, slackConnectorSchema, startChannelServer, toRequest };
package/dist/index.js CHANGED
@@ -597,8 +597,8 @@ var FunnelClaude = class {
597
597
  this.writePidFile(options.profileName);
598
598
  this.installCleanup(options.profileName);
599
599
  }
600
- const sessionId = channel.resume ? this.resolveSessionId(channel.id, cwd, options.userArgs ?? []) : null;
601
- const claudeArgs = this.buildArgs(channel.options, options.userArgs ?? [], cwd, sessionId);
600
+ const session = channel.resume ? this.resolveSession(channel.id, cwd, options.userArgs ?? []) : null;
601
+ const claudeArgs = this.buildArgs(channel.options, options.userArgs ?? [], cwd, session);
602
602
  const env = this.buildEnv(channel.id, channel.env);
603
603
  this.logger.info(`claude launch`, {
604
604
  channel: options.channel,
@@ -649,25 +649,35 @@ var FunnelClaude = class {
649
649
  isProcessAlive(pid) {
650
650
  return this.process.isAlive(pid);
651
651
  }
652
- buildArgs(channelOptions, userArgs, cwd, sessionId) {
652
+ buildArgs(channelOptions, userArgs, cwd, session) {
653
653
  const result = [...channelOptions, ...userArgs];
654
- if (sessionId !== null) result.push("--session-id", sessionId);
654
+ if (session !== null) if (session.mode === "resume") result.push("--resume", session.id);
655
+ else result.push("--session-id", session.id);
655
656
  const mcpName = this.mcp.findInstalledName(cwd);
656
657
  if (mcpName && !result.includes("--dangerously-load-development-channels") && !result.includes("--channels")) result.push("--dangerously-load-development-channels", `server:${mcpName}`);
657
658
  return result;
658
659
  }
659
660
  /**
660
- * Decides whether funnel should inject `--session-id`. We back off when
661
- * the user already passed a session-shaping flag, since combining them
662
- * would either confuse claude or override the explicit user intent.
661
+ * Decides whether funnel should resume an existing claude session or start
662
+ * a freshly minted one. Backs off when the user already passed a
663
+ * session-shaping flag, since combining them would either confuse claude
664
+ * or override the explicit user intent.
663
665
  */
664
- resolveSessionId(channelId, cwd, userArgs) {
666
+ resolveSession(channelId, cwd, userArgs) {
665
667
  for (const arg of userArgs) {
666
668
  if (arg === "-c" || arg === "--continue") return null;
667
669
  if (arg === "--resume" || arg.startsWith("--resume=")) return null;
668
670
  if (arg === "--session-id" || arg.startsWith("--session-id=")) return null;
669
671
  }
670
- return this.sessions.getOrCreate(channelId, cwd);
672
+ const existing = this.sessions.get(channelId, cwd);
673
+ if (existing !== null) return {
674
+ id: existing,
675
+ mode: "resume"
676
+ };
677
+ return {
678
+ id: this.sessions.create(channelId, cwd),
679
+ mode: "new"
680
+ };
671
681
  }
672
682
  buildEnv(channelId, channelEnv) {
673
683
  const env = {};
@@ -1492,10 +1502,15 @@ const sessionsMapSchema = z.record(z.string(), z.string());
1492
1502
  /**
1493
1503
  * Per-channel persistent Claude Code session IDs, keyed by the cwd the
1494
1504
  * channel was launched from. The whole point is to give each (channel, cwd)
1495
- * its own stable conversation: relaunching from the same path picks up the
1496
- * previous claude session via `--session-id <uuid>`, while a different cwd
1497
- * (or a different channel) gets an independent one — so sessions never
1498
- * silently bleed across workspaces the way claude's `-c` does.
1505
+ * its own stable conversation: relaunching from the same path resumes the
1506
+ * previous claude session via `--resume <uuid>`, while a different cwd (or
1507
+ * a different channel) gets an independent one — so sessions never silently
1508
+ * bleed across workspaces the way claude's `-c` does.
1509
+ *
1510
+ * `get` and `create` are intentionally separate: claude's `--session-id`
1511
+ * only accepts a fresh UUID (it errors if the session jsonl already
1512
+ * exists), so callers must check `get` first and fall back to `create`
1513
+ * only when there is nothing to resume.
1499
1514
  *
1500
1515
  * Storage lives under `<dir>/channels/<channel-id>/sessions.json` (channel
1501
1516
  * id, not name, so renames don't lose history). The file is a flat
@@ -1511,20 +1526,18 @@ var FunnelSessions = class {
1511
1526
  this.dir = deps.dir;
1512
1527
  Object.freeze(this);
1513
1528
  }
1514
- /** Returns the existing session id for (channelId, cwd) or generates and persists a new one. */
1515
- getOrCreate(channelId, cwd) {
1529
+ /** Returns the existing session id for (channelId, cwd) or null. */
1530
+ get(channelId, cwd) {
1531
+ return this.readMap(channelId)[cwd] ?? null;
1532
+ }
1533
+ /** Generates a new session id for (channelId, cwd) and persists it, overwriting any prior entry. */
1534
+ create(channelId, cwd) {
1516
1535
  const map = this.readMap(channelId);
1517
- const existing = map[cwd];
1518
- if (existing) return existing;
1519
1536
  const sessionId = this.idGenerator.generate();
1520
1537
  map[cwd] = sessionId;
1521
1538
  this.writeMap(channelId, map);
1522
1539
  return sessionId;
1523
1540
  }
1524
- /** Returns the existing session id for (channelId, cwd) or null. */
1525
- get(channelId, cwd) {
1526
- return this.readMap(channelId)[cwd] ?? null;
1527
- }
1528
1541
  /** Drops the recorded session id for (channelId, cwd). No-op if absent. */
1529
1542
  clear(channelId, cwd) {
1530
1543
  const map = this.readMap(channelId);
@@ -1969,6 +1982,7 @@ const DEFAULT_MAX_BUFFERED_BYTES = 1024 * 1024;
1969
1982
  const DEFAULT_REPLAY_BUFFER_SIZE = 200;
1970
1983
  const DEFAULT_REPLAY_BUFFER_MAX_BYTES = 4 * 1024 * 1024;
1971
1984
  const defaultLogger$3 = new NoopFunnelLogger();
1985
+ const defaultOnError$2 = () => {};
1972
1986
  /**
1973
1987
  * In-process pub/sub for connector events.
1974
1988
  *
@@ -1991,6 +2005,7 @@ var FunnelBroadcaster = class {
1991
2005
  clients = /* @__PURE__ */ new Map();
1992
2006
  subscribers = /* @__PURE__ */ new Set();
1993
2007
  logger;
2008
+ onError;
1994
2009
  maxBufferedBytes;
1995
2010
  now;
1996
2011
  replayBufferSize;
@@ -2005,6 +2020,7 @@ var FunnelBroadcaster = class {
2005
2020
  latestOffset = 0;
2006
2021
  constructor(deps = {}) {
2007
2022
  this.logger = deps.logger ?? defaultLogger$3;
2023
+ this.onError = deps.onError ?? defaultOnError$2;
2008
2024
  this.maxBufferedBytes = deps.maxBufferedBytes ?? DEFAULT_MAX_BUFFERED_BYTES;
2009
2025
  this.now = deps.now ?? (() => Date.now());
2010
2026
  this.replayBufferSize = Math.max(0, deps.replayBufferSize ?? DEFAULT_REPLAY_BUFFER_SIZE);
@@ -2143,7 +2159,14 @@ var FunnelBroadcaster = class {
2143
2159
  for (const handler of this.subscribers) try {
2144
2160
  handler(event);
2145
2161
  } catch (error) {
2146
- this.logger.error("broadcast subscriber threw", { error: error instanceof Error ? error.message : String(error) });
2162
+ const err = error instanceof Error ? error : new Error(String(error));
2163
+ this.logger.error("broadcast subscriber threw", { error: err.message });
2164
+ this.onError(err, {
2165
+ component: "broadcaster.subscriber",
2166
+ offset: event.offset,
2167
+ connector: event.meta?.connector ?? null,
2168
+ channel: event.meta?.channel ?? null
2169
+ });
2147
2170
  }
2148
2171
  return event;
2149
2172
  }
@@ -2514,6 +2537,7 @@ function truncate$1(content) {
2514
2537
  //#endregion
2515
2538
  //#region lib/gateway/listener-supervisor.ts
2516
2539
  const defaultLogger$2 = new NodeFunnelLogger();
2540
+ const defaultOnError$1 = () => {};
2517
2541
  const DEFAULT_HEALTH_INTERVAL_MS = 3e4;
2518
2542
  const DEFAULT_MAX_BACKOFF_MS = 6e4;
2519
2543
  const defaultSleep = (ms) => new Promise((r) => {
@@ -2535,6 +2559,7 @@ var FunnelListenerSupervisor = class FunnelListenerSupervisor {
2535
2559
  channels;
2536
2560
  notify;
2537
2561
  logger;
2562
+ onError;
2538
2563
  running = /* @__PURE__ */ new Map();
2539
2564
  failureCounts = /* @__PURE__ */ new Map();
2540
2565
  stats = /* @__PURE__ */ new Map();
@@ -2548,6 +2573,7 @@ var FunnelListenerSupervisor = class FunnelListenerSupervisor {
2548
2573
  this.channels = deps.channels;
2549
2574
  this.notify = deps.notify;
2550
2575
  this.logger = deps.logger ?? defaultLogger$2;
2576
+ this.onError = deps.onError ?? defaultOnError$1;
2551
2577
  this.healthCheckIntervalMs = deps.healthCheckIntervalMs ?? DEFAULT_HEALTH_INTERVAL_MS;
2552
2578
  this.maxBackoffMs = deps.maxBackoffMs ?? DEFAULT_MAX_BACKOFF_MS;
2553
2579
  this.sleep = deps.sleep ?? defaultSleep;
@@ -2610,14 +2636,21 @@ var FunnelListenerSupervisor = class FunnelListenerSupervisor {
2610
2636
  });
2611
2637
  return { ok: true };
2612
2638
  } catch (error) {
2639
+ const err = error instanceof Error ? error : new Error(String(error));
2613
2640
  this.logger.error(`${created.config.type} listener failed to start`, {
2614
2641
  channel: channelName,
2615
2642
  connector: connectorName,
2616
- error: error instanceof Error ? error.message : String(error)
2643
+ error: err.message
2644
+ });
2645
+ this.onError(err, {
2646
+ component: "listener-supervisor.start",
2647
+ channel: channelName,
2648
+ connector: connectorName,
2649
+ type: created.config.type
2617
2650
  });
2618
2651
  return {
2619
2652
  ok: false,
2620
- reason: error instanceof Error ? error.message : String(error)
2653
+ reason: err.message
2621
2654
  };
2622
2655
  }
2623
2656
  }
@@ -2638,14 +2671,21 @@ var FunnelListenerSupervisor = class FunnelListenerSupervisor {
2638
2671
  });
2639
2672
  return { ok: true };
2640
2673
  } catch (error) {
2674
+ const err = error instanceof Error ? error : new Error(String(error));
2641
2675
  this.logger.error(`${entry.config.type} listener failed to stop`, {
2642
2676
  channel: channelName,
2643
2677
  connector: connectorName,
2644
- error: error instanceof Error ? error.message : String(error)
2678
+ error: err.message
2679
+ });
2680
+ this.onError(err, {
2681
+ component: "listener-supervisor.stop",
2682
+ channel: channelName,
2683
+ connector: connectorName,
2684
+ type: entry.config.type
2645
2685
  });
2646
2686
  return {
2647
2687
  ok: false,
2648
- reason: error instanceof Error ? error.message : String(error)
2688
+ reason: err.message
2649
2689
  };
2650
2690
  }
2651
2691
  }
@@ -2926,6 +2966,7 @@ const gatewayRoutes = factory$1.createApp().get("/health", ...healthHandler).get
2926
2966
  const DEFAULT_PORT = 9742;
2927
2967
  const defaultDbPath = () => join(funnelTmpDir(), "events.db");
2928
2968
  const defaultLogger = new NodeFunnelLogger();
2969
+ const defaultOnError = () => {};
2929
2970
  /**
2930
2971
  * In-process gateway: runs `Bun.serve` (HTTP + WebSocket /ws), boots connector
2931
2972
  * listeners through `FunnelListenerSupervisor`, fans events out via
@@ -2943,6 +2984,7 @@ var FunnelGatewayServer = class {
2943
2984
  dbPath;
2944
2985
  process;
2945
2986
  logger;
2987
+ onError;
2946
2988
  selfPid;
2947
2989
  dir;
2948
2990
  killCompetingSlack;
@@ -2961,6 +3003,7 @@ var FunnelGatewayServer = class {
2961
3003
  this.dbPath = deps.dbPath ?? defaultDbPath();
2962
3004
  this.process = deps.process;
2963
3005
  this.logger = deps.logger ?? defaultLogger;
3006
+ this.onError = deps.onError ?? defaultOnError;
2964
3007
  this.selfPid = deps.selfPid ?? globalThis.process.pid;
2965
3008
  this.dir = deps.dir ?? FUNNEL_DIR;
2966
3009
  this.killCompetingSlack = deps.killCompetingSlack ?? true;
@@ -2976,6 +3019,7 @@ var FunnelGatewayServer = class {
2976
3019
  });
2977
3020
  this.broadcaster = new FunnelBroadcaster({
2978
3021
  logger: this.logger,
3022
+ onError: this.onError,
2979
3023
  now: this.nowMs,
2980
3024
  persistentReplay: this.eventStore
2981
3025
  });
@@ -2983,6 +3027,7 @@ var FunnelGatewayServer = class {
2983
3027
  this.supervisor = new FunnelListenerSupervisor({
2984
3028
  channels: this.channels,
2985
3029
  logger: this.logger,
3030
+ onError: this.onError,
2986
3031
  notify: async (channelName, connectorName, content, meta) => {
2987
3032
  this.emit({
2988
3033
  channel: channelName,
@@ -3364,6 +3409,7 @@ var FunnelListenersClient = class {
3364
3409
  //#region lib/funnel.ts
3365
3410
  const SANDBOX_DIR = "/sandbox/.funnel";
3366
3411
  const SANDBOX_TMP_DIR = "/sandbox/tmp";
3412
+ const noopOnError = () => {};
3367
3413
  /**
3368
3414
  * Facade exposing every funnel facet as a getter.
3369
3415
  *
@@ -3435,6 +3481,13 @@ var Funnel = class Funnel {
3435
3481
  if (!this.memos.clock) this.memos.clock = this.props.clock ?? new NodeFunnelClock();
3436
3482
  return this.memos.clock;
3437
3483
  }
3484
+ /**
3485
+ * Error hook. Forwards Funnel-internal exceptions that would otherwise be
3486
+ * swallowed. Defaults to a no-op when no host hook was passed.
3487
+ */
3488
+ get onError() {
3489
+ return this.props.onError ?? noopOnError;
3490
+ }
3438
3491
  /** ID generator boundary. Defaults to NodeFunnelIdGenerator. */
3439
3492
  get idGenerator() {
3440
3493
  if (!this.memos.idGenerator) this.memos.idGenerator = this.props.idGenerator ?? new NodeFunnelIdGenerator();
@@ -3595,6 +3648,7 @@ var Funnel = class Funnel {
3595
3648
  process: this.process,
3596
3649
  clock: this.clock,
3597
3650
  logger: this.logger,
3651
+ onError: this.onError,
3598
3652
  dir: this.paths.dir,
3599
3653
  killCompetingSlack: options.killCompetingSlack,
3600
3654
  token: options.token ?? this.gatewayToken.ensure(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@interactive-inc/claude-funnel",
3
- "version": "0.22.0",
3
+ "version": "0.23.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",
@@ -90,5 +90,8 @@
90
90
  },
91
91
  "engines": {
92
92
  "bun": ">=1.3.0"
93
+ },
94
+ "scripts": {
95
+ "prepack": "make build"
93
96
  }
94
97
  }