@interactive-inc/claude-funnel 0.26.1 → 0.28.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.
Files changed (27) hide show
  1. package/dist/bin.js +786 -717
  2. package/dist/connector-diagnostic-log-OPpPi9V9.d.ts +208 -0
  3. package/dist/connectors/discord.d.ts +16 -6
  4. package/dist/connectors/discord.js +1 -1
  5. package/dist/connectors/gh.d.ts +12 -5
  6. package/dist/connectors/gh.js +1 -1
  7. package/dist/connectors/schedule.d.ts +1 -1
  8. package/dist/connectors/schedule.js +1 -1
  9. package/dist/connectors/slack.d.ts +5 -4
  10. package/dist/connectors/slack.js +1 -1
  11. package/dist/{discord-connector-schema-Dww2I4zH.d.ts → discord-connector-schema-Df_McRJI.d.ts} +7 -1
  12. package/dist/{discord-connector-schema-CpuI6rmE.js → discord-connector-schema-RzDvrNE5.js} +81 -8
  13. package/dist/gateway/daemon.js +225 -225
  14. package/dist/{gh-connector-schema-CQRIvPpz.js → gh-connector-schema-eYE4g77K.js} +51 -3
  15. package/dist/index.d.ts +221 -39
  16. package/dist/index.js +781 -104
  17. package/dist/resolve-connector-token-Ch6XWMJM.js +22 -0
  18. package/dist/{schedule-connector-schema-CuCjP7z4.js → schedule-connector-schema-CM-sRkac.js} +53 -3
  19. package/dist/{schedule-listener-CBYF2bGZ.d.ts → schedule-listener-3M6WkH1Y.d.ts} +10 -3
  20. package/dist/{slack-connector-schema-BWL7dWlY.js → slack-connector-schema-CHbRJHGp.js} +140 -19
  21. package/dist/slack-listener-CLTiOEJw.d.ts +112 -0
  22. package/package.json +1 -1
  23. package/dist/logger-B3aXsVcX.d.ts +0 -33
  24. package/dist/slack-listener-DbNCPMqY.d.ts +0 -77
  25. /package/dist/{connector-adapter-CXB-q_XC.d.ts → connector-adapter-VA6undzc.d.ts} +0 -0
  26. /package/dist/{gh-connector-schema-Cmi57jvL.d.ts → gh-connector-schema-CQmEWzdV.d.ts} +0 -0
  27. /package/dist/{logger-D1A3_JXV.js → logger-Czli2OKh.js} +0 -0
package/dist/index.js CHANGED
@@ -1,10 +1,10 @@
1
- import { i as FunnelDiscordAdapter, n as FunnelDiscordListener, t as discordConnectorSchema } from "./discord-connector-schema-CpuI6rmE.js";
2
- import { n as FunnelConnectorListener, t as FunnelLogger } from "./logger-D1A3_JXV.js";
3
- import { a as FunnelProcessRunner, i as NodeFunnelProcessRunner, n as FunnelGhListener, r as FunnelGhAdapter, t as ghConnectorSchema } from "./gh-connector-schema-CQRIvPpz.js";
4
- import { a as ScheduleStateStore, i as FunnelScheduleListener, n as scheduleConnectorSchema, o as NodeFunnelFileSystem, r as scheduleEntrySchema, s as FunnelFileSystem, t as scheduleCatchupPolicySchema } from "./schedule-connector-schema-CuCjP7z4.js";
5
- import { i as FunnelSlackAdapter, n as FunnelSlackListener, r as FunnelSlackEventProcessor, t as slackConnectorSchema } from "./slack-connector-schema-BWL7dWlY.js";
1
+ import { i as FunnelDiscordAdapter, n as FunnelDiscordListener, t as discordConnectorSchema } from "./discord-connector-schema-RzDvrNE5.js";
2
+ import { n as FunnelConnectorListener, t as FunnelLogger } from "./logger-Czli2OKh.js";
3
+ import { a as FunnelProcessRunner, i as NodeFunnelProcessRunner, n as FunnelGhListener, r as FunnelGhAdapter, t as ghConnectorSchema } from "./gh-connector-schema-eYE4g77K.js";
4
+ import { a as ScheduleStateStore, i as FunnelScheduleListener, n as scheduleConnectorSchema, o as NodeFunnelFileSystem, r as scheduleEntrySchema, s as FunnelFileSystem, t as scheduleCatchupPolicySchema } from "./schedule-connector-schema-CM-sRkac.js";
5
+ import { i as FunnelSlackAdapter, n as FunnelSlackListener, r as FunnelSlackEventProcessor, t as slackConnectorSchema } from "./slack-connector-schema-CHbRJHGp.js";
6
6
  import { dirname, join, resolve } from "node:path";
7
- import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
7
+ import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync } from "node:fs";
8
8
  import { z } from "zod";
9
9
  import { homedir, tmpdir } from "node:os";
10
10
  import { stderr, stdin } from "node:process";
@@ -104,6 +104,18 @@ const settingsSchema = z.object({
104
104
  });
105
105
  //#endregion
106
106
  //#region lib/engine/settings/settings-store.ts
107
+ /**
108
+ * Resolves the funnel home dir. Defaults to `~/.funnel`, overridable via
109
+ * `FUNNEL_DIR` so a funnel.json-scoped launch can point everything (settings,
110
+ * gateway pid/token, claude pids) at a repo-local `<repo>/.funnel` and never
111
+ * touch the global home. Read at call time, not module load, so a daemon
112
+ * spawned with the env set resolves the override.
113
+ */
114
+ function resolveFunnelDir() {
115
+ const override = process.env.FUNNEL_DIR;
116
+ if (override && override.length > 0) return override;
117
+ return join(homedir(), ".funnel");
118
+ }
107
119
  const FUNNEL_DIR = join(homedir(), ".funnel");
108
120
  const SETTINGS_PATH = join(FUNNEL_DIR, "settings.json");
109
121
  const defaultFs$5 = new NodeFunnelFileSystem();
