@interactive-inc/claude-funnel 0.36.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()`;
@@ -2582,7 +2581,7 @@ var SqliteFunnelEventLog = class extends FunnelEventLog {
2582
2581
  record(record) {
2583
2582
  const event = {
2584
2583
  type: record.meta?.event_type ?? "unknown",
2585
- content: truncate(record.content),
2584
+ content: truncate$1(record.content),
2586
2585
  channel_id: record.channelId,
2587
2586
  connector_id: record.connectorId,
2588
2587
  meta: record.meta
@@ -2639,7 +2638,7 @@ var SqliteFunnelEventLog = class extends FunnelEventLog {
2639
2638
  this.sink.close();
2640
2639
  }
2641
2640
  };
2642
- function truncate(content) {
2641
+ function truncate$1(content) {
2643
2642
  if (content.length <= MAX_CONTENT_CHARS) return content;
2644
2643
  return `${content.slice(0, MAX_CONTENT_CHARS)}...`;
2645
2644
  }
@@ -2995,6 +2994,240 @@ const channelsPublishHandler$1 = factory$1.createHandlers(zParam(z.object({ chan
2995
2994
  return c.json(response);
2996
2995
  });
2997
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
2998
3231
  //#region lib/gateway/routes/health.ts
2999
3232
  /** GET /health — liveness + listener registry snapshot. */
3000
3233
  const healthHandler = factory$1.createHandlers((c) => {
@@ -3067,7 +3300,7 @@ const statusHandler$1 = factory$1.createHandlers((c) => {
3067
3300
  * from the `deps` variable set by `FunnelGatewayServer`'s middleware — same
3068
3301
  * shape as CLI's `c.var.funnel`.
3069
3302
  */
3070
- 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);
3071
3304
  //#endregion
3072
3305
  //#region lib/gateway/gateway-server.ts
3073
3306
  const DEFAULT_HOST = "127.0.0.1";
@@ -3300,6 +3533,7 @@ var FunnelGatewayServer = class {
3300
3533
  if (this.token) {
3301
3534
  base.use("/listeners/*", requireBearerToken({ expected: this.token }));
3302
3535
  base.use("/status", requireBearerToken({ expected: this.token }));
3536
+ base.use("/debug", requireBearerToken({ expected: this.token }));
3303
3537
  base.use("/channels/*", requireBearerToken({ expected: this.token }));
3304
3538
  }
3305
3539
  return (this.extraRoutes ? base.route("/", this.extraRoutes) : base).route("/", gatewayRoutes);
@@ -3535,6 +3769,84 @@ var FunnelListenersClient = class {
3535
3769
  }
3536
3770
  };
3537
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
3538
3850
  //#region lib/funnel.ts
3539
3851
  const SANDBOX_DIR = "/sandbox/.funnel";
3540
3852
  const SANDBOX_TMP_DIR = "/sandbox/tmp";
@@ -3781,6 +4093,13 @@ var Funnel = class Funnel {
3781
4093
  extraRoutes: options.extraRoutes
3782
4094
  });
3783
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
+ }
3784
4103
  };
3785
4104
  //#endregion
3786
4105
  //#region lib/engine/mcp/channel-subscriber.ts
@@ -3874,14 +4193,44 @@ const readGatewayToken = (dir) => {
3874
4193
  //#endregion
3875
4194
  //#region lib/engine/mcp/usage-hint-for-type.ts
3876
4195
  const usageHintForType = (type) => {
3877
- if (type === "slack") return "Slack Web API. method=POST path=chat.postMessage body={channel,text,thread_ts?}";
3878
- if (type === "discord") return "Discord REST API. method=POST path=/channels/<id>/messages body={content,...}";
3879
- 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(" ");
3880
4212
  return "Generic adapter call.";
3881
4213
  };
3882
4214
  //#endregion
3883
4215
  //#region lib/engine/mcp/channel-server.ts
3884
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
+ };
3885
4234
  const startChannelServer = async (options = {}) => {
3886
4235
  const dir = options.dir ?? DEFAULT_FUNNEL_DIR;
3887
4236
  const gatewayBaseUrl = options.gatewayUrl ?? process.env.FUNNEL_GATEWAY_URL ?? `http://127.0.0.1:${resolveFunnelPort()}`;
@@ -3889,6 +4238,13 @@ const startChannelServer = async (options = {}) => {
3889
4238
  const channelId = options.channelId ?? process.env.FUNNEL_CHANNEL_ID;
3890
4239
  const channel = channelId ? readChannelConnectors(dir, channelId) : null;
3891
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") : "";
3892
4248
  const server = new Server({
3893
4249
  name: FUNNEL_MCP_NAME,
3894
4250
  version: "1.0.0"
@@ -3898,13 +4254,35 @@ const startChannelServer = async (options = {}) => {
3898
4254
  tools: {}
3899
4255
  },
3900
4256
  instructions: [
3901
- `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 }`,
4271
+ "",
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" }`,
3902
4276
  "",
3903
- "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."
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
3904
4282
  ].join("\n")
3905
4283
  });
3906
4284
  server.setRequestHandler(ListToolsRequestSchema, async () => {
3907
- return { tools: (channel?.connectors ?? []).map((c) => ({
4285
+ const connectorTools = (channel?.connectors ?? []).map((c) => ({
3908
4286
  name: c.name,
3909
4287
  description: `Call the "${c.name}" (${c.type}) connector. ${usageHintForType(c.type)}`,
3910
4288
  inputSchema: {
@@ -3925,17 +4303,42 @@ const startChannelServer = async (options = {}) => {
3925
4303
  },
3926
4304
  required: ["method", "path"]
3927
4305
  }
3928
- })) };
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] };
3929
4331
  });
3930
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);
3931
4335
  if (!channel) throw new Error("FUNNEL_CHANNEL_ID is not set or channel not found in settings.json");
3932
- const connectorName = request.params.name;
3933
4336
  const args = request.params.arguments ?? {};
3934
4337
  const method = typeof args.method === "string" ? args.method : "";
3935
4338
  const path = typeof args.path === "string" ? args.path : "";
3936
4339
  const body = args.body ?? {};
3937
4340
  if (!method || !path) throw new Error("`method` and `path` are required");
3938
- const url = `${gatewayBaseUrl}/channels/${encodeURIComponent(channel.channelName)}/connectors/${encodeURIComponent(connectorName)}/call`;
4341
+ const url = `${gatewayBaseUrl}/channels/${encodeURIComponent(channel.channelName)}/connectors/${encodeURIComponent(toolName)}/call`;
3939
4342
  const headers = { "content-type": "application/json" };
3940
4343
  if (token) headers.authorization = `Bearer ${token}`;
3941
4344
  const res = await fetch(url, {
@@ -3963,6 +4366,51 @@ const startChannelServer = async (options = {}) => {
3963
4366
  protocols: token ? [`funnel.token.${token}`] : void 0
3964
4367
  }).start();
3965
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
+ };
3966
4414
  //#endregion
3967
4415
  //#region lib/engine/local-config/local-config-json-schema.ts
3968
4416
  /**
@@ -4506,87 +4954,6 @@ const takeRecent = (events, limit) => {
4506
4954
  return events.slice(-limit);
4507
4955
  };
4508
4956
  //#endregion
4509
- //#region lib/gateway/connector-diagnostic-sql-reader.ts
4510
- /**
4511
- * Read-only SQL surface over the three diagnostic tables, for Claude to query
4512
- * the log with arbitrary `SELECT`s. It opens all files read-only and exposes
4513
- * three views — `raw`, `processed`, `connection` — that hide the storage
4514
- * details (the physical table is `leuco_log` and each row's columns live
4515
- * inside a JSON `event` blob): the views surface the columns as plain fields,
4516
- * with `payload` already pulled out of the nested JSON.
4517
- *
4518
- * The tables are separate files. `raw` and `processed` share an `event_id`,
4519
- * so a `JOIN` answers "the event arrived, but what verdict did it get?";
4520
- * `connection` answers the other half — "did the listener ever connect at
4521
- * all?". Writes are impossible: the connection is read-only and `query`
4522
- * rejects anything but a single `SELECT`.
4523
- */
4524
- var ConnectorDiagnosticSqlReader = class {
4525
- db;
4526
- constructor(props) {
4527
- const db = new Database(props.rawPath, { readonly: true });
4528
- try {
4529
- db.run("PRAGMA busy_timeout = 500");
4530
- db.prepare("ATTACH DATABASE ? AS processeddb").run(props.processedPath);
4531
- db.prepare("ATTACH DATABASE ? AS connectiondb").run(props.connectionPath);
4532
- db.run(rawViewSql);
4533
- db.run(processedViewSql);
4534
- db.run(connectionViewSql);
4535
- } catch (error) {
4536
- db.close();
4537
- throw error;
4538
- }
4539
- this.db = db;
4540
- Object.freeze(this);
4541
- }
4542
- /**
4543
- * Run one read-only `SELECT` and return the rows. Returns an `Error` (rather
4544
- * than throwing) for a non-SELECT statement or a SQL error, so the caller
4545
- * can surface the message without a stack trace.
4546
- */
4547
- query(sql) {
4548
- const trimmed = sql.trim().replace(/;$/, "").trim();
4549
- if (!/^select\b/i.test(trimmed)) return /* @__PURE__ */ new Error("only a single SELECT statement is allowed");
4550
- if (trimmed.includes(";")) return /* @__PURE__ */ new Error("only a single statement is allowed (remove the ';')");
4551
- try {
4552
- return this.db.prepare(trimmed).all();
4553
- } catch (error) {
4554
- return error instanceof Error ? error : new Error(String(error));
4555
- }
4556
- }
4557
- close() {
4558
- this.db.close();
4559
- }
4560
- };
4561
- const rawViewSql = `CREATE TEMP VIEW raw 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, '$.payload') AS payload
4569
- FROM main.leuco_log`;
4570
- const processedViewSql = `CREATE TEMP VIEW processed AS SELECT
4571
- seq,
4572
- ts,
4573
- json_extract(event, '$.event_id') AS event_id,
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, '$.outcome') AS outcome,
4578
- json_extract(event, '$.payload') AS payload
4579
- FROM processeddb.leuco_log`;
4580
- const connectionViewSql = `CREATE TEMP VIEW connection AS SELECT
4581
- seq,
4582
- ts,
4583
- json_extract(event, '$.type') AS type,
4584
- json_extract(event, '$.connector_id') AS connector_id,
4585
- json_extract(event, '$.channel_id') AS channel_id,
4586
- json_extract(event, '$.status') AS status,
4587
- json_extract(event, '$.detail') AS detail
4588
- FROM connectiondb.leuco_log`;
4589
- //#endregion
4590
4957
  //#region lib/cli/factory.ts
4591
4958
  const factory = createFactory();
4592
4959
  //#endregion
@@ -5051,9 +5418,16 @@ const channelsShowHandler = factory.createHandlers(zValidator$1("param", z.objec
5051
5418
  ];
5052
5419
  return c.text(lines.join("\n"));
5053
5420
  });
5054
- 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
5426
+
5427
+ usage: funnel channels [--json]
5055
5428
 
5056
- usage: funnel channels [subcommand]
5429
+ options:
5430
+ --json output as JSON array (machine-readable, useful for Claude)
5057
5431
 
5058
5432
  subcommands:
5059
5433
  (none) list
@@ -5064,13 +5438,26 @@ subcommands:
5064
5438
  <name> connectors add <c> --type=... add a connector
5065
5439
 
5066
5440
  examples:
5441
+ funnel channels
5442
+ funnel channels --json
5067
5443
  funnel channels add prod-inbox
5068
5444
  funnel channels prod-inbox connectors add prod-slack --type=slack --bot-token=xoxb-... --app-token=xapp-...
5069
5445
  funnel channels prod-inbox`), (c) => {
5446
+ const query = c.req.valid("query");
5070
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
+ })));
5071
5458
  if (channels.length === 0) return c.text("no channels");
5072
5459
  const lines = channels.map((ch) => {
5073
- const names = ch.connectors.map((c) => c.name);
5460
+ const names = ch.connectors.map((conn) => conn.name);
5074
5461
  const connectors = names.length > 0 ? names.join(", ") : "(none)";
5075
5462
  return `${ch.name} [${connectors}]`;
5076
5463
  });
@@ -5161,12 +5548,848 @@ const claudeHandler = factory.createHandlers(zValidator$1("query", z.object({
5161
5548
  process.exit(exitCode);
5162
5549
  });
5163
5550
  //#endregion
5164
- //#region lib/cli/routes/gateway.ts
5165
- const groupHelp$1 = `funnel gatewaymanage the funnel daemon
5551
+ //#region lib/cli/routes/debug.ts
5552
+ const debugHelp = `funnel debugdiagnose why Claude is not receiving events
5166
5553
 
5167
- The gateway daemon hosts the WebSocket /ws (used by Claude MCP), the
5168
- local web UI at /, and the listener supervisor that runs every
5169
- connector. One daemon, one port (9742), one PID file.
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
6388
+ const groupHelp$1 = `funnel gateway — manage the funnel daemon
6389
+
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.
5170
6393
 
5171
6394
  usage: funnel gateway [subcommand]
5172
6395
 
@@ -5177,20 +6400,49 @@ subcommands:
5177
6400
  restart stop then start
5178
6401
  run start in foreground (for developers)
5179
6402
  logs [-n <N>] tail the daemon diagnostic log (lifecycle, listener boot)
5180
- sql --query "<SQL>" query inbound connector traffic (raw + processed verdict)
6403
+ sql query inbound connector traffic (raw + processed verdict)
5181
6404
  listeners list running connector listeners (alive / dead)
5182
6405
 
5183
6406
  examples:
5184
- funnel gateway check status
5185
- 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)`;
5186
6413
  const renderGatewayStatus = async (c) => {
5187
6414
  const status = c.var.funnel.gateway.getStatus();
5188
6415
  if (!status.running) throw new HTTPException(503, { message: "funnel gateway: not running" });
5189
- const res = await fetch(`http://127.0.0.1:${status.port}/health`).catch(() => null);
6416
+ const res = await fetch(`http://127.0.0.1:${status.port}/status`).catch(() => null);
5190
6417
  if (!res) return c.text(`funnel gateway: running (pid ${status.pid}) — health check failed`);
5191
- const health = await res.json();
5192
- const clients = health !== null && typeof health === "object" && "clients" in health ? health.clients : 0;
5193
- 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"));
5194
6446
  };
5195
6447
  const gatewayGroupHandler = factory.createHandlers(zValidator$1("query", z.object({}), groupHelp$1), renderGatewayStatus);
5196
6448
  const gatewayListenersHandler = factory.createHandlers(zValidator$1("query", z.object({}), `funnel gateway listeners — show running connector listeners
@@ -5212,24 +6464,31 @@ examples:
5212
6464
  });
5213
6465
  //#endregion
5214
6466
  //#region lib/cli/routes/gateway.logs.ts
5215
- const logsHelp = `funnel gateway logs — tail diagnostic logs
6467
+ const logsHelp = `funnel gateway logs — tail the daemon diagnostic log
5216
6468
 
5217
- usage: funnel gateway logs [-n <N>]
6469
+ usage: funnel gateway logs [-n <N>] [--format <plain|json>]
5218
6470
 
5219
6471
  options:
5220
6472
  -n <N> number of trailing lines to show (default: 20)
6473
+ --format <plain|json> output format (default: plain)
5221
6474
 
5222
- Tails ${join(funnelTmpDir(), "funnel.log")} (the daemon's diagnostic stream — gateway
5223
- lifecycle, channel connect/disconnect, listener boot). Exit with SIGINT.
5224
- Output is formatted as YAML.
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.
5225
6478
 
5226
- Domain events fanned out to WebSocket clients live in the SQLite event
5227
- store (${join(funnelTmpDir(), "events.db")}); they are not shown here. Subscribe via
5228
- the WS endpoint or query the store directly.
6479
+ plain format: HH:MM:SS LEVEL message key=value ...
6480
+ json format: raw JSON lines (pipe to jq for filtering)
6481
+
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
5229
6485
 
5230
6486
  examples:
5231
6487
  funnel gateway logs
5232
- 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`;
5233
6492
  const logger = new NodeFunnelLogger();
5234
6493
  const tryParseJson = (line) => {
5235
6494
  try {
@@ -5245,11 +6504,27 @@ const isLogEntry = (value) => {
5245
6504
  if (!("message" in value) || typeof value.message !== "string") return false;
5246
6505
  return true;
5247
6506
  };
5248
- 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) => {
5249
6523
  const query = c.req.valid("query");
5250
6524
  const path = logger.file;
5251
6525
  if (!path || !existsSync(path)) return c.text("no logs");
5252
6526
  const lineCount = query.n ? Number(query.n) : 20;
6527
+ const format = query.format ?? "plain";
5253
6528
  const tail = Bun.spawn([
5254
6529
  "tail",
5255
6530
  "-f",
@@ -5268,7 +6543,6 @@ const gatewayLogsHandler = factory.createHandlers(zValidator$1("query", z.object
5268
6543
  const reader = tail.stdout.getReader();
5269
6544
  const decoder = new TextDecoder();
5270
6545
  let buffer = "";
5271
- logger.info("gateway.logs tail start", { file: path });
5272
6546
  while (true) {
5273
6547
  const result = await reader.read();
5274
6548
  if (result.done) break;
@@ -5282,13 +6556,8 @@ const gatewayLogsHandler = factory.createHandlers(zValidator$1("query", z.object
5282
6556
  process.stdout.write(`${line}\n`);
5283
6557
  continue;
5284
6558
  }
5285
- const output = {
5286
- time: parsed.time,
5287
- level: parsed.level,
5288
- message: parsed.message,
5289
- ...parsed.meta ? { meta: parsed.meta } : {}
5290
- };
5291
- process.stdout.write(`---\n${stringify(output)}`);
6559
+ if (format === "json") process.stdout.write(`${JSON.stringify(parsed)}\n`);
6560
+ else process.stdout.write(formatPlain(parsed));
5292
6561
  }
5293
6562
  }
5294
6563
  await tail.exited;
@@ -5296,47 +6565,78 @@ const gatewayLogsHandler = factory.createHandlers(zValidator$1("query", z.object
5296
6565
  });
5297
6566
  //#endregion
5298
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
+ };
5299
6575
  const sqlHelp = `funnel gateway sql — query inbound connector traffic with SQL
5300
6576
 
5301
- 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
5302
6592
 
5303
- Runs one read-only SELECT against the daemon's diagnostic store of inbound
5304
- connector events and prints the rows as JSON. Use it to answer "Slack
5305
- delivered an event, so why was there no notification?".
6593
+ Output is always JSON pipe to jq or pass directly to Claude.
5306
6594
 
5307
- Three views:
5308
- raw every inbound event, untouched, before any filtering
5309
- processed the verdict for that event after the per-type processor ran
5310
- 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 | ...)
5311
6599
 
5312
- Shared columns (all three views):
5313
- seq row id within the view (not comparable across views)
5314
- ts epoch milliseconds
5315
- type connector kind: slack | discord | gh | schedule
5316
- connector_id funnel connector id
5317
- channel_id funnel channel id
5318
- raw and processed also have:
5319
- event_id correlation id shared by an event's raw and processed rows
5320
- payload raw: the original event JSON (text); processed: the delivered body, or "" when skipped
5321
- processed also has:
5322
- outcome 'emitted' | 'emitted:delivery-failed' | 'skip:<reason>'
5323
- (skip reasons: skip:type, skip:subtype, skip:dedup,
5324
- skip:self-user, skip:self-bot, skip:preprocess)
5325
- connection also has:
5326
- status 'started' | 'connected' | 'disconnected' | 'auth-failed' | 'stopped' | 'error'
5327
- 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'
5328
6601
 
5329
- To trace one event end to end, join raw and processed on event_id. When the
5330
- event tables are empty, query connection — a listener that never connected, or
5331
- failed auth, explains why nothing arrived.
6602
+ skip reasons: skip:type skip:subtype skip:dedup skip:self-user skip:self-bot skip:preprocess
5332
6603
 
5333
6604
  examples:
5334
- 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
5335
6610
  funnel gateway sql --query "SELECT outcome, COUNT(*) n FROM processed GROUP BY outcome"
5336
- funnel gateway sql --query "SELECT r.payload FROM raw r JOIN processed p USING(event_id) WHERE p.outcome='skip:dedup'"
5337
- funnel gateway sql --query "SELECT ts, status, detail FROM connection WHERE status IN ('auth-failed','error') ORDER BY seq DESC"`;
5338
- const gatewaySqlHandler = factory.createHandlers(zValidator$1("query", z.object({ query: z.string().optional() }), sqlHelp), async (c) => {
5339
- 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;
5340
6640
  if (!sql) return c.text(sqlHelp);
5341
6641
  const tmpDir = funnelTmpDir();
5342
6642
  const rawPath = join(tmpDir, "connector-raw.db");
@@ -5350,7 +6650,7 @@ const gatewaySqlHandler = factory.createHandlers(zValidator$1("query", z.object(
5350
6650
  });
5351
6651
  const rows = (() => {
5352
6652
  try {
5353
- return reader.query(sql);
6653
+ return reader.query(sql, params);
5354
6654
  } finally {
5355
6655
  reader.close();
5356
6656
  }
@@ -5428,15 +6728,41 @@ const gatewayStartHandler = factory.createHandlers(zValidator$1("query", z.objec
5428
6728
  if (!await funnel.gateway.start({ caffeinate: query["no-caffeine"] !== "true" })) throw new HTTPException(500, { message: "funnel gateway: failed to start" });
5429
6729
  return c.text("funnel gateway: started");
5430
6730
  });
5431
- 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
6736
+
6737
+ usage: funnel gateway status [--json]
5432
6738
 
5433
- usage: funnel gateway status
6739
+ options:
6740
+ --json output as JSON
5434
6741
 
5435
- 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.
5436
6744
 
5437
6745
  examples:
5438
6746
  funnel gateway status
5439
- 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
+ });
5440
6766
  const gatewayStopHandler = factory.createHandlers(zValidator$1("query", z.object({}), `funnel gateway stop — stop the gateway
5441
6767
 
5442
6768
  usage: funnel gateway stop
@@ -5675,29 +7001,77 @@ can validate and autocomplete the config:
5675
7001
  });
5676
7002
  //#endregion
5677
7003
  //#region lib/cli/routes/status.ts
5678
- 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.
5679
7015
 
5680
- usage: funnel status
7016
+ examples:
7017
+ funnel status
7018
+ funnel status --watch
7019
+ funnel status --watch --interval 5
5681
7020
 
5682
- Lists configured connectors / channels / profiles, gateway running status,
5683
- and active MCP WebSocket clients.`;
7021
+ see also: fnl debug --channel <name> (per-channel diagnosis with next steps)`;
5684
7022
  const isGatewayStatus = (value) => {
5685
7023
  if (value === null || typeof value !== "object") return false;
5686
7024
  if (!("clients" in value) || !Array.isArray(value.clients)) return false;
5687
- 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;
5688
7027
  };
5689
- const statusHandler = factory.createHandlers(zValidator$1("query", z.object({}), statusHelp), async (c) => {
5690
- const funnel = c.var.funnel;
7028
+ const buildStatusLines = async (funnel) => {
5691
7029
  const channels = funnel.channels.list();
5692
7030
  const profiles = funnel.profiles.list();
5693
7031
  const gatewayStatus = funnel.gateway.getStatus();
5694
7032
  const lines = [];
5695
7033
  lines.push("= funnel status =");
5696
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);
5697
7066
  lines.push(`channels: ${channels.length}`);
5698
7067
  for (const ch of channels) {
5699
- const attached = ch.connectors.length > 0 ? ch.connectors.map((c) => `${c.name}:${c.type}`).join(", ") : "(none)";
5700
- 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}`);
5701
7075
  }
5702
7076
  lines.push("");
5703
7077
  lines.push(`profiles: ${profiles.length}`);
@@ -5707,23 +7081,41 @@ const statusHandler = factory.createHandlers(zValidator$1("query", z.object({}),
5707
7081
  const channelLabel = channel ? channel.name : `id:${profile.channelId}`;
5708
7082
  lines.push(` - ${profile.name}${tag} [path=${profile.path}, channel=${channelLabel}]`);
5709
7083
  }
5710
- lines.push("");
5711
- if (!gatewayStatus.running) lines.push("gateway: not running");
5712
- else {
5713
- lines.push(`gateway: running (pid ${gatewayStatus.pid}, port ${gatewayStatus.port})`);
5714
- const res = await fetch(`http://127.0.0.1:${gatewayStatus.port}/status`).catch(() => null);
5715
- if (res && res.ok) {
5716
- const body = await res.json();
5717
- if (isGatewayStatus(body)) {
5718
- lines.push(` clients: ${body.clients.length}`);
5719
- for (const client of body.clients) {
5720
- const connectorList = client.connectors.length > 0 ? client.connectors.join(", ") : "(none)";
5721
- lines.push(` - channel=${client.channel || "(unset)"} [${connectorList}]`);
5722
- }
5723
- }
5724
- }
5725
- }
5726
- 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("");
5727
7119
  });
5728
7120
  //#endregion
5729
7121
  //#region lib/cli/routes/update.ts
@@ -5766,7 +7158,7 @@ const createCliApp = (funnel) => {
5766
7158
  if (error instanceof HTTPException) return c.text(`error: ${error.message}`, error.status);
5767
7159
  return c.text(`error: ${error instanceof Error ? error.message : String(error)}`, 400);
5768
7160
  });
5769
- 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);
5770
7162
  };
5771
7163
  /** CLI Hono app wired to a default `new Funnel()`. For embedding with a custom Funnel use `createCliApp`. */
5772
7164
  const app = createCliApp(new Funnel());