@interactive-inc/claude-funnel 0.35.0 → 0.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -17,7 +17,6 @@ import { zValidator } from "@hono/zod-validator";
17
17
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
18
18
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
19
19
  import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
20
- import { stringify } from "yaml";
21
20
  //#region lib/engine/id/id-generator.ts
22
21
  /**
23
22
  * ID generator boundary. Default NodeFunnelIdGenerator wraps `crypto.randomUUID()`;
@@ -787,14 +786,15 @@ var FunnelClaude = class {
787
786
  /**
788
787
  * Mirrors claude's session storage path
789
788
  * (`<config-dir>/projects/<cwd-with-slashes-as-dashes>/<id>.jsonl`) to check
790
- * whether a recorded session still exists. Reads the same `CLAUDE_CONFIG_DIR`
791
- * the child will run under so the check matches reality; a wrong guess can
792
- * only ever produce a false negative (start fresh), never a bad resume.
789
+ * whether a recorded session still exists AND is non-empty. Reads the same
790
+ * `CLAUDE_CONFIG_DIR` the child will run under so the check matches reality; a
791
+ * wrong guess can only ever produce a false negative (start fresh), never a
792
+ * bad resume.
793
793
  */
794
794
  sessionFileExists(cwd, sessionId, recipeEnv) {
795
- const configDir = recipeEnv.CLAUDE_CONFIG_DIR ?? globalThis.process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude");
796
- const projectSlug = cwd.replace(/\//g, "-");
797
- return this.fs.existsSync(join(configDir, "projects", projectSlug, `${sessionId}.jsonl`));
795
+ const path = join(recipeEnv.CLAUDE_CONFIG_DIR ?? globalThis.process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude"), "projects", cwd.replace(/\//g, "-"), `${sessionId}.jsonl`);
796
+ if (!this.fs.existsSync(path)) return false;
797
+ return this.fs.readFileSync(path).trim().length > 0;
798
798
  }
799
799
  buildEnv(channelId, recipeEnv) {
800
800
  const env = {};
@@ -1316,7 +1316,8 @@ var MemoryFunnelLogger = class extends FunnelLogger {
1316
1316
  };
1317
1317
  //#endregion
1318
1318
  //#region lib/engine/mcp/mcp.ts
1319
- const FUNNEL_MCP_COMMAND = "funnel";
1319
+ const FUNNEL_MCP_COMMAND = "bun";
1320
+ const FUNNEL_MCP_ARGS = ["funnel", "mcp"];
1320
1321
  const FUNNEL_MCP_NAME = "funnel";
1321
1322
  const mcpEntrySchema = z.object({
1322
1323
  command: z.string().optional(),
@@ -1341,8 +1342,8 @@ var FunnelMcp = class {
1341
1342
  const servers = config.mcpServers ?? {};
1342
1343
  const targetName = this.findServerName(servers) ?? "funnel";
1343
1344
  servers[targetName] = {
1344
- command: FUNNEL_MCP_COMMAND,
1345
- args: ["mcp"]
1345
+ command: "bun",
1346
+ args: FUNNEL_MCP_ARGS
1346
1347
  };
1347
1348
  this.writeConfig(repoPath, {
1348
1349
  ...config,
@@ -1369,10 +1370,17 @@ var FunnelMcp = class {
1369
1370
  findServerName(servers) {
1370
1371
  for (const entry of Object.entries(servers)) {
1371
1372
  const name = entry[0];
1372
- if (entry[1]?.command === "funnel") return name;
1373
+ const value = entry[1];
1374
+ if (this.isFunnelEntry(value)) return name;
1373
1375
  }
1374
1376
  return null;
1375
1377
  }
1378
+ isFunnelEntry(value) {
1379
+ if (!value) return false;
1380
+ if (value.command === "bun" && value.args?.[0] === "funnel") return true;
1381
+ if (value.command === "funnel") return true;
1382
+ return false;
1383
+ }
1376
1384
  readConfig(repoPath) {
1377
1385
  const mcpPath = join(repoPath, ".mcp.json");
1378
1386
  if (!this.fs.existsSync(mcpPath)) return {};
@@ -1758,7 +1766,7 @@ var FunnelChannelPublisher = class {
1758
1766
  async publish(channelName, request) {
1759
1767
  if (!this.isDaemonRunning()) return OFFLINE$1;
1760
1768
  try {
1761
- const url = `http://localhost:${this.port}/channels/${encodeURIComponent(channelName)}/publish`;
1769
+ const url = `http://127.0.0.1:${this.port}/channels/${encodeURIComponent(channelName)}/publish`;
1762
1770
  const res = await fetch(url, {
1763
1771
  method: "POST",
1764
1772
  headers: {
@@ -2573,7 +2581,7 @@ var SqliteFunnelEventLog = class extends FunnelEventLog {
2573
2581
  record(record) {
2574
2582
  const event = {
2575
2583
  type: record.meta?.event_type ?? "unknown",
2576
- content: truncate(record.content),
2584
+ content: truncate$1(record.content),
2577
2585
  channel_id: record.channelId,
2578
2586
  connector_id: record.connectorId,
2579
2587
  meta: record.meta
@@ -2630,7 +2638,7 @@ var SqliteFunnelEventLog = class extends FunnelEventLog {
2630
2638
  this.sink.close();
2631
2639
  }
2632
2640
  };
2633
- function truncate(content) {
2641
+ function truncate$1(content) {
2634
2642
  if (content.length <= MAX_CONTENT_CHARS) return content;
2635
2643
  return `${content.slice(0, MAX_CONTENT_CHARS)}...`;
2636
2644
  }
@@ -2986,6 +2994,240 @@ const channelsPublishHandler$1 = factory$1.createHandlers(zParam(z.object({ chan
2986
2994
  return c.json(response);
2987
2995
  });
2988
2996
  //#endregion
2997
+ //#region lib/gateway/connector-diagnostic-sql-reader.ts
2998
+ /**
2999
+ * Read-only SQL surface over the three diagnostic tables, for Claude to query
3000
+ * the log with arbitrary `SELECT`s. It opens all files read-only and exposes
3001
+ * three views — `raw`, `processed`, `connection` — that hide the storage
3002
+ * details (the physical table is `leuco_log` and each row's columns live
3003
+ * inside a JSON `event` blob): the views surface the columns as plain fields,
3004
+ * with `payload` already pulled out of the nested JSON.
3005
+ *
3006
+ * The tables are separate files. `raw` and `processed` share an `event_id`,
3007
+ * so a `JOIN` answers "the event arrived, but what verdict did it get?";
3008
+ * `connection` answers the other half — "did the listener ever connect at
3009
+ * all?". Writes are impossible: the connection is read-only and `query`
3010
+ * rejects anything but a single `SELECT`.
3011
+ */
3012
+ var ConnectorDiagnosticSqlReader = class {
3013
+ db;
3014
+ constructor(props) {
3015
+ const db = new Database(props.rawPath, { readonly: true });
3016
+ try {
3017
+ db.run("PRAGMA busy_timeout = 500");
3018
+ db.prepare("ATTACH DATABASE ? AS processeddb").run(props.processedPath);
3019
+ db.prepare("ATTACH DATABASE ? AS connectiondb").run(props.connectionPath);
3020
+ db.run(rawViewSql);
3021
+ db.run(processedViewSql);
3022
+ db.run(connectionViewSql);
3023
+ } catch (error) {
3024
+ db.close();
3025
+ throw error;
3026
+ }
3027
+ this.db = db;
3028
+ Object.freeze(this);
3029
+ }
3030
+ /**
3031
+ * Run one read-only `SELECT` and return the rows. Returns an `Error` (rather
3032
+ * than throwing) for a non-SELECT statement or a SQL error, so the caller
3033
+ * can surface the message without a stack trace.
3034
+ */
3035
+ query(sql, params = []) {
3036
+ const trimmed = sql.trim().replace(/;$/, "").trim();
3037
+ if (!/^select\b/i.test(trimmed)) return /* @__PURE__ */ new Error("only a single SELECT statement is allowed");
3038
+ if (trimmed.includes(";")) return /* @__PURE__ */ new Error("only a single statement is allowed (remove the ';')");
3039
+ try {
3040
+ return this.db.prepare(trimmed).all(...params);
3041
+ } catch (error) {
3042
+ return error instanceof Error ? error : new Error(String(error));
3043
+ }
3044
+ }
3045
+ close() {
3046
+ this.db.close();
3047
+ }
3048
+ };
3049
+ const rawViewSql = `CREATE TEMP VIEW raw AS SELECT
3050
+ seq,
3051
+ ts,
3052
+ json_extract(event, '$.event_id') AS event_id,
3053
+ json_extract(event, '$.type') AS type,
3054
+ json_extract(event, '$.connector_id') AS connector_id,
3055
+ json_extract(event, '$.channel_id') AS channel_id,
3056
+ json_extract(event, '$.payload') AS payload
3057
+ FROM main.leuco_log`;
3058
+ const processedViewSql = `CREATE TEMP VIEW processed AS SELECT
3059
+ seq,
3060
+ ts,
3061
+ json_extract(event, '$.event_id') AS event_id,
3062
+ json_extract(event, '$.type') AS type,
3063
+ json_extract(event, '$.connector_id') AS connector_id,
3064
+ json_extract(event, '$.channel_id') AS channel_id,
3065
+ json_extract(event, '$.outcome') AS outcome,
3066
+ json_extract(event, '$.payload') AS payload
3067
+ FROM processeddb.leuco_log`;
3068
+ const connectionViewSql = `CREATE TEMP VIEW connection AS SELECT
3069
+ seq,
3070
+ ts,
3071
+ json_extract(event, '$.type') AS type,
3072
+ json_extract(event, '$.connector_id') AS connector_id,
3073
+ json_extract(event, '$.channel_id') AS channel_id,
3074
+ json_extract(event, '$.status') AS status,
3075
+ json_extract(event, '$.detail') AS detail
3076
+ FROM connectiondb.leuco_log`;
3077
+ //#endregion
3078
+ //#region lib/gateway/routes/debug.ts
3079
+ const extractPreview$1 = (payload) => {
3080
+ if (typeof payload !== "string" || payload.length === 0) return null;
3081
+ try {
3082
+ const parsed = JSON.parse(payload);
3083
+ if (parsed !== null && typeof parsed === "object" && "text" in parsed) {
3084
+ const text = String(parsed.text);
3085
+ return text.length > 80 ? `${text.slice(0, 80)}…` : text;
3086
+ }
3087
+ } catch {
3088
+ return payload.length > 80 ? `${payload.slice(0, 80)}…` : payload;
3089
+ }
3090
+ return payload.length > 80 ? `${payload.slice(0, 80)}…` : payload;
3091
+ };
3092
+ const buildChannelDiagnosis = (channel) => {
3093
+ const rootCause = (channel.connectionErrors[channel.connectionErrors.length - 1] ?? null)?.detail ?? null;
3094
+ if (channel.connectors.length === 0) return {
3095
+ status: "warn",
3096
+ message: "no connectors configured on this channel",
3097
+ nextActions: [`fnl channels ${channel.name} connectors add <name> --type=slack ...`],
3098
+ rootCause: null
3099
+ };
3100
+ if (!channel.listener) return {
3101
+ status: "error",
3102
+ message: "no listener running for this channel",
3103
+ nextActions: ["fnl gateway restart"],
3104
+ rootCause
3105
+ };
3106
+ if (!channel.listener.alive) return {
3107
+ status: "error",
3108
+ message: "listener is dead",
3109
+ nextActions: ["fnl gateway logs", "fnl gateway restart"],
3110
+ rootCause
3111
+ };
3112
+ if (channel.claudeClients === 0) return {
3113
+ status: "warn",
3114
+ message: "no Claude connected to this channel",
3115
+ nextActions: [`fnl claude --channel ${channel.name}`],
3116
+ rootCause: null
3117
+ };
3118
+ if (channel.listener.errors > 0) return {
3119
+ status: "warn",
3120
+ message: "listener has errors",
3121
+ nextActions: ["fnl gateway logs"],
3122
+ rootCause
3123
+ };
3124
+ return {
3125
+ status: "ok",
3126
+ message: "healthy",
3127
+ nextActions: [],
3128
+ rootCause: null
3129
+ };
3130
+ };
3131
+ /** GET /debug[?channel=<name>] — per-channel diagnosis with recent events. Used by MCP fnl_debug tool. */
3132
+ const debugHandler$1 = factory$1.createHandlers(async (c) => {
3133
+ const deps = c.var.deps;
3134
+ const channelFilter = c.req.query("channel") ?? null;
3135
+ const allChannels = deps.channels.list();
3136
+ const targetChannels = channelFilter ? allChannels.filter((ch) => ch.name === channelFilter || ch.id === channelFilter) : allChannels;
3137
+ const gatewayListeners = deps.supervisor.list();
3138
+ const gatewayClients = deps.broadcaster.listChannels();
3139
+ const metrics = deps.broadcaster.getMetrics();
3140
+ const tmpDir = funnelTmpDir();
3141
+ const rawPath = join(tmpDir, "connector-raw.db");
3142
+ const processedPath = join(tmpDir, "connector-processed.db");
3143
+ const connectionPath = join(tmpDir, "connector-connection.db");
3144
+ const hasStore = existsSync(rawPath) && existsSync(processedPath) && existsSync(connectionPath);
3145
+ const channels = targetChannels.map((ch) => {
3146
+ const listenerEntry = gatewayListeners.find((l) => l.channelName === ch.name) ?? null;
3147
+ const listener = listenerEntry ? {
3148
+ alive: listenerEntry.alive,
3149
+ events: listenerEntry.events,
3150
+ errors: listenerEntry.errors,
3151
+ lastEventAt: listenerEntry.lastEventAt
3152
+ } : null;
3153
+ const claudeClients = gatewayClients.filter((cl) => cl.channel === ch.id || cl.channel === ch.name).length;
3154
+ const recentEvents = [];
3155
+ const connectionErrors = [];
3156
+ if (hasStore) {
3157
+ const reader = new ConnectorDiagnosticSqlReader({
3158
+ rawPath,
3159
+ processedPath,
3160
+ connectionPath
3161
+ });
3162
+ const rows = (() => {
3163
+ try {
3164
+ return reader.query("SELECT seq, ts, type, outcome, payload FROM processed WHERE channel_id = ? ORDER BY seq DESC LIMIT 10", [ch.id]);
3165
+ } finally {
3166
+ reader.close();
3167
+ }
3168
+ })();
3169
+ if (!(rows instanceof Error)) for (const row of [...rows].reverse()) {
3170
+ const rawPayload = typeof row.payload === "string" ? row.payload : null;
3171
+ let payloadParsed = null;
3172
+ if (rawPayload) try {
3173
+ const parsed = JSON.parse(rawPayload);
3174
+ if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) payloadParsed = parsed;
3175
+ } catch {
3176
+ payloadParsed = null;
3177
+ }
3178
+ recentEvents.push({
3179
+ seq: typeof row.seq === "number" ? row.seq : null,
3180
+ ts: typeof row.ts === "number" ? row.ts : null,
3181
+ type: typeof row.type === "string" ? row.type : "?",
3182
+ outcome: typeof row.outcome === "string" ? row.outcome : "?",
3183
+ payload: rawPayload,
3184
+ payloadParsed,
3185
+ preview: extractPreview$1(row.payload)
3186
+ });
3187
+ }
3188
+ if (listener && (!listener.alive || listener.errors > 0) || !listener) {
3189
+ const errReader = new ConnectorDiagnosticSqlReader({
3190
+ rawPath,
3191
+ processedPath,
3192
+ connectionPath
3193
+ });
3194
+ const errRows = (() => {
3195
+ try {
3196
+ return errReader.query("SELECT ts, type, status, detail FROM connection WHERE channel_id = ? AND status IN ('auth-failed','error') ORDER BY seq DESC LIMIT 3", [ch.id]);
3197
+ } finally {
3198
+ errReader.close();
3199
+ }
3200
+ })();
3201
+ if (!(errRows instanceof Error)) for (const row of [...errRows].reverse()) connectionErrors.push({
3202
+ ts: typeof row.ts === "number" ? row.ts : null,
3203
+ type: typeof row.type === "string" ? row.type : "?",
3204
+ status: typeof row.status === "string" ? row.status : "?",
3205
+ detail: typeof row.detail === "string" && row.detail.length > 0 ? row.detail : null
3206
+ });
3207
+ }
3208
+ }
3209
+ const base = {
3210
+ id: ch.id,
3211
+ name: ch.name,
3212
+ connectors: ch.connectors.map((conn) => conn.name),
3213
+ listener,
3214
+ claudeClients,
3215
+ recentEvents,
3216
+ connectionErrors
3217
+ };
3218
+ return {
3219
+ ...base,
3220
+ diagnosis: buildChannelDiagnosis(base)
3221
+ };
3222
+ });
3223
+ return c.json({
3224
+ pid: deps.selfPid,
3225
+ uptimeMs: deps.uptimeMs(),
3226
+ eventsBroadcast: metrics.eventsBroadcast,
3227
+ channels
3228
+ });
3229
+ });
3230
+ //#endregion
2989
3231
  //#region lib/gateway/routes/health.ts
2990
3232
  /** GET /health — liveness + listener registry snapshot. */
2991
3233
  const healthHandler = factory$1.createHandlers((c) => {
@@ -3058,7 +3300,7 @@ const statusHandler$1 = factory$1.createHandlers((c) => {
3058
3300
  * from the `deps` variable set by `FunnelGatewayServer`'s middleware — same
3059
3301
  * shape as CLI's `c.var.funnel`.
3060
3302
  */
3061
- const gatewayRoutes = factory$1.createApp().get("/health", ...healthHandler).get("/status", ...statusHandler$1).get("/listeners", ...listenersListHandler).post("/listeners/:channel/:connector/start", ...listenersStartHandler).delete("/listeners/:channel/:connector", ...listenersStopHandler).post("/listeners/:channel/:connector/restart", ...listenersRestartHandler).post("/channels/:channel/connectors/:connector/call", ...channelsConnectorsCallHandler).post("/channels/:channel/publish", ...channelsPublishHandler$1);
3303
+ const gatewayRoutes = factory$1.createApp().get("/health", ...healthHandler).get("/status", ...statusHandler$1).get("/debug", ...debugHandler$1).get("/listeners", ...listenersListHandler).post("/listeners/:channel/:connector/start", ...listenersStartHandler).delete("/listeners/:channel/:connector", ...listenersStopHandler).post("/listeners/:channel/:connector/restart", ...listenersRestartHandler).post("/channels/:channel/connectors/:connector/call", ...channelsConnectorsCallHandler).post("/channels/:channel/publish", ...channelsPublishHandler$1);
3062
3304
  //#endregion
3063
3305
  //#region lib/gateway/gateway-server.ts
3064
3306
  const DEFAULT_HOST = "127.0.0.1";
@@ -3291,6 +3533,7 @@ var FunnelGatewayServer = class {
3291
3533
  if (this.token) {
3292
3534
  base.use("/listeners/*", requireBearerToken({ expected: this.token }));
3293
3535
  base.use("/status", requireBearerToken({ expected: this.token }));
3536
+ base.use("/debug", requireBearerToken({ expected: this.token }));
3294
3537
  base.use("/channels/*", requireBearerToken({ expected: this.token }));
3295
3538
  }
3296
3539
  return (this.extraRoutes ? base.route("/", this.extraRoutes) : base).route("/", gatewayRoutes);
@@ -3463,7 +3706,7 @@ var FunnelListenersClient = class {
3463
3706
  async list() {
3464
3707
  if (!this.isDaemonRunning()) return { state: "offline" };
3465
3708
  try {
3466
- const res = await fetch(`http://localhost:${this.port}/listeners`, { headers: this.authHeaders() });
3709
+ const res = await fetch(`http://127.0.0.1:${this.port}/listeners`, { headers: this.authHeaders() });
3467
3710
  if (!res.ok) return {
3468
3711
  state: "error",
3469
3712
  reason: `HTTP ${res.status}`
@@ -3505,7 +3748,7 @@ var FunnelListenersClient = class {
3505
3748
  }
3506
3749
  async call(method, path) {
3507
3750
  try {
3508
- const res = await fetch(`http://localhost:${this.port}${path}`, {
3751
+ const res = await fetch(`http://127.0.0.1:${this.port}${path}`, {
3509
3752
  method,
3510
3753
  headers: this.authHeaders()
3511
3754
  });
@@ -3526,6 +3769,84 @@ var FunnelListenersClient = class {
3526
3769
  }
3527
3770
  };
3528
3771
  //#endregion
3772
+ //#region lib/gateway/funnel-debug.ts
3773
+ const isGatewayStatusResponse$1 = (value) => {
3774
+ if (value === null || typeof value !== "object") return false;
3775
+ if (!("clients" in value) || !Array.isArray(value.clients)) return false;
3776
+ if (!("listeners" in value) || !Array.isArray(value.listeners)) return false;
3777
+ return true;
3778
+ };
3779
+ const buildFunnelDebugReport = async (deps, channelFilter) => {
3780
+ const gatewayStatus = deps.gateway.getStatus();
3781
+ const report = {
3782
+ gateway: {
3783
+ running: gatewayStatus.running,
3784
+ pid: gatewayStatus.pid,
3785
+ port: gatewayStatus.running ? gatewayStatus.port : null,
3786
+ uptimeMs: null
3787
+ },
3788
+ channels: [],
3789
+ recentEvents: null
3790
+ };
3791
+ const allChannels = deps.channels.list();
3792
+ const filteredChannels = channelFilter ? allChannels.filter((ch) => ch.name === channelFilter) : allChannels;
3793
+ let gatewayData = null;
3794
+ if (gatewayStatus.running) {
3795
+ const res = await fetch(`http://127.0.0.1:${gatewayStatus.port}/status`).catch(() => null);
3796
+ if (res && res.ok) {
3797
+ const body = await res.json();
3798
+ if (isGatewayStatusResponse$1(body)) {
3799
+ gatewayData = body;
3800
+ report.gateway.uptimeMs = body.uptimeMs;
3801
+ }
3802
+ }
3803
+ }
3804
+ for (const ch of filteredChannels) {
3805
+ const listenerEntry = gatewayData?.listeners.find((l) => l.channelName === ch.name) ?? null;
3806
+ const listener = listenerEntry ? {
3807
+ alive: listenerEntry.alive,
3808
+ events: listenerEntry.events,
3809
+ errors: listenerEntry.errors,
3810
+ lastEventAt: listenerEntry.lastEventAt
3811
+ } : null;
3812
+ const claudeClients = (gatewayData?.clients ?? []).filter((cl) => !cl.tapAll && (cl.channelName === ch.name || cl.channel === ch.name));
3813
+ report.channels.push({
3814
+ name: ch.name,
3815
+ connectors: ch.connectors.map((conn) => conn.name),
3816
+ listener,
3817
+ claudeConnected: claudeClients.length > 0,
3818
+ claudeClientCount: claudeClients.length
3819
+ });
3820
+ }
3821
+ const rawPath = join(deps.tmpDir, "connector-raw.db");
3822
+ const processedPath = join(deps.tmpDir, "connector-processed.db");
3823
+ const connectionPath = join(deps.tmpDir, "connector-connection.db");
3824
+ if (existsSync(rawPath) && existsSync(processedPath) && existsSync(connectionPath)) {
3825
+ const reader = new ConnectorDiagnosticSqlReader({
3826
+ rawPath,
3827
+ processedPath,
3828
+ connectionPath
3829
+ });
3830
+ const filteredChannelId = channelFilter ? allChannels.find((ch) => ch.name === channelFilter)?.id ?? null : null;
3831
+ const sql = filteredChannelId ? "SELECT ts, outcome, payload FROM processed WHERE channel_id = ? ORDER BY seq DESC LIMIT 20" : "SELECT ts, outcome, payload FROM processed ORDER BY seq DESC LIMIT 20";
3832
+ const params = filteredChannelId ? [filteredChannelId] : [];
3833
+ const rows = reader.query(sql, params);
3834
+ reader.close();
3835
+ if (!(rows instanceof Error)) report.recentEvents = rows.map((row) => {
3836
+ const ts = typeof row.ts === "number" ? row.ts : 0;
3837
+ const outcome = typeof row.outcome === "string" ? row.outcome : "";
3838
+ const payload = typeof row.payload === "string" ? row.payload : null;
3839
+ return {
3840
+ ts,
3841
+ outcome,
3842
+ payload,
3843
+ preview: payload ? payload.slice(0, 120) : null
3844
+ };
3845
+ });
3846
+ }
3847
+ return report;
3848
+ };
3849
+ //#endregion
3529
3850
  //#region lib/funnel.ts
3530
3851
  const SANDBOX_DIR = "/sandbox/.funnel";
3531
3852
  const SANDBOX_TMP_DIR = "/sandbox/tmp";
@@ -3772,6 +4093,13 @@ var Funnel = class Funnel {
3772
4093
  extraRoutes: options.extraRoutes
3773
4094
  });
3774
4095
  }
4096
+ async debug(channelName) {
4097
+ return buildFunnelDebugReport({
4098
+ gateway: this.gateway,
4099
+ channels: this.channels,
4100
+ tmpDir: this.paths.tmpDir
4101
+ }, channelName ?? null);
4102
+ }
3775
4103
  };
3776
4104
  //#endregion
3777
4105
  //#region lib/engine/mcp/channel-subscriber.ts
@@ -3865,21 +4193,58 @@ const readGatewayToken = (dir) => {
3865
4193
  //#endregion
3866
4194
  //#region lib/engine/mcp/usage-hint-for-type.ts
3867
4195
  const usageHintForType = (type) => {
3868
- if (type === "slack") return "Slack Web API. method=POST path=chat.postMessage body={channel,text,thread_ts?}";
3869
- if (type === "discord") return "Discord REST API. method=POST path=/channels/<id>/messages body={content,...}";
3870
- if (type === "gh") return "GitHub REST via gh CLI. method=POST path=repos/owner/repo/issues/N/comments body={body}";
4196
+ if (type === "slack") return [
4197
+ "Slack Web API.",
4198
+ "To reply in the same thread: method=POST path=chat.postMessage body={ channel: meta.channel_id, text: \"...\", thread_ts: meta.thread_ts }",
4199
+ "To react: method=POST path=reactions.add body={ channel: meta.channel_id, timestamp: meta.thread_ts, name: \"thumbsup\" }",
4200
+ "Use meta fields from the incoming event: channel_id (Slack channel), thread_ts (thread anchor), user_id (sender)."
4201
+ ].join(" ");
4202
+ if (type === "discord") return [
4203
+ "Discord REST API.",
4204
+ "To reply: method=POST path=/channels/<meta.channel_id>/messages body={ content: \"...\" }",
4205
+ "Use meta fields: channel_id (Discord channel), user_id (sender), guild_id."
4206
+ ].join(" ");
4207
+ if (type === "gh") return [
4208
+ "GitHub REST via gh CLI.",
4209
+ "To comment: method=POST path=repos/<meta.repository>/issues/<number>/comments body={ body: \"...\" }",
4210
+ "Parse <number> from meta.subject_url. meta fields: repository (owner/repo), subject_type, subject_url, reason."
4211
+ ].join(" ");
3871
4212
  return "Generic adapter call.";
3872
4213
  };
3873
4214
  //#endregion
3874
4215
  //#region lib/engine/mcp/channel-server.ts
3875
4216
  const DEFAULT_FUNNEL_DIR = join(homedir(), ".funnel");
4217
+ const BUILTIN_TOOL_NAMES = ["fnl_status", "fnl_debug"];
4218
+ const isBuiltinTool = (name) => BUILTIN_TOOL_NAMES.includes(name);
4219
+ const readAllChannels = (dir) => {
4220
+ const settingsPath = join(dir, "settings.json");
4221
+ if (!existsSync(settingsPath)) return [];
4222
+ try {
4223
+ const raw = JSON.parse(readFileSync(settingsPath, "utf-8"));
4224
+ const parsed = settingsSchema.safeParse(raw);
4225
+ if (!parsed.success) return [];
4226
+ return parsed.data.channels.map((c) => ({
4227
+ id: c.id,
4228
+ name: c.name
4229
+ }));
4230
+ } catch {
4231
+ return [];
4232
+ }
4233
+ };
3876
4234
  const startChannelServer = async (options = {}) => {
3877
4235
  const dir = options.dir ?? DEFAULT_FUNNEL_DIR;
3878
- const gatewayBaseUrl = options.gatewayUrl ?? process.env.FUNNEL_GATEWAY_URL ?? `http://localhost:${resolveFunnelPort()}`;
4236
+ const gatewayBaseUrl = options.gatewayUrl ?? process.env.FUNNEL_GATEWAY_URL ?? `http://127.0.0.1:${resolveFunnelPort()}`;
3879
4237
  const gatewayWsUrl = `${gatewayBaseUrl.replace(/^http/, "ws")}/ws`;
3880
4238
  const channelId = options.channelId ?? process.env.FUNNEL_CHANNEL_ID;
3881
4239
  const channel = channelId ? readChannelConnectors(dir, channelId) : null;
3882
4240
  const token = options.token ?? readGatewayToken(dir);
4241
+ const allChannels = readAllChannels(dir);
4242
+ const currentChannelName = channel?.channelName ?? null;
4243
+ const channelContext = allChannels.length > 0 ? [
4244
+ "",
4245
+ "Configured channels (use as the `channel` argument to fnl_debug):",
4246
+ ...allChannels.map((ch) => ` ${ch.name}${ch.name === currentChannelName ? " ← this session" : ""}`)
4247
+ ].join("\n") : "";
3883
4248
  const server = new Server({
3884
4249
  name: FUNNEL_MCP_NAME,
3885
4250
  version: "1.0.0"
@@ -3889,13 +4254,35 @@ const startChannelServer = async (options = {}) => {
3889
4254
  tools: {}
3890
4255
  },
3891
4256
  instructions: [
3892
- `Events arrive inside <channel source="${FUNNEL_MCP_NAME}"> tags. Use meta.event_type to discriminate.`,
4257
+ `Events arrive as notifications (method: notifications/claude/channel) with two fields:`,
4258
+ ` content — the event payload as a JSON string (parse it to read the message)`,
4259
+ ` meta — key/value strings describing the event`,
4260
+ "",
4261
+ "meta fields by event_type:",
4262
+ " slack: event_type=slack channel_id=C… thread_ts=1234.5678 user_id=U… mentioned=true|false",
4263
+ " gh: event_type=gh repository=owner/repo subject_type=Issue|PullRequest subject_url=… reason=…",
4264
+ " discord: event_type=discord channel_id=… user_id=… guild_id=… mentioned=true|false",
4265
+ " schedule: event_type=schedule entry_id=…",
4266
+ "",
4267
+ "To reply to a Slack message in the same thread, call the connector tool with:",
4268
+ ` method: POST`,
4269
+ ` path: chat.postMessage`,
4270
+ ` body: { channel: meta.channel_id, text: "your reply", thread_ts: meta.thread_ts }`,
3893
4271
  "",
3894
- "To reply or act, call the connector tool exposed by this MCP (one tool per connector configured on this channel). Each tool takes { method, path, body } matching the underlying adapter's CallInput."
4272
+ "To comment on a GitHub issue/PR (extract from subject_url in meta):",
4273
+ ` method: POST`,
4274
+ ` path: repos/<meta.repository>/issues/<number>/comments (parse number from meta.subject_url)`,
4275
+ ` body: { body: "your reply" }`,
4276
+ "",
4277
+ "Built-in diagnostic tools — call proactively when events seem missing or delayed:",
4278
+ " fnl_status — gateway running state, all listeners alive/dead, Claude WS clients",
4279
+ " fnl_debug — per-channel diagnosis with last 10 events, rootCause, suggestedActions",
4280
+ " omit channel arg to diagnose all channels; check summary.suggestedActions first",
4281
+ channelContext
3895
4282
  ].join("\n")
3896
4283
  });
3897
4284
  server.setRequestHandler(ListToolsRequestSchema, async () => {
3898
- return { tools: (channel?.connectors ?? []).map((c) => ({
4285
+ const connectorTools = (channel?.connectors ?? []).map((c) => ({
3899
4286
  name: c.name,
3900
4287
  description: `Call the "${c.name}" (${c.type}) connector. ${usageHintForType(c.type)}`,
3901
4288
  inputSchema: {
@@ -3916,17 +4303,42 @@ const startChannelServer = async (options = {}) => {
3916
4303
  },
3917
4304
  required: ["method", "path"]
3918
4305
  }
3919
- })) };
4306
+ }));
4307
+ const channelEnum = allChannels.length > 0 ? allChannels.map((ch) => ch.name) : void 0;
4308
+ const builtinTools = [{
4309
+ name: "fnl_status",
4310
+ description: "Return the current funnel gateway status as JSON — gateway running state, listener alive/dead per channel, and connected Claude WS clients. Call this when you need to check whether the gateway is up or why events stopped arriving.",
4311
+ inputSchema: {
4312
+ type: "object",
4313
+ properties: {}
4314
+ }
4315
+ }, {
4316
+ name: "fnl_debug",
4317
+ description: "Return a full channel diagnosis as JSON — gateway health, listener state, Claude WS connection, last 10 inbound events with outcome, connectionErrors (when listener is dead), and diagnosis.rootCause. Call this first when debugging missing events. Omit `channel` to diagnose all channels at once.",
4318
+ inputSchema: {
4319
+ type: "object",
4320
+ properties: { channel: channelEnum ? {
4321
+ type: "string",
4322
+ description: `Channel name to inspect. One of: ${channelEnum.join(", ")}. Omit to get all channels.`,
4323
+ enum: channelEnum
4324
+ } : {
4325
+ type: "string",
4326
+ description: "Channel name to inspect. Omit to get all channels."
4327
+ } }
4328
+ }
4329
+ }];
4330
+ return { tools: [...connectorTools, ...builtinTools] };
3920
4331
  });
3921
4332
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
4333
+ const toolName = request.params.name;
4334
+ if (isBuiltinTool(toolName)) return handleBuiltinTool(toolName, request.params.arguments, gatewayBaseUrl, token, allChannels);
3922
4335
  if (!channel) throw new Error("FUNNEL_CHANNEL_ID is not set or channel not found in settings.json");
3923
- const connectorName = request.params.name;
3924
4336
  const args = request.params.arguments ?? {};
3925
4337
  const method = typeof args.method === "string" ? args.method : "";
3926
4338
  const path = typeof args.path === "string" ? args.path : "";
3927
4339
  const body = args.body ?? {};
3928
4340
  if (!method || !path) throw new Error("`method` and `path` are required");
3929
- const url = `${gatewayBaseUrl}/channels/${encodeURIComponent(channel.channelName)}/connectors/${encodeURIComponent(connectorName)}/call`;
4341
+ const url = `${gatewayBaseUrl}/channels/${encodeURIComponent(channel.channelName)}/connectors/${encodeURIComponent(toolName)}/call`;
3930
4342
  const headers = { "content-type": "application/json" };
3931
4343
  if (token) headers.authorization = `Bearer ${token}`;
3932
4344
  const res = await fetch(url, {
@@ -3954,6 +4366,51 @@ const startChannelServer = async (options = {}) => {
3954
4366
  protocols: token ? [`funnel.token.${token}`] : void 0
3955
4367
  }).start();
3956
4368
  };
4369
+ const handleBuiltinTool = async (name, args, gatewayBaseUrl, token, allChannels) => {
4370
+ const headers = {};
4371
+ if (token) headers.authorization = `Bearer ${token}`;
4372
+ if (name === "fnl_status") {
4373
+ const res = await fetch(`${gatewayBaseUrl}/status`, { headers }).catch(() => null);
4374
+ if (!res) return { content: [{
4375
+ type: "text",
4376
+ text: JSON.stringify({
4377
+ running: false,
4378
+ error: "gateway unreachable",
4379
+ hint: "run: fnl gateway start",
4380
+ knownChannels: allChannels.map((ch) => ch.name)
4381
+ })
4382
+ }] };
4383
+ const body = await res.json();
4384
+ return { content: [{
4385
+ type: "text",
4386
+ text: JSON.stringify(body)
4387
+ }] };
4388
+ }
4389
+ const channelArg = typeof args?.channel === "string" ? args.channel : null;
4390
+ const url = channelArg ? `${gatewayBaseUrl}/debug?channel=${encodeURIComponent(channelArg)}` : `${gatewayBaseUrl}/debug`;
4391
+ const res = await fetch(url, { headers }).catch(() => null);
4392
+ if (!res) return { content: [{
4393
+ type: "text",
4394
+ text: JSON.stringify({
4395
+ gateway: { running: false },
4396
+ channels: allChannels.map((ch) => ({
4397
+ id: ch.id,
4398
+ name: ch.name,
4399
+ diagnosis: {
4400
+ status: "error",
4401
+ message: "gateway is not running",
4402
+ nextAction: "fnl gateway start",
4403
+ rootCause: null
4404
+ }
4405
+ }))
4406
+ })
4407
+ }] };
4408
+ const body = await res.json();
4409
+ return { content: [{
4410
+ type: "text",
4411
+ text: JSON.stringify(body)
4412
+ }] };
4413
+ };
3957
4414
  //#endregion
3958
4415
  //#region lib/engine/local-config/local-config-json-schema.ts
3959
4416
  /**
@@ -4497,87 +4954,6 @@ const takeRecent = (events, limit) => {
4497
4954
  return events.slice(-limit);
4498
4955
  };
4499
4956
  //#endregion
4500
- //#region lib/gateway/connector-diagnostic-sql-reader.ts
4501
- /**
4502
- * Read-only SQL surface over the three diagnostic tables, for Claude to query
4503
- * the log with arbitrary `SELECT`s. It opens all files read-only and exposes
4504
- * three views — `raw`, `processed`, `connection` — that hide the storage
4505
- * details (the physical table is `leuco_log` and each row's columns live
4506
- * inside a JSON `event` blob): the views surface the columns as plain fields,
4507
- * with `payload` already pulled out of the nested JSON.
4508
- *
4509
- * The tables are separate files. `raw` and `processed` share an `event_id`,
4510
- * so a `JOIN` answers "the event arrived, but what verdict did it get?";
4511
- * `connection` answers the other half — "did the listener ever connect at
4512
- * all?". Writes are impossible: the connection is read-only and `query`
4513
- * rejects anything but a single `SELECT`.
4514
- */
4515
- var ConnectorDiagnosticSqlReader = class {
4516
- db;
4517
- constructor(props) {
4518
- const db = new Database(props.rawPath, { readonly: true });
4519
- try {
4520
- db.run("PRAGMA busy_timeout = 500");
4521
- db.prepare("ATTACH DATABASE ? AS processeddb").run(props.processedPath);
4522
- db.prepare("ATTACH DATABASE ? AS connectiondb").run(props.connectionPath);
4523
- db.run(rawViewSql);
4524
- db.run(processedViewSql);
4525
- db.run(connectionViewSql);
4526
- } catch (error) {
4527
- db.close();
4528
- throw error;
4529
- }
4530
- this.db = db;
4531
- Object.freeze(this);
4532
- }
4533
- /**
4534
- * Run one read-only `SELECT` and return the rows. Returns an `Error` (rather
4535
- * than throwing) for a non-SELECT statement or a SQL error, so the caller
4536
- * can surface the message without a stack trace.
4537
- */
4538
- query(sql) {
4539
- const trimmed = sql.trim().replace(/;$/, "").trim();
4540
- if (!/^select\b/i.test(trimmed)) return /* @__PURE__ */ new Error("only a single SELECT statement is allowed");
4541
- if (trimmed.includes(";")) return /* @__PURE__ */ new Error("only a single statement is allowed (remove the ';')");
4542
- try {
4543
- return this.db.prepare(trimmed).all();
4544
- } catch (error) {
4545
- return error instanceof Error ? error : new Error(String(error));
4546
- }
4547
- }
4548
- close() {
4549
- this.db.close();
4550
- }
4551
- };
4552
- const rawViewSql = `CREATE TEMP VIEW raw AS SELECT
4553
- seq,
4554
- ts,
4555
- json_extract(event, '$.event_id') AS event_id,
4556
- json_extract(event, '$.type') AS type,
4557
- json_extract(event, '$.connector_id') AS connector_id,
4558
- json_extract(event, '$.channel_id') AS channel_id,
4559
- json_extract(event, '$.payload') AS payload
4560
- FROM main.leuco_log`;
4561
- const processedViewSql = `CREATE TEMP VIEW processed AS SELECT
4562
- seq,
4563
- ts,
4564
- json_extract(event, '$.event_id') AS event_id,
4565
- json_extract(event, '$.type') AS type,
4566
- json_extract(event, '$.connector_id') AS connector_id,
4567
- json_extract(event, '$.channel_id') AS channel_id,
4568
- json_extract(event, '$.outcome') AS outcome,
4569
- json_extract(event, '$.payload') AS payload
4570
- FROM processeddb.leuco_log`;
4571
- const connectionViewSql = `CREATE TEMP VIEW connection AS SELECT
4572
- seq,
4573
- ts,
4574
- json_extract(event, '$.type') AS type,
4575
- json_extract(event, '$.connector_id') AS connector_id,
4576
- json_extract(event, '$.channel_id') AS channel_id,
4577
- json_extract(event, '$.status') AS status,
4578
- json_extract(event, '$.detail') AS detail
4579
- FROM connectiondb.leuco_log`;
4580
- //#endregion
4581
4957
  //#region lib/cli/factory.ts
4582
4958
  const factory = createFactory();
4583
4959
  //#endregion
@@ -5042,9 +5418,16 @@ const channelsShowHandler = factory.createHandlers(zValidator$1("param", z.objec
5042
5418
  ];
5043
5419
  return c.text(lines.join("\n"));
5044
5420
  });
5045
- const channelsGroupHandler = factory.createHandlers(zValidator$1("query", z.object({}), `funnel channels — manage subscription boxes
5421
+ const channelsGroupHandler = factory.createHandlers(zValidator$1("query", z.object({ json: z.enum([
5422
+ "true",
5423
+ "false",
5424
+ ""
5425
+ ]).optional() }), `funnel channels — manage subscription boxes
5046
5426
 
5047
- usage: funnel channels [subcommand]
5427
+ usage: funnel channels [--json]
5428
+
5429
+ options:
5430
+ --json output as JSON array (machine-readable, useful for Claude)
5048
5431
 
5049
5432
  subcommands:
5050
5433
  (none) list
@@ -5055,13 +5438,26 @@ subcommands:
5055
5438
  <name> connectors add <c> --type=... add a connector
5056
5439
 
5057
5440
  examples:
5441
+ funnel channels
5442
+ funnel channels --json
5058
5443
  funnel channels add prod-inbox
5059
5444
  funnel channels prod-inbox connectors add prod-slack --type=slack --bot-token=xoxb-... --app-token=xapp-...
5060
5445
  funnel channels prod-inbox`), (c) => {
5446
+ const query = c.req.valid("query");
5061
5447
  const channels = c.var.funnel.channels.list();
5448
+ if (query.json === "true" || query.json === "") return c.json(channels.map((ch) => ({
5449
+ id: ch.id,
5450
+ name: ch.name,
5451
+ delivery: ch.delivery,
5452
+ connectors: ch.connectors.map((conn) => ({
5453
+ id: conn.id,
5454
+ name: conn.name,
5455
+ type: conn.type
5456
+ }))
5457
+ })));
5062
5458
  if (channels.length === 0) return c.text("no channels");
5063
5459
  const lines = channels.map((ch) => {
5064
- const names = ch.connectors.map((c) => c.name);
5460
+ const names = ch.connectors.map((conn) => conn.name);
5065
5461
  const connectors = names.length > 0 ? names.join(", ") : "(none)";
5066
5462
  return `${ch.name} [${connectors}]`;
5067
5463
  });
@@ -5152,12 +5548,848 @@ const claudeHandler = factory.createHandlers(zValidator$1("query", z.object({
5152
5548
  process.exit(exitCode);
5153
5549
  });
5154
5550
  //#endregion
5155
- //#region lib/cli/routes/gateway.ts
5551
+ //#region lib/cli/routes/debug.ts
5552
+ const debugHelp = `funnel debug — diagnose why Claude is not receiving events
5553
+
5554
+ usage: funnel debug [subcommand] [--channel <name>] [--all] [--json]
5555
+
5556
+ subcommands:
5557
+ (none) full diagnosis (gateway + listener + Claude + last 5 events)
5558
+ events last N processed events with outcome and preview
5559
+ dropped events filtered out (skip:*) with payload detail
5560
+ errors listener connection errors (auth-failed, error)
5561
+
5562
+ options:
5563
+ --channel <name> channel to inspect (auto-selected when only one exists)
5564
+ --all diagnose all channels at once (JSON output)
5565
+ --limit <N> number of recent events to include (default: 5; events/dropped/errors default: 20)
5566
+ --json output as JSON (machine-readable, useful for Claude)
5567
+
5568
+ when a listener is dead the diagnosis includes rootCause — the most recent
5569
+ connection error detail pulled from the connection log automatically.
5570
+
5571
+ use --json when asking Claude to analyse the output — it returns structured
5572
+ data that Claude can parse without guessing at text formatting.
5573
+
5574
+ examples:
5575
+ funnel debug
5576
+ funnel debug --all --json
5577
+ funnel debug --channel open-karte
5578
+ funnel debug --channel open-karte --json
5579
+ funnel debug events --channel open-karte --limit 50
5580
+ funnel debug dropped --channel open-karte --json
5581
+ funnel debug errors`;
5582
+ const debugEventsHelp = `funnel debug events — last N processed events with outcome and preview
5583
+
5584
+ usage: funnel debug events [--channel <name>] [--limit <N>] [--json]
5585
+
5586
+ options:
5587
+ --channel <name> channel to inspect (auto-selected when only one exists)
5588
+ --limit <N> number of rows (default: 20)
5589
+ --json output as JSON
5590
+
5591
+ examples:
5592
+ funnel debug events
5593
+ funnel debug events --channel open-karte --limit 50
5594
+ funnel debug events --json`;
5595
+ const debugDroppedHelp = `funnel debug dropped — events filtered out (skip:*)
5596
+
5597
+ usage: funnel debug dropped [--channel <name>] [--limit <N>] [--json]
5598
+
5599
+ options:
5600
+ --channel <name> channel to inspect (auto-selected when only one exists)
5601
+ --limit <N> number of rows (default: 20)
5602
+ --json output as JSON
5603
+
5604
+ shows why events were skipped: skip:type, skip:subtype, skip:dedup,
5605
+ skip:self-user, skip:self-bot, skip:preprocess
5606
+
5607
+ examples:
5608
+ funnel debug dropped
5609
+ funnel debug dropped --channel open-karte --json`;
5610
+ const debugErrorsHelp = `funnel debug errors — listener connection errors
5611
+
5612
+ usage: funnel debug errors [--channel <name>] [--limit <N>] [--json]
5613
+
5614
+ options:
5615
+ --channel <name> channel to inspect (auto-selected when only one exists)
5616
+ --limit <N> number of rows (default: 20)
5617
+ --json output as JSON
5618
+
5619
+ shows auth-failed and error events from the connection lifecycle log.
5620
+ use this when a listener never connects or keeps disconnecting.
5621
+
5622
+ examples:
5623
+ funnel debug errors
5624
+ funnel debug errors --channel open-karte`;
5625
+ const isGatewayStatusResponse = (value) => {
5626
+ if (value === null || typeof value !== "object") return false;
5627
+ if (!("clients" in value) || !Array.isArray(value.clients)) return false;
5628
+ if (!("listeners" in value) || !Array.isArray(value.listeners)) return false;
5629
+ return true;
5630
+ };
5631
+ const formatUptime = (ms) => {
5632
+ const sec = Math.floor(ms / 1e3);
5633
+ const min = Math.floor(sec / 60);
5634
+ if (min >= 60) return `${Math.floor(min / 60)}h ${min % 60}m`;
5635
+ if (sec >= 60) return `${min}m ${sec % 60}s`;
5636
+ return `${sec}s`;
5637
+ };
5638
+ const formatTs = (epochMs) => {
5639
+ if (typeof epochMs !== "number") return "?";
5640
+ return new Date(epochMs).toISOString().slice(11, 19);
5641
+ };
5642
+ const truncate = (text, max) => text.length <= max ? text : `${text.slice(0, max)}…`;
5643
+ const extractPreview = (payload) => {
5644
+ if (typeof payload !== "string" || payload.length === 0) return null;
5645
+ try {
5646
+ const parsed = JSON.parse(payload);
5647
+ if (parsed !== null && typeof parsed === "object" && "text" in parsed) return truncate(String(parsed.text), 60);
5648
+ } catch {
5649
+ return truncate(payload, 60);
5650
+ }
5651
+ return truncate(payload, 60);
5652
+ };
5653
+ const buildDiagnosis = (report) => {
5654
+ const rootCause = (report.connectionErrors[report.connectionErrors.length - 1] ?? null)?.detail ?? null;
5655
+ if (!report.gateway.running) return {
5656
+ status: "error",
5657
+ message: "gateway is not running",
5658
+ nextActions: ["fnl gateway start"],
5659
+ rootCause: null
5660
+ };
5661
+ const channel = report.channel;
5662
+ if (!(report.listeners.length > 0)) return {
5663
+ status: "warn",
5664
+ message: "no connectors configured on this channel",
5665
+ nextActions: [`fnl channels ${channel} connectors add <name> --type=slack ...`],
5666
+ rootCause: null
5667
+ };
5668
+ const allDead = report.listeners.every((l) => !l.alive);
5669
+ const someDead = report.listeners.some((l) => !l.alive);
5670
+ if (allDead) return {
5671
+ status: "error",
5672
+ message: "all listeners are dead",
5673
+ nextActions: ["fnl gateway logs", "fnl gateway restart"],
5674
+ rootCause
5675
+ };
5676
+ if (someDead) return {
5677
+ status: "warn",
5678
+ message: "some listeners are dead",
5679
+ nextActions: ["fnl gateway logs"],
5680
+ rootCause
5681
+ };
5682
+ const hasErrors = report.listeners.some((l) => l.errors > 0);
5683
+ if (report.claudeClients === 0) return {
5684
+ status: "warn",
5685
+ message: "no Claude connected to this channel",
5686
+ nextActions: [`fnl claude --channel ${channel}`],
5687
+ rootCause: null
5688
+ };
5689
+ if (hasErrors) return {
5690
+ status: "warn",
5691
+ message: "listeners have errors",
5692
+ nextActions: ["fnl gateway logs"],
5693
+ rootCause
5694
+ };
5695
+ return {
5696
+ status: "ok",
5697
+ message: "everything looks healthy",
5698
+ nextActions: [],
5699
+ rootCause: null
5700
+ };
5701
+ };
5702
+ const renderText = (report) => {
5703
+ const lines = [];
5704
+ lines.push(`= funnel debug: ${report.channel} =`);
5705
+ lines.push("");
5706
+ const gw = report.gateway;
5707
+ if (!gw.running) lines.push("[gateway] ○ not running");
5708
+ else {
5709
+ const uptime = gw.uptimeMs !== null ? ` · up ${formatUptime(gw.uptimeMs)}` : "";
5710
+ lines.push(`[gateway] ● running pid ${gw.pid} · port ${gw.port}${uptime}`);
5711
+ }
5712
+ if (report.listeners.length === 0) lines.push("[listener] - no listener");
5713
+ else for (const listener of report.listeners) {
5714
+ const indicator = listener.alive ? "●" : "○";
5715
+ const state = listener.alive ? "alive " : "dead ";
5716
+ const eventsStr = `${listener.events} events`;
5717
+ const lastStr = listener.lastEventAt ? ` · last ${listener.lastEventAt.slice(11, 19)}` : "";
5718
+ const errStr = listener.errors > 0 ? ` · ⚠ ${listener.errors} errors` : "";
5719
+ lines.push(`[listener] ${indicator} ${state} ${eventsStr}${lastStr}${errStr}`);
5720
+ }
5721
+ const claudeCount = report.claudeClients;
5722
+ if (claudeCount === 0) lines.push("[claude] ○ not connected");
5723
+ else lines.push(`[claude] ● connected (${claudeCount} WS client${claudeCount > 1 ? "s" : ""})`);
5724
+ if (report.recentEvents.length === 0) lines.push("[events] no events recorded");
5725
+ else {
5726
+ lines.push(`[events] last ${report.recentEvents.length} event${report.recentEvents.length > 1 ? "s" : ""}:`);
5727
+ for (const event of report.recentEvents) {
5728
+ const time = formatTs(event.ts);
5729
+ const type = event.type.padEnd(8);
5730
+ const outcome = event.outcome.padEnd(20);
5731
+ const preview = event.preview ? ` "${event.preview}"` : "";
5732
+ const seq = event.seq !== null ? ` (seq=${event.seq})` : "";
5733
+ lines.push(` ${time} ${type} ${outcome}${preview}${seq}`);
5734
+ }
5735
+ }
5736
+ if (report.connectionErrors.length > 0) {
5737
+ lines.push("[conn errs] recent connection errors:");
5738
+ for (const err of report.connectionErrors) {
5739
+ const time = formatTs(err.ts);
5740
+ const status = err.status.padEnd(14);
5741
+ const detail = err.detail ? ` "${err.detail}"` : "";
5742
+ lines.push(` ${time} ${err.type.padEnd(8)} ${status}${detail}`);
5743
+ }
5744
+ }
5745
+ lines.push("");
5746
+ const diag = report.diagnosis;
5747
+ const icon = diag.status === "ok" ? "✓" : diag.status === "warn" ? "⚠" : "✗";
5748
+ lines.push(`diagnosis: ${icon} ${diag.message}`);
5749
+ if (diag.rootCause) lines.push(` root cause: ${diag.rootCause}`);
5750
+ for (const action of diag.nextActions) lines.push(` → ${action}`);
5751
+ return lines.join("\n");
5752
+ };
5753
+ const resolveStoreOrNull = () => {
5754
+ const tmpDir = funnelTmpDir();
5755
+ const rawPath = join(tmpDir, "connector-raw.db");
5756
+ const processedPath = join(tmpDir, "connector-processed.db");
5757
+ const connectionPath = join(tmpDir, "connector-connection.db");
5758
+ if (!existsSync(rawPath) || !existsSync(processedPath) || !existsSync(connectionPath)) return null;
5759
+ return {
5760
+ rawPath,
5761
+ processedPath,
5762
+ connectionPath
5763
+ };
5764
+ };
5765
+ const resolveChannelId = (channels, channelName) => {
5766
+ if (channelName) {
5767
+ const match = channels.find((ch) => ch.name === channelName);
5768
+ if (match) return {
5769
+ found: true,
5770
+ channel: match
5771
+ };
5772
+ return {
5773
+ found: false,
5774
+ reason: "not-found",
5775
+ name: channelName
5776
+ };
5777
+ }
5778
+ if (channels.length === 1 && channels[0]) return {
5779
+ found: true,
5780
+ channel: channels[0]
5781
+ };
5782
+ if (channels.length === 0) return {
5783
+ found: false,
5784
+ reason: "none"
5785
+ };
5786
+ return {
5787
+ found: false,
5788
+ reason: "ambiguous",
5789
+ names: channels.map((ch) => ch.name)
5790
+ };
5791
+ };
5792
+ const debugEventsHandler = factory.createHandlers(zValidator$1("query", z.object({
5793
+ channel: z.string().optional(),
5794
+ limit: z.string().optional(),
5795
+ json: z.enum([
5796
+ "true",
5797
+ "false",
5798
+ ""
5799
+ ]).optional()
5800
+ }), debugEventsHelp), async (c) => {
5801
+ const query = c.req.valid("query");
5802
+ const channels = c.var.funnel.channels.list();
5803
+ const isJson = query.json === "true" || query.json === "";
5804
+ const limit = query.limit ? Math.max(1, Number(query.limit)) : 20;
5805
+ const store = resolveStoreOrNull();
5806
+ if (!store) {
5807
+ if (isJson) return c.json([]);
5808
+ return c.text("no diagnostic store yet (start the gateway first)");
5809
+ }
5810
+ const resolved = resolveChannelId(channels, query.channel);
5811
+ if (!resolved.found) {
5812
+ if (resolved.reason === "not-found") {
5813
+ if (isJson) return c.json({
5814
+ error: `channel not found: ${resolved.name}`,
5815
+ availableChannels: channels.map((ch) => ch.name)
5816
+ });
5817
+ return c.text(`channel not found: ${resolved.name}`);
5818
+ }
5819
+ if (resolved.reason === "ambiguous") {
5820
+ if (isJson) return c.json({
5821
+ error: "multiple channels — specify one with --channel",
5822
+ channels: resolved.names
5823
+ });
5824
+ return c.text(`multiple channels — specify one with --channel:\n${resolved.names.map((n) => ` - ${n}`).join("\n")}`);
5825
+ }
5826
+ if (isJson) return c.json([]);
5827
+ return c.text("no channels configured");
5828
+ }
5829
+ const channel = resolved.channel;
5830
+ const reader = new ConnectorDiagnosticSqlReader(store);
5831
+ const rows = (() => {
5832
+ try {
5833
+ if (channel) return reader.query("SELECT seq, ts, type, outcome, payload FROM processed WHERE channel_id = ? ORDER BY seq DESC LIMIT ?", [channel.id, limit]);
5834
+ return reader.query("SELECT seq, ts, type, outcome, payload FROM processed ORDER BY seq DESC LIMIT ?", [limit]);
5835
+ } finally {
5836
+ reader.close();
5837
+ }
5838
+ })();
5839
+ if (rows instanceof Error) return c.text(`error: ${rows.message}`);
5840
+ const events = [...rows].reverse().map((row) => {
5841
+ const rawPayload = typeof row.payload === "string" ? row.payload : null;
5842
+ let payloadParsed = null;
5843
+ if (rawPayload) try {
5844
+ const parsed = JSON.parse(rawPayload);
5845
+ if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) payloadParsed = parsed;
5846
+ } catch {
5847
+ payloadParsed = null;
5848
+ }
5849
+ return {
5850
+ seq: typeof row.seq === "number" ? row.seq : null,
5851
+ ts: typeof row.ts === "number" ? row.ts : null,
5852
+ type: typeof row.type === "string" ? row.type : "?",
5853
+ outcome: typeof row.outcome === "string" ? row.outcome : "?",
5854
+ payload: rawPayload,
5855
+ payloadParsed,
5856
+ preview: extractPreview(row.payload)
5857
+ };
5858
+ });
5859
+ if (isJson) return c.json(events);
5860
+ if (events.length === 0) return c.text("no events recorded");
5861
+ const lines = events.map((ev) => {
5862
+ const time = formatTs(ev.ts);
5863
+ const type = ev.type.padEnd(8);
5864
+ const outcome = ev.outcome.padEnd(20);
5865
+ const preview = ev.preview ? ` "${ev.preview}"` : "";
5866
+ return `${time} ${type} ${outcome}${ev.seq !== null ? ` seq=${ev.seq}` : ""}${preview}`;
5867
+ });
5868
+ return c.text(lines.join("\n"));
5869
+ });
5870
+ const debugDroppedHandler = factory.createHandlers(zValidator$1("query", z.object({
5871
+ channel: z.string().optional(),
5872
+ limit: z.string().optional(),
5873
+ json: z.enum([
5874
+ "true",
5875
+ "false",
5876
+ ""
5877
+ ]).optional()
5878
+ }), debugDroppedHelp), async (c) => {
5879
+ const query = c.req.valid("query");
5880
+ const channels = c.var.funnel.channels.list();
5881
+ const isJson = query.json === "true" || query.json === "";
5882
+ const limit = query.limit ? Math.max(1, Number(query.limit)) : 20;
5883
+ const store = resolveStoreOrNull();
5884
+ if (!store) {
5885
+ if (isJson) return c.json([]);
5886
+ return c.text("no diagnostic store yet (start the gateway first)");
5887
+ }
5888
+ const resolvedDropped = resolveChannelId(channels, query.channel);
5889
+ if (!resolvedDropped.found) {
5890
+ if (resolvedDropped.reason === "not-found") {
5891
+ if (isJson) return c.json({
5892
+ error: `channel not found: ${resolvedDropped.name}`,
5893
+ availableChannels: channels.map((ch) => ch.name)
5894
+ });
5895
+ return c.text(`channel not found: ${resolvedDropped.name}`);
5896
+ }
5897
+ if (resolvedDropped.reason === "ambiguous") {
5898
+ if (isJson) return c.json({
5899
+ error: "multiple channels — specify one with --channel",
5900
+ channels: resolvedDropped.names
5901
+ });
5902
+ return c.text(`multiple channels — specify one with --channel:\n${resolvedDropped.names.map((n) => ` - ${n}`).join("\n")}`);
5903
+ }
5904
+ if (isJson) return c.json([]);
5905
+ return c.text("no channels configured");
5906
+ }
5907
+ const channel = resolvedDropped.channel;
5908
+ const reader = new ConnectorDiagnosticSqlReader(store);
5909
+ const rows = (() => {
5910
+ try {
5911
+ if (channel) return reader.query("SELECT p.seq, p.ts, p.type, p.outcome, p.payload, p.event_id FROM processed p WHERE p.channel_id = ? AND p.outcome LIKE 'skip:%' ORDER BY p.seq DESC LIMIT ?", [channel.id, limit]);
5912
+ return reader.query("SELECT seq, ts, type, outcome, payload, event_id FROM processed WHERE outcome LIKE 'skip:%' ORDER BY seq DESC LIMIT ?", [limit]);
5913
+ } finally {
5914
+ reader.close();
5915
+ }
5916
+ })();
5917
+ if (rows instanceof Error) return c.text(`error: ${rows.message}`);
5918
+ const events = [...rows].reverse().map((row) => {
5919
+ const rawPayload = typeof row.payload === "string" ? row.payload : null;
5920
+ let payloadParsed = null;
5921
+ if (rawPayload) try {
5922
+ const parsed = JSON.parse(rawPayload);
5923
+ if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) payloadParsed = parsed;
5924
+ } catch {
5925
+ payloadParsed = null;
5926
+ }
5927
+ return {
5928
+ seq: typeof row.seq === "number" ? row.seq : null,
5929
+ ts: typeof row.ts === "number" ? row.ts : null,
5930
+ type: typeof row.type === "string" ? row.type : "?",
5931
+ outcome: typeof row.outcome === "string" ? row.outcome : "?",
5932
+ event_id: typeof row.event_id === "string" ? row.event_id : null,
5933
+ payload: rawPayload,
5934
+ payloadParsed,
5935
+ preview: extractPreview(row.payload)
5936
+ };
5937
+ });
5938
+ if (isJson) return c.json(events);
5939
+ if (events.length === 0) return c.text("no dropped events recorded");
5940
+ const lines = events.map((ev) => {
5941
+ return `${formatTs(ev.ts)} ${ev.type.padEnd(8)} ${ev.outcome.padEnd(20)}${ev.seq !== null ? ` seq=${ev.seq}` : ""}${ev.event_id ? ` event_id=${ev.event_id.slice(0, 8)}` : ""}${ev.preview ? ` "${ev.preview}"` : ""}`;
5942
+ });
5943
+ return c.text(lines.join("\n"));
5944
+ });
5945
+ const debugErrorsHandler = factory.createHandlers(zValidator$1("query", z.object({
5946
+ channel: z.string().optional(),
5947
+ limit: z.string().optional(),
5948
+ json: z.enum([
5949
+ "true",
5950
+ "false",
5951
+ ""
5952
+ ]).optional()
5953
+ }), debugErrorsHelp), async (c) => {
5954
+ const query = c.req.valid("query");
5955
+ const channels = c.var.funnel.channels.list();
5956
+ const isJson = query.json === "true" || query.json === "";
5957
+ const limit = query.limit ? Math.max(1, Number(query.limit)) : 20;
5958
+ const store = resolveStoreOrNull();
5959
+ if (!store) {
5960
+ if (isJson) return c.json([]);
5961
+ return c.text("no diagnostic store yet (start the gateway first)");
5962
+ }
5963
+ const resolvedErrors = resolveChannelId(channels, query.channel);
5964
+ if (!resolvedErrors.found) {
5965
+ if (resolvedErrors.reason === "not-found") {
5966
+ if (isJson) return c.json({
5967
+ error: `channel not found: ${resolvedErrors.name}`,
5968
+ availableChannels: channels.map((ch) => ch.name)
5969
+ });
5970
+ return c.text(`channel not found: ${resolvedErrors.name}`);
5971
+ }
5972
+ if (resolvedErrors.reason === "ambiguous") {
5973
+ if (isJson) return c.json({
5974
+ error: "multiple channels — specify one with --channel",
5975
+ channels: resolvedErrors.names
5976
+ });
5977
+ return c.text(`multiple channels — specify one with --channel:\n${resolvedErrors.names.map((n) => ` - ${n}`).join("\n")}`);
5978
+ }
5979
+ if (isJson) return c.json([]);
5980
+ return c.text("no channels configured");
5981
+ }
5982
+ const channel = resolvedErrors.channel;
5983
+ const reader = new ConnectorDiagnosticSqlReader(store);
5984
+ const rows = (() => {
5985
+ try {
5986
+ if (channel) return reader.query("SELECT seq, ts, type, status, detail FROM connection WHERE channel_id = ? AND status IN ('auth-failed','error') ORDER BY seq DESC LIMIT ?", [channel.id, limit]);
5987
+ return reader.query("SELECT seq, ts, type, status, detail FROM connection WHERE status IN ('auth-failed','error') ORDER BY seq DESC LIMIT ?", [limit]);
5988
+ } finally {
5989
+ reader.close();
5990
+ }
5991
+ })();
5992
+ if (rows instanceof Error) return c.text(`error: ${rows.message}`);
5993
+ const errors = [...rows].reverse().map((row) => ({
5994
+ seq: typeof row.seq === "number" ? row.seq : null,
5995
+ ts: typeof row.ts === "number" ? row.ts : null,
5996
+ type: typeof row.type === "string" ? row.type : "?",
5997
+ status: typeof row.status === "string" ? row.status : "?",
5998
+ detail: typeof row.detail === "string" && row.detail.length > 0 ? row.detail : null
5999
+ }));
6000
+ if (isJson) return c.json(errors);
6001
+ if (errors.length === 0) return c.text("no connection errors recorded");
6002
+ const lines = errors.map((ev) => {
6003
+ return `${formatTs(ev.ts)} ${ev.type.padEnd(8)} ${ev.status.padEnd(16)}${ev.detail ? ` "${ev.detail}"` : ""}`;
6004
+ });
6005
+ return c.text(lines.join("\n"));
6006
+ });
6007
+ const buildChannelReport = async (targetChannel, gatewayStatus, gatewayBodyOrNull, store, limit = 5) => {
6008
+ const targetChannelName = targetChannel.name;
6009
+ const baseReport = {
6010
+ channel: targetChannelName,
6011
+ channelId: targetChannel.id,
6012
+ gateway: {
6013
+ running: gatewayStatus.running,
6014
+ pid: gatewayStatus.pid,
6015
+ port: gatewayStatus.running ? gatewayStatus.port : null,
6016
+ uptimeMs: gatewayBodyOrNull?.uptimeMs ?? null
6017
+ },
6018
+ listeners: [],
6019
+ claudeClients: 0,
6020
+ recentEvents: [],
6021
+ connectionErrors: []
6022
+ };
6023
+ if (gatewayBodyOrNull) {
6024
+ baseReport.listeners = gatewayBodyOrNull.listeners.filter((l) => l.channelName === targetChannelName).map((l) => ({
6025
+ name: l.name,
6026
+ type: l.type,
6027
+ alive: l.alive,
6028
+ events: l.events,
6029
+ errors: l.errors,
6030
+ lastEventAt: l.lastEventAt
6031
+ }));
6032
+ baseReport.claudeClients = gatewayBodyOrNull.clients.filter((cl) => !cl.tapAll && cl.channelName === targetChannelName).length;
6033
+ }
6034
+ if (store) {
6035
+ const reader = new ConnectorDiagnosticSqlReader(store);
6036
+ const evRows = (() => {
6037
+ try {
6038
+ return reader.query("SELECT seq, ts, type, outcome, payload FROM processed WHERE channel_id = ? ORDER BY seq DESC LIMIT ?", [targetChannel.id, limit]);
6039
+ } finally {
6040
+ reader.close();
6041
+ }
6042
+ })();
6043
+ if (!(evRows instanceof Error)) baseReport.recentEvents = evRows.reverse().map((row) => {
6044
+ const rawPayload = typeof row.payload === "string" ? row.payload : null;
6045
+ let payloadParsed = null;
6046
+ if (rawPayload) try {
6047
+ const parsed = JSON.parse(rawPayload);
6048
+ if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) payloadParsed = parsed;
6049
+ } catch {
6050
+ payloadParsed = null;
6051
+ }
6052
+ return {
6053
+ seq: typeof row.seq === "number" ? row.seq : null,
6054
+ ts: typeof row.ts === "number" ? row.ts : null,
6055
+ type: typeof row.type === "string" ? row.type : "?",
6056
+ outcome: typeof row.outcome === "string" ? row.outcome : "?",
6057
+ payload: rawPayload,
6058
+ payloadParsed,
6059
+ preview: extractPreview(row.payload)
6060
+ };
6061
+ });
6062
+ const hasDeadListeners = baseReport.listeners.some((l) => !l.alive);
6063
+ const hasListenerErrors = baseReport.listeners.some((l) => l.errors > 0);
6064
+ if (hasDeadListeners || hasListenerErrors) {
6065
+ const errReader = new ConnectorDiagnosticSqlReader(store);
6066
+ const errRows = (() => {
6067
+ try {
6068
+ return errReader.query("SELECT ts, type, status, detail FROM connection WHERE channel_id = ? AND status IN ('auth-failed','error') ORDER BY seq DESC LIMIT 3", [targetChannel.id]);
6069
+ } finally {
6070
+ errReader.close();
6071
+ }
6072
+ })();
6073
+ if (!(errRows instanceof Error)) baseReport.connectionErrors = errRows.reverse().map((row) => ({
6074
+ ts: typeof row.ts === "number" ? row.ts : null,
6075
+ type: typeof row.type === "string" ? row.type : "?",
6076
+ status: typeof row.status === "string" ? row.status : "?",
6077
+ detail: typeof row.detail === "string" && row.detail.length > 0 ? row.detail : null
6078
+ }));
6079
+ }
6080
+ }
6081
+ return {
6082
+ ...baseReport,
6083
+ diagnosis: buildDiagnosis(baseReport)
6084
+ };
6085
+ };
6086
+ const debugHandler = factory.createHandlers(zValidator$1("query", z.object({
6087
+ channel: z.string().optional(),
6088
+ all: z.enum([
6089
+ "true",
6090
+ "false",
6091
+ ""
6092
+ ]).optional(),
6093
+ json: z.enum([
6094
+ "true",
6095
+ "false",
6096
+ ""
6097
+ ]).optional(),
6098
+ limit: z.string().optional()
6099
+ }), debugHelp), async (c) => {
6100
+ const query = c.req.valid("query");
6101
+ const funnel = c.var.funnel;
6102
+ const channels = funnel.channels.list();
6103
+ const gatewayStatus = funnel.gateway.getStatus();
6104
+ const isJson = query.json === "true" || query.json === "";
6105
+ const isAll = query.all === "true" || query.all === "";
6106
+ const eventLimit = query.limit ? Math.max(1, Number(query.limit)) : 5;
6107
+ if (channels.length === 0) {
6108
+ if (isJson) return c.json({
6109
+ error: "no channels configured",
6110
+ nextAction: "fnl channels add <name>"
6111
+ });
6112
+ return c.text("no channels configured — run: fnl channels add <name>");
6113
+ }
6114
+ const token = funnel.gatewayToken.read();
6115
+ const headers = token ? { Authorization: `Bearer ${token}` } : {};
6116
+ let gatewayBodyOrNull = null;
6117
+ if (gatewayStatus.running) {
6118
+ const res = await fetch(`http://127.0.0.1:${gatewayStatus.port}/status`, { headers }).catch(() => null);
6119
+ if (res && res.ok) {
6120
+ const body = await res.json();
6121
+ if (isGatewayStatusResponse(body)) gatewayBodyOrNull = body;
6122
+ }
6123
+ }
6124
+ const store = resolveStoreOrNull();
6125
+ if (isAll) {
6126
+ const reports = await Promise.all(channels.map((ch) => buildChannelReport(ch, gatewayStatus, gatewayBodyOrNull, store, eventLimit)));
6127
+ const errorChannels = reports.filter((r) => r.diagnosis.status === "error").map((r) => r.channel);
6128
+ const warnChannels = reports.filter((r) => r.diagnosis.status === "warn").map((r) => r.channel);
6129
+ const okChannels = reports.filter((r) => r.diagnosis.status === "ok").map((r) => r.channel);
6130
+ const uniqueActions = [...new Set(reports.flatMap((r) => r.diagnosis.nextActions))];
6131
+ return c.json({
6132
+ summary: {
6133
+ total: reports.length,
6134
+ ok: okChannels.length,
6135
+ warn: warnChannels.length,
6136
+ error: errorChannels.length,
6137
+ criticalChannels: errorChannels,
6138
+ warnChannels,
6139
+ suggestedActions: uniqueActions
6140
+ },
6141
+ channels: reports
6142
+ });
6143
+ }
6144
+ let targetChannel = null;
6145
+ if (query.channel) {
6146
+ targetChannel = channels.find((ch) => ch.name === query.channel) ?? null;
6147
+ if (!targetChannel) {
6148
+ if (isJson) return c.json({
6149
+ error: `channel not found: ${query.channel}`,
6150
+ availableChannels: channels.map((ch) => ch.name)
6151
+ });
6152
+ return c.text(`channel not found: ${query.channel}`);
6153
+ }
6154
+ } else if (channels.length === 1 && channels[0]) targetChannel = channels[0];
6155
+ else {
6156
+ const names = channels.map((ch) => ch.name);
6157
+ if (isJson) return c.json({
6158
+ error: "multiple channels — specify one with --channel or use --all",
6159
+ channels: names,
6160
+ hint: "use --all for all channels at once"
6161
+ });
6162
+ return c.text(`multiple channels — specify one with --channel or use --all:\n${names.map((n) => ` - ${n}`).join("\n")}`);
6163
+ }
6164
+ const report = await buildChannelReport(targetChannel, gatewayStatus, gatewayBodyOrNull, store, eventLimit);
6165
+ if (isJson) return c.json(report);
6166
+ return c.text(renderText(report));
6167
+ });
6168
+ const debugReplayHandler = factory.createHandlers(zValidator$1("query", z.object({
6169
+ channel: z.string().optional(),
6170
+ seq: z.string().optional(),
6171
+ json: z.enum([
6172
+ "true",
6173
+ "false",
6174
+ ""
6175
+ ]).optional()
6176
+ }), `funnel debug replay — re-publish a past event into a channel
6177
+
6178
+ usage: funnel debug replay --channel <name> [--seq <N>] [--json]
6179
+
6180
+ options:
6181
+ --channel <name> channel to replay into (required when multiple channels exist)
6182
+ --seq <N> replay the event at this processed-table seq (default: most recent emitted)
6183
+ --json output result as JSON
6184
+
6185
+ Re-sends a past event from the diagnostic store through the publisher path,
6186
+ so subscribers receive it again. Useful to verify that Claude handles an event
6187
+ correctly without waiting for a real external trigger.
6188
+
6189
+ Gateway must be running. The event is injected via POST /channels/<name>/publish.
6190
+
6191
+ examples:
6192
+ fnl debug replay --channel open-karte
6193
+ fnl debug replay --channel open-karte --seq 412
6194
+ fnl debug replay --channel open-karte --json`), async (c) => {
6195
+ const query = c.req.valid("query");
6196
+ const funnel = c.var.funnel;
6197
+ const channels = funnel.channels.list();
6198
+ const isJson = query.json === "true" || query.json === "";
6199
+ const resolved = resolveChannelId(channels, query.channel);
6200
+ if (!resolved.found) {
6201
+ if (resolved.reason === "not-found") {
6202
+ if (isJson) return c.json({
6203
+ error: `channel not found: ${resolved.name}`,
6204
+ availableChannels: channels.map((ch) => ch.name)
6205
+ });
6206
+ return c.text(`channel not found: ${resolved.name}`);
6207
+ }
6208
+ if (resolved.reason === "ambiguous") {
6209
+ if (isJson) return c.json({
6210
+ error: "multiple channels — specify one with --channel",
6211
+ channels: resolved.names
6212
+ });
6213
+ return c.text(`multiple channels — specify one with --channel:\n${resolved.names.map((n) => ` - ${n}`).join("\n")}`);
6214
+ }
6215
+ if (isJson) return c.json({ error: "no channels configured" });
6216
+ return c.text("no channels configured");
6217
+ }
6218
+ const targetChannel = resolved.channel;
6219
+ const store = resolveStoreOrNull();
6220
+ if (!store) {
6221
+ if (isJson) return c.json({ error: "no diagnostic store yet (start the gateway first)" });
6222
+ return c.text("no diagnostic store yet (start the gateway first)");
6223
+ }
6224
+ const reader = new ConnectorDiagnosticSqlReader(store);
6225
+ const rows = (() => {
6226
+ try {
6227
+ if (query.seq) return reader.query("SELECT seq, event_id, type, payload, connector_id, channel_id FROM processed WHERE channel_id = ? AND seq = ? LIMIT 1", [targetChannel.id, Number(query.seq)]);
6228
+ return reader.query("SELECT seq, event_id, type, payload, connector_id, channel_id FROM processed WHERE channel_id = ? AND outcome LIKE 'emitted%' ORDER BY seq DESC LIMIT 1", [targetChannel.id]);
6229
+ } finally {
6230
+ reader.close();
6231
+ }
6232
+ })();
6233
+ if (rows instanceof Error) {
6234
+ if (isJson) return c.json({ error: rows.message });
6235
+ return c.text(`error: ${rows.message}`);
6236
+ }
6237
+ const firstRow = rows[0];
6238
+ if (!firstRow) {
6239
+ if (isJson) return c.json({ error: "no matching event found" });
6240
+ return c.text("no matching event found");
6241
+ }
6242
+ const seq = typeof firstRow.seq === "number" ? firstRow.seq : null;
6243
+ const eventId = typeof firstRow.event_id === "string" ? firstRow.event_id : null;
6244
+ const connectorId = typeof firstRow.connector_id === "string" ? firstRow.connector_id : null;
6245
+ let content = typeof firstRow.payload === "string" ? firstRow.payload : null;
6246
+ if ((!content || content.length === 0) && eventId) {
6247
+ const rawReader = new ConnectorDiagnosticSqlReader(store);
6248
+ const rawRows = (() => {
6249
+ try {
6250
+ return rawReader.query("SELECT payload FROM raw WHERE event_id = ? LIMIT 1", [eventId]);
6251
+ } finally {
6252
+ rawReader.close();
6253
+ }
6254
+ })();
6255
+ if (!(rawRows instanceof Error) && rawRows[0]) {
6256
+ const rawRow = rawRows[0];
6257
+ content = typeof rawRow.payload === "string" ? rawRow.payload : null;
6258
+ }
6259
+ }
6260
+ if (!content) {
6261
+ if (isJson) return c.json({ error: "event has no payload to replay" });
6262
+ return c.text("event has no payload to replay");
6263
+ }
6264
+ const connectorName = connectorId ? targetChannel.connectors?.find((c) => c.id === connectorId)?.name : void 0;
6265
+ const result = await funnel.publisher.publish(targetChannel.name, {
6266
+ content,
6267
+ connector: connectorName
6268
+ });
6269
+ if (result.state === "offline") {
6270
+ if (isJson) return c.json({
6271
+ error: "gateway daemon is not running",
6272
+ nextAction: "fnl gateway start"
6273
+ });
6274
+ return c.text("error: gateway daemon is not running — run: fnl gateway start");
6275
+ }
6276
+ if (result.state === "error") {
6277
+ if (isJson) return c.json({ error: result.reason });
6278
+ return c.text(`error: ${result.reason}`);
6279
+ }
6280
+ const preview = extractPreview(content);
6281
+ if (isJson) return c.json({
6282
+ replayed: true,
6283
+ seq,
6284
+ offset: result.offset,
6285
+ preview
6286
+ });
6287
+ return c.text(`replayed seq=${seq ?? "?"} → offset=${result.offset}${preview ? ` "${preview}"` : ""}`);
6288
+ });
6289
+ //#endregion
6290
+ //#region lib/cli/routes/channels.$channel.validate.ts
6291
+ const validateHelp = `funnel channels <channel> validate — check connector configuration
6292
+
6293
+ usage: funnel channels <channel> validate [--json]
6294
+
6295
+ options:
6296
+ --json output as JSON
6297
+
6298
+ Checks that each connector has the required tokens and fields set.
6299
+ Does not make any network calls — static config check only.
6300
+
6301
+ examples:
6302
+ funnel channels open-karte validate
6303
+ funnel channels open-karte validate --json`;
6304
+ const validateConnector = (connector) => {
6305
+ const issues = [];
6306
+ if (connector.type === "slack") {
6307
+ if (!(connector.botToken || connector.botTokenEnv)) issues.push({
6308
+ connector: connector.name,
6309
+ field: "botToken",
6310
+ message: "missing botToken (xoxb-...) or botTokenEnv"
6311
+ });
6312
+ if (!(connector.appToken || connector.appTokenEnv)) issues.push({
6313
+ connector: connector.name,
6314
+ field: "appToken",
6315
+ message: "missing appToken (xapp-...) or appTokenEnv"
6316
+ });
6317
+ if (connector.botToken && typeof connector.botToken === "string" && !connector.botToken.startsWith("xoxb-")) issues.push({
6318
+ connector: connector.name,
6319
+ field: "botToken",
6320
+ message: `botToken must start with xoxb- (got: ${connector.botToken.slice(0, 8)}...)`
6321
+ });
6322
+ if (connector.appToken && typeof connector.appToken === "string" && !connector.appToken.startsWith("xapp-")) issues.push({
6323
+ connector: connector.name,
6324
+ field: "appToken",
6325
+ message: `appToken must start with xapp- (got: ${connector.appToken.slice(0, 8)}...)`
6326
+ });
6327
+ }
6328
+ if (connector.type === "gh") {
6329
+ if (!(connector.token || connector.tokenEnv)) issues.push({
6330
+ connector: connector.name,
6331
+ field: "token",
6332
+ message: "missing token or tokenEnv for GitHub connector"
6333
+ });
6334
+ if (!connector.repo) issues.push({
6335
+ connector: connector.name,
6336
+ field: "repo",
6337
+ message: "missing repo (expected owner/repo format)"
6338
+ });
6339
+ }
6340
+ if (connector.type === "discord") {
6341
+ if (!(connector.botToken || connector.botTokenEnv)) issues.push({
6342
+ connector: connector.name,
6343
+ field: "botToken",
6344
+ message: "missing botToken or botTokenEnv for Discord connector"
6345
+ });
6346
+ }
6347
+ return issues;
6348
+ };
6349
+ const channelsValidateHandler = factory.createHandlers(zValidator$1("param", z.object({ channel: z.string() })), zValidator$1("query", z.object({ json: z.enum([
6350
+ "true",
6351
+ "false",
6352
+ ""
6353
+ ]).optional() }), validateHelp), (c) => {
6354
+ const param = c.req.valid("param");
6355
+ const query = c.req.valid("query");
6356
+ const funnel = c.var.funnel;
6357
+ const isJson = query.json === "true" || query.json === "";
6358
+ const channel = funnel.channels.get(param.channel);
6359
+ if (!channel) throw new HTTPException(404, { message: `channel "${param.channel}" not found` });
6360
+ if (channel.connectors.length === 0) {
6361
+ if (isJson) return c.json({
6362
+ channel: channel.name,
6363
+ valid: false,
6364
+ issues: [{
6365
+ connector: "(none)",
6366
+ field: "connectors",
6367
+ message: "no connectors configured"
6368
+ }]
6369
+ });
6370
+ return c.text(`⚠ ${channel.name}: no connectors configured`);
6371
+ }
6372
+ const allIssues = [];
6373
+ for (const connector of channel.connectors) {
6374
+ const issues = validateConnector(connector);
6375
+ allIssues.push(...issues);
6376
+ }
6377
+ if (isJson) return c.json({
6378
+ channel: channel.name,
6379
+ valid: allIssues.length === 0,
6380
+ issues: allIssues
6381
+ });
6382
+ if (allIssues.length === 0) return c.text(`✓ ${channel.name}: all connectors valid`);
6383
+ const lines = allIssues.map((issue) => `✗ ${channel.name}/${issue.connector}: ${issue.message}`);
6384
+ return c.text(lines.join("\n"));
6385
+ });
6386
+ //#endregion
6387
+ //#region lib/cli/routes/gateway.ts
5156
6388
  const groupHelp$1 = `funnel gateway — manage the funnel daemon
5157
6389
 
5158
- The gateway daemon hosts the WebSocket /ws (used by Claude MCP), the
5159
- local web UI at /, and the listener supervisor that runs every
5160
- connector. One daemon, one port (9742), one PID file.
6390
+ The gateway daemon hosts the WebSocket /ws (used by Claude MCP) and the
6391
+ listener supervisor that runs every connector. One daemon, one port (9743
6392
+ for the CLI, 9742 for programmatic use), one PID file.
5161
6393
 
5162
6394
  usage: funnel gateway [subcommand]
5163
6395
 
@@ -5168,20 +6400,49 @@ subcommands:
5168
6400
  restart stop then start
5169
6401
  run start in foreground (for developers)
5170
6402
  logs [-n <N>] tail the daemon diagnostic log (lifecycle, listener boot)
5171
- sql --query "<SQL>" query inbound connector traffic (raw + processed verdict)
6403
+ sql query inbound connector traffic (raw + processed verdict)
5172
6404
  listeners list running connector listeners (alive / dead)
5173
6405
 
5174
6406
  examples:
5175
- funnel gateway check status
5176
- funnel gateway restart restart`;
6407
+ funnel gateway check status
6408
+ funnel gateway restart restart after config changes
6409
+ funnel gateway logs stream the daemon log
6410
+ funnel gateway sql --preset recent inspect last 20 inbound events
6411
+
6412
+ see also: fnl debug --channel <name> (higher-level diagnosis with next-action hints)`;
5177
6413
  const renderGatewayStatus = async (c) => {
5178
6414
  const status = c.var.funnel.gateway.getStatus();
5179
6415
  if (!status.running) throw new HTTPException(503, { message: "funnel gateway: not running" });
5180
- const res = await fetch(`http://localhost:${status.port}/health`).catch(() => null);
6416
+ const res = await fetch(`http://127.0.0.1:${status.port}/status`).catch(() => null);
5181
6417
  if (!res) return c.text(`funnel gateway: running (pid ${status.pid}) — health check failed`);
5182
- const health = await res.json();
5183
- const clients = health !== null && typeof health === "object" && "clients" in health ? health.clients : 0;
5184
- return c.text(`funnel gateway: running (pid ${status.pid})\n port: ${status.port}\n clients: ${clients ?? 0}`);
6418
+ const data = await res.json();
6419
+ const lines = [];
6420
+ lines.push(`funnel gateway: running (pid ${data.pid})`);
6421
+ lines.push(` port: ${status.port}`);
6422
+ const uptimeSec = Math.floor(data.uptimeMs / 1e3);
6423
+ const uptimeMin = Math.floor(uptimeSec / 60);
6424
+ const uptimeStr = uptimeMin >= 60 ? `${Math.floor(uptimeMin / 60)}h ${uptimeMin % 60}m` : uptimeSec >= 60 ? `${uptimeMin}m ${uptimeSec % 60}s` : `${uptimeSec}s`;
6425
+ lines.push(` uptime: ${uptimeStr}`);
6426
+ lines.push(` events: ${data.broadcaster.eventsBroadcast} broadcast`);
6427
+ if (data.listeners.length === 0) lines.push(` listeners: none`);
6428
+ else {
6429
+ lines.push(` listeners:`);
6430
+ for (const l of data.listeners) {
6431
+ const indicator = l.alive ? "●" : "○";
6432
+ const eventsStr = l.events > 0 ? ` (${l.events} events)` : "";
6433
+ const errStr = l.errors > 0 ? ` ⚠ ${l.errors} errors` : "";
6434
+ lines.push(` ${indicator} ${l.channelName}/${l.name} [${l.type}]${eventsStr}${errStr}`);
6435
+ }
6436
+ }
6437
+ if (data.clients.length === 0) lines.push(` clients: none`);
6438
+ else {
6439
+ lines.push(` clients: ${data.clients.length}`);
6440
+ for (const cl of data.clients) {
6441
+ const connectors = cl.connectors.length > 0 ? ` → ${cl.connectors.join(", ")}` : "";
6442
+ lines.push(` · ${cl.channel}${connectors}`);
6443
+ }
6444
+ }
6445
+ return c.text(lines.join("\n"));
5185
6446
  };
5186
6447
  const gatewayGroupHandler = factory.createHandlers(zValidator$1("query", z.object({}), groupHelp$1), renderGatewayStatus);
5187
6448
  const gatewayListenersHandler = factory.createHandlers(zValidator$1("query", z.object({}), `funnel gateway listeners — show running connector listeners
@@ -5203,24 +6464,31 @@ examples:
5203
6464
  });
5204
6465
  //#endregion
5205
6466
  //#region lib/cli/routes/gateway.logs.ts
5206
- const logsHelp = `funnel gateway logs — tail diagnostic logs
6467
+ const logsHelp = `funnel gateway logs — tail the daemon diagnostic log
5207
6468
 
5208
- usage: funnel gateway logs [-n <N>]
6469
+ usage: funnel gateway logs [-n <N>] [--format <plain|json>]
5209
6470
 
5210
6471
  options:
5211
6472
  -n <N> number of trailing lines to show (default: 20)
6473
+ --format <plain|json> output format (default: plain)
6474
+
6475
+ Streams ${join(funnelTmpDir(), "funnel.log")} — the daemon's diagnostic stream covering
6476
+ gateway lifecycle, listener start/stop/error, and WebSocket connect/disconnect.
6477
+ Exit with Ctrl-C.
5212
6478
 
5213
- Tails ${join(funnelTmpDir(), "funnel.log")} (the daemon's diagnostic stream — gateway
5214
- lifecycle, channel connect/disconnect, listener boot). Exit with SIGINT.
5215
- Output is formatted as YAML.
6479
+ plain format: HH:MM:SS LEVEL message key=value ...
6480
+ json format: raw JSON lines (pipe to jq for filtering)
5216
6481
 
5217
- Domain events fanned out to WebSocket clients live in the SQLite event
5218
- store (${join(funnelTmpDir(), "events.db")}); they are not shown here. Subscribe via
5219
- the WS endpoint or query the store directly.
6482
+ This log does NOT contain inbound Slack/connector events. For those, use:
6483
+ fnl gateway sql --preset recent last 20 processed events
6484
+ fnl debug --channel <name> per-channel diagnosis with outcome summary
5220
6485
 
5221
6486
  examples:
5222
6487
  funnel gateway logs
5223
- funnel gateway logs -n 100`;
6488
+ funnel gateway logs -n 100
6489
+ funnel gateway logs --format json | jq 'select(.level == "error")'
6490
+
6491
+ see also: fnl debug, fnl gateway sql`;
5224
6492
  const logger = new NodeFunnelLogger();
5225
6493
  const tryParseJson = (line) => {
5226
6494
  try {
@@ -5236,11 +6504,27 @@ const isLogEntry = (value) => {
5236
6504
  if (!("message" in value) || typeof value.message !== "string") return false;
5237
6505
  return true;
5238
6506
  };
5239
- const gatewayLogsHandler = factory.createHandlers(zValidator$1("query", z.object({ n: z.string().optional() }), logsHelp), async (c) => {
6507
+ const formatMetaValue = (value) => {
6508
+ const str = typeof value === "string" ? value : JSON.stringify(value);
6509
+ return str.includes(" ") ? `"${str}"` : str;
6510
+ };
6511
+ const formatMeta = (meta) => {
6512
+ if (meta === null || typeof meta !== "object") return "";
6513
+ const pairs = Object.entries(meta).map(([k, v]) => `${k}=${formatMetaValue(v)}`).join(" ");
6514
+ return pairs ? ` ${pairs}` : "";
6515
+ };
6516
+ const formatPlain = (entry) => {
6517
+ return `${entry.time.slice(11, 19)} ${entry.level.toUpperCase().padEnd(5)} ${entry.message.padEnd(30)}${formatMeta(entry.meta)}\n`;
6518
+ };
6519
+ const gatewayLogsHandler = factory.createHandlers(zValidator$1("query", z.object({
6520
+ n: z.string().optional(),
6521
+ format: z.enum(["plain", "json"]).optional()
6522
+ }), logsHelp), async (c) => {
5240
6523
  const query = c.req.valid("query");
5241
6524
  const path = logger.file;
5242
6525
  if (!path || !existsSync(path)) return c.text("no logs");
5243
6526
  const lineCount = query.n ? Number(query.n) : 20;
6527
+ const format = query.format ?? "plain";
5244
6528
  const tail = Bun.spawn([
5245
6529
  "tail",
5246
6530
  "-f",
@@ -5259,7 +6543,6 @@ const gatewayLogsHandler = factory.createHandlers(zValidator$1("query", z.object
5259
6543
  const reader = tail.stdout.getReader();
5260
6544
  const decoder = new TextDecoder();
5261
6545
  let buffer = "";
5262
- logger.info("gateway.logs tail start", { file: path });
5263
6546
  while (true) {
5264
6547
  const result = await reader.read();
5265
6548
  if (result.done) break;
@@ -5273,13 +6556,8 @@ const gatewayLogsHandler = factory.createHandlers(zValidator$1("query", z.object
5273
6556
  process.stdout.write(`${line}\n`);
5274
6557
  continue;
5275
6558
  }
5276
- const output = {
5277
- time: parsed.time,
5278
- level: parsed.level,
5279
- message: parsed.message,
5280
- ...parsed.meta ? { meta: parsed.meta } : {}
5281
- };
5282
- process.stdout.write(`---\n${stringify(output)}`);
6559
+ if (format === "json") process.stdout.write(`${JSON.stringify(parsed)}\n`);
6560
+ else process.stdout.write(formatPlain(parsed));
5283
6561
  }
5284
6562
  }
5285
6563
  await tail.exited;
@@ -5287,47 +6565,78 @@ const gatewayLogsHandler = factory.createHandlers(zValidator$1("query", z.object
5287
6565
  });
5288
6566
  //#endregion
5289
6567
  //#region lib/cli/routes/gateway.sql.ts
6568
+ const PRESETS = {
6569
+ recent: "SELECT seq, ts, type, outcome FROM processed ORDER BY seq DESC LIMIT 20",
6570
+ skipped: "SELECT seq, ts, type, outcome, payload FROM processed WHERE outcome LIKE 'skip:%' ORDER BY seq DESC LIMIT 20",
6571
+ errors: "SELECT ts, status, detail FROM connection WHERE status IN ('auth-failed','error') ORDER BY seq DESC LIMIT 20",
6572
+ summary: "SELECT outcome, COUNT(*) AS count FROM processed GROUP BY outcome ORDER BY count DESC",
6573
+ "trace-dedup": "SELECT r.seq, r.ts, r.event_id, r.payload FROM raw r JOIN processed p USING(event_id) WHERE p.outcome='skip:dedup' ORDER BY r.seq DESC LIMIT 20"
6574
+ };
5290
6575
  const sqlHelp = `funnel gateway sql — query inbound connector traffic with SQL
5291
6576
 
5292
- usage: funnel gateway sql --query "<SELECT ...>"
6577
+ usage: funnel gateway sql --preset <name> [--channel <name|id>] [--limit <N>]
6578
+ funnel gateway sql --query "<SELECT ...>"
6579
+
6580
+ options:
6581
+ --preset <name> run a named preset (quickest starting point)
6582
+ --channel <name|id> filter preset results by channel name or id
6583
+ --limit <N> override the row limit for presets (default: 20)
6584
+ --query "<SQL>" run a custom SELECT; output is JSON
6585
+
6586
+ quick-start presets (--preset <name>):
6587
+ recent last N processed events — type, outcome, preview
6588
+ skipped last N events filtered out (skip:*) — see why events were dropped
6589
+ errors listener auth-failed or error events — start here for connection failures
6590
+ summary outcome counts grouped by type — high-level health snapshot (no limit)
6591
+ trace-dedup raw payload of events dropped as duplicates
5293
6592
 
5294
- Runs one read-only SELECT against the daemon's diagnostic store of inbound
5295
- connector events and prints the rows as JSON. Use it to answer "Slack
5296
- delivered an event, so why was there no notification?".
6593
+ Output is always JSON pipe to jq or pass directly to Claude.
5297
6594
 
5298
- Three views:
5299
- raw every inbound event, untouched, before any filtering
5300
- processed the verdict for that event after the per-type processor ran
5301
- connection the listener lifecycle (so you can tell events never arrived)
6595
+ three SQL views (for --query):
6596
+ raw every inbound event before filtering (payload = original JSON)
6597
+ processed filter verdict per event (outcome: emitted | skip:<reason>)
6598
+ connection listener lifecycle (status: connected | auth-failed | error | ...)
5302
6599
 
5303
- Shared columns (all three views):
5304
- seq row id within the view (not comparable across views)
5305
- ts epoch milliseconds
5306
- type connector kind: slack | discord | gh | schedule
5307
- connector_id funnel connector id
5308
- channel_id funnel channel id
5309
- raw and processed also have:
5310
- event_id correlation id shared by an event's raw and processed rows
5311
- payload raw: the original event JSON (text); processed: the delivered body, or "" when skipped
5312
- processed also has:
5313
- outcome 'emitted' | 'emitted:delivery-failed' | 'skip:<reason>'
5314
- (skip reasons: skip:type, skip:subtype, skip:dedup,
5315
- skip:self-user, skip:self-bot, skip:preprocess)
5316
- connection also has:
5317
- status 'started' | 'connected' | 'disconnected' | 'auth-failed' | 'stopped' | 'error'
5318
- detail an error message or reason, or "" when none
6600
+ common join: SELECT r.payload FROM raw r JOIN processed p USING(event_id) WHERE p.outcome = 'skip:dedup'
5319
6601
 
5320
- To trace one event end to end, join raw and processed on event_id. When the
5321
- event tables are empty, query connection — a listener that never connected, or
5322
- failed auth, explains why nothing arrived.
6602
+ skip reasons: skip:type skip:subtype skip:dedup skip:self-user skip:self-bot skip:preprocess
5323
6603
 
5324
6604
  examples:
5325
- funnel gateway sql --query "SELECT event_id, ts, type FROM raw ORDER BY seq DESC LIMIT 20"
6605
+ funnel gateway sql --preset recent
6606
+ funnel gateway sql --preset recent --limit 100
6607
+ funnel gateway sql --preset skipped --channel open-karte
6608
+ funnel gateway sql --preset errors
6609
+ funnel gateway sql --preset summary
5326
6610
  funnel gateway sql --query "SELECT outcome, COUNT(*) n FROM processed GROUP BY outcome"
5327
- funnel gateway sql --query "SELECT r.payload FROM raw r JOIN processed p USING(event_id) WHERE p.outcome='skip:dedup'"
5328
- funnel gateway sql --query "SELECT ts, status, detail FROM connection WHERE status IN ('auth-failed','error') ORDER BY seq DESC"`;
5329
- const gatewaySqlHandler = factory.createHandlers(zValidator$1("query", z.object({ query: z.string().optional() }), sqlHelp), async (c) => {
5330
- const sql = c.req.valid("query").query;
6611
+
6612
+ tip: for a higher-level view without writing SQL, use: fnl debug --channel <name> --json
6613
+
6614
+ see also: fnl debug, fnl gateway logs`;
6615
+ const gatewaySqlHandler = factory.createHandlers(zValidator$1("query", z.object({
6616
+ query: z.string().optional(),
6617
+ preset: z.enum(Object.keys(PRESETS)).optional(),
6618
+ channel: z.string().optional(),
6619
+ limit: z.string().optional()
6620
+ }), sqlHelp), async (c) => {
6621
+ const query = c.req.valid("query");
6622
+ const funnel = c.var.funnel;
6623
+ let sql = null;
6624
+ let params = [];
6625
+ let resolvedChannelId = null;
6626
+ if (query.channel) resolvedChannelId = funnel.channels.list().find((ch) => ch.id === query.channel || ch.name === query.channel)?.id ?? query.channel;
6627
+ if (query.preset) {
6628
+ const base = PRESETS[query.preset] ?? null;
6629
+ if (!base) return c.text(sqlHelp);
6630
+ let applied = base;
6631
+ if (query.limit) {
6632
+ const n = Math.max(1, Number(query.limit));
6633
+ applied = applied.replace(/LIMIT \d+/, `LIMIT ${n}`);
6634
+ }
6635
+ if (resolvedChannelId) {
6636
+ sql = applied.replace(/FROM (raw|processed|connection)\b/, "FROM $1 WHERE channel_id = ?");
6637
+ params = [resolvedChannelId];
6638
+ } else sql = applied;
6639
+ } else if (query.query) sql = query.query;
5331
6640
  if (!sql) return c.text(sqlHelp);
5332
6641
  const tmpDir = funnelTmpDir();
5333
6642
  const rawPath = join(tmpDir, "connector-raw.db");
@@ -5341,7 +6650,7 @@ const gatewaySqlHandler = factory.createHandlers(zValidator$1("query", z.object(
5341
6650
  });
5342
6651
  const rows = (() => {
5343
6652
  try {
5344
- return reader.query(sql);
6653
+ return reader.query(sql, params);
5345
6654
  } finally {
5346
6655
  reader.close();
5347
6656
  }
@@ -5419,15 +6728,41 @@ const gatewayStartHandler = factory.createHandlers(zValidator$1("query", z.objec
5419
6728
  if (!await funnel.gateway.start({ caffeinate: query["no-caffeine"] !== "true" })) throw new HTTPException(500, { message: "funnel gateway: failed to start" });
5420
6729
  return c.text("funnel gateway: started");
5421
6730
  });
5422
- const gatewayStatusHandler = factory.createHandlers(zValidator$1("query", z.object({}), `funnel gateway status — show gateway running status
6731
+ const gatewayStatusHandler = factory.createHandlers(zValidator$1("query", z.object({ json: z.enum([
6732
+ "true",
6733
+ "false",
6734
+ ""
6735
+ ]).optional() }), `funnel gateway status — show gateway running status
5423
6736
 
5424
- usage: funnel gateway status
6737
+ usage: funnel gateway status [--json]
6738
+
6739
+ options:
6740
+ --json output as JSON
5425
6741
 
5426
- When running, prints PID, port, and connected channel count. When not running, exits with 503.
6742
+ When running, prints PID, port, uptime, listeners (alive/dead), and WS clients.
6743
+ When not running, exits with 503.
5427
6744
 
5428
6745
  examples:
5429
6746
  funnel gateway status
5430
- funnel gateway`), renderGatewayStatus);
6747
+ funnel gateway status --json`), async (c) => {
6748
+ const query = c.req.valid("query");
6749
+ if (!(query.json === "true" || query.json === "")) return renderGatewayStatus(c);
6750
+ const status = c.var.funnel.gateway.getStatus();
6751
+ if (!status.running) throw new HTTPException(503, { message: "funnel gateway: not running" });
6752
+ const res = await fetch(`http://127.0.0.1:${status.port}/status`).catch(() => null);
6753
+ if (!res) return c.json({
6754
+ running: true,
6755
+ pid: status.pid,
6756
+ port: status.port,
6757
+ error: "health check failed"
6758
+ });
6759
+ const data = await res.json();
6760
+ return c.json({
6761
+ running: true,
6762
+ port: status.port,
6763
+ ...data
6764
+ });
6765
+ });
5431
6766
  const gatewayStopHandler = factory.createHandlers(zValidator$1("query", z.object({}), `funnel gateway stop — stop the gateway
5432
6767
 
5433
6768
  usage: funnel gateway stop
@@ -5666,29 +7001,77 @@ can validate and autocomplete the config:
5666
7001
  });
5667
7002
  //#endregion
5668
7003
  //#region lib/cli/routes/status.ts
5669
- const statusHelp = `funnel status — show overall connection status
7004
+ const statusHelp = `funnel status — overall health at a glance
7005
+
7006
+ usage: funnel status [--watch] [--interval <N>]
7007
+
7008
+ options:
7009
+ --watch continuously refresh (Ctrl+C to stop)
7010
+ --interval <N> polling interval in seconds (used with --watch, default: 3)
7011
+
7012
+ Shows gateway running state (pid, port, uptime), per-channel listener health
7013
+ (● alive / ○ dead), and whether Claude is connected to each channel as a
7014
+ WebSocket client. Use this as the first step when debugging missing events.
5670
7015
 
5671
- usage: funnel status
7016
+ examples:
7017
+ funnel status
7018
+ funnel status --watch
7019
+ funnel status --watch --interval 5
5672
7020
 
5673
- Lists configured connectors / channels / profiles, gateway running status,
5674
- and active MCP WebSocket clients.`;
7021
+ see also: fnl debug --channel <name> (per-channel diagnosis with next steps)`;
5675
7022
  const isGatewayStatus = (value) => {
5676
7023
  if (value === null || typeof value !== "object") return false;
5677
7024
  if (!("clients" in value) || !Array.isArray(value.clients)) return false;
5678
- return value.clients.every((client) => typeof client === "object" && client !== null && "channel" in client && typeof client.channel === "string" && "connectors" in client && Array.isArray(client.connectors));
7025
+ if (!("listeners" in value) || !Array.isArray(value.listeners)) return false;
7026
+ return true;
5679
7027
  };
5680
- const statusHandler = factory.createHandlers(zValidator$1("query", z.object({}), statusHelp), async (c) => {
5681
- const funnel = c.var.funnel;
7028
+ const buildStatusLines = async (funnel) => {
5682
7029
  const channels = funnel.channels.list();
5683
7030
  const profiles = funnel.profiles.list();
5684
7031
  const gatewayStatus = funnel.gateway.getStatus();
5685
7032
  const lines = [];
5686
7033
  lines.push("= funnel status =");
5687
7034
  lines.push("");
7035
+ let gatewayData = null;
7036
+ if (!gatewayStatus.running) lines.push("gateway: not running");
7037
+ else {
7038
+ const res = await fetch(`http://127.0.0.1:${gatewayStatus.port}/status`).catch(() => null);
7039
+ let uptimeStr = "";
7040
+ if (res && res.ok) {
7041
+ const body = await res.json();
7042
+ if (isGatewayStatus(body)) {
7043
+ gatewayData = body;
7044
+ const uptimeSec = Math.floor(body.uptimeMs / 1e3);
7045
+ const uptimeMin = Math.floor(uptimeSec / 60);
7046
+ uptimeStr = uptimeMin >= 60 ? ` · ${Math.floor(uptimeMin / 60)}h ${uptimeMin % 60}m` : uptimeSec >= 60 ? ` · ${uptimeMin}m ${uptimeSec % 60}s` : ` · ${uptimeSec}s`;
7047
+ }
7048
+ }
7049
+ lines.push(`gateway: running (pid ${gatewayStatus.pid}, port ${gatewayStatus.port})${uptimeStr}`);
7050
+ }
7051
+ lines.push("");
7052
+ const clientsByChannel = /* @__PURE__ */ new Map();
7053
+ const listenerAliveByChannel = /* @__PURE__ */ new Map();
7054
+ if (gatewayData) {
7055
+ for (const client of gatewayData.clients) {
7056
+ if (client.tapAll) continue;
7057
+ const key = client.channelName ?? client.channel;
7058
+ clientsByChannel.set(key, (clientsByChannel.get(key) ?? 0) + 1);
7059
+ }
7060
+ for (const listener of gatewayData.listeners) {
7061
+ const current = listenerAliveByChannel.get(listener.channelName);
7062
+ listenerAliveByChannel.set(listener.channelName, current === void 0 ? listener.alive : current && listener.alive);
7063
+ }
7064
+ }
7065
+ const maxNameLen = Math.max(...channels.map((ch) => ch.name.length), 0);
5688
7066
  lines.push(`channels: ${channels.length}`);
5689
7067
  for (const ch of channels) {
5690
- const attached = ch.connectors.length > 0 ? ch.connectors.map((c) => `${c.name}:${c.type}`).join(", ") : "(none)";
5691
- lines.push(` - ${ch.name} [${attached}]`);
7068
+ const connectorLabel = ch.connectors.length > 0 ? ch.connectors.map((conn) => conn.type).join(", ") : "no connectors";
7069
+ const isAlive = listenerAliveByChannel.get(ch.name);
7070
+ const indicator = gatewayData === null ? "-" : isAlive === true ? "●" : isAlive === false ? "○" : "-";
7071
+ const claudeCount = clientsByChannel.get(ch.name) ?? 0;
7072
+ const claudeLabel = gatewayData === null ? "" : claudeCount === 0 ? " no Claude" : claudeCount === 1 ? " Claude connected (1 client)" : ` Claude connected (${claudeCount} clients)`;
7073
+ const paddedName = ch.name.padEnd(maxNameLen);
7074
+ lines.push(` ${indicator} ${paddedName} [${connectorLabel}]${claudeLabel}`);
5692
7075
  }
5693
7076
  lines.push("");
5694
7077
  lines.push(`profiles: ${profiles.length}`);
@@ -5698,23 +7081,41 @@ const statusHandler = factory.createHandlers(zValidator$1("query", z.object({}),
5698
7081
  const channelLabel = channel ? channel.name : `id:${profile.channelId}`;
5699
7082
  lines.push(` - ${profile.name}${tag} [path=${profile.path}, channel=${channelLabel}]`);
5700
7083
  }
5701
- lines.push("");
5702
- if (!gatewayStatus.running) lines.push("gateway: not running");
5703
- else {
5704
- lines.push(`gateway: running (pid ${gatewayStatus.pid}, port ${gatewayStatus.port})`);
5705
- const res = await fetch(`http://localhost:${gatewayStatus.port}/status`).catch(() => null);
5706
- if (res && res.ok) {
5707
- const body = await res.json();
5708
- if (isGatewayStatus(body)) {
5709
- lines.push(` clients: ${body.clients.length}`);
5710
- for (const client of body.clients) {
5711
- const connectorList = client.connectors.length > 0 ? client.connectors.join(", ") : "(none)";
5712
- lines.push(` - channel=${client.channel || "(unset)"} [${connectorList}]`);
5713
- }
5714
- }
5715
- }
5716
- }
5717
- return c.text(lines.join("\n"));
7084
+ return lines;
7085
+ };
7086
+ const statusHandler = factory.createHandlers(zValidator$1("query", z.object({
7087
+ watch: z.enum([
7088
+ "true",
7089
+ "false",
7090
+ ""
7091
+ ]).optional(),
7092
+ interval: z.string().optional()
7093
+ }), statusHelp), async (c) => {
7094
+ const query = c.req.valid("query");
7095
+ const funnel = c.var.funnel;
7096
+ const isWatch = query.watch === "true" || query.watch === "";
7097
+ const intervalSec = Math.min(60, Math.max(1, query.interval ? Number(query.interval) : 3));
7098
+ if (!isWatch) {
7099
+ const lines = await buildStatusLines(funnel);
7100
+ return c.text(lines.join("\n"));
7101
+ }
7102
+ const render = async () => {
7103
+ const lines = await buildStatusLines(funnel);
7104
+ const ts = (/* @__PURE__ */ new Date()).toISOString().slice(11, 19);
7105
+ process.stdout.write("\x1B[2J\x1B[H");
7106
+ process.stdout.write(lines.join("\n"));
7107
+ process.stdout.write(`\n\n refreshing every ${intervalSec}s · ${ts} · Ctrl+C to stop\n`);
7108
+ };
7109
+ process.on("SIGINT", () => {
7110
+ process.stdout.write("\n");
7111
+ process.exit(0);
7112
+ });
7113
+ await render();
7114
+ const timer = setInterval(render, intervalSec * 1e3);
7115
+ await new Promise(() => {
7116
+ process.on("exit", () => clearInterval(timer));
7117
+ });
7118
+ return c.text("");
5718
7119
  });
5719
7120
  //#endregion
5720
7121
  //#region lib/cli/routes/update.ts
@@ -5757,9 +7158,9 @@ const createCliApp = (funnel) => {
5757
7158
  if (error instanceof HTTPException) return c.text(`error: ${error.message}`, error.status);
5758
7159
  return c.text(`error: ${error instanceof Error ? error.message : String(error)}`, 400);
5759
7160
  });
5760
- 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);
7161
+ 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/validate", ...channelsValidateHandler).get("/channels/validate", ...helpRoute(validateHelp)).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("/debug", ...debugHandler).get("/debug/events", ...debugEventsHandler).get("/debug/dropped", ...debugDroppedHandler).get("/debug/errors", ...debugErrorsHandler).get("/debug/replay", ...debugReplayHandler).get("/schema", ...schemaHandler).get("/status", ...statusHandler).get("/update", ...updateHandler);
5761
7162
  };
5762
7163
  /** CLI Hono app wired to a default `new Funnel()`. For embedding with a custom Funnel use `createCliApp`. */
5763
7164
  const app = createCliApp(new Funnel());
5764
7165
  //#endregion
5765
- export { CONNECTOR_CONNECTION_STATUSES, ConnectorDiagnosticLog, ConnectorDiagnosticSqlReader, DEFAULT_GATEWAY_PORT, DEFAULT_GATEWAY_TOKEN_PATH, FUNNEL_DIR, FUNNEL_MCP_COMMAND, FUNNEL_MCP_NAME, Funnel, FunnelBroadcaster, FunnelChannelPublisher, FunnelChannels, FunnelClaude, FunnelClock, FunnelConnectorFactory, FunnelConnectorListener, FunnelEventLog, FunnelFileSystem, FunnelGateway, FunnelGatewayServer, FunnelGatewayToken, FunnelIdGenerator, FunnelListenerSupervisor, FunnelListenersClient, FunnelLocalConfig, FunnelLocalConfigSync, FunnelLocalConfigWriter, FunnelLogger, FunnelMcp, FunnelProcessRunner, FunnelProfiles, FunnelSettingsReader, FunnelSettingsStore, FunnelSlackEventProcessor, FunnelTokenPrompter, LOCAL_CONFIG_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, localConfigSchema, profileConfigSchema, profileSpecSchema, publishRequestSchema, publishResponseSchema, queryToCliArgs, resolveFunnelDir, resolveFunnelPort, scheduleCatchupPolicySchema, scheduleConnectorSchema, scheduleEntrySchema, settingsSchema, slackConnectorSchema, startChannelServer, toRequest };
7166
+ export { CONNECTOR_CONNECTION_STATUSES, ConnectorDiagnosticLog, ConnectorDiagnosticSqlReader, DEFAULT_GATEWAY_PORT, DEFAULT_GATEWAY_TOKEN_PATH, FUNNEL_DIR, FUNNEL_MCP_ARGS, FUNNEL_MCP_COMMAND, FUNNEL_MCP_NAME, Funnel, FunnelBroadcaster, FunnelChannelPublisher, FunnelChannels, FunnelClaude, FunnelClock, FunnelConnectorFactory, FunnelConnectorListener, FunnelEventLog, FunnelFileSystem, FunnelGateway, FunnelGatewayServer, FunnelGatewayToken, FunnelIdGenerator, FunnelListenerSupervisor, FunnelListenersClient, FunnelLocalConfig, FunnelLocalConfigSync, FunnelLocalConfigWriter, FunnelLogger, FunnelMcp, FunnelProcessRunner, FunnelProfiles, FunnelSettingsReader, FunnelSettingsStore, FunnelSlackEventProcessor, FunnelTokenPrompter, LOCAL_CONFIG_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, localConfigSchema, profileConfigSchema, profileSpecSchema, publishRequestSchema, publishResponseSchema, queryToCliArgs, resolveFunnelDir, resolveFunnelPort, scheduleCatchupPolicySchema, scheduleConnectorSchema, scheduleEntrySchema, settingsSchema, slackConnectorSchema, startChannelServer, toRequest };