@@ -198,6 +210,7 @@ var FunnelConnectorFactory = class {
198
210
  fs;
199
211
  process;
200
212
  logger;
213
+ diagnosticLog;
201
214
  dir;
202
215
  slackListenerOptions;
203
216
  scheduleListenerOptions;
@@ -205,6 +218,7 @@ var FunnelConnectorFactory = class {
205
218
  this.fs = deps.fs ?? defaultFs$4;
206
219
  this.process = deps.process ?? defaultProcess$3;
207
220
  this.logger = deps.logger;
221
+ this.diagnosticLog = deps.diagnosticLog;
208
222
  this.dir = deps.dir ?? FUNNEL_DIR;
209
223
  this.slackListenerOptions = deps.slackListenerOptions ?? {};
210
224
  this.scheduleListenerOptions = deps.scheduleListenerOptions ?? {};
@@ -213,18 +227,24 @@ var FunnelConnectorFactory = class {
213
227
  createListener(channelId, config) {
214
228
  if (config.type === "slack") return new FunnelSlackListener({
215
229
  config,
230
+ channelId,
216
231
  logger: this.logger,
232
+ diagnosticLog: this.diagnosticLog,
217
233
  onAppCreated: this.slackListenerOptions.onAppCreated,
218
234
  preprocessEvent: this.slackListenerOptions.preprocessEvent
219
235
  });
220
236
  if (config.type === "gh") return new FunnelGhListener({
221
237
  config,
238
+ channelId,
222
239
  process: this.process,
223
- logger: this.logger
240
+ logger: this.logger,
241
+ diagnosticLog: this.diagnosticLog
224
242
  });
225
243
  if (config.type === "discord") return new FunnelDiscordListener({
226
244
  config,
227
- logger: this.logger
245
+ channelId,
246
+ logger: this.logger,
247
+ diagnosticLog: this.diagnosticLog
228
248
  });
229
249
  return new FunnelScheduleListener({
230
250
  config,
@@ -232,7 +252,9 @@ var FunnelConnectorFactory = class {
232
252
  path: join(this.connectorDir(channelId, config.id), "state.json"),
233
253
  fs: this.fs
234
254
  }),
255
+ channelId,
235
256
  logger: this.logger,
257
+ diagnosticLog: this.diagnosticLog,
236
258
  onFired: this.scheduleListenerOptions.onFired
237
259
  });
238
260
  }
@@ -252,15 +274,17 @@ var FunnelConnectorFactory = class {
252
274
  //#endregion
253
275
  //#region lib/engine/channels/connector-tokens.ts
254
276
  /**
255
- * Return every secret token contained in a connector config. Used by token
256
- * collision detection at add/update time so the same Slack bot or Discord
257
- * bot cannot be registered under two connectors. Centralizes the per-type
258
- * switch so the channels facade does not embed type-specific knowledge.
277
+ * Return every literal secret token contained in a connector config. Used by
278
+ * token collision detection at add/update time so the same Slack bot or
279
+ * Discord bot cannot be registered under two connectors. Connectors that hold
280
+ * an env *reference* instead of a literal contribute nothing here — two
281
+ * connectors naming the same env var is not a secret collision, and the secret
282
+ * is not in settings.json to compare anyway.
259
283
  */
260
284
  function connectorTokens(connector) {
261
285
  switch (connector.type) {
262
- case "slack": return [connector.botToken, connector.appToken];
263
- case "discord": return [connector.botToken];
286
+ case "slack": return [connector.botToken, connector.appToken].filter((token) => token !== void 0);
287
+ case "discord": return [connector.botToken].filter((token) => token !== void 0);
264
288
  case "gh":
265
289
  case "schedule": return [];
266
290
  }
@@ -306,6 +330,26 @@ var NodeFunnelClock = class extends FunnelClock {
306
330
  };
307
331
  //#endregion
308
332
  //#region lib/engine/channels/channels.ts
333
+ /**
334
+ * Resolves one token slot (e.g. botToken/botTokenEnv) for an update. The
335
+ * literal and the env-ref form are mutually exclusive: if `fields` supplies
336
+ * either, that form wins and the other key is omitted entirely; if it supplies
337
+ * neither, the connector's current slot is carried over unchanged. Returns a
338
+ * partial object spread into the rebuilt connector, so an omitted key is truly
339
+ * absent rather than set to undefined.
340
+ */
341
+ const slotFields = (literalKey, envKey, fields, current) => {
342
+ const literal = fields[literalKey];
343
+ if (literal !== void 0) return { [literalKey]: literal };
344
+ const envVar = fields[envKey];
345
+ if (envVar !== void 0) return { [envKey]: envVar };
346
+ const result = {};
347
+ const currentLiteral = current[literalKey];
348
+ const currentEnv = current[envKey];
349
+ if (typeof currentLiteral === "string") result[literalKey] = currentLiteral;
350
+ if (typeof currentEnv === "string") result[envKey] = currentEnv;
351
+ return result;
352
+ };
309
353
  const defaultClock$1 = new NodeFunnelClock();
310
354
  const defaultIdGenerator$1 = new NodeFunnelIdGenerator();
311
355
  /**
@@ -412,8 +456,10 @@ var FunnelChannels = class {
412
456
  id,
413
457
  type: "slack",
414
458
  name: input.name,
415
- botToken: input.botToken,
416
- appToken: input.appToken,
459
+ ...input.botToken !== void 0 ? { botToken: input.botToken } : {},
460
+ ...input.appToken !== void 0 ? { appToken: input.appToken } : {},
461
+ ...input.botTokenEnv !== void 0 ? { botTokenEnv: input.botTokenEnv } : {},
462
+ ...input.appTokenEnv !== void 0 ? { appTokenEnv: input.appTokenEnv } : {},
417
463
  minify: input.minify ?? true,
418
464
  createdAt,
419
465
  updatedAt
@@ -430,7 +476,8 @@ var FunnelChannels = class {
430
476
  id,
431
477
  type: "discord",
432
478
  name: input.name,
433
- botToken: input.botToken,
479
+ ...input.botToken !== void 0 ? { botToken: input.botToken } : {},
480
+ ...input.botTokenEnv !== void 0 ? { botTokenEnv: input.botTokenEnv } : {},
434
481
  createdAt,
435
482
  updatedAt
436
483
  };
@@ -464,15 +511,20 @@ var FunnelChannels = class {
464
511
  }
465
512
  updateSlackConnector(channelName, connectorName, fields) {
466
513
  const settings = this.store.read();
467
- const connector = requireConnectorOfType(this.requireChannel(settings, channelName), connectorName, "slack");
514
+ const channel = this.requireChannel(settings, channelName);
515
+ const connector = requireConnectorOfType(channel, connectorName, "slack");
468
516
  const updated = {
469
- ...connector,
470
- botToken: fields.botToken ?? connector.botToken,
471
- appToken: fields.appToken ?? connector.appToken,
472
- updatedAt: this.clock.iso()
517
+ id: connector.id,
518
+ name: connector.name,
519
+ type: "slack",
520
+ minify: connector.minify,
521
+ createdAt: connector.createdAt,
522
+ updatedAt: this.clock.iso(),
523
+ ...slotFields("botToken", "botTokenEnv", fields, connector),
524
+ ...slotFields("appToken", "appTokenEnv", fields, connector)
473
525
  };
474
526
  this.assertNoTokenCollision(settings, updated);
475
- Object.assign(connector, updated);
527
+ this.replaceConnector(channel, connector.name, updated);
476
528
  this.store.write(settings);
477
529
  }
478
530
  updateGhConnector(channelName, connectorName, fields) {
@@ -484,14 +536,18 @@ var FunnelChannels = class {
484
536
  }
485
537
  updateDiscordConnector(channelName, connectorName, fields) {
486
538
  const settings = this.store.read();
487
- const connector = requireConnectorOfType(this.requireChannel(settings, channelName), connectorName, "discord");
539
+ const channel = this.requireChannel(settings, channelName);
540
+ const connector = requireConnectorOfType(channel, connectorName, "discord");
488
541
  const updated = {
489
- ...connector,
490
- botToken: fields.botToken ?? connector.botToken,
491
- updatedAt: this.clock.iso()
542
+ id: connector.id,
543
+ name: connector.name,
544
+ type: "discord",
545
+ createdAt: connector.createdAt,
546
+ updatedAt: this.clock.iso(),
547
+ ...slotFields("botToken", "botTokenEnv", fields, connector)
492
548
  };
493
549
  this.assertNoTokenCollision(settings, updated);
494
- Object.assign(connector, updated);
550
+ this.replaceConnector(channel, connector.name, updated);
495
551
  this.store.write(settings);
496
552
  }
497
553
  listScheduleEntries(channelName, connectorName) {
@@ -554,6 +610,11 @@ var FunnelChannels = class {
554
610
  if (!channel) throw new Error(`channel "${name}" not found`);
555
611
  return channel;
556
612
  }
613
+ replaceConnector(channel, connectorName, next) {
614
+ const index = channel.connectors.findIndex((c) => c.name === connectorName);
615
+ if (index < 0) throw new Error(`connector "${connectorName}" not found in channel "${channel.name}"`);
616
+ channel.connectors[index] = next;
617
+ }
557
618
  assertNoTokenCollision(settings, candidate) {
558
619
  const tokens = connectorTokens(candidate);
559
620
  if (tokens.length === 0) return;
@@ -1054,26 +1115,31 @@ var FunnelLocalConfigSync = class {
1054
1115
  }
1055
1116
  async ensureSlack(channelName, spec, dotenv) {
1056
1117
  const byName = this.findExistingSlack(channelName, spec.name);
1057
- const botToken = await this.resolveField({
1118
+ const bot = await this.resolveSlot({
1058
1119
  literal: spec.botToken,
1059
1120
  envVar: spec.env?.botToken,
1060
1121
  dotenv,
1061
1122
  label: `${spec.name}.botToken`,
1062
- existing: byName?.botToken
1123
+ existingLiteral: byName?.botToken,
1124
+ existingEnv: byName?.botTokenEnv
1063
1125
  });
1064
- const appToken = await this.resolveField({
1126
+ const app = await this.resolveSlot({
1065
1127
  literal: spec.appToken,
1066
1128
  envVar: spec.env?.appToken,
1067
1129
  dotenv,
1068
1130
  label: `${spec.name}.appToken`,
1069
- existing: byName?.appToken
1131
+ existingLiteral: byName?.appToken,
1132
+ existingEnv: byName?.appTokenEnv
1070
1133
  });
1134
+ const update = {
1135
+ botToken: bot.token,
1136
+ botTokenEnv: bot.tokenEnv,
1137
+ appToken: app.token,
1138
+ appTokenEnv: app.tokenEnv
1139
+ };
1071
1140
  if (byName) {
1072
- if (byName.botToken !== botToken || byName.appToken !== appToken) {
1073
- this.channels.updateSlackConnector(channelName, spec.name, {
1074
- botToken,
1075
- appToken
1076
- });
1141
+ if (!(byName.botToken === bot.token && byName.botTokenEnv === bot.tokenEnv && byName.appToken === app.token && byName.appTokenEnv === app.tokenEnv)) {
1142
+ this.channels.updateSlackConnector(channelName, spec.name, update);
1077
1143
  return {
1078
1144
  id: byName.id,
1079
1145
  name: spec.name,
@@ -1086,25 +1152,11 @@ var FunnelLocalConfigSync = class {
1086
1152
  changed: false
1087
1153
  };
1088
1154
  }
1089
- const byToken = this.findSlackByToken(channelName, [botToken, appToken]);
1090
- if (byToken) {
1091
- this.channels.renameConnector(channelName, byToken.name, spec.name);
1092
- if (byToken.botToken !== botToken || byToken.appToken !== appToken) this.channels.updateSlackConnector(channelName, spec.name, {
1093
- botToken,
1094
- appToken
1095
- });
1096
- return {
1097
- id: byToken.id,
1098
- name: spec.name,
1099
- changed: true
1100
- };
1101
- }
1102
1155
  return {
1103
1156
  id: this.channels.addConnector(channelName, {
1104
1157
  type: "slack",
1105
1158
  name: spec.name,
1106
- botToken,
1107
- appToken,
1159
+ ...update,
1108
1160
  ...spec.minify !== void 0 ? { minify: spec.minify } : {}
1109
1161
  }).id,
1110
1162
  name: spec.name,
@@ -1113,16 +1165,21 @@ var FunnelLocalConfigSync = class {
1113
1165
  }
1114
1166
  async ensureDiscord(channelName, spec, dotenv) {
1115
1167
  const byName = this.findExistingDiscord(channelName, spec.name);
1116
- const botToken = await this.resolveField({
1168
+ const bot = await this.resolveSlot({
1117
1169
  literal: spec.botToken,
1118
1170
  envVar: spec.env?.botToken,
1119
1171
  dotenv,
1120
1172
  label: `${spec.name}.botToken`,
1121
- existing: byName?.botToken
1173
+ existingLiteral: byName?.botToken,
1174
+ existingEnv: byName?.botTokenEnv
1122
1175
  });
1176
+ const update = {
1177
+ botToken: bot.token,
1178
+ botTokenEnv: bot.tokenEnv
1179
+ };
1123
1180
  if (byName) {
1124
- if (byName.botToken !== botToken) {
1125
- this.channels.updateDiscordConnector(channelName, spec.name, { botToken });
1181
+ if (byName.botToken !== bot.token || byName.botTokenEnv !== bot.tokenEnv) {
1182
+ this.channels.updateDiscordConnector(channelName, spec.name, update);
1126
1183
  return {
1127
1184
  id: byName.id,
1128
1185
  name: spec.name,
@@ -1135,21 +1192,11 @@ var FunnelLocalConfigSync = class {
1135
1192
  changed: false
1136
1193
  };
1137
1194
  }
1138
- const byToken = this.findDiscordByToken(channelName, botToken);
1139
- if (byToken) {
1140
- this.channels.renameConnector(channelName, byToken.name, spec.name);
1141
- if (byToken.botToken !== botToken) this.channels.updateDiscordConnector(channelName, spec.name, { botToken });
1142
- return {
1143
- id: byToken.id,
1144
- name: spec.name,
1145
- changed: true
1146
- };
1147
- }
1148
1195
  return {
1149
1196
  id: this.channels.addConnector(channelName, {
1150
1197
  type: "discord",
1151
1198
  name: spec.name,
1152
- botToken
1199
+ ...update
1153
1200
  }).id,
1154
1201
  name: spec.name,
1155
1202
  changed: true
@@ -1212,24 +1259,6 @@ var FunnelLocalConfigSync = class {
1212
1259
  if (existing.type !== "discord") throw new Error(`connector "${connectorName}" exists in channel "${channelName}" with type "${existing.type}", funnel.json declares "discord"`);
1213
1260
  return existing;
1214
1261
  }
1215
- findSlackByToken(channelName, tokens) {
1216
- const channel = this.channels.get(channelName);
1217
- if (!channel) return null;
1218
- for (const connector of channel.connectors) {
1219
- if (connector.type !== "slack") continue;
1220
- if (tokens.includes(connector.botToken) || tokens.includes(connector.appToken)) return connector;
1221
- }
1222
- return null;
1223
- }
1224
- findDiscordByToken(channelName, token) {
1225
- const channel = this.channels.get(channelName);
1226
- if (!channel) return null;
1227
- for (const connector of channel.connectors) {
1228
- if (connector.type !== "discord") continue;
1229
- if (connector.botToken === token) return connector;
1230
- }
1231
- return null;
1232
- }
1233
1262
  removeExtras(channelName, touched) {
1234
1263
  const channel = this.channels.get(channelName);
1235
1264
  if (!channel) return [];
@@ -1237,18 +1266,43 @@ var FunnelLocalConfigSync = class {
1237
1266
  for (const connector of stale) this.channels.removeConnector(channelName, connector.name);
1238
1267
  return stale.map((c) => c.name);
1239
1268
  }
1240
- async resolveField(input) {
1269
+ /**
1270
+ * Decides how a single token slot is stored in settings.json:
1271
+ *
1272
+ * - `env.<field>` reference → `{ tokenEnv: "<VAR>" }`; the secret is NOT
1273
+ * resolved into settings, it stays in the environment / `.env.local` and
1274
+ * the listener resolves it at start. We still assert the var is set so a
1275
+ * typo fails loudly here instead of as a dead listener later.
1276
+ * - literal → `{ token: "<secret>" }`.
1277
+ * - neither, but a prior value exists → carry it over verbatim (whichever
1278
+ * form it already was), so a tokenless re-sync is a no-op.
1279
+ * - nothing at all → prompt for a literal (TTY only; throws otherwise).
1280
+ */
1281
+ async resolveSlot(input) {
1241
1282
  if (input.literal !== void 0 && input.envVar !== void 0) throw new Error(`${input.label} is set both as a literal and as env.${input.label.split(".").pop()}; pick one`);
1242
- if (input.literal !== void 0 && input.literal !== "") return input.literal;
1243
1283
  if (input.envVar !== void 0 && input.envVar !== "") {
1244
- const fromProcessEnv = this.env[input.envVar];
1245
- if (fromProcessEnv) return fromProcessEnv;
1246
- const fromDotenv = input.dotenv[input.envVar];
1247
- if (fromDotenv) return fromDotenv;
1248
- throw new Error(`${input.label} references env var "${input.envVar}" but it is not set in process env or .env.local`);
1284
+ if (!this.env[input.envVar] && !input.dotenv[input.envVar]) throw new Error(`${input.label} references env var "${input.envVar}" but it is not set in process env or .env.local`);
1285
+ return {
1286
+ token: void 0,
1287
+ tokenEnv: input.envVar
1288
+ };
1249
1289
  }
1250
- if (input.existing) return input.existing;
1251
- return await this.prompter.promptSecret(input.label);
1290
+ if (input.literal !== void 0 && input.literal !== "") return {
1291
+ token: input.literal,
1292
+ tokenEnv: void 0
1293
+ };
1294
+ if (input.existingEnv !== void 0) return {
1295
+ token: void 0,
1296
+ tokenEnv: input.existingEnv
1297
+ };
1298
+ if (input.existingLiteral !== void 0) return {
1299
+ token: input.existingLiteral,
1300
+ tokenEnv: void 0
1301
+ };
1302
+ return {
1303
+ token: await this.prompter.promptSecret(input.label),
1304
+ tokenEnv: void 0
1305
+ };
1252
1306
  }
1253
1307
  };
1254
1308
  //#endregion
@@ -1845,6 +1899,7 @@ var FunnelGateway = class {
1845
1899
  const gatewayScript = resolveDaemonScript();
1846
1900
  const command = this.buildStartCommand(gatewayScript, options);
1847
1901
  this.process.detach(command, {
1902
+ env: { FUNNEL_DIR: this.dir },
1848
1903
  stdoutFile: this.gatewayLog,
1849
1904
  stderrFile: this.gatewayLog
1850
1905
  });
@@ -2208,6 +2263,8 @@ var FunnelEventLog = class {};
2208
2263
  //#region lib/logger/leuco-logger-sqlite-sink.ts
2209
2264
  /** Conservative whitelist for column names interpolated into SQL. */
2210
2265
  const COLUMN_NAME_RE = /^[a-z_][a-z0-9_]*$/;
2266
+ /** How many inserts between on-disk size checks (see insertsSinceByteCheck). */
2267
+ const BYTE_CHECK_INTERVAL = 500;
2211
2268
  const RESERVED_COLUMNS = new Set([
2212
2269
  "seq",
2213
2270
  "ts",
@@ -2261,6 +2318,8 @@ var LeucoLoggerSqliteSink = class {
2261
2318
  db;
2262
2319
  maxRows;
2263
2320
  maxAgeMs;
2321
+ maxBytes;
2322
+ targetBytes;
2264
2323
  now;
2265
2324
  indexes;
2266
2325
  extractIndexes;
@@ -2270,12 +2329,16 @@ var LeucoLoggerSqliteSink = class {
2270
2329
  countStmt;
2271
2330
  trimRowsStmt;
2272
2331
  trimAgeStmt;
2332
+ trimOldestStmt;
2333
+ insertsSinceByteCheck = 0;
2273
2334
  constructor(props) {
2274
2335
  this.db = new Database(props.path);
2275
2336
  this.db.run("PRAGMA journal_mode = WAL");
2276
2337
  this.migrate();
2277
2338
  this.maxRows = props.maxRows ?? null;
2278
2339
  this.maxAgeMs = props.maxAgeMs ?? null;
2340
+ this.maxBytes = props.maxBytes ?? null;
2341
+ this.targetBytes = props.targetBytes ?? (props.maxBytes !== void 0 ? Math.floor(props.maxBytes / 4) : null);
2279
2342
  this.now = props.now ?? (() => Date.now());
2280
2343
  this.indexes = props.indexes ?? [];
2281
2344
  if (this.indexes.length > 0) {
@@ -2298,6 +2361,7 @@ var LeucoLoggerSqliteSink = class {
2298
2361
  this.countStmt = this.db.prepare("SELECT COUNT(*) AS n FROM leuco_log");
2299
2362
  this.trimRowsStmt = this.db.prepare("DELETE FROM leuco_log WHERE seq <= (SELECT seq FROM leuco_log ORDER BY seq DESC LIMIT 1 OFFSET ?)");
2300
2363
  this.trimAgeStmt = this.db.prepare("DELETE FROM leuco_log WHERE ts < ?");
2364
+ this.trimOldestStmt = this.db.prepare("DELETE FROM leuco_log WHERE seq IN (SELECT seq FROM leuco_log ORDER BY seq ASC LIMIT ?)");
2301
2365
  }
2302
2366
  insert(input) {
2303
2367
  try {
@@ -2358,8 +2422,11 @@ var LeucoLoggerSqliteSink = class {
2358
2422
  if (props.where) this.appendWhereConditions(props.where, conditions, params);
2359
2423
  const limit = props.limit ?? 1e3;
2360
2424
  params.push(limit);
2361
- const sql = `SELECT seq, ts, type, event FROM leuco_log WHERE ${conditions.join(" AND ")} ORDER BY seq ASC LIMIT ?`;
2362
- return this.db.prepare(sql).all(...params).map(toRecord);
2425
+ const dir = props.order === "desc" ? "DESC" : "ASC";
2426
+ const sql = `SELECT seq, ts, type, event FROM leuco_log WHERE ${conditions.join(" AND ")} ORDER BY seq ${dir} LIMIT ?`;
2427
+ const rows = this.db.prepare(sql).all(...params);
2428
+ if (dir === "DESC") rows.reverse();
2429
+ return rows.map(toRecord);
2363
2430
  }
2364
2431
  /**
2365
2432
  * Current schema version. Useful for diagnostics and for tests that want
@@ -2405,6 +2472,40 @@ var LeucoLoggerSqliteSink = class {
2405
2472
  if (row && row.n > this.maxRows) this.trimRowsStmt.run(this.maxRows);
2406
2473
  }
2407
2474
  if (this.maxAgeMs !== null) this.trimAgeStmt.run(this.now() - this.maxAgeMs);
2475
+ this.maybeTrimBytes();
2476
+ }
2477
+ /**
2478
+ * Throttled byte-size enforcement. Only every BYTE_CHECK_INTERVAL inserts do
2479
+ * we measure the file; on overflow we estimate how many of the oldest rows to
2480
+ * drop to land near targetBytes (by the byte/row ratio), delete them in one
2481
+ * statement, then VACUUM once to return the freed pages to the filesystem (a
2482
+ * plain DELETE only frees pages inside the file). One DELETE + one VACUUM per
2483
+ * overflow keeps the expensive rewrite rare — the file must refill the whole
2484
+ * maxBytes→targetBytes delta before the next overflow can trigger.
2485
+ */
2486
+ maybeTrimBytes() {
2487
+ if (this.maxBytes === null || this.targetBytes === null) return;
2488
+ this.insertsSinceByteCheck += 1;
2489
+ if (this.insertsSinceByteCheck < BYTE_CHECK_INTERVAL) return;
2490
+ this.insertsSinceByteCheck = 0;
2491
+ const bytes = this.byteSize();
2492
+ if (bytes <= this.maxBytes) return;
2493
+ const rows = this.countStmt.get()?.n ?? 0;
2494
+ if (rows === 0) return;
2495
+ const bytesToFree = bytes - this.targetBytes;
2496
+ const bytesPerRow = bytes / rows;
2497
+ const rowsToDrop = Math.min(rows, Math.ceil(bytesToFree / bytesPerRow));
2498
+ this.trimOldestStmt.run(rowsToDrop);
2499
+ this.db.run("VACUUM");
2500
+ }
2501
+ byteSize() {
2502
+ return (this.db.prepare("PRAGMA page_count").get()?.n ?? 0) * (this.db.prepare("PRAGMA page_size").get()?.n ?? 0);
2503
+ }
2504
+ /** Drop every row and reclaim the file space. Used by `<log>.clear()`. */
2505
+ clear() {
2506
+ this.db.run("DELETE FROM leuco_log");
2507
+ this.db.run("VACUUM");
2508
+ this.insertsSinceByteCheck = 0;
2408
2509
  }
2409
2510
  syncIndexColumns() {
2410
2511
  const existing = new Set(this.db.prepare("PRAGMA table_info(leuco_log)").all().map((r) => r.name));
@@ -2481,7 +2582,9 @@ var SqliteFunnelEventLog = class extends FunnelEventLog {
2481
2582
  }),
2482
2583
  now: this.now,
2483
2584
  ...props.maxRows !== void 0 ? { maxRows: props.maxRows } : {},
2484
- ...props.maxAgeMs !== void 0 ? { maxAgeMs: props.maxAgeMs } : {}
2585
+ ...props.maxAgeMs !== void 0 ? { maxAgeMs: props.maxAgeMs } : {},
2586
+ ...props.maxBytes !== void 0 ? { maxBytes: props.maxBytes } : {},
2587
+ ...props.targetBytes !== void 0 ? { targetBytes: props.targetBytes } : {}
2485
2588
  });
2486
2589
  }
2487
2590
  /**
@@ -2542,6 +2645,9 @@ var SqliteFunnelEventLog = class extends FunnelEventLog {
2542
2645
  findMaxOffset() {
2543
2646
  return this.sink.getMaxSeq();
2544
2647
  }
2648
+ clear() {
2649
+ this.sink.clear();
2650
+ }
2545
2651
  close() {
2546
2652
  this.sink.close();
2547
2653
  }
@@ -3480,7 +3586,7 @@ var Funnel = class Funnel {
3480
3586
  }
3481
3587
  /** Resolved on-disk paths the facade will read/write when methods are called. Pure compute, not memoized. */
3482
3588
  get paths() {
3483
- const dir = this.props.dir ?? FUNNEL_DIR;
3589
+ const dir = this.props.dir ?? resolveFunnelDir();
3484
3590
  return {
3485
3591
  dir,
3486
3592
  tmpDir: this.props.tmpDir ?? funnelTmpDir(),
@@ -3533,6 +3639,7 @@ var Funnel = class Funnel {
3533
3639
  fs: this.fs,
3534
3640
  process: this.process,
3535
3641
  logger: this.logger,
3642
+ diagnosticLog: this.props.diagnosticLog,
3536
3643
  dir: this.paths.dir,
3537
3644
  slackListenerOptions: this.props.slackListenerOptions,
3538
3645
  scheduleListenerOptions: this.props.scheduleListenerOptions
@@ -3976,6 +4083,506 @@ var MemoryFunnelEventLog = class extends FunnelEventLog {
3976
4083
  close() {}
3977
4084
  };
3978
4085
  //#endregion
4086
+ //#region lib/gateway/connector-diagnostic-log.ts
4087
+ /**
4088
+ * Points in the listener's connection lifecycle. The single source of truth
4089
+ * for the value set: the `status` column schema, the `ConnectorConnectionStatus`
4090
+ * union, and the runtime Set used to narrow on read-back all derive from this
4091
+ * array, so adding a status is a one-line change that cannot drift out of sync.
4092
+ *
4093
+ * started start() was called
4094
+ * connected the socket opened and events can flow
4095
+ * disconnected the socket was closed by a stop() call (a clean teardown)
4096
+ * auth-failed the token was rejected before the socket opened
4097
+ * stopped the listener was fully torn down (always follows a stop(),
4098
+ * paired with the disconnected/error that preceded it)
4099
+ * error start/stop threw, or Bolt surfaced an error frame — this is
4100
+ * also where an unsolicited socket drop shows up when Bolt
4101
+ * reports it (an `error` with no following `stopped` means the
4102
+ * supervisor recycled the listener, not a clean stop)
4103
+ *
4104
+ * A connection row is independent of any single inbound event, so it carries
4105
+ * no `eventId`. This is how "no notification arrived because the listener
4106
+ * never connected (or dropped, or failed auth)" becomes visible: the
4107
+ * raw/processed tables only hold events that *did* arrive.
4108
+ */
4109
+ const CONNECTOR_CONNECTION_STATUSES = [
4110
+ "started",
4111
+ "connected",
4112
+ "disconnected",
4113
+ "auth-failed",
4114
+ "stopped",
4115
+ "error"
4116
+ ];
4117
+ /**
4118
+ * Rows stored in the diagnostic tables. Connector-agnostic on purpose: `type`
4119
+ * carries the listener kind ("slack" | "discord" | "gh" | "schedule") so new
4120
+ * connectors land in the same tables without a schema change. `event_id` is
4121
+ * the correlation key the listener mints once per inbound event and stamps
4122
+ * onto both the raw and processed rows, so the two are joinable even though
4123
+ * they live in separate tables with independent `seq` counters.
4124
+ *
4125
+ * These schemas mirror the stored shape (snake_case columns) the way
4126
+ * `FunnelEvent` does for the replay log; they exist for `z.infer` and to
4127
+ * document the column set, not as a parse boundary.
4128
+ */
4129
+ const connectorRawEventSchema = z.object({
4130
+ event_id: z.string(),
4131
+ type: z.string(),
4132
+ connector_id: z.string().nullable(),
4133
+ channel_id: z.string().nullable(),
4134
+ payload: z.string()
4135
+ });
4136
+ const connectorProcessedEventSchema = z.object({
4137
+ event_id: z.string(),
4138
+ type: z.string(),
4139
+ connector_id: z.string().nullable(),
4140
+ channel_id: z.string().nullable(),
4141
+ outcome: z.string(),
4142
+ payload: z.string()
4143
+ });
4144
+ const connectorConnectionEventSchema = z.object({
4145
+ type: z.string(),
4146
+ connector_id: z.string().nullable(),
4147
+ channel_id: z.string().nullable(),
4148
+ status: z.enum(CONNECTOR_CONNECTION_STATUSES),
4149
+ detail: z.string()
4150
+ });
4151
+ /**
4152
+ * Three-table diagnostic log of everything a connector listener does, so
4153
+ * "why was there no notification?" is answerable whichever way it failed:
4154
+ * - `raw` — every inbound event, before any filtering, with the listener's
4155
+ * untouched payload (the Slack Bolt event, the GH webhook, …)
4156
+ * - `processed` — the verdict for that event: `outcome` (emitted, or the
4157
+ * reason it was dropped) and, when emitted, the body that was delivered.
4158
+ * Shares an `eventId` with its raw row, so the two join into one story.
4159
+ * - `connection` — the listener's lifecycle (started, connected, dropped,
4160
+ * auth-failed, stopped, errored). This is the half the event tables can't
4161
+ * show: an event that never arrived leaves no raw row, but a listener that
4162
+ * never connected leaves a `connection` trail that says so.
4163
+ *
4164
+ * The three are physically separate (independent retention and payload-size
4165
+ * policy) so a query never crosses them by accident and a huge raw payload
4166
+ * never bloats the verdict or lifecycle trails. None flow to WS clients or the
4167
+ * MCP channel — this is a separate store from `FunnelEventLog` (replay) and
4168
+ * exists solely for debugging.
4169
+ *
4170
+ * Implementations:
4171
+ * - `SqliteConnectorDiagnosticLog` — the default; survives daemon restarts,
4172
+ * bounded by per-table row/age caps.
4173
+ * - `MemoryConnectorDiagnosticLog` — an in-process double for tests.
4174
+ */
4175
+ var ConnectorDiagnosticLog = class {};
4176
+ //#endregion
4177
+ //#region lib/gateway/sqlite-connector-diagnostic-log.ts
4178
+ /**
4179
+ * Cap on a raw payload kept verbatim. The point of the raw table is to see
4180
+ * what Slack/Discord actually sent, and a typical event is a few KB — so 256
4181
+ * KiB keeps essentially everything intact while bounding the rare giant
4182
+ * payload (a huge Block Kit message, a file dump) that would otherwise let a
4183
+ * single row bloat the debug database without limit.
4184
+ */
4185
+ const RAW_PAYLOAD_CAP = 256 * 1024;
4186
+ /**
4187
+ * Default `ConnectorDiagnosticLog`: three independent `LeucoLoggerSqliteSink`s, one
4188
+ * per table (raw / processed / connection), in separate files. Each sink
4189
+ * indexes the columns its queries filter on — `event_id` / `connector_id` /
4190
+ * `channel_id` for raw, plus `outcome` for processed and `status` for
4191
+ * connection — so those lookups are indexed scans (`type` is a fixed column
4192
+ * the sink extracts separately, not an index, so filtering by it is a scan).
4193
+ *
4194
+ * The raw table offloads any payload over `RAW_PAYLOAD_CAP`: rather than
4195
+ * truncating mid-string (which yields unparseable JSON), it replaces the
4196
+ * body with a small JSON object that keeps the diagnostic essentials and
4197
+ * records the dropped size under `_funnel_oversized`. Every stored payload
4198
+ * therefore stays valid JSON.
4199
+ */
4200
+ var SqliteConnectorDiagnosticLog = class extends ConnectorDiagnosticLog {
4201
+ raw;
4202
+ processed;
4203
+ connection;
4204
+ now;
4205
+ logger;
4206
+ constructor(props) {
4207
+ super();
4208
+ this.now = props.now ?? (() => Date.now());
4209
+ this.logger = props.logger;
4210
+ const ageCap = props.maxAgeMs !== void 0 ? { maxAgeMs: props.maxAgeMs } : {};
4211
+ const verdictCap = {
4212
+ now: this.now,
4213
+ ...ageCap,
4214
+ ...props.maxRows !== void 0 ? { maxRows: props.maxRows } : {}
4215
+ };
4216
+ const rawMax = props.rawMaxRows ?? props.maxRows;
4217
+ const rawCap = {
4218
+ now: this.now,
4219
+ ...ageCap,
4220
+ ...rawMax !== void 0 ? { maxRows: rawMax } : {}
4221
+ };
4222
+ this.raw = new LeucoLoggerSqliteSink({
4223
+ path: props.rawPath,
4224
+ indexes: [
4225
+ "event_id",
4226
+ "connector_id",
4227
+ "channel_id"
4228
+ ],
4229
+ extractIndexes: (event) => ({
4230
+ event_id: event.event_id,
4231
+ connector_id: event.connector_id,
4232
+ channel_id: event.channel_id
4233
+ }),
4234
+ ...rawCap
4235
+ });
4236
+ this.processed = new LeucoLoggerSqliteSink({
4237
+ path: props.processedPath,
4238
+ indexes: [
4239
+ "event_id",
4240
+ "connector_id",
4241
+ "channel_id",
4242
+ "outcome"
4243
+ ],
4244
+ extractIndexes: (event) => ({
4245
+ event_id: event.event_id,
4246
+ connector_id: event.connector_id,
4247
+ channel_id: event.channel_id,
4248
+ outcome: event.outcome
4249
+ }),
4250
+ ...verdictCap
4251
+ });
4252
+ this.connection = new LeucoLoggerSqliteSink({
4253
+ path: props.connectionPath,
4254
+ indexes: [
4255
+ "connector_id",
4256
+ "channel_id",
4257
+ "status"
4258
+ ],
4259
+ extractIndexes: (event) => ({
4260
+ connector_id: event.connector_id,
4261
+ channel_id: event.channel_id,
4262
+ status: event.status
4263
+ }),
4264
+ ...verdictCap
4265
+ });
4266
+ restrictPermissions(props.rawPath);
4267
+ restrictPermissions(props.processedPath);
4268
+ restrictPermissions(props.connectionPath);
4269
+ Object.freeze(this);
4270
+ }
4271
+ recordRaw(record) {
4272
+ const event = {
4273
+ event_id: record.eventId,
4274
+ type: record.type,
4275
+ connector_id: record.connectorId,
4276
+ channel_id: record.channelId,
4277
+ payload: capPayload(record.payload, record.type)
4278
+ };
4279
+ this.report("raw", this.raw.insert({
4280
+ ts: this.now(),
4281
+ event
4282
+ }));
4283
+ }
4284
+ recordProcessed(record) {
4285
+ const event = {
4286
+ event_id: record.eventId,
4287
+ type: record.type,
4288
+ connector_id: record.connectorId,
4289
+ channel_id: record.channelId,
4290
+ outcome: record.outcome,
4291
+ payload: record.payload
4292
+ };
4293
+ this.report("processed", this.processed.insert({
4294
+ ts: this.now(),
4295
+ event
4296
+ }));
4297
+ }
4298
+ recordConnection(record) {
4299
+ const event = {
4300
+ type: record.type,
4301
+ connector_id: record.connectorId,
4302
+ channel_id: record.channelId,
4303
+ status: record.status,
4304
+ detail: record.detail
4305
+ };
4306
+ this.report("connection", this.connection.insert({
4307
+ ts: this.now(),
4308
+ event
4309
+ }));
4310
+ }
4311
+ report(table, result) {
4312
+ if (result instanceof Error) this.logger?.error("diagnostic log insert failed", {
4313
+ table,
4314
+ error: result.message
4315
+ });
4316
+ }
4317
+ queryRaw(query) {
4318
+ return this.raw.getRecords({
4319
+ ...query.type !== void 0 ? { type: query.type } : {},
4320
+ ...query.limit !== void 0 ? { limit: query.limit } : {},
4321
+ where: buildWhere(query),
4322
+ order: "desc"
4323
+ }).map((record) => ({
4324
+ seq: record.seq,
4325
+ ts: record.ts,
4326
+ eventId: record.event.event_id,
4327
+ type: record.event.type,
4328
+ connectorId: record.event.connector_id,
4329
+ channelId: record.event.channel_id,
4330
+ payload: record.event.payload
4331
+ }));
4332
+ }
4333
+ queryProcessed(query) {
4334
+ const where = buildWhere(query);
4335
+ if (query.outcome !== void 0) where.outcome = query.outcome;
4336
+ return this.processed.getRecords({
4337
+ ...query.type !== void 0 ? { type: query.type } : {},
4338
+ ...query.limit !== void 0 ? { limit: query.limit } : {},
4339
+ where,
4340
+ order: "desc"
4341
+ }).map((record) => ({
4342
+ seq: record.seq,
4343
+ ts: record.ts,
4344
+ eventId: record.event.event_id,
4345
+ type: record.event.type,
4346
+ connectorId: record.event.connector_id,
4347
+ channelId: record.event.channel_id,
4348
+ outcome: record.event.outcome,
4349
+ payload: record.event.payload
4350
+ }));
4351
+ }
4352
+ queryConnection(query) {
4353
+ const where = buildWhere(query);
4354
+ if (query.status !== void 0) where.status = query.status;
4355
+ return this.connection.getRecords({
4356
+ ...query.type !== void 0 ? { type: query.type } : {},
4357
+ ...query.limit !== void 0 ? { limit: query.limit } : {},
4358
+ where,
4359
+ order: "desc"
4360
+ }).map((record) => ({
4361
+ seq: record.seq,
4362
+ ts: record.ts,
4363
+ type: record.event.type,
4364
+ connectorId: record.event.connector_id,
4365
+ channelId: record.event.channel_id,
4366
+ status: statusOf(record.event.status),
4367
+ detail: record.event.detail
4368
+ }));
4369
+ }
4370
+ clear() {
4371
+ this.raw.clear();
4372
+ this.processed.clear();
4373
+ this.connection.clear();
4374
+ }
4375
+ close() {
4376
+ this.raw.close();
4377
+ this.processed.close();
4378
+ this.connection.close();
4379
+ }
4380
+ };
4381
+ const restrictPermissions = (path) => {
4382
+ if (path === ":memory:") return;
4383
+ for (const suffix of [
4384
+ "",
4385
+ "-wal",
4386
+ "-shm"
4387
+ ]) try {
4388
+ chmodSync(`${path}${suffix}`, 384);
4389
+ } catch {}
4390
+ };
4391
+ const buildWhere = (query) => {
4392
+ const where = {};
4393
+ if (query.connectorId !== void 0) where.connector_id = query.connectorId;
4394
+ if (query.channelId !== void 0) where.channel_id = query.channelId;
4395
+ return where;
4396
+ };
4397
+ const statusField = connectorConnectionEventSchema.shape.status;
4398
+ const statusOf = (value) => {
4399
+ const parsed = statusField.safeParse(value);
4400
+ return parsed.success ? parsed.data : "error";
4401
+ };
4402
+ const capPayload = (payload, type) => {
4403
+ const size = Buffer.byteLength(payload, "utf8");
4404
+ if (size <= RAW_PAYLOAD_CAP) return payload;
4405
+ return JSON.stringify({
4406
+ ...headFields(payload),
4407
+ _funnel_oversized: size,
4408
+ _funnel_type: type
4409
+ });
4410
+ };
4411
+ const HEAD_KEYS = [
4412
+ "type",
4413
+ "subtype",
4414
+ "ts",
4415
+ "channel",
4416
+ "channel_type",
4417
+ "user",
4418
+ "bot_id"
4419
+ ];
4420
+ const headFields = (payload) => {
4421
+ try {
4422
+ const parsed = JSON.parse(payload);
4423
+ if (typeof parsed !== "object" || parsed === null) return {};
4424
+ const source = parsed;
4425
+ const head = {};
4426
+ for (const key of HEAD_KEYS) if (source[key] !== void 0) head[key] = source[key];
4427
+ return head;
4428
+ } catch {
4429
+ return {};
4430
+ }
4431
+ };
4432
+ //#endregion
4433
+ //#region lib/gateway/memory-connector-diagnostic-log.ts
4434
+ /**
4435
+ * In-process `ConnectorDiagnosticLog` backed by one array per table. Used by tests
4436
+ * and embedders that do not need durability. Like the SQLite log it keeps
4437
+ * `seq` per-table (each array's 1-based position) and returns the most recent
4438
+ * `limit` rows oldest-first; unlike it, it never prunes and never offloads
4439
+ * oversized payloads — it keeps whatever the caller hands it, which is fine
4440
+ * for the bounded volumes a test produces. Payload-validity is therefore a
4441
+ * SQLite-only guarantee; do not write a test that leans on this double
4442
+ * rejecting a malformed payload.
4443
+ */
4444
+ var MemoryConnectorDiagnosticLog = class extends ConnectorDiagnosticLog {
4445
+ raws = [];
4446
+ processeds = [];
4447
+ connections = [];
4448
+ constructor(now = () => Date.now()) {
4449
+ super();
4450
+ this.now = now;
4451
+ Object.freeze(this);
4452
+ }
4453
+ recordRaw(record) {
4454
+ this.raws.push({
4455
+ ...record,
4456
+ seq: this.raws.length + 1,
4457
+ ts: this.now()
4458
+ });
4459
+ }
4460
+ recordProcessed(record) {
4461
+ this.processeds.push({
4462
+ ...record,
4463
+ seq: this.processeds.length + 1,
4464
+ ts: this.now()
4465
+ });
4466
+ }
4467
+ recordConnection(record) {
4468
+ this.connections.push({
4469
+ ...record,
4470
+ seq: this.connections.length + 1,
4471
+ ts: this.now()
4472
+ });
4473
+ }
4474
+ queryRaw(query) {
4475
+ return takeRecent(this.raws.filter((event) => matches$1(event, query)), query.limit);
4476
+ }
4477
+ queryProcessed(query) {
4478
+ return takeRecent(this.processeds.filter((event) => {
4479
+ if (!matches$1(event, query)) return false;
4480
+ if (query.outcome !== void 0 && event.outcome !== query.outcome) return false;
4481
+ return true;
4482
+ }), query.limit);
4483
+ }
4484
+ queryConnection(query) {
4485
+ return takeRecent(this.connections.filter((event) => {
4486
+ if (!matches$1(event, query)) return false;
4487
+ if (query.status !== void 0 && event.status !== query.status) return false;
4488
+ return true;
4489
+ }), query.limit);
4490
+ }
4491
+ close() {}
4492
+ };
4493
+ const matches$1 = (event, query) => {
4494
+ if (query.type !== void 0 && event.type !== query.type) return false;
4495
+ if (query.connectorId !== void 0 && event.connectorId !== query.connectorId) return false;
4496
+ if (query.channelId !== void 0 && event.channelId !== query.channelId) return false;
4497
+ return true;
4498
+ };
4499
+ const takeRecent = (events, limit) => {
4500
+ if (limit === void 0) return events;
4501
+ if (limit <= 0) return [];
4502
+ return events.slice(-limit);
4503
+ };
4504
+ //#endregion
4505
+ //#region lib/gateway/connector-diagnostic-sql-reader.ts
4506
+ /**
4507
+ * Read-only SQL surface over the three diagnostic tables, for Claude to query
4508
+ * the log with arbitrary `SELECT`s. It opens all files read-only and exposes
4509
+ * three views — `raw`, `processed`, `connection` — that hide the storage
4510
+ * details (the physical table is `leuco_log` and each row's columns live
4511
+ * inside a JSON `event` blob): the views surface the columns as plain fields,
4512
+ * with `payload` already pulled out of the nested JSON.
4513
+ *
4514
+ * The tables are separate files. `raw` and `processed` share an `event_id`,
4515
+ * so a `JOIN` answers "the event arrived, but what verdict did it get?";
4516
+ * `connection` answers the other half — "did the listener ever connect at
4517
+ * all?". Writes are impossible: the connection is read-only and `query`
4518
+ * rejects anything but a single `SELECT`.
4519
+ */
4520
+ var ConnectorDiagnosticSqlReader = class {
4521
+ db;
4522
+ constructor(props) {
4523
+ const db = new Database(props.rawPath, { readonly: true });
4524
+ try {
4525
+ db.run("PRAGMA busy_timeout = 500");
4526
+ db.prepare("ATTACH DATABASE ? AS processeddb").run(props.processedPath);
4527
+ db.prepare("ATTACH DATABASE ? AS connectiondb").run(props.connectionPath);
4528
+ db.run(rawViewSql);
4529
+ db.run(processedViewSql);
4530
+ db.run(connectionViewSql);
4531
+ } catch (error) {
4532
+ db.close();
4533
+ throw error;
4534
+ }
4535
+ this.db = db;
4536
+ Object.freeze(this);
4537
+ }
4538
+ /**
4539
+ * Run one read-only `SELECT` and return the rows. Returns an `Error` (rather
4540
+ * than throwing) for a non-SELECT statement or a SQL error, so the caller
4541
+ * can surface the message without a stack trace.
4542
+ */
4543
+ query(sql) {
4544
+ const trimmed = sql.trim().replace(/;$/, "").trim();
4545
+ if (!/^select\b/i.test(trimmed)) return /* @__PURE__ */ new Error("only a single SELECT statement is allowed");
4546
+ if (trimmed.includes(";")) return /* @__PURE__ */ new Error("only a single statement is allowed (remove the ';')");
4547
+ try {
4548
+ return this.db.prepare(trimmed).all();
4549
+ } catch (error) {
4550
+ return error instanceof Error ? error : new Error(String(error));
4551
+ }
4552
+ }
4553
+ close() {
4554
+ this.db.close();
4555
+ }
4556
+ };
4557
+ const rawViewSql = `CREATE TEMP VIEW raw AS SELECT
4558
+ seq,
4559
+ ts,
4560
+ json_extract(event, '$.event_id') AS event_id,
4561
+ json_extract(event, '$.type') AS type,
4562
+ json_extract(event, '$.connector_id') AS connector_id,
4563
+ json_extract(event, '$.channel_id') AS channel_id,
4564
+ json_extract(event, '$.payload') AS payload
4565
+ FROM main.leuco_log`;
4566
+ const processedViewSql = `CREATE TEMP VIEW processed AS SELECT
4567
+ seq,
4568
+ ts,
4569
+ json_extract(event, '$.event_id') AS event_id,
4570
+ json_extract(event, '$.type') AS type,
4571
+ json_extract(event, '$.connector_id') AS connector_id,
4572
+ json_extract(event, '$.channel_id') AS channel_id,
4573
+ json_extract(event, '$.outcome') AS outcome,
4574
+ json_extract(event, '$.payload') AS payload
4575
+ FROM processeddb.leuco_log`;
4576
+ const connectionViewSql = `CREATE TEMP VIEW connection AS SELECT
4577
+ seq,
4578
+ ts,
4579
+ json_extract(event, '$.type') AS type,
4580
+ json_extract(event, '$.connector_id') AS connector_id,
4581
+ json_extract(event, '$.channel_id') AS channel_id,
4582
+ json_extract(event, '$.status') AS status,
4583
+ json_extract(event, '$.detail') AS detail
4584
+ FROM connectiondb.leuco_log`;
4585
+ //#endregion
3979
4586
  //#region lib/cli/factory.ts
3980
4587
  const factory = createFactory();
3981
4588
  //#endregion
@@ -4565,7 +5172,8 @@ subcommands:
4565
5172
  stop stop
4566
5173
  restart stop then start
4567
5174
  run start in foreground (for developers)
4568
- logs [-n <N>] show event logs
5175
+ logs [-n <N>] tail the daemon diagnostic log (lifecycle, listener boot)
5176
+ sql --query "<SQL>" query inbound connector traffic (raw + processed verdict)
4569
5177
  listeners list running connector listeners (alive / dead)
4570
5178
 
4571
5179
  examples:
@@ -4682,6 +5290,70 @@ const gatewayLogsHandler = factory.createHandlers(zValidator$1("query", z.object
4682
5290
  await tail.exited;
4683
5291
  process.exit(0);
4684
5292
  });
5293
+ //#endregion
5294
+ //#region lib/cli/routes/gateway.sql.ts
5295
+ const sqlHelp = `funnel gateway sql — query inbound connector traffic with SQL
5296
+
5297
+ usage: funnel gateway sql --query "<SELECT ...>"
5298
+
5299
+ Runs one read-only SELECT against the daemon's diagnostic store of inbound
5300
+ connector events and prints the rows as JSON. Use it to answer "Slack
5301
+ delivered an event, so why was there no notification?".
5302
+
5303
+ Three views:
5304
+ raw every inbound event, untouched, before any filtering
5305
+ processed the verdict for that event after the per-type processor ran
5306
+ connection the listener lifecycle (so you can tell events never arrived)
5307
+
5308
+ Shared columns (all three views):
5309
+ seq row id within the view (not comparable across views)
5310
+ ts epoch milliseconds
5311
+ type connector kind: slack | discord | gh | schedule
5312
+ connector_id funnel connector id
5313
+ channel_id funnel channel id
5314
+ raw and processed also have:
5315
+ event_id correlation id shared by an event's raw and processed rows
5316
+ payload raw: the original event JSON (text); processed: the delivered body, or "" when skipped
5317
+ processed also has:
5318
+ outcome 'emitted' | 'emitted:delivery-failed' | 'skip:<reason>'
5319
+ (skip reasons: skip:type, skip:subtype, skip:dedup,
5320
+ skip:self-user, skip:self-bot, skip:preprocess)
5321
+ connection also has:
5322
+ status 'started' | 'connected' | 'disconnected' | 'auth-failed' | 'stopped' | 'error'
5323
+ detail an error message or reason, or "" when none
5324
+
5325
+ To trace one event end to end, join raw and processed on event_id. When the
5326
+ event tables are empty, query connection — a listener that never connected, or
5327
+ failed auth, explains why nothing arrived.
5328
+
5329
+ examples:
5330
+ funnel gateway sql --query "SELECT event_id, ts, type FROM raw ORDER BY seq DESC LIMIT 20"
5331
+ funnel gateway sql --query "SELECT outcome, COUNT(*) n FROM processed GROUP BY outcome"
5332
+ funnel gateway sql --query "SELECT r.payload FROM raw r JOIN processed p USING(event_id) WHERE p.outcome='skip:dedup'"
5333
+ funnel gateway sql --query "SELECT ts, status, detail FROM connection WHERE status IN ('auth-failed','error') ORDER BY seq DESC"`;
5334
+ const gatewaySqlHandler = factory.createHandlers(zValidator$1("query", z.object({ query: z.string().optional() }), sqlHelp), async (c) => {
5335
+ const sql = c.req.valid("query").query;
5336
+ if (!sql) return c.text(sqlHelp);
5337
+ const tmpDir = funnelTmpDir();
5338
+ const rawPath = join(tmpDir, "connector-raw.db");
5339
+ const processedPath = join(tmpDir, "connector-processed.db");
5340
+ const connectionPath = join(tmpDir, "connector-connection.db");
5341
+ if (!existsSync(rawPath) || !existsSync(processedPath) || !existsSync(connectionPath)) return c.text("no diagnostic store yet (the gateway has not initialized it)");
5342
+ const reader = new ConnectorDiagnosticSqlReader({
5343
+ rawPath,
5344
+ processedPath,
5345
+ connectionPath
5346
+ });
5347
+ const rows = (() => {
5348
+ try {
5349
+ return reader.query(sql);
5350
+ } finally {
5351
+ reader.close();
5352
+ }
5353
+ })();
5354
+ if (rows instanceof Error) return c.text(`error: ${rows.message}`);
5355
+ return c.text(JSON.stringify(rows, null, 2));
5356
+ });
4685
5357
  const gatewayRestartHandler = factory.createHandlers(zValidator$1("query", z.object({ "no-caffeine": z.string().optional() }), `funnel gateway restart — restart the gateway
4686
5358
 
4687
5359
  usage: funnel gateway restart [--no-caffeine]
@@ -5090,7 +5762,7 @@ const createCliApp = (funnel) => {
5090
5762
  if (error instanceof HTTPException) return c.text(`error: ${error.message}`, error.status);
5091
5763
  return c.text(`error: ${error instanceof Error ? error.message : String(error)}`, 400);
5092
5764
  });
5093
- return base.get("/claude", ...claudeHandler).get("/channels", ...channelsGroupHandler).post("/channels/add", ...helpRoute(addHelp$3)).post("/channels/add/:channel", ...channelsAddHandler).post("/channels/remove", ...helpRoute(removeHelp$1)).post("/channels/remove/:channel", ...channelsRemoveHandler).post("/channels/rename/:channel/:newName", ...channelsRenameHandler).post("/channels/:channel/rename/:newName", ...channelsRenameHandler).post("/channels/rename", ...helpRoute(renameHelp$1)).post("/channels/:channel/rename", ...helpRoute(renameHelp$1)).post("/channels/:channel/set/delivery/:mode", ...channelsSetDeliveryHandler).post("/channels/publish", ...helpRoute(publishHelp)).post("/channels/:channel/publish", ...channelsPublishHandler).get("/channels/:channel", ...channelsShowHandler).get("/channels/:channel/connectors", ...channelsConnectorsGroupHandler).post("/channels/:channel/connectors/add", ...helpRoute(addHelp$2)).post("/channels/:channel/connectors/add/:connector", ...channelsConnectorsAddHandler).post("/channels/:channel/connectors/remove", ...helpRoute(removeHelp$3)).post("/channels/:channel/connectors/remove/:connector", ...channelsConnectorsRemoveHandler).post("/channels/:channel/connectors/set", ...helpRoute(setHelp$1)).post("/channels/:channel/connectors/set/:connector", ...channelsConnectorsSetHandler).post("/channels/:channel/connectors/rename/:connector/:newName", ...channelsConnectorsRenameHandler).post("/channels/:channel/connectors/:connector/rename/:newName", ...channelsConnectorsRenameHandler).post("/channels/:channel/connectors/rename", ...helpRoute(renameHelp$2)).post("/channels/:channel/connectors/:connector/rename", ...helpRoute(renameHelp$2)).post("/channels/:channel/connectors/:connector/request", ...channelsConnectorsRequestHandler).get("/channels/:channel/connectors/:connector", ...channelsConnectorsShowHandler).get("/channels/:channel/connectors/:connector/schedules", ...channelsConnectorsSchedulesGroupHandler).post("/channels/:channel/connectors/:connector/schedules/add", ...helpRoute(addHelp$1)).post("/channels/:channel/connectors/:connector/schedules/add/:id", ...channelsConnectorsSchedulesAddHandler).post("/channels/:channel/connectors/:connector/schedules/remove", ...helpRoute(removeHelp$2)).post("/channels/:channel/connectors/:connector/schedules/remove/:id", ...channelsConnectorsSchedulesRemoveHandler).get("/profiles", ...profilesGroupHandler).post("/profiles/add", ...helpRoute(addHelp)).post("/profiles/add/:profile", ...profilesAddHandler).post("/profiles/set", ...helpRoute(setHelp)).post("/profiles/set/:profile", ...profilesSetHandler).post("/profiles/remove", ...helpRoute(removeHelp)).post("/profiles/remove/:profile", ...profilesRemoveHandler).post("/profiles/rename/:profile/:newName", ...profilesRenameHandler).post("/profiles/:profile/rename/:newName", ...profilesRenameHandler).post("/profiles/rename", ...helpRoute(renameHelp)).post("/profiles/:profile/rename", ...helpRoute(renameHelp)).post("/profiles/:profile/as-default", ...profilesAsDefaultHandler).get("/profiles/:profile/run", ...profilesLaunchHandler).get("/profiles/:profile", ...profilesLaunchHandler).get("/gateway", ...gatewayGroupHandler).get("/gateway/status", ...gatewayStatusHandler).get("/gateway/start", ...gatewayStartHandler).get("/gateway/stop", ...gatewayStopHandler).get("/gateway/restart", ...gatewayRestartHandler).get("/gateway/run", ...gatewayRunHandler).get("/gateway/logs", ...gatewayLogsHandler).get("/gateway/listeners", ...gatewayListenersHandler).get("/schema", ...schemaHandler).get("/status", ...statusHandler).get("/update", ...updateHandler);
5765
+ return base.get("/claude", ...claudeHandler).get("/channels", ...channelsGroupHandler).post("/channels/add", ...helpRoute(addHelp$3)).post("/channels/add/:channel", ...channelsAddHandler).post("/channels/remove", ...helpRoute(removeHelp$1)).post("/channels/remove/:channel", ...channelsRemoveHandler).post("/channels/rename/:channel/:newName", ...channelsRenameHandler).post("/channels/:channel/rename/:newName", ...channelsRenameHandler).post("/channels/rename", ...helpRoute(renameHelp$1)).post("/channels/:channel/rename", ...helpRoute(renameHelp$1)).post("/channels/:channel/set/delivery/:mode", ...channelsSetDeliveryHandler).post("/channels/publish", ...helpRoute(publishHelp)).post("/channels/:channel/publish", ...channelsPublishHandler).get("/channels/:channel", ...channelsShowHandler).get("/channels/:channel/connectors", ...channelsConnectorsGroupHandler).post("/channels/:channel/connectors/add", ...helpRoute(addHelp$2)).post("/channels/:channel/connectors/add/:connector", ...channelsConnectorsAddHandler).post("/channels/:channel/connectors/remove", ...helpRoute(removeHelp$3)).post("/channels/:channel/connectors/remove/:connector", ...channelsConnectorsRemoveHandler).post("/channels/:channel/connectors/set", ...helpRoute(setHelp$1)).post("/channels/:channel/connectors/set/:connector", ...channelsConnectorsSetHandler).post("/channels/:channel/connectors/rename/:connector/:newName", ...channelsConnectorsRenameHandler).post("/channels/:channel/connectors/:connector/rename/:newName", ...channelsConnectorsRenameHandler).post("/channels/:channel/connectors/rename", ...helpRoute(renameHelp$2)).post("/channels/:channel/connectors/:connector/rename", ...helpRoute(renameHelp$2)).post("/channels/:channel/connectors/:connector/request", ...channelsConnectorsRequestHandler).get("/channels/:channel/connectors/:connector", ...channelsConnectorsShowHandler).get("/channels/:channel/connectors/:connector/schedules", ...channelsConnectorsSchedulesGroupHandler).post("/channels/:channel/connectors/:connector/schedules/add", ...helpRoute(addHelp$1)).post("/channels/:channel/connectors/:connector/schedules/add/:id", ...channelsConnectorsSchedulesAddHandler).post("/channels/:channel/connectors/:connector/schedules/remove", ...helpRoute(removeHelp$2)).post("/channels/:channel/connectors/:connector/schedules/remove/:id", ...channelsConnectorsSchedulesRemoveHandler).get("/profiles", ...profilesGroupHandler).post("/profiles/add", ...helpRoute(addHelp)).post("/profiles/add/:profile", ...profilesAddHandler).post("/profiles/set", ...helpRoute(setHelp)).post("/profiles/set/:profile", ...profilesSetHandler).post("/profiles/remove", ...helpRoute(removeHelp)).post("/profiles/remove/:profile", ...profilesRemoveHandler).post("/profiles/rename/:profile/:newName", ...profilesRenameHandler).post("/profiles/:profile/rename/:newName", ...profilesRenameHandler).post("/profiles/rename", ...helpRoute(renameHelp)).post("/profiles/:profile/rename", ...helpRoute(renameHelp)).post("/profiles/:profile/as-default", ...profilesAsDefaultHandler).get("/profiles/:profile/run", ...profilesLaunchHandler).get("/profiles/:profile", ...profilesLaunchHandler).get("/gateway", ...gatewayGroupHandler).get("/gateway/status", ...gatewayStatusHandler).get("/gateway/start", ...gatewayStartHandler).get("/gateway/stop", ...gatewayStopHandler).get("/gateway/restart", ...gatewayRestartHandler).get("/gateway/run", ...gatewayRunHandler).get("/gateway/logs", ...gatewayLogsHandler).get("/gateway/sql", ...gatewaySqlHandler).get("/gateway/listeners", ...gatewayListenersHandler).get("/schema", ...schemaHandler).get("/status", ...statusHandler).get("/update", ...updateHandler);
5094
5766
  };
5095
5767
  /** CLI Hono app wired to a default `new Funnel()`. For embedding with a custom Funnel use `createCliApp`. */
5096
5768
  const app = createCliApp(new Funnel());
@@ -6493,6 +7165,11 @@ function ChannelsView(props) {
6493
7165
  }
6494
7166
  //#endregion
6495
7167
  //#region lib/tui/views/connectors-view.tsx
7168
+ const tokenDisplay = (literal, envVar) => {
7169
+ if (literal !== void 0 && literal !== "") return literal;
7170
+ if (envVar !== void 0 && envVar !== "") return `env:${envVar}`;
7171
+ return "—";
7172
+ };
6496
7173
  const formatTimestamp = (iso) => {
6497
7174
  if (!iso) return "—";
6498
7175
  const d = new Date(iso);
@@ -6577,10 +7254,10 @@ function ConnectorsView(props) {
6577
7254
  }),
6578
7255
  connector.type === "slack" ? /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(ReadonlyField, {
6579
7256
  label: "bot-token",
6580
- value: connector.botToken
7257
+ value: tokenDisplay(connector.botToken, connector.botTokenEnv)
6581
7258
  }), /* @__PURE__ */ jsx(ReadonlyField, {
6582
7259
  label: "app-token",
6583
- value: connector.appToken
7260
+ value: tokenDisplay(connector.appToken, connector.appTokenEnv)
6584
7261
  })] }) : null,
6585
7262
  connector.type === "gh" ? /* @__PURE__ */ jsx(ReadonlyField, {
6586
7263
  label: "poll",
@@ -6588,7 +7265,7 @@ function ConnectorsView(props) {
6588
7265
  }) : null,
6589
7266
  connector.type === "discord" ? /* @__PURE__ */ jsx(ReadonlyField, {
6590
7267
  label: "bot-token",
6591
- value: connector.botToken
7268
+ value: tokenDisplay(connector.botToken, connector.botTokenEnv)
6592
7269
  }) : null,
6593
7270
  connector.type === "schedule" ? /* @__PURE__ */ jsx(ReadonlyField, {
6594
7271
  label: "entries",
@@ -7295,4 +7972,4 @@ async function launchTui(funnel) {
7295
7972
  });
7296
7973
  }
7297
7974
  //#endregion
7298
- export { DEFAULT_GATEWAY_TOKEN_PATH, FUNNEL_DIR, FUNNEL_MCP_COMMAND, FUNNEL_MCP_NAME, Funnel, FunnelBroadcaster, FunnelChannelPublisher, FunnelChannels, FunnelClaude, FunnelClock, FunnelConnectorFactory, FunnelConnectorListener, FunnelDotenvReader, FunnelEventLog, FunnelFileSystem, FunnelGateway, FunnelGatewayServer, FunnelGatewayToken, FunnelIdGenerator, FunnelListenerSupervisor, FunnelListenersClient, FunnelLocalConfig, FunnelLocalConfigSync, FunnelLogger, FunnelMcp, FunnelProcessRunner, FunnelProfiles, FunnelSettingsReader, FunnelSettingsStore, FunnelSlackEventProcessor, FunnelTokenPrompter, LOCAL_CONFIG_FILENAME, LOCAL_ENV_FILENAME, MemoryFunnelClock, MemoryFunnelEventLog, MemoryFunnelFileSystem, MemoryFunnelIdGenerator, MemoryFunnelLogger, MemoryFunnelProcessRunner, MemoryFunnelTokenPrompter, MockFunnelSettingsReader, NodeFunnelClock, NodeFunnelFileSystem, NodeFunnelIdGenerator, NodeFunnelLogger, NodeFunnelProcessRunner, NodeFunnelTokenPrompter, NoopFunnelLogger, SETTINGS_PATH, SETTINGS_VERSION, SqliteFunnelEventLog, channelConfigSchema, channelDeliveryModeSchema, channelSpecSchema, app as cliApp, connectorConfigSchema, connectorSpecSchema, createCliApp, createSettings, discordConnectorSchema, factory, funnelEventSchema, funnelJsonSchema, ghConnectorSchema, launchTui, localConfigSchema, profileConfigSchema, profileSpecSchema, publishRequestSchema, publishResponseSchema, queryToCliArgs, scheduleCatchupPolicySchema, scheduleConnectorSchema, scheduleEntrySchema, settingsSchema, slackConnectorSchema, startChannelServer, toRequest };
7975
+ export { CONNECTOR_CONNECTION_STATUSES, ConnectorDiagnosticLog, ConnectorDiagnosticSqlReader, DEFAULT_GATEWAY_TOKEN_PATH, FUNNEL_DIR, FUNNEL_MCP_COMMAND, FUNNEL_MCP_NAME, Funnel, FunnelBroadcaster, FunnelChannelPublisher, FunnelChannels, FunnelClaude, FunnelClock, FunnelConnectorFactory, FunnelConnectorListener, FunnelDotenvReader, FunnelEventLog, FunnelFileSystem, FunnelGateway, FunnelGatewayServer, FunnelGatewayToken, FunnelIdGenerator, FunnelListenerSupervisor, FunnelListenersClient, FunnelLocalConfig, FunnelLocalConfigSync, FunnelLogger, FunnelMcp, FunnelProcessRunner, FunnelProfiles, FunnelSettingsReader, FunnelSettingsStore, FunnelSlackEventProcessor, FunnelTokenPrompter, LOCAL_CONFIG_FILENAME, LOCAL_ENV_FILENAME, MemoryConnectorDiagnosticLog, MemoryFunnelClock, MemoryFunnelEventLog, MemoryFunnelFileSystem, MemoryFunnelIdGenerator, MemoryFunnelLogger, MemoryFunnelProcessRunner, MemoryFunnelTokenPrompter, MockFunnelSettingsReader, NodeFunnelClock, NodeFunnelFileSystem, NodeFunnelIdGenerator, NodeFunnelLogger, NodeFunnelProcessRunner, NodeFunnelTokenPrompter, NoopFunnelLogger, SETTINGS_PATH, SETTINGS_VERSION, SqliteConnectorDiagnosticLog, SqliteFunnelEventLog, channelConfigSchema, channelDeliveryModeSchema, channelSpecSchema, app as cliApp, connectorConfigSchema, connectorConnectionEventSchema, connectorProcessedEventSchema, connectorRawEventSchema, connectorSpecSchema, createCliApp, createSettings, discordConnectorSchema, factory, funnelEventSchema, funnelJsonSchema, ghConnectorSchema, launchTui, localConfigSchema, profileConfigSchema, profileSpecSchema, publishRequestSchema, publishResponseSchema, queryToCliArgs, resolveFunnelDir, scheduleCatchupPolicySchema, scheduleConnectorSchema, scheduleEntrySchema, settingsSchema, slackConnectorSchema, startChannelServer, toRequest };