@interactive-inc/claude-funnel 0.60.1 → 0.63.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 (88) hide show
  1. package/README.md +2 -2
  2. package/dist/bin.js +428 -761
  3. package/dist/{channels-2g_BU1N0.d.ts → channels-B8RQPrVq.d.ts} +17 -16
  4. package/dist/claude.d.ts +5 -7
  5. package/dist/claude.js +143 -36
  6. package/dist/{connector-descriptor-6SXJoszo.d.ts → connector-descriptor-ClEEbuW3.d.ts} +50 -11
  7. package/dist/connector-diagnostics-recorder-COtNEmUp.js +42 -0
  8. package/dist/connectors/discord.d.ts +31 -37
  9. package/dist/connectors/discord.js +3 -3
  10. package/dist/connectors/gh.d.ts +37 -33
  11. package/dist/connectors/gh.js +3 -3
  12. package/dist/connectors/schedule.d.ts +9 -57
  13. package/dist/connectors/schedule.js +3 -3
  14. package/dist/connectors/slack.d.ts +71 -131
  15. package/dist/connectors/slack.js +4 -3
  16. package/dist/diagnostics.d.ts +1 -1
  17. package/dist/diagnostics.js +1 -1
  18. package/dist/discord-connector-DIFkYBbi.js +250 -0
  19. package/dist/discord-connector-schema-D-bOVAKt.d.ts +22 -0
  20. package/dist/docs.js +1 -1
  21. package/dist/doctor.d.ts +1 -1
  22. package/dist/doctor.js +1 -1
  23. package/dist/{file-process-guard-C_PLxfUX.d.ts → file-process-guard-DGHxALfI.d.ts} +6 -6
  24. package/dist/{file-system-o51IsM0W.d.ts → file-system-VhwwXZbm.d.ts} +8 -0
  25. package/dist/flume-source-listener-Dim5szHG.d.ts +133 -0
  26. package/dist/{funnel-diagnostics-CSiJmPlZ.js → funnel-diagnostics-Cvk6Sk4x.js} +193 -43
  27. package/dist/{funnel-diagnostics-DpXOsCty.d.ts → funnel-diagnostics-b9ar0Ing.d.ts} +67 -5
  28. package/dist/{funnel-docs-BxXZ9Ksx.js → funnel-docs-C-ge0MuB.js} +42 -6
  29. package/dist/{funnel-doctor-CZf_0Luq.d.ts → funnel-doctor-CnRQi4kM.d.ts} +2 -2
  30. package/dist/{funnel-doctor-DiJCjHsg.js → funnel-doctor-XrI2GBH8.js} +1 -1
  31. package/dist/funnel-error-0t1MK1R6.js +75 -0
  32. package/dist/{funnel-recovery-DnLrdWO9.d.ts → funnel-recovery-CMhY8Jfk.d.ts} +1 -1
  33. package/dist/gateway/daemon.js +167 -527
  34. package/dist/gateway.d.ts +3 -3
  35. package/dist/gateway.js +3 -3
  36. package/dist/gh-connector-BUGCOEWS.js +187 -0
  37. package/dist/{gh-connector-schema-Rzwc1c1N.js → gh-connector-schema-CAqIhzGr.js} +7 -0
  38. package/dist/gh-connector-schema-DWQaB6gX.d.ts +16 -0
  39. package/dist/{index-CgY8NdMz.d.ts → index-DxRikYmu.d.ts} +37 -19
  40. package/dist/index.d.ts +182 -22
  41. package/dist/index.js +363 -173
  42. package/dist/{local-config-json-schema-JyLqOQNX.js → local-config-json-schema-DexV8vX3.js} +24 -4
  43. package/dist/local-config.d.ts +39 -2
  44. package/dist/local-config.js +53 -2
  45. package/dist/logger.js +1 -1
  46. package/dist/loopback-fetch-CVNuN3YZ.js +40 -0
  47. package/dist/{local-config-sync-Dh1Croqe.d.ts → memory-token-prompter-DP_YV9xX.d.ts} +30 -3
  48. package/dist/node-file-system-BOXIHW_Q.js +174 -0
  49. package/dist/{profiles-DSzTeKQw.js → profiles-ZHLONml4.js} +49 -49
  50. package/dist/{profiles-Cy5wXQ0L.d.ts → profiles-cVZQkM69.d.ts} +3 -3
  51. package/dist/profiles.d.ts +1 -1
  52. package/dist/profiles.js +1 -1
  53. package/dist/recovery.d.ts +1 -1
  54. package/dist/recovery.js +1 -1
  55. package/dist/resolve-connector-token-DxDG9mhf.js +22 -0
  56. package/dist/{schedule-connector-L4uzg5M8.js → schedule-connector-9k3gOIgl.js} +54 -55
  57. package/dist/schedule-connector-schema-Z0RXLgPI.d.ts +49 -0
  58. package/dist/settings-reader-BNxjsxCB.d.ts +27 -0
  59. package/dist/{settings-store-CUKSeTXC.js → settings-store-C2QdOH-t.js} +23 -4
  60. package/dist/slack-connector-BU86fIge.js +359 -0
  61. package/dist/slack-event-processor-BhCf5Wiy.d.ts +95 -0
  62. package/dist/slack-event-processor-xFDG3US0.js +176 -0
  63. package/dist/slot-fields-D-pvMgTK.js +249 -0
  64. package/dist/{memory-diagnostic-log-CI60kNfB.js → sqlite-diagnostic-log-DOTPW-tG.js} +373 -249
  65. package/dist/{yaml-render-93pX7EF7.js → yaml-render--J1_3BSA.js} +25 -21
  66. package/package.json +2 -4
  67. package/dist/discord-connector-BL36yvbL.js +0 -250
  68. package/dist/gateway-base-url-Dy4Ykuoh.js +0 -14
  69. package/dist/gh-connector-DpiixfQZ.js +0 -226
  70. package/dist/http-client-oICicjuO.d.ts +0 -18
  71. package/dist/memory-token-prompter-B4sjyaAq.d.ts +0 -57
  72. package/dist/memory-token-prompter-CZde7e6y.js +0 -61
  73. package/dist/node-file-system-Blr8pAir.js +0 -48
  74. package/dist/settings-reader-BIFB_j2f.d.ts +0 -18
  75. package/dist/slack-connector-DQIFPdBF.js +0 -484
  76. package/dist/slot-fields-CMoRpwuy.js +0 -45
  77. /package/dist/{connector-adapter-DU9Rvyec.js → connector-adapter-Dvs8N7ew.js} +0 -0
  78. /package/dist/{connector-listener-DR3aKOuK.js → connector-listener-mPGZYa8e.js} +0 -0
  79. /package/dist/{diagnostic-sql-reader-C9zR-Csp.js → diagnostic-sql-reader-oXZnWFf_.js} +0 -0
  80. /package/dist/{discord-connector-schema-B_N6IXLz.js → discord-connector-schema-B4YpWpR3.js} +0 -0
  81. /package/dist/{error-message-of-Byi4y0Uf.js → error-message-of-ColuYmAk.js} +0 -0
  82. /package/dist/{funnel-log-sqlite-sink-kqJbx2H7.js → funnel-log-sqlite-sink-DLYkY0pZ.js} +0 -0
  83. /package/dist/{funnel-recovery-BFdPjL6Z.js → funnel-recovery-DKnEutUS.js} +0 -0
  84. /package/dist/{node-http-client-lowp60Oa.js → node-http-client-u00atiKx.js} +0 -0
  85. /package/dist/{schedule-connector-schema-CfyuMCMh.js → schedule-connector-schema-DKEPZnVv.js} +0 -0
  86. /package/dist/{settings-reader-CtQ-Ix8_.js → settings-reader-9FcX3qS1.js} +0 -0
  87. /package/dist/{settings-schema-D1xcOqRu.d.ts → settings-schema-BL_c2Udm.d.ts} +0 -0
  88. /package/dist/{slack-connector-schema-C1zEf4TG.js → slack-connector-schema-Dem8to4P.js} +0 -0
@@ -1,7 +1,8 @@
1
- import { t as gatewayLoopbackUrl } from "./gateway-base-url-Dy4Ykuoh.js";
2
- import { t as ConnectorDiagnosticSqlReader } from "./diagnostic-sql-reader-C9zR-Csp.js";
1
+ import { n as gatewayLoopbackUrl, t as loopbackFetch } from "./loopback-fetch-CVNuN3YZ.js";
2
+ import { t as errorMessageOf } from "./error-message-of-ColuYmAk.js";
3
+ import { t as ConnectorDiagnosticSqlReader } from "./diagnostic-sql-reader-oXZnWFf_.js";
3
4
  import { join } from "node:path";
4
- import { existsSync } from "node:fs";
5
+ import { existsSync, readFileSync } from "node:fs";
5
6
  //#region lib/services/diagnostics/diagnostic-event.ts
6
7
  const stringOrNull = (value) => typeof value === "string" && value.length > 0 ? value : null;
7
8
  const numberOrNull = (value) => typeof value === "number" ? value : null;
@@ -63,21 +64,44 @@ const connectorOf = (channel, connectorId) => {
63
64
  if (connectorId === null) return void 0;
64
65
  return channel.connectors?.find((connector) => connector.id === connectorId)?.name;
65
66
  };
67
+ const FLAPPING_ERROR_THRESHOLD = 3;
66
68
  const buildDiagnosis = (report) => {
67
69
  const rootCause = (report.connectionErrors[report.connectionErrors.length - 1] ?? null)?.detail ?? null;
70
+ const channel = report.channel;
68
71
  if (!report.gateway.running) return {
69
72
  status: "error",
70
73
  message: "gateway is not running",
71
74
  nextActions: ["fnl gateway start"],
72
75
  rootCause: null
73
76
  };
74
- const channel = report.channel;
75
- if (!(report.listeners.length > 0)) return {
77
+ if (report.gateway.statusError !== null) return {
78
+ status: "error",
79
+ message: `gateway running but status probe failed: ${report.gateway.statusError}`,
80
+ nextActions: ["fnl gateway restart"],
81
+ rootCause: report.gateway.statusError
82
+ };
83
+ if (report.configuredConnectors > report.listeners.length) return {
84
+ status: "error",
85
+ message: `${report.configuredConnectors} connector(s) configured but ${report.listeners.length} registered with supervisor`,
86
+ nextActions: ["fnl gateway restart"],
87
+ rootCause: "supervisor missing listeners declared in settings.json"
88
+ };
89
+ if (report.configuredConnectors === 0) return {
76
90
  status: "warn",
77
91
  message: "no connectors configured on this channel",
78
92
  nextActions: [`fnl channels ${channel} connectors add <name> --type=slack ...`],
79
93
  rootCause: null
80
94
  };
95
+ const authFailed = report.connectionErrors.filter((e) => e.status === "auth-failed");
96
+ if (authFailed.length > 0) {
97
+ const detail = authFailed[authFailed.length - 1]?.detail ?? null;
98
+ return {
99
+ status: "error",
100
+ message: "connector credentials rejected (auth-failed)",
101
+ nextActions: [`fnl channels ${channel} connectors set <connector> --bot-token=<new>`, "fnl gateway restart"],
102
+ rootCause: detail ?? "token rejected by upstream auth.test"
103
+ };
104
+ }
81
105
  const allDead = report.listeners.every((l) => !l.alive);
82
106
  const someDead = report.listeners.some((l) => !l.alive);
83
107
  if (allDead) return {
@@ -92,6 +116,13 @@ const buildDiagnosis = (report) => {
92
116
  nextActions: ["fnl doctor --fix"],
93
117
  rootCause
94
118
  };
119
+ const flapping = report.listeners.filter((l) => l.errors >= FLAPPING_ERROR_THRESHOLD);
120
+ if (flapping.length > 0) return {
121
+ status: "warn",
122
+ message: `listener(s) flapping (≥${FLAPPING_ERROR_THRESHOLD} errors): ${flapping.map((l) => l.name).join(", ")}`,
123
+ nextActions: ["fnl gateway logs"],
124
+ rootCause
125
+ };
95
126
  if (report.claudeClients === 0) return {
96
127
  status: "warn",
97
128
  message: "no Claude connected to this channel",
@@ -141,15 +172,15 @@ var FunnelDiagnostics = class {
141
172
  const channels = this.props.channels.list();
142
173
  const target = channelName ? channels.find((ch) => ch.name === channelName) ?? null : channels[0] ?? null;
143
174
  if (!target) return null;
144
- const gatewayBody = await this.fetchGatewayStatus();
175
+ const gatewayProbe = await this.fetchGatewayStatus();
145
176
  const store = this.resolveStore();
146
- return this.buildChannelDiagnosis(target, gatewayBody, store, 5);
177
+ return this.buildChannelDiagnosis(target, gatewayProbe, store, 5);
147
178
  }
148
179
  async diagnoseAll() {
149
180
  const channels = this.props.channels.list();
150
- const gatewayBody = await this.fetchGatewayStatus();
181
+ const gatewayProbe = await this.fetchGatewayStatus();
151
182
  const store = this.resolveStore();
152
- const reports = await Promise.all(channels.map((ch) => this.buildChannelDiagnosis(ch, gatewayBody, store, 5)));
183
+ const reports = await Promise.all(channels.map((ch) => this.buildChannelDiagnosis(ch, gatewayProbe, store, 5)));
153
184
  const errorChannels = reports.filter((r) => r.diagnosis.status === "error").map((r) => r.channel);
154
185
  const warnChannels = reports.filter((r) => r.diagnosis.status === "warn").map((r) => r.channel);
155
186
  const okChannels = reports.filter((r) => r.diagnosis.status === "ok").map((r) => r.channel);
@@ -167,36 +198,97 @@ var FunnelDiagnostics = class {
167
198
  channels: reports
168
199
  };
169
200
  }
170
- async recentEvents(channelName, limit = 20) {
171
- const store = this.resolveStore();
172
- if (!store) return [];
173
- const channelId = this.resolveChannelId(channelName);
174
- if (channelName && !channelId) return [];
175
- const reader = new ConnectorDiagnosticSqlReader(store);
176
- const rows = channelId ? queryRows(reader, "SELECT seq, ts, type, outcome, payload FROM processed WHERE channel_id = ? ORDER BY seq DESC LIMIT ?", [channelId, limit]) : queryRows(reader, "SELECT seq, ts, type, outcome, payload FROM processed ORDER BY seq DESC LIMIT ?", [limit]);
201
+ async recentEvents(channelName, options = {}) {
202
+ const limit = options.limit ?? 20;
203
+ const ids = this.resolveScope(channelName, options.connector);
204
+ if (ids === null) return [];
205
+ const rows = queryRows(new ConnectorDiagnosticSqlReader(ids.store), `SELECT seq, ts, type, outcome, payload, event_id FROM processed ${ids.whereClause} ORDER BY seq DESC LIMIT ?`, [...ids.params, limit]);
177
206
  if (rows instanceof Error) return [];
178
207
  return rows.reverse().map(toDiagnosticEvent);
179
208
  }
180
- async droppedEvents(channelName, limit = 20) {
181
- const store = this.resolveStore();
182
- if (!store) return [];
183
- const channelId = this.resolveChannelId(channelName);
184
- if (channelName && !channelId) return [];
185
- const reader = new ConnectorDiagnosticSqlReader(store);
186
- const rows = channelId ? queryRows(reader, "SELECT seq, ts, type, outcome, payload, event_id FROM processed WHERE channel_id = ? AND outcome LIKE 'skip:%' ORDER BY seq DESC LIMIT ?", [channelId, limit]) : queryRows(reader, "SELECT seq, ts, type, outcome, payload, event_id FROM processed WHERE outcome LIKE 'skip:%' ORDER BY seq DESC LIMIT ?", [limit]);
209
+ async droppedEvents(channelName, options = {}) {
210
+ const limit = options.limit ?? 20;
211
+ const ids = this.resolveScope(channelName, options.connector);
212
+ if (ids === null) return [];
213
+ const where = ids.whereClause ? `${ids.whereClause} AND outcome LIKE 'skip:%'` : "WHERE outcome LIKE 'skip:%'";
214
+ const rows = queryRows(new ConnectorDiagnosticSqlReader(ids.store), `SELECT seq, ts, type, outcome, payload, event_id FROM processed ${where} ORDER BY seq DESC LIMIT ?`, [...ids.params, limit]);
187
215
  if (rows instanceof Error) return [];
188
216
  return rows.reverse().map(toDiagnosticEvent);
189
217
  }
190
- async connectionErrors(channelName, limit = 20) {
191
- const store = this.resolveStore();
192
- if (!store) return [];
193
- const channelId = this.resolveChannelId(channelName);
194
- if (channelName && !channelId) return [];
195
- const reader = new ConnectorDiagnosticSqlReader(store);
196
- const rows = channelId ? queryRows(reader, "SELECT seq, ts, type, status, detail FROM connection WHERE channel_id = ? AND status IN ('auth-failed','error') ORDER BY seq DESC LIMIT ?", [channelId, limit]) : queryRows(reader, "SELECT seq, ts, type, status, detail FROM connection WHERE status IN ('auth-failed','error') ORDER BY seq DESC LIMIT ?", [limit]);
218
+ /**
219
+ * Raw inbound rows the connector recorded before any processing. The most
220
+ * useful read when "did the event even reach us?" is the question, since
221
+ * the processed table never gets a row for an event the listener dropped
222
+ * pre-processor.
223
+ */
224
+ async rawEvents(channelName, options = {}) {
225
+ const limit = options.limit ?? 20;
226
+ const ids = this.resolveScope(channelName, options.connector);
227
+ if (ids === null) return [];
228
+ const rows = queryRows(new ConnectorDiagnosticSqlReader(ids.store), `SELECT seq, ts, type, '' AS outcome, payload, event_id FROM raw ${ids.whereClause} ORDER BY seq DESC LIMIT ?`, [...ids.params, limit]);
229
+ if (rows instanceof Error) return [];
230
+ return rows.reverse().map(toDiagnosticEvent);
231
+ }
232
+ async connectionErrors(channelName, options = {}) {
233
+ const limit = options.limit ?? 20;
234
+ const ids = this.resolveScope(channelName, options.connector);
235
+ if (ids === null) return [];
236
+ const where = ids.whereClause ? `${ids.whereClause} AND status IN ('auth-failed','error')` : "WHERE status IN ('auth-failed','error')";
237
+ const rows = queryRows(new ConnectorDiagnosticSqlReader(ids.store), `SELECT seq, ts, type, status, detail FROM connection ${where} ORDER BY seq DESC LIMIT ?`, [...ids.params, limit]);
197
238
  if (rows instanceof Error) return [];
198
239
  return rows.reverse().map(toDiagnosticConnectionError);
199
240
  }
241
+ /**
242
+ * Full connection lifecycle for one channel/connector — started, connected,
243
+ * disconnected, stopped, plus the auth-failed / error rows that
244
+ * `connectionErrors()` already surfaces. Use when you need to see the shape
245
+ * of a flap (connected → reconnecting → connected → disconnected) instead
246
+ * of just the failures.
247
+ */
248
+ async connectionTimeline(channelName, options = {}) {
249
+ const limit = options.limit ?? 20;
250
+ const ids = this.resolveScope(channelName, options.connector);
251
+ if (ids === null) return [];
252
+ const rows = queryRows(new ConnectorDiagnosticSqlReader(ids.store), `SELECT seq, ts, type, status, detail FROM connection ${ids.whereClause} ORDER BY seq DESC LIMIT ?`, [...ids.params, limit]);
253
+ if (rows instanceof Error) return [];
254
+ return rows.reverse().map(toDiagnosticConnectionError);
255
+ }
256
+ /**
257
+ * Tail of `~/.funnel/.../funnel.log`. Use when a flume internal log (e.g.
258
+ * `slack/auth.test failed`) needs to be read from MCP — the gateway file
259
+ * sink is the only place that captures structured FunnelLogger output.
260
+ *
261
+ * `grep` is a case-insensitive substring filter applied after read so all
262
+ * matching levels and sources are scanned.
263
+ */
264
+ async recentLogs(options = {}) {
265
+ const limit = options.limit ?? 200;
266
+ const path = join(this.props.tmpDir, "funnel.log");
267
+ if (!existsSync(path)) return {
268
+ lines: [],
269
+ path: null,
270
+ truncated: false
271
+ };
272
+ let content;
273
+ try {
274
+ content = readFileSync(path, "utf-8");
275
+ } catch (error) {
276
+ return {
277
+ lines: [`(read failed: ${errorMessageOf(error)})`],
278
+ path,
279
+ truncated: false
280
+ };
281
+ }
282
+ const all = content.split("\n").filter((line) => line.length > 0);
283
+ const needle = options.grep?.toLowerCase();
284
+ const filtered = needle ? all.filter((line) => line.toLowerCase().includes(needle)) : all;
285
+ const truncated = filtered.length > limit;
286
+ return {
287
+ lines: truncated ? filtered.slice(filtered.length - limit) : filtered,
288
+ path,
289
+ truncated
290
+ };
291
+ }
200
292
  async replay(channelName, seq) {
201
293
  const channel = this.props.channels.list().find((ch) => ch.name === channelName);
202
294
  if (!channel) return { state: "not-found" };
@@ -259,19 +351,79 @@ var FunnelDiagnostics = class {
259
351
  if (!channelName) return null;
260
352
  return this.props.channels.list().find((ch) => ch.name === channelName)?.id ?? null;
261
353
  }
354
+ /**
355
+ * Resolves a (channel, connector) filter into the SQL where-clause + the
356
+ * positional params, or returns `null` when the requested scope cannot be
357
+ * resolved (channel not found, connector not found in that channel, no
358
+ * store on disk yet). Centralises the channel/connector → id mapping so
359
+ * each read method does not redo the lookup.
360
+ */
361
+ resolveScope(channelName, connectorName) {
362
+ const store = this.resolveStore();
363
+ if (!store) return null;
364
+ if (!channelName) return {
365
+ store,
366
+ whereClause: "",
367
+ params: []
368
+ };
369
+ const channel = this.props.channels.list().find((ch) => ch.name === channelName) ?? null;
370
+ if (!channel) return null;
371
+ if (!connectorName) return {
372
+ store,
373
+ whereClause: "WHERE channel_id = ?",
374
+ params: [channel.id]
375
+ };
376
+ const connectorId = channel.connectors?.find((c) => c.name === connectorName)?.id ?? null;
377
+ if (!connectorId) return null;
378
+ return {
379
+ store,
380
+ whereClause: "WHERE channel_id = ? AND connector_id = ?",
381
+ params: [channel.id, connectorId]
382
+ };
383
+ }
262
384
  async fetchGatewayStatus() {
263
385
  const gatewayStatus = this.props.gateway.getStatus();
264
- if (!gatewayStatus.running) return null;
386
+ if (!gatewayStatus.running) return {
387
+ body: null,
388
+ error: null
389
+ };
265
390
  const token = this.props.gatewayToken.read();
266
391
  const headers = token ? { Authorization: `Bearer ${token}` } : {};
267
- const res = await fetch(`${gatewayLoopbackUrl(gatewayStatus.port)}/status`, { headers }).catch(() => null);
268
- if (!res || !res.ok) return null;
269
- const body = await res.json();
270
- return isGatewayStatusResponse(body) ? body : null;
392
+ let res = null;
393
+ try {
394
+ res = await loopbackFetch(`${gatewayLoopbackUrl(gatewayStatus.port)}/status`, { headers });
395
+ } catch (error) {
396
+ return {
397
+ body: null,
398
+ error: `fetch failed: ${errorMessageOf(error)}`
399
+ };
400
+ }
401
+ if (!res.ok) return {
402
+ body: null,
403
+ error: `gateway /status returned ${res.status}`
404
+ };
405
+ let body;
406
+ try {
407
+ body = await res.json();
408
+ } catch (error) {
409
+ return {
410
+ body: null,
411
+ error: `gateway /status body parse failed: ${errorMessageOf(error)}`
412
+ };
413
+ }
414
+ if (!isGatewayStatusResponse(body)) return {
415
+ body: null,
416
+ error: "gateway /status returned an unrecognized shape"
417
+ };
418
+ return {
419
+ body,
420
+ error: null
421
+ };
271
422
  }
272
- async buildChannelDiagnosis(target, gatewayBody, store, eventLimit) {
423
+ async buildChannelDiagnosis(target, gatewayProbe, store, eventLimit) {
273
424
  const gatewayStatus = this.props.gateway.getStatus();
274
425
  const targetName = target.name;
426
+ const gatewayBody = gatewayProbe.body;
275
427
  const baseReport = {
276
428
  channel: targetName,
277
429
  channelId: target.id,
@@ -279,8 +431,10 @@ var FunnelDiagnostics = class {
279
431
  running: gatewayStatus.running,
280
432
  pid: gatewayStatus.pid,
281
433
  port: gatewayStatus.running ? gatewayStatus.port : null,
282
- uptimeMs: gatewayBody?.uptimeMs ?? null
434
+ uptimeMs: gatewayBody?.uptimeMs ?? null,
435
+ statusError: gatewayProbe.error
283
436
  },
437
+ configuredConnectors: target.connectors?.length ?? 0,
284
438
  listeners: [],
285
439
  claudeClients: 0,
286
440
  recentEvents: [],
@@ -300,12 +454,8 @@ var FunnelDiagnostics = class {
300
454
  if (store) {
301
455
  const evRows = queryRows(new ConnectorDiagnosticSqlReader(store), "SELECT seq, ts, type, outcome, payload FROM processed WHERE channel_id = ? ORDER BY seq DESC LIMIT ?", [target.id, eventLimit]);
302
456
  if (!(evRows instanceof Error)) baseReport.recentEvents = evRows.reverse().map(toDiagnosticEvent);
303
- const hasDeadListeners = baseReport.listeners.some((l) => !l.alive);
304
- const hasListenerErrors = baseReport.listeners.some((l) => l.errors > 0);
305
- if (hasDeadListeners || hasListenerErrors) {
306
- const errRows = queryRows(new ConnectorDiagnosticSqlReader(store), "SELECT ts, type, status, detail FROM connection WHERE channel_id = ? AND status IN ('auth-failed','error') ORDER BY seq DESC LIMIT 3", [target.id]);
307
- if (!(errRows instanceof Error)) baseReport.connectionErrors = errRows.reverse().map(toDiagnosticConnectionError);
308
- }
457
+ const errRows = queryRows(new ConnectorDiagnosticSqlReader(store), "SELECT ts, type, status, detail FROM connection WHERE channel_id = ? AND status IN ('auth-failed','error') ORDER BY seq DESC LIMIT 3", [target.id]);
458
+ if (!(errRows instanceof Error)) baseReport.connectionErrors = errRows.reverse().map(toDiagnosticConnectionError);
309
459
  }
310
460
  return {
311
461
  ...baseReport,
@@ -1,4 +1,4 @@
1
- import { t as ChannelConfig } from "./settings-schema-D1xcOqRu.js";
1
+ import { t as ChannelConfig } from "./settings-schema-BL_c2Udm.js";
2
2
 
3
3
  //#region lib/engine/diagnostic-log/diagnostic-sql-reader.d.ts
4
4
  type Props$1 = {
@@ -104,7 +104,15 @@ type ChannelDiagnosis = {
104
104
  pid: number | null;
105
105
  port: number | null;
106
106
  uptimeMs: number | null;
107
- };
107
+ /**
108
+ * Why the gateway /status probe failed to return a body. `null` when the
109
+ * gateway is not running (running=false makes the absence self-explanatory)
110
+ * or when the probe succeeded. A non-null value signals the daemon is up
111
+ * but the probe failed (auth refused, fetch error, non-OK response).
112
+ */
113
+ statusError: string | null;
114
+ }; /** Connectors declared in settings for this channel. */
115
+ configuredConnectors: number;
108
116
  listeners: Array<{
109
117
  name: string;
110
118
  type: string;
@@ -163,12 +171,66 @@ declare class FunnelDiagnostics {
163
171
  constructor(props: Props);
164
172
  diagnose(channelName?: string): Promise<ChannelDiagnosis | null>;
165
173
  diagnoseAll(): Promise<DiagnoseAllReport>;
166
- recentEvents(channelName: string | null, limit?: number): Promise<DiagnosticEvent[]>;
167
- droppedEvents(channelName: string | null, limit?: number): Promise<DiagnosticEvent[]>;
168
- connectionErrors(channelName: string | null, limit?: number): Promise<DiagnosticConnectionError[]>;
174
+ recentEvents(channelName: string | null, options?: {
175
+ connector?: string;
176
+ limit?: number;
177
+ }): Promise<DiagnosticEvent[]>;
178
+ droppedEvents(channelName: string | null, options?: {
179
+ connector?: string;
180
+ limit?: number;
181
+ }): Promise<DiagnosticEvent[]>;
182
+ /**
183
+ * Raw inbound rows the connector recorded before any processing. The most
184
+ * useful read when "did the event even reach us?" is the question, since
185
+ * the processed table never gets a row for an event the listener dropped
186
+ * pre-processor.
187
+ */
188
+ rawEvents(channelName: string | null, options?: {
189
+ connector?: string;
190
+ limit?: number;
191
+ }): Promise<DiagnosticEvent[]>;
192
+ connectionErrors(channelName: string | null, options?: {
193
+ connector?: string;
194
+ limit?: number;
195
+ }): Promise<DiagnosticConnectionError[]>;
196
+ /**
197
+ * Full connection lifecycle for one channel/connector — started, connected,
198
+ * disconnected, stopped, plus the auth-failed / error rows that
199
+ * `connectionErrors()` already surfaces. Use when you need to see the shape
200
+ * of a flap (connected → reconnecting → connected → disconnected) instead
201
+ * of just the failures.
202
+ */
203
+ connectionTimeline(channelName: string | null, options?: {
204
+ connector?: string;
205
+ limit?: number;
206
+ }): Promise<DiagnosticConnectionError[]>;
207
+ /**
208
+ * Tail of `~/.funnel/.../funnel.log`. Use when a flume internal log (e.g.
209
+ * `slack/auth.test failed`) needs to be read from MCP — the gateway file
210
+ * sink is the only place that captures structured FunnelLogger output.
211
+ *
212
+ * `grep` is a case-insensitive substring filter applied after read so all
213
+ * matching levels and sources are scanned.
214
+ */
215
+ recentLogs(options?: {
216
+ grep?: string;
217
+ limit?: number;
218
+ }): Promise<{
219
+ lines: string[];
220
+ path: string | null;
221
+ truncated: boolean;
222
+ }>;
169
223
  replay(channelName: string, seq?: number): Promise<ReplayResult>;
170
224
  resolveStore(): StorePaths | null;
171
225
  private resolveChannelId;
226
+ /**
227
+ * Resolves a (channel, connector) filter into the SQL where-clause + the
228
+ * positional params, or returns `null` when the requested scope cannot be
229
+ * resolved (channel not found, connector not found in that channel, no
230
+ * store on disk yet). Centralises the channel/connector → id mapping so
231
+ * each read method does not redo the lookup.
232
+ */
233
+ private resolveScope;
172
234
  private fetchGatewayStatus;
173
235
  private buildChannelDiagnosis;
174
236
  }
@@ -494,9 +494,10 @@ Hono app over these services.
494
494
  import { Funnel } from "@interactive-inc/claude-funnel"
495
495
  import { slackConnector } from "@interactive-inc/claude-funnel/connectors/slack"
496
496
 
497
- // Connectors are fully DI: pass only the types you use. The core import never
498
- // bundles a connector SDK (@slack/bolt, discord.js) importing the sub-entry
499
- // does. With no connectors, the funnel handles zero connector types.
497
+ // Connectors are fully DI: pass only the types you use. The core import
498
+ // never bundles a connector's protocol code (Socket Mode / Gateway / poller)
499
+ // — importing the sub-entry does. With no connectors, the funnel handles
500
+ // zero connector types.
500
501
  const funnel = new Funnel({ connectors: [slackConnector()] }) // uses ~/.funnel
501
502
  const sandbox = Funnel.inMemory() // touches no disk / process / clock
502
503
 
@@ -543,9 +544,44 @@ For targeted imports (smaller bundle / clearer dependency footprint):
543
544
  import { discordConnector } from "@interactive-inc/claude-funnel/connectors/discord"
544
545
  import { scheduleConnector } from "@interactive-inc/claude-funnel/connectors/schedule"
545
546
 
546
- // Connector launch hooks are closed over by the descriptor factory:
547
- // slackConnector({ onAppCreated, preprocessEvent })
548
- // scheduleConnector({ onFired })
547
+ // Schedule fires can be observed by passing onFired to the descriptor:
548
+ // scheduleConnector({ onFired: (entry, firedAt) => { ... } })
549
+
550
+ ── flume 0.9 transport notes ───────────────────────────────────────────────
551
+
552
+ Slack / Discord / GitHub connectors wrap @interactive-inc/flume 0.9. Each
553
+ listener owns a single-source Flume FSM and reconnect is enabled by
554
+ default (infinite attempts, 1s base / 30s max exponential backoff +
555
+ jitter), so a wifi drop or upstream socket close auto-recovers without
556
+ the supervisor intervening.
557
+
558
+ Source ctor Flume options (cross-cutting)
559
+ ----------- -----------------------------
560
+ FlumeSlackSource({appToken, sources / onEvent (firehose) /
561
+ botToken}) onError / signal / deps / reconnect
562
+ FlumeDiscordSource({token,
563
+ intents}) Flume 0.9 collapsed every
564
+ FlumeGitHubSource({token, observation into one firehose: the
565
+ pollInterval}) onEvent callback receives a union of
566
+ { kind: "event" } | { kind: "log" }.
567
+ Funnel's base listener splits this
568
+ back into typed events, log forward,
569
+ and status mapping for subclasses.
570
+
571
+ new Funnel({ signal: controller.signal }) plumbs the AbortSignal down to
572
+ every Flume so a host SIGTERM handler can stop every listener cleanly:
573
+
574
+ const controller = new AbortController()
575
+ process.on("SIGTERM", () => controller.abort())
576
+ const funnel = new Funnel({
577
+ connectors: [slackConnector(), ghConnector()],
578
+ signal: controller.signal,
579
+ })
580
+
581
+ Custom connector types: extend FlumeSource from the flume package and
582
+ write your own ConnectorDescriptor — that's the only escape hatch for
583
+ host-specific protocol logic, since the bundled descriptors don't take
584
+ extension hooks.
549
585
 
550
586
  ── in-process gateway: receive events in your own process ──────────────────
551
587
 
@@ -1,5 +1,5 @@
1
- import { c as FunnelDiagnostics, n as DiagnoseAllReport, t as ChannelDiagnosis } from "./funnel-diagnostics-DpXOsCty.js";
2
- import { n as RecoveryAction, t as FunnelRecovery } from "./funnel-recovery-DnLrdWO9.js";
1
+ import { c as FunnelDiagnostics, n as DiagnoseAllReport, t as ChannelDiagnosis } from "./funnel-diagnostics-b9ar0Ing.js";
2
+ import { n as RecoveryAction, t as FunnelRecovery } from "./funnel-recovery-CMhY8Jfk.js";
3
3
 
4
4
  //#region lib/services/doctor/funnel-doctor.d.ts
5
5
  type Props = {
@@ -34,7 +34,7 @@ var FunnelDoctor = class {
34
34
  if (!result.ok && result.actions.length === 0) fixFailed = true;
35
35
  }
36
36
  if (mode === "aggressive") {
37
- if (applied.length === 0 || before.channels.some((ch) => ch.diagnosis.status === "error")) {
37
+ if ((await this.props.diagnostics.diagnoseAll()).channels.some((ch) => ch.diagnosis.status === "error")) {
38
38
  const result = await this.props.recovery.restartGateway();
39
39
  applied.push(...result.actions);
40
40
  if (!result.ok) fixFailed = true;
@@ -0,0 +1,75 @@
1
+ //#region lib/engine/error/funnel-error.ts
2
+ /**
3
+ * Base class every typed funnel error extends. Hosts can branch with
4
+ * `instanceof FunnelError` to distinguish library failures from arbitrary
5
+ * thrown values, then narrow to a specific subclass for action-grade
6
+ * matching. The `code` field is the discriminant for serialisation /
7
+ * cross-process boundaries where prototypes do not survive.
8
+ */
9
+ var FunnelError = class extends Error {
10
+ constructor(message, options) {
11
+ super(message);
12
+ this.name = new.target.name;
13
+ if (options?.cause !== void 0) Object.defineProperty(this, "cause", {
14
+ value: options.cause,
15
+ enumerable: false
16
+ });
17
+ }
18
+ };
19
+ var FunnelChannelNotFoundError = class extends FunnelError {
20
+ code = "channel-not-found";
21
+ constructor(channel, options) {
22
+ super(`channel not found: ${channel}`, options);
23
+ this.channel = channel;
24
+ }
25
+ };
26
+ var FunnelChannelAlreadyExistsError = class extends FunnelError {
27
+ code = "channel-already-exists";
28
+ constructor(channel, options) {
29
+ super(`channel already exists: ${channel}`, options);
30
+ this.channel = channel;
31
+ }
32
+ };
33
+ var FunnelConnectorNotFoundError = class extends FunnelError {
34
+ code = "connector-not-found";
35
+ constructor(channel, connector, options) {
36
+ super(`connector not found in ${channel}: ${connector}`, options);
37
+ this.channel = channel;
38
+ this.connector = connector;
39
+ }
40
+ };
41
+ var FunnelConnectorTypeMismatchError = class extends FunnelError {
42
+ code = "connector-type-mismatch";
43
+ constructor(connector, expected, actual, options) {
44
+ super(`connector ${connector} type mismatch: expected ${expected}, got ${actual}`, options);
45
+ this.connector = connector;
46
+ this.expected = expected;
47
+ this.actual = actual;
48
+ }
49
+ };
50
+ var FunnelAuthFailedError = class extends FunnelError {
51
+ code = "auth-failed";
52
+ constructor(connector, detail, options) {
53
+ super(`${connector}: auth failed — ${detail}`, options);
54
+ this.connector = connector;
55
+ this.detail = detail;
56
+ }
57
+ };
58
+ var FunnelGatewayBindError = class extends FunnelError {
59
+ code = "gateway-bind";
60
+ constructor(host, port, detail, options) {
61
+ super(`gateway failed to bind ${host}:${port} — ${detail}`, options);
62
+ this.host = host;
63
+ this.port = port;
64
+ this.detail = detail;
65
+ }
66
+ };
67
+ var FunnelTokenCollisionError = class extends FunnelError {
68
+ code = "token-collision";
69
+ constructor(connector, options) {
70
+ super(`${connector}: both literal token and tokenEnv reference are set — pick one`, options);
71
+ this.connector = connector;
72
+ }
73
+ };
74
+ //#endregion
75
+ export { FunnelConnectorTypeMismatchError as a, FunnelTokenCollisionError as c, FunnelConnectorNotFoundError as i, FunnelChannelAlreadyExistsError as n, FunnelError as o, FunnelChannelNotFoundError as r, FunnelGatewayBindError as s, FunnelAuthFailedError as t };
@@ -1,4 +1,4 @@
1
- import { t as ChannelConfig } from "./settings-schema-D1xcOqRu.js";
1
+ import { t as ChannelConfig } from "./settings-schema-BL_c2Udm.js";
2
2
 
3
3
  //#region lib/services/recovery/funnel-recovery.d.ts
4
4
  /** Narrow gateway control — start / stop / restart and a probe. */