@interactive-inc/claude-funnel 0.26.0 → 0.27.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-Clb2sCcz.d.ts +206 -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 +213 -38
  16. package/dist/index.js +727 -103
  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-C2-KqHQc.d.ts} +10 -3
  20. package/dist/{slack-connector-schema-BWL7dWlY.js → slack-connector-schema-CHbRJHGp.js} +140 -19
  21. package/dist/slack-listener-BMknoyVr.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
  });
@@ -2358,8 +2413,11 @@ var LeucoLoggerSqliteSink = class {
2358
2413
  if (props.where) this.appendWhereConditions(props.where, conditions, params);
2359
2414
  const limit = props.limit ?? 1e3;
2360
2415
  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);
2416
+ const dir = props.order === "desc" ? "DESC" : "ASC";
2417
+ const sql = `SELECT seq, ts, type, event FROM leuco_log WHERE ${conditions.join(" AND ")} ORDER BY seq ${dir} LIMIT ?`;
2418
+ const rows = this.db.prepare(sql).all(...params);
2419
+ if (dir === "DESC") rows.reverse();
2420
+ return rows.map(toRecord);
2363
2421
  }
2364
2422
  /**
2365
2423
  * Current schema version. Useful for diagnostics and for tests that want
@@ -3480,7 +3538,7 @@ var Funnel = class Funnel {
3480
3538
  }
3481
3539
  /** Resolved on-disk paths the facade will read/write when methods are called. Pure compute, not memoized. */
3482
3540
  get paths() {
3483
- const dir = this.props.dir ?? FUNNEL_DIR;
3541
+ const dir = this.props.dir ?? resolveFunnelDir();
3484
3542
  return {
3485
3543
  dir,
3486
3544
  tmpDir: this.props.tmpDir ?? funnelTmpDir(),
@@ -3533,6 +3591,7 @@ var Funnel = class Funnel {
3533
3591
  fs: this.fs,
3534
3592
  process: this.process,
3535
3593
  logger: this.logger,
3594
+ diagnosticLog: this.props.diagnosticLog,
3536
3595
  dir: this.paths.dir,
3537
3596
  slackListenerOptions: this.props.slackListenerOptions,
3538
3597
  scheduleListenerOptions: this.props.scheduleListenerOptions
@@ -3976,6 +4035,501 @@ var MemoryFunnelEventLog = class extends FunnelEventLog {
3976
4035
  close() {}
3977
4036
  };
3978
4037
  //#endregion
4038
+ //#region lib/gateway/connector-diagnostic-log.ts
4039
+ /**
4040
+ * Points in the listener's connection lifecycle. The single source of truth
4041
+ * for the value set: the `status` column schema, the `ConnectorConnectionStatus`
4042
+ * union, and the runtime Set used to narrow on read-back all derive from this
4043
+ * array, so adding a status is a one-line change that cannot drift out of sync.
4044
+ *
4045
+ * started start() was called
4046
+ * connected the socket opened and events can flow
4047
+ * disconnected the socket was closed by a stop() call (a clean teardown)
4048
+ * auth-failed the token was rejected before the socket opened
4049
+ * stopped the listener was fully torn down (always follows a stop(),
4050
+ * paired with the disconnected/error that preceded it)
4051
+ * error start/stop threw, or Bolt surfaced an error frame — this is
4052
+ * also where an unsolicited socket drop shows up when Bolt
4053
+ * reports it (an `error` with no following `stopped` means the
4054
+ * supervisor recycled the listener, not a clean stop)
4055
+ *
4056
+ * A connection row is independent of any single inbound event, so it carries
4057
+ * no `eventId`. This is how "no notification arrived because the listener
4058
+ * never connected (or dropped, or failed auth)" becomes visible: the
4059
+ * raw/processed tables only hold events that *did* arrive.
4060
+ */
4061
+ const CONNECTOR_CONNECTION_STATUSES = [
4062
+ "started",
4063
+ "connected",
4064
+ "disconnected",
4065
+ "auth-failed",
4066
+ "stopped",
4067
+ "error"
4068
+ ];
4069
+ /**
4070
+ * Rows stored in the diagnostic tables. Connector-agnostic on purpose: `type`
4071
+ * carries the listener kind ("slack" | "discord" | "gh" | "schedule") so new
4072
+ * connectors land in the same tables without a schema change. `event_id` is
4073
+ * the correlation key the listener mints once per inbound event and stamps
4074
+ * onto both the raw and processed rows, so the two are joinable even though
4075
+ * they live in separate tables with independent `seq` counters.
4076
+ *
4077
+ * These schemas mirror the stored shape (snake_case columns) the way
4078
+ * `FunnelEvent` does for the replay log; they exist for `z.infer` and to
4079
+ * document the column set, not as a parse boundary.
4080
+ */
4081
+ const connectorRawEventSchema = z.object({
4082
+ event_id: z.string(),
4083
+ type: z.string(),
4084
+ connector_id: z.string().nullable(),
4085
+ channel_id: z.string().nullable(),
4086
+ payload: z.string()
4087
+ });
4088
+ const connectorProcessedEventSchema = z.object({
4089
+ event_id: z.string(),
4090
+ type: z.string(),
4091
+ connector_id: z.string().nullable(),
4092
+ channel_id: z.string().nullable(),
4093
+ outcome: z.string(),
4094
+ payload: z.string()
4095
+ });
4096
+ const connectorConnectionEventSchema = z.object({
4097
+ type: z.string(),
4098
+ connector_id: z.string().nullable(),
4099
+ channel_id: z.string().nullable(),
4100
+ status: z.enum(CONNECTOR_CONNECTION_STATUSES),
4101
+ detail: z.string()
4102
+ });
4103
+ /**
4104
+ * Three-table diagnostic log of everything a connector listener does, so
4105
+ * "why was there no notification?" is answerable whichever way it failed:
4106
+ * - `raw` — every inbound event, before any filtering, with the listener's
4107
+ * untouched payload (the Slack Bolt event, the GH webhook, …)
4108
+ * - `processed` — the verdict for that event: `outcome` (emitted, or the
4109
+ * reason it was dropped) and, when emitted, the body that was delivered.
4110
+ * Shares an `eventId` with its raw row, so the two join into one story.
4111
+ * - `connection` — the listener's lifecycle (started, connected, dropped,
4112
+ * auth-failed, stopped, errored). This is the half the event tables can't
4113
+ * show: an event that never arrived leaves no raw row, but a listener that
4114
+ * never connected leaves a `connection` trail that says so.
4115
+ *
4116
+ * The three are physically separate (independent retention and payload-size
4117
+ * policy) so a query never crosses them by accident and a huge raw payload
4118
+ * never bloats the verdict or lifecycle trails. None flow to WS clients or the
4119
+ * MCP channel — this is a separate store from `FunnelEventLog` (replay) and
4120
+ * exists solely for debugging.
4121
+ *
4122
+ * Implementations:
4123
+ * - `SqliteConnectorDiagnosticLog` — the default; survives daemon restarts,
4124
+ * bounded by per-table row/age caps.
4125
+ * - `MemoryConnectorDiagnosticLog` — an in-process double for tests.
4126
+ */
4127
+ var ConnectorDiagnosticLog = class {};
4128
+ //#endregion
4129
+ //#region lib/gateway/sqlite-connector-diagnostic-log.ts
4130
+ /**
4131
+ * Cap on a raw payload kept verbatim. The point of the raw table is to see
4132
+ * what Slack/Discord actually sent, and a typical event is a few KB — so 256
4133
+ * KiB keeps essentially everything intact while bounding the rare giant
4134
+ * payload (a huge Block Kit message, a file dump) that would otherwise let a
4135
+ * single row bloat the debug database without limit.
4136
+ */
4137
+ const RAW_PAYLOAD_CAP = 256 * 1024;
4138
+ /**
4139
+ * Default `ConnectorDiagnosticLog`: three independent `LeucoLoggerSqliteSink`s, one
4140
+ * per table (raw / processed / connection), in separate files. Each sink
4141
+ * indexes the columns its queries filter on — `event_id` / `connector_id` /
4142
+ * `channel_id` for raw, plus `outcome` for processed and `status` for
4143
+ * connection — so those lookups are indexed scans (`type` is a fixed column
4144
+ * the sink extracts separately, not an index, so filtering by it is a scan).
4145
+ *
4146
+ * The raw table offloads any payload over `RAW_PAYLOAD_CAP`: rather than
4147
+ * truncating mid-string (which yields unparseable JSON), it replaces the
4148
+ * body with a small JSON object that keeps the diagnostic essentials and
4149
+ * records the dropped size under `_funnel_oversized`. Every stored payload
4150
+ * therefore stays valid JSON.
4151
+ */
4152
+ var SqliteConnectorDiagnosticLog = class extends ConnectorDiagnosticLog {
4153
+ raw;
4154
+ processed;
4155
+ connection;
4156
+ now;
4157
+ logger;
4158
+ constructor(props) {
4159
+ super();
4160
+ this.now = props.now ?? (() => Date.now());
4161
+ this.logger = props.logger;
4162
+ const ageCap = props.maxAgeMs !== void 0 ? { maxAgeMs: props.maxAgeMs } : {};
4163
+ const verdictCap = {
4164
+ now: this.now,
4165
+ ...ageCap,
4166
+ ...props.maxRows !== void 0 ? { maxRows: props.maxRows } : {}
4167
+ };
4168
+ const rawMax = props.rawMaxRows ?? props.maxRows;
4169
+ const rawCap = {
4170
+ now: this.now,
4171
+ ...ageCap,
4172
+ ...rawMax !== void 0 ? { maxRows: rawMax } : {}
4173
+ };
4174
+ this.raw = new LeucoLoggerSqliteSink({
4175
+ path: props.rawPath,
4176
+ indexes: [
4177
+ "event_id",
4178
+ "connector_id",
4179
+ "channel_id"
4180
+ ],
4181
+ extractIndexes: (event) => ({
4182
+ event_id: event.event_id,
4183
+ connector_id: event.connector_id,
4184
+ channel_id: event.channel_id
4185
+ }),
4186
+ ...rawCap
4187
+ });
4188
+ this.processed = new LeucoLoggerSqliteSink({
4189
+ path: props.processedPath,
4190
+ indexes: [
4191
+ "event_id",
4192
+ "connector_id",
4193
+ "channel_id",
4194
+ "outcome"
4195
+ ],
4196
+ extractIndexes: (event) => ({
4197
+ event_id: event.event_id,
4198
+ connector_id: event.connector_id,
4199
+ channel_id: event.channel_id,
4200
+ outcome: event.outcome
4201
+ }),
4202
+ ...verdictCap
4203
+ });
4204
+ this.connection = new LeucoLoggerSqliteSink({
4205
+ path: props.connectionPath,
4206
+ indexes: [
4207
+ "connector_id",
4208
+ "channel_id",
4209
+ "status"
4210
+ ],
4211
+ extractIndexes: (event) => ({
4212
+ connector_id: event.connector_id,
4213
+ channel_id: event.channel_id,
4214
+ status: event.status
4215
+ }),
4216
+ ...verdictCap
4217
+ });
4218
+ restrictPermissions(props.rawPath);
4219
+ restrictPermissions(props.processedPath);
4220
+ restrictPermissions(props.connectionPath);
4221
+ Object.freeze(this);
4222
+ }
4223
+ recordRaw(record) {
4224
+ const event = {
4225
+ event_id: record.eventId,
4226
+ type: record.type,
4227
+ connector_id: record.connectorId,
4228
+ channel_id: record.channelId,
4229
+ payload: capPayload(record.payload, record.type)
4230
+ };
4231
+ this.report("raw", this.raw.insert({
4232
+ ts: this.now(),
4233
+ event
4234
+ }));
4235
+ }
4236
+ recordProcessed(record) {
4237
+ const event = {
4238
+ event_id: record.eventId,
4239
+ type: record.type,
4240
+ connector_id: record.connectorId,
4241
+ channel_id: record.channelId,
4242
+ outcome: record.outcome,
4243
+ payload: record.payload
4244
+ };
4245
+ this.report("processed", this.processed.insert({
4246
+ ts: this.now(),
4247
+ event
4248
+ }));
4249
+ }
4250
+ recordConnection(record) {
4251
+ const event = {
4252
+ type: record.type,
4253
+ connector_id: record.connectorId,
4254
+ channel_id: record.channelId,
4255
+ status: record.status,
4256
+ detail: record.detail
4257
+ };
4258
+ this.report("connection", this.connection.insert({
4259
+ ts: this.now(),
4260
+ event
4261
+ }));
4262
+ }
4263
+ report(table, result) {
4264
+ if (result instanceof Error) this.logger?.error("diagnostic log insert failed", {
4265
+ table,
4266
+ error: result.message
4267
+ });
4268
+ }
4269
+ queryRaw(query) {
4270
+ return this.raw.getRecords({
4271
+ ...query.type !== void 0 ? { type: query.type } : {},
4272
+ ...query.limit !== void 0 ? { limit: query.limit } : {},
4273
+ where: buildWhere(query),
4274
+ order: "desc"
4275
+ }).map((record) => ({
4276
+ seq: record.seq,
4277
+ ts: record.ts,
4278
+ eventId: record.event.event_id,
4279
+ type: record.event.type,
4280
+ connectorId: record.event.connector_id,
4281
+ channelId: record.event.channel_id,
4282
+ payload: record.event.payload
4283
+ }));
4284
+ }
4285
+ queryProcessed(query) {
4286
+ const where = buildWhere(query);
4287
+ if (query.outcome !== void 0) where.outcome = query.outcome;
4288
+ return this.processed.getRecords({
4289
+ ...query.type !== void 0 ? { type: query.type } : {},
4290
+ ...query.limit !== void 0 ? { limit: query.limit } : {},
4291
+ where,
4292
+ order: "desc"
4293
+ }).map((record) => ({
4294
+ seq: record.seq,
4295
+ ts: record.ts,
4296
+ eventId: record.event.event_id,
4297
+ type: record.event.type,
4298
+ connectorId: record.event.connector_id,
4299
+ channelId: record.event.channel_id,
4300
+ outcome: record.event.outcome,
4301
+ payload: record.event.payload
4302
+ }));
4303
+ }
4304
+ queryConnection(query) {
4305
+ const where = buildWhere(query);
4306
+ if (query.status !== void 0) where.status = query.status;
4307
+ return this.connection.getRecords({
4308
+ ...query.type !== void 0 ? { type: query.type } : {},
4309
+ ...query.limit !== void 0 ? { limit: query.limit } : {},
4310
+ where,
4311
+ order: "desc"
4312
+ }).map((record) => ({
4313
+ seq: record.seq,
4314
+ ts: record.ts,
4315
+ type: record.event.type,
4316
+ connectorId: record.event.connector_id,
4317
+ channelId: record.event.channel_id,
4318
+ status: statusOf(record.event.status),
4319
+ detail: record.event.detail
4320
+ }));
4321
+ }
4322
+ close() {
4323
+ this.raw.close();
4324
+ this.processed.close();
4325
+ this.connection.close();
4326
+ }
4327
+ };
4328
+ const restrictPermissions = (path) => {
4329
+ if (path === ":memory:") return;
4330
+ for (const suffix of [
4331
+ "",
4332
+ "-wal",
4333
+ "-shm"
4334
+ ]) try {
4335
+ chmodSync(`${path}${suffix}`, 384);
4336
+ } catch {}
4337
+ };
4338
+ const buildWhere = (query) => {
4339
+ const where = {};
4340
+ if (query.connectorId !== void 0) where.connector_id = query.connectorId;
4341
+ if (query.channelId !== void 0) where.channel_id = query.channelId;
4342
+ return where;
4343
+ };
4344
+ const statusField = connectorConnectionEventSchema.shape.status;
4345
+ const statusOf = (value) => {
4346
+ const parsed = statusField.safeParse(value);
4347
+ return parsed.success ? parsed.data : "error";
4348
+ };
4349
+ const capPayload = (payload, type) => {
4350
+ const size = Buffer.byteLength(payload, "utf8");
4351
+ if (size <= RAW_PAYLOAD_CAP) return payload;
4352
+ return JSON.stringify({
4353
+ ...headFields(payload),
4354
+ _funnel_oversized: size,
4355
+ _funnel_type: type
4356
+ });
4357
+ };
4358
+ const HEAD_KEYS = [
4359
+ "type",
4360
+ "subtype",
4361
+ "ts",
4362
+ "channel",
4363
+ "channel_type",
4364
+ "user",
4365
+ "bot_id"
4366
+ ];
4367
+ const headFields = (payload) => {
4368
+ try {
4369
+ const parsed = JSON.parse(payload);
4370
+ if (typeof parsed !== "object" || parsed === null) return {};
4371
+ const source = parsed;
4372
+ const head = {};
4373
+ for (const key of HEAD_KEYS) if (source[key] !== void 0) head[key] = source[key];
4374
+ return head;
4375
+ } catch {
4376
+ return {};
4377
+ }
4378
+ };
4379
+ //#endregion
4380
+ //#region lib/gateway/memory-connector-diagnostic-log.ts
4381
+ /**
4382
+ * In-process `ConnectorDiagnosticLog` backed by one array per table. Used by tests
4383
+ * and embedders that do not need durability. Like the SQLite log it keeps
4384
+ * `seq` per-table (each array's 1-based position) and returns the most recent
4385
+ * `limit` rows oldest-first; unlike it, it never prunes and never offloads
4386
+ * oversized payloads — it keeps whatever the caller hands it, which is fine
4387
+ * for the bounded volumes a test produces. Payload-validity is therefore a
4388
+ * SQLite-only guarantee; do not write a test that leans on this double
4389
+ * rejecting a malformed payload.
4390
+ */
4391
+ var MemoryConnectorDiagnosticLog = class extends ConnectorDiagnosticLog {
4392
+ raws = [];
4393
+ processeds = [];
4394
+ connections = [];
4395
+ constructor(now = () => Date.now()) {
4396
+ super();
4397
+ this.now = now;
4398
+ Object.freeze(this);
4399
+ }
4400
+ recordRaw(record) {
4401
+ this.raws.push({
4402
+ ...record,
4403
+ seq: this.raws.length + 1,
4404
+ ts: this.now()
4405
+ });
4406
+ }
4407
+ recordProcessed(record) {
4408
+ this.processeds.push({
4409
+ ...record,
4410
+ seq: this.processeds.length + 1,
4411
+ ts: this.now()
4412
+ });
4413
+ }
4414
+ recordConnection(record) {
4415
+ this.connections.push({
4416
+ ...record,
4417
+ seq: this.connections.length + 1,
4418
+ ts: this.now()
4419
+ });
4420
+ }
4421
+ queryRaw(query) {
4422
+ return takeRecent(this.raws.filter((event) => matches$1(event, query)), query.limit);
4423
+ }
4424
+ queryProcessed(query) {
4425
+ return takeRecent(this.processeds.filter((event) => {
4426
+ if (!matches$1(event, query)) return false;
4427
+ if (query.outcome !== void 0 && event.outcome !== query.outcome) return false;
4428
+ return true;
4429
+ }), query.limit);
4430
+ }
4431
+ queryConnection(query) {
4432
+ return takeRecent(this.connections.filter((event) => {
4433
+ if (!matches$1(event, query)) return false;
4434
+ if (query.status !== void 0 && event.status !== query.status) return false;
4435
+ return true;
4436
+ }), query.limit);
4437
+ }
4438
+ close() {}
4439
+ };
4440
+ const matches$1 = (event, query) => {
4441
+ if (query.type !== void 0 && event.type !== query.type) return false;
4442
+ if (query.connectorId !== void 0 && event.connectorId !== query.connectorId) return false;
4443
+ if (query.channelId !== void 0 && event.channelId !== query.channelId) return false;
4444
+ return true;
4445
+ };
4446
+ const takeRecent = (events, limit) => {
4447
+ if (limit === void 0) return events;
4448
+ if (limit <= 0) return [];
4449
+ return events.slice(-limit);
4450
+ };
4451
+ //#endregion
4452
+ //#region lib/gateway/connector-diagnostic-sql-reader.ts
4453
+ /**
4454
+ * Read-only SQL surface over the three diagnostic tables, for Claude to query
4455
+ * the log with arbitrary `SELECT`s. It opens all files read-only and exposes
4456
+ * three views — `raw`, `processed`, `connection` — that hide the storage
4457
+ * details (the physical table is `leuco_log` and each row's columns live
4458
+ * inside a JSON `event` blob): the views surface the columns as plain fields,
4459
+ * with `payload` already pulled out of the nested JSON.
4460
+ *
4461
+ * The tables are separate files. `raw` and `processed` share an `event_id`,
4462
+ * so a `JOIN` answers "the event arrived, but what verdict did it get?";
4463
+ * `connection` answers the other half — "did the listener ever connect at
4464
+ * all?". Writes are impossible: the connection is read-only and `query`
4465
+ * rejects anything but a single `SELECT`.
4466
+ */
4467
+ var ConnectorDiagnosticSqlReader = class {
4468
+ db;
4469
+ constructor(props) {
4470
+ const db = new Database(props.rawPath, { readonly: true });
4471
+ try {
4472
+ db.run("PRAGMA busy_timeout = 500");
4473
+ db.prepare("ATTACH DATABASE ? AS processeddb").run(props.processedPath);
4474
+ db.prepare("ATTACH DATABASE ? AS connectiondb").run(props.connectionPath);
4475
+ db.run(rawViewSql);
4476
+ db.run(processedViewSql);
4477
+ db.run(connectionViewSql);
4478
+ } catch (error) {
4479
+ db.close();
4480
+ throw error;
4481
+ }
4482
+ this.db = db;
4483
+ Object.freeze(this);
4484
+ }
4485
+ /**
4486
+ * Run one read-only `SELECT` and return the rows. Returns an `Error` (rather
4487
+ * than throwing) for a non-SELECT statement or a SQL error, so the caller
4488
+ * can surface the message without a stack trace.
4489
+ */
4490
+ query(sql) {
4491
+ const trimmed = sql.trim().replace(/;$/, "").trim();
4492
+ if (!/^select\b/i.test(trimmed)) return /* @__PURE__ */ new Error("only a single SELECT statement is allowed");
4493
+ if (trimmed.includes(";")) return /* @__PURE__ */ new Error("only a single statement is allowed (remove the ';')");
4494
+ try {
4495
+ return this.db.prepare(trimmed).all();
4496
+ } catch (error) {
4497
+ return error instanceof Error ? error : new Error(String(error));
4498
+ }
4499
+ }
4500
+ close() {
4501
+ this.db.close();
4502
+ }
4503
+ };
4504
+ const rawViewSql = `CREATE TEMP VIEW raw AS SELECT
4505
+ seq,
4506
+ ts,
4507
+ json_extract(event, '$.event_id') AS event_id,
4508
+ json_extract(event, '$.type') AS type,
4509
+ json_extract(event, '$.connector_id') AS connector_id,
4510
+ json_extract(event, '$.channel_id') AS channel_id,
4511
+ json_extract(event, '$.payload') AS payload
4512
+ FROM main.leuco_log`;
4513
+ const processedViewSql = `CREATE TEMP VIEW processed AS SELECT
4514
+ seq,
4515
+ ts,
4516
+ json_extract(event, '$.event_id') AS event_id,
4517
+ json_extract(event, '$.type') AS type,
4518
+ json_extract(event, '$.connector_id') AS connector_id,
4519
+ json_extract(event, '$.channel_id') AS channel_id,
4520
+ json_extract(event, '$.outcome') AS outcome,
4521
+ json_extract(event, '$.payload') AS payload
4522
+ FROM processeddb.leuco_log`;
4523
+ const connectionViewSql = `CREATE TEMP VIEW connection AS SELECT
4524
+ seq,
4525
+ ts,
4526
+ json_extract(event, '$.type') AS type,
4527
+ json_extract(event, '$.connector_id') AS connector_id,
4528
+ json_extract(event, '$.channel_id') AS channel_id,
4529
+ json_extract(event, '$.status') AS status,
4530
+ json_extract(event, '$.detail') AS detail
4531
+ FROM connectiondb.leuco_log`;
4532
+ //#endregion
3979
4533
  //#region lib/cli/factory.ts
3980
4534
  const factory = createFactory();
3981
4535
  //#endregion
@@ -4565,7 +5119,8 @@ subcommands:
4565
5119
  stop stop
4566
5120
  restart stop then start
4567
5121
  run start in foreground (for developers)
4568
- logs [-n <N>] show event logs
5122
+ logs [-n <N>] tail the daemon diagnostic log (lifecycle, listener boot)
5123
+ sql --query "<SQL>" query inbound connector traffic (raw + processed verdict)
4569
5124
  listeners list running connector listeners (alive / dead)
4570
5125
 
4571
5126
  examples:
@@ -4682,6 +5237,70 @@ const gatewayLogsHandler = factory.createHandlers(zValidator$1("query", z.object
4682
5237
  await tail.exited;
4683
5238
  process.exit(0);
4684
5239
  });
5240
+ //#endregion
5241
+ //#region lib/cli/routes/gateway.sql.ts
5242
+ const sqlHelp = `funnel gateway sql — query inbound connector traffic with SQL
5243
+
5244
+ usage: funnel gateway sql --query "<SELECT ...>"
5245
+
5246
+ Runs one read-only SELECT against the daemon's diagnostic store of inbound
5247
+ connector events and prints the rows as JSON. Use it to answer "Slack
5248
+ delivered an event, so why was there no notification?".
5249
+
5250
+ Three views:
5251
+ raw every inbound event, untouched, before any filtering
5252
+ processed the verdict for that event after the per-type processor ran
5253
+ connection the listener lifecycle (so you can tell events never arrived)
5254
+
5255
+ Shared columns (all three views):
5256
+ seq row id within the view (not comparable across views)
5257
+ ts epoch milliseconds
5258
+ type connector kind: slack | discord | gh | schedule
5259
+ connector_id funnel connector id
5260
+ channel_id funnel channel id
5261
+ raw and processed also have:
5262
+ event_id correlation id shared by an event's raw and processed rows
5263
+ payload raw: the original event JSON (text); processed: the delivered body, or "" when skipped
5264
+ processed also has:
5265
+ outcome 'emitted' | 'emitted:delivery-failed' | 'skip:<reason>'
5266
+ (skip reasons: skip:type, skip:subtype, skip:dedup,
5267
+ skip:self-user, skip:self-bot, skip:preprocess)
5268
+ connection also has:
5269
+ status 'started' | 'connected' | 'disconnected' | 'auth-failed' | 'stopped' | 'error'
5270
+ detail an error message or reason, or "" when none
5271
+
5272
+ To trace one event end to end, join raw and processed on event_id. When the
5273
+ event tables are empty, query connection — a listener that never connected, or
5274
+ failed auth, explains why nothing arrived.
5275
+
5276
+ examples:
5277
+ funnel gateway sql --query "SELECT event_id, ts, type FROM raw ORDER BY seq DESC LIMIT 20"
5278
+ funnel gateway sql --query "SELECT outcome, COUNT(*) n FROM processed GROUP BY outcome"
5279
+ funnel gateway sql --query "SELECT r.payload FROM raw r JOIN processed p USING(event_id) WHERE p.outcome='skip:dedup'"
5280
+ funnel gateway sql --query "SELECT ts, status, detail FROM connection WHERE status IN ('auth-failed','error') ORDER BY seq DESC"`;
5281
+ const gatewaySqlHandler = factory.createHandlers(zValidator$1("query", z.object({ query: z.string().optional() }), sqlHelp), async (c) => {
5282
+ const sql = c.req.valid("query").query;
5283
+ if (!sql) return c.text(sqlHelp);
5284
+ const tmpDir = funnelTmpDir();
5285
+ const rawPath = join(tmpDir, "connector-raw.db");
5286
+ const processedPath = join(tmpDir, "connector-processed.db");
5287
+ const connectionPath = join(tmpDir, "connector-connection.db");
5288
+ if (!existsSync(rawPath) || !existsSync(processedPath) || !existsSync(connectionPath)) return c.text("no diagnostic store yet (the gateway has not initialized it)");
5289
+ const reader = new ConnectorDiagnosticSqlReader({
5290
+ rawPath,
5291
+ processedPath,
5292
+ connectionPath
5293
+ });
5294
+ const rows = (() => {
5295
+ try {
5296
+ return reader.query(sql);
5297
+ } finally {
5298
+ reader.close();
5299
+ }
5300
+ })();
5301
+ if (rows instanceof Error) return c.text(`error: ${rows.message}`);
5302
+ return c.text(JSON.stringify(rows, null, 2));
5303
+ });
4685
5304
  const gatewayRestartHandler = factory.createHandlers(zValidator$1("query", z.object({ "no-caffeine": z.string().optional() }), `funnel gateway restart — restart the gateway
4686
5305
 
4687
5306
  usage: funnel gateway restart [--no-caffeine]
@@ -5090,7 +5709,7 @@ const createCliApp = (funnel) => {
5090
5709
  if (error instanceof HTTPException) return c.text(`error: ${error.message}`, error.status);
5091
5710
  return c.text(`error: ${error instanceof Error ? error.message : String(error)}`, 400);
5092
5711
  });
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);
5712
+ 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
5713
  };
5095
5714
  /** CLI Hono app wired to a default `new Funnel()`. For embedding with a custom Funnel use `createCliApp`. */
5096
5715
  const app = createCliApp(new Funnel());
@@ -6493,6 +7112,11 @@ function ChannelsView(props) {
6493
7112
  }
6494
7113
  //#endregion
6495
7114
  //#region lib/tui/views/connectors-view.tsx
7115
+ const tokenDisplay = (literal, envVar) => {
7116
+ if (literal !== void 0 && literal !== "") return literal;
7117
+ if (envVar !== void 0 && envVar !== "") return `env:${envVar}`;
7118
+ return "—";
7119
+ };
6496
7120
  const formatTimestamp = (iso) => {
6497
7121
  if (!iso) return "—";
6498
7122
  const d = new Date(iso);
@@ -6577,10 +7201,10 @@ function ConnectorsView(props) {
6577
7201
  }),
6578
7202
  connector.type === "slack" ? /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(ReadonlyField, {
6579
7203
  label: "bot-token",
6580
- value: connector.botToken
7204
+ value: tokenDisplay(connector.botToken, connector.botTokenEnv)
6581
7205
  }), /* @__PURE__ */ jsx(ReadonlyField, {
6582
7206
  label: "app-token",
6583
- value: connector.appToken
7207
+ value: tokenDisplay(connector.appToken, connector.appTokenEnv)
6584
7208
  })] }) : null,
6585
7209
  connector.type === "gh" ? /* @__PURE__ */ jsx(ReadonlyField, {
6586
7210
  label: "poll",
@@ -6588,7 +7212,7 @@ function ConnectorsView(props) {
6588
7212
  }) : null,
6589
7213
  connector.type === "discord" ? /* @__PURE__ */ jsx(ReadonlyField, {
6590
7214
  label: "bot-token",
6591
- value: connector.botToken
7215
+ value: tokenDisplay(connector.botToken, connector.botTokenEnv)
6592
7216
  }) : null,
6593
7217
  connector.type === "schedule" ? /* @__PURE__ */ jsx(ReadonlyField, {
6594
7218
  label: "entries",
@@ -7295,4 +7919,4 @@ async function launchTui(funnel) {
7295
7919
  });
7296
7920
  }
7297
7921
  //#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 };
7922
+ 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 };