@interactive-inc/claude-funnel 0.36.0 → 0.38.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
@@ -4,6 +4,7 @@ import { a as FunnelProcessRunner, i as NodeFunnelProcessRunner, n as FunnelGhLi
4
4
  import { a as ScheduleStateStore, i as FunnelScheduleListener, n as scheduleConnectorSchema, o as NodeFunnelFileSystem, r as scheduleEntrySchema, s as FunnelFileSystem, t as scheduleCatchupPolicySchema } from "./schedule-connector-schema-CM-sRkac.js";
5
5
  import { i as FunnelSlackAdapter, n as FunnelSlackListener, r as FunnelSlackEventProcessor, t as slackConnectorSchema } from "./slack-connector-schema-DDbSGPZn.js";
6
6
  import { dirname, join, resolve } from "node:path";
7
+ import { hc } from "hono/client";
7
8
  import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync } from "node:fs";
8
9
  import { z } from "zod";
9
10
  import { homedir, tmpdir } from "node:os";
@@ -17,7 +18,6 @@ import { zValidator } from "@hono/zod-validator";
17
18
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
18
19
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
19
20
  import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
20
- import { stringify } from "yaml";
21
21
  //#region lib/engine/id/id-generator.ts
22
22
  /**
23
23
  * ID generator boundary. Default NodeFunnelIdGenerator wraps `crypto.randomUUID()`;
@@ -2582,7 +2582,7 @@ var SqliteFunnelEventLog = class extends FunnelEventLog {
2582
2582
  record(record) {
2583
2583
  const event = {
2584
2584
  type: record.meta?.event_type ?? "unknown",
2585
- content: truncate(record.content),
2585
+ content: truncate$1(record.content),
2586
2586
  channel_id: record.channelId,
2587
2587
  connector_id: record.connectorId,
2588
2588
  meta: record.meta
@@ -2639,7 +2639,7 @@ var SqliteFunnelEventLog = class extends FunnelEventLog {
2639
2639
  this.sink.close();
2640
2640
  }
2641
2641
  };
2642
- function truncate(content) {
2642
+ function truncate$1(content) {
2643
2643
  if (content.length <= MAX_CONTENT_CHARS) return content;
2644
2644
  return `${content.slice(0, MAX_CONTENT_CHARS)}...`;
2645
2645
  }
@@ -2995,6 +2995,240 @@ const channelsPublishHandler$1 = factory$1.createHandlers(zParam(z.object({ chan
2995
2995
  return c.json(response);
2996
2996
  });
2997
2997
  //#endregion
2998
+ //#region lib/gateway/connector-diagnostic-sql-reader.ts
2999
+ /**
3000
+ * Read-only SQL surface over the three diagnostic tables, for Claude to query
3001
+ * the log with arbitrary `SELECT`s. It opens all files read-only and exposes
3002
+ * three views — `raw`, `processed`, `connection` — that hide the storage
3003
+ * details (the physical table is `leuco_log` and each row's columns live
3004
+ * inside a JSON `event` blob): the views surface the columns as plain fields,
3005
+ * with `payload` already pulled out of the nested JSON.
3006
+ *
3007
+ * The tables are separate files. `raw` and `processed` share an `event_id`,
3008
+ * so a `JOIN` answers "the event arrived, but what verdict did it get?";
3009
+ * `connection` answers the other half — "did the listener ever connect at
3010
+ * all?". Writes are impossible: the connection is read-only and `query`
3011
+ * rejects anything but a single `SELECT`.
3012
+ */
3013
+ var ConnectorDiagnosticSqlReader = class {
3014
+ db;
3015
+ constructor(props) {
3016
+ const db = new Database(props.rawPath, { readonly: true });
3017
+ try {
3018
+ db.run("PRAGMA busy_timeout = 500");
3019
+ db.prepare("ATTACH DATABASE ? AS processeddb").run(props.processedPath);
3020
+ db.prepare("ATTACH DATABASE ? AS connectiondb").run(props.connectionPath);
3021
+ db.run(rawViewSql);
3022
+ db.run(processedViewSql);
3023
+ db.run(connectionViewSql);
3024
+ } catch (error) {
3025
+ db.close();
3026
+ throw error;
3027
+ }
3028
+ this.db = db;
3029
+ Object.freeze(this);
3030
+ }
3031
+ /**
3032
+ * Run one read-only `SELECT` and return the rows. Returns an `Error` (rather
3033
+ * than throwing) for a non-SELECT statement or a SQL error, so the caller
3034
+ * can surface the message without a stack trace.
3035
+ */
3036
+ query(sql, params = []) {
3037
+ const trimmed = sql.trim().replace(/;$/, "").trim();
3038
+ if (!/^select\b/i.test(trimmed)) return /* @__PURE__ */ new Error("only a single SELECT statement is allowed");
3039
+ if (trimmed.includes(";")) return /* @__PURE__ */ new Error("only a single statement is allowed (remove the ';')");
3040
+ try {
3041
+ return this.db.prepare(trimmed).all(...params);
3042
+ } catch (error) {
3043
+ return error instanceof Error ? error : new Error(String(error));
3044
+ }
3045
+ }
3046
+ close() {
3047
+ this.db.close();
3048
+ }
3049
+ };
3050
+ const rawViewSql = `CREATE TEMP VIEW raw AS SELECT
3051
+ seq,
3052
+ ts,
3053
+ json_extract(event, '$.event_id') AS event_id,
3054
+ json_extract(event, '$.type') AS type,
3055
+ json_extract(event, '$.connector_id') AS connector_id,
3056
+ json_extract(event, '$.channel_id') AS channel_id,
3057
+ json_extract(event, '$.payload') AS payload
3058
+ FROM main.leuco_log`;
3059
+ const processedViewSql = `CREATE TEMP VIEW processed AS SELECT
3060
+ seq,
3061
+ ts,
3062
+ json_extract(event, '$.event_id') AS event_id,
3063
+ json_extract(event, '$.type') AS type,
3064
+ json_extract(event, '$.connector_id') AS connector_id,
3065
+ json_extract(event, '$.channel_id') AS channel_id,
3066
+ json_extract(event, '$.outcome') AS outcome,
3067
+ json_extract(event, '$.payload') AS payload
3068
+ FROM processeddb.leuco_log`;
3069
+ const connectionViewSql = `CREATE TEMP VIEW connection AS SELECT
3070
+ seq,
3071
+ ts,
3072
+ json_extract(event, '$.type') AS type,
3073
+ json_extract(event, '$.connector_id') AS connector_id,
3074
+ json_extract(event, '$.channel_id') AS channel_id,
3075
+ json_extract(event, '$.status') AS status,
3076
+ json_extract(event, '$.detail') AS detail
3077
+ FROM connectiondb.leuco_log`;
3078
+ //#endregion
3079
+ //#region lib/gateway/routes/debug.ts
3080
+ const extractPreview = (payload) => {
3081
+ if (typeof payload !== "string" || payload.length === 0) return null;
3082
+ try {
3083
+ const parsed = JSON.parse(payload);
3084
+ if (parsed !== null && typeof parsed === "object" && "text" in parsed) {
3085
+ const text = String(parsed.text);
3086
+ return text.length > 80 ? `${text.slice(0, 80)}…` : text;
3087
+ }
3088
+ } catch {
3089
+ return payload.length > 80 ? `${payload.slice(0, 80)}…` : payload;
3090
+ }
3091
+ return payload.length > 80 ? `${payload.slice(0, 80)}…` : payload;
3092
+ };
3093
+ const buildChannelDiagnosis = (channel) => {
3094
+ const rootCause = (channel.connectionErrors[channel.connectionErrors.length - 1] ?? null)?.detail ?? null;
3095
+ if (channel.connectors.length === 0) return {
3096
+ status: "warn",
3097
+ message: "no connectors configured on this channel",
3098
+ nextActions: [`fnl channels ${channel.name} connectors add <name> --type=slack ...`],
3099
+ rootCause: null
3100
+ };
3101
+ if (!channel.listener) return {
3102
+ status: "error",
3103
+ message: "no listener running for this channel",
3104
+ nextActions: ["fnl gateway restart"],
3105
+ rootCause
3106
+ };
3107
+ if (!channel.listener.alive) return {
3108
+ status: "error",
3109
+ message: "listener is dead",
3110
+ nextActions: ["fnl gateway logs", "fnl gateway restart"],
3111
+ rootCause
3112
+ };
3113
+ if (channel.claudeClients === 0) return {
3114
+ status: "warn",
3115
+ message: "no Claude connected to this channel",
3116
+ nextActions: [`fnl claude --channel ${channel.name}`],
3117
+ rootCause: null
3118
+ };
3119
+ if (channel.listener.errors > 0) return {
3120
+ status: "warn",
3121
+ message: "listener has errors",
3122
+ nextActions: ["fnl gateway logs"],
3123
+ rootCause
3124
+ };
3125
+ return {
3126
+ status: "ok",
3127
+ message: "healthy",
3128
+ nextActions: [],
3129
+ rootCause: null
3130
+ };
3131
+ };
3132
+ /** GET /debug[?channel=<name>] — per-channel diagnosis with recent events. Used by MCP fnl_debug tool. */
3133
+ const debugHandler$1 = factory$1.createHandlers(async (c) => {
3134
+ const deps = c.var.deps;
3135
+ const channelFilter = c.req.query("channel") ?? null;
3136
+ const allChannels = deps.channels.list();
3137
+ const targetChannels = channelFilter ? allChannels.filter((ch) => ch.name === channelFilter || ch.id === channelFilter) : allChannels;
3138
+ const gatewayListeners = deps.supervisor.list();
3139
+ const gatewayClients = deps.broadcaster.listChannels();
3140
+ const metrics = deps.broadcaster.getMetrics();
3141
+ const tmpDir = funnelTmpDir();
3142
+ const rawPath = join(tmpDir, "connector-raw.db");
3143
+ const processedPath = join(tmpDir, "connector-processed.db");
3144
+ const connectionPath = join(tmpDir, "connector-connection.db");
3145
+ const hasStore = existsSync(rawPath) && existsSync(processedPath) && existsSync(connectionPath);
3146
+ const channels = targetChannels.map((ch) => {
3147
+ const listenerEntry = gatewayListeners.find((l) => l.channelName === ch.name) ?? null;
3148
+ const listener = listenerEntry ? {
3149
+ alive: listenerEntry.alive,
3150
+ events: listenerEntry.events,
3151
+ errors: listenerEntry.errors,
3152
+ lastEventAt: listenerEntry.lastEventAt
3153
+ } : null;
3154
+ const claudeClients = gatewayClients.filter((cl) => cl.channel === ch.id || cl.channel === ch.name).length;
3155
+ const recentEvents = [];
3156
+ const connectionErrors = [];
3157
+ if (hasStore) {
3158
+ const reader = new ConnectorDiagnosticSqlReader({
3159
+ rawPath,
3160
+ processedPath,
3161
+ connectionPath
3162
+ });
3163
+ const rows = (() => {
3164
+ try {
3165
+ return reader.query("SELECT seq, ts, type, outcome, payload FROM processed WHERE channel_id = ? ORDER BY seq DESC LIMIT 10", [ch.id]);
3166
+ } finally {
3167
+ reader.close();
3168
+ }
3169
+ })();
3170
+ if (!(rows instanceof Error)) for (const row of [...rows].reverse()) {
3171
+ const rawPayload = typeof row.payload === "string" ? row.payload : null;
3172
+ let payloadParsed = null;
3173
+ if (rawPayload) try {
3174
+ const parsed = JSON.parse(rawPayload);
3175
+ if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) payloadParsed = parsed;
3176
+ } catch {
3177
+ payloadParsed = null;
3178
+ }
3179
+ recentEvents.push({
3180
+ seq: typeof row.seq === "number" ? row.seq : null,
3181
+ ts: typeof row.ts === "number" ? row.ts : null,
3182
+ type: typeof row.type === "string" ? row.type : "?",
3183
+ outcome: typeof row.outcome === "string" ? row.outcome : "?",
3184
+ payload: rawPayload,
3185
+ payloadParsed,
3186
+ preview: extractPreview(row.payload)
3187
+ });
3188
+ }
3189
+ if (listener && (!listener.alive || listener.errors > 0) || !listener) {
3190
+ const errReader = new ConnectorDiagnosticSqlReader({
3191
+ rawPath,
3192
+ processedPath,
3193
+ connectionPath
3194
+ });
3195
+ const errRows = (() => {
3196
+ try {
3197
+ 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]);
3198
+ } finally {
3199
+ errReader.close();
3200
+ }
3201
+ })();
3202
+ if (!(errRows instanceof Error)) for (const row of [...errRows].reverse()) connectionErrors.push({
3203
+ ts: typeof row.ts === "number" ? row.ts : null,
3204
+ type: typeof row.type === "string" ? row.type : "?",
3205
+ status: typeof row.status === "string" ? row.status : "?",
3206
+ detail: typeof row.detail === "string" && row.detail.length > 0 ? row.detail : null
3207
+ });
3208
+ }
3209
+ }
3210
+ const base = {
3211
+ id: ch.id,
3212
+ name: ch.name,
3213
+ connectors: ch.connectors.map((conn) => conn.name),
3214
+ listener,
3215
+ claudeClients,
3216
+ recentEvents,
3217
+ connectionErrors
3218
+ };
3219
+ return {
3220
+ ...base,
3221
+ diagnosis: buildChannelDiagnosis(base)
3222
+ };
3223
+ });
3224
+ return c.json({
3225
+ pid: deps.selfPid,
3226
+ uptimeMs: deps.uptimeMs(),
3227
+ eventsBroadcast: metrics.eventsBroadcast,
3228
+ channels
3229
+ });
3230
+ });
3231
+ //#endregion
2998
3232
  //#region lib/gateway/routes/health.ts
2999
3233
  /** GET /health — liveness + listener registry snapshot. */
3000
3234
  const healthHandler = factory$1.createHandlers((c) => {
@@ -3061,13 +3295,10 @@ const statusHandler$1 = factory$1.createHandlers((c) => {
3061
3295
  });
3062
3296
  //#endregion
3063
3297
  //#region lib/gateway/routes/index.ts
3064
- /**
3065
- * Top-level Hono app for the gateway daemon. Mounts every HTTP endpoint flat
3066
- * (the WebSocket /ws upgrade is handled directly by `Bun.serve`). Deps come
3067
- * from the `deps` variable set by `FunnelGatewayServer`'s middleware — same
3068
- * shape as CLI's `c.var.funnel`.
3069
- */
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);
3298
+ function buildGatewayRoutes() {
3299
+ return 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);
3300
+ }
3301
+ const gatewayRoutes = buildGatewayRoutes();
3071
3302
  //#endregion
3072
3303
  //#region lib/gateway/gateway-server.ts
3073
3304
  const DEFAULT_HOST = "127.0.0.1";
@@ -3300,6 +3531,7 @@ var FunnelGatewayServer = class {
3300
3531
  if (this.token) {
3301
3532
  base.use("/listeners/*", requireBearerToken({ expected: this.token }));
3302
3533
  base.use("/status", requireBearerToken({ expected: this.token }));
3534
+ base.use("/debug", requireBearerToken({ expected: this.token }));
3303
3535
  base.use("/channels/*", requireBearerToken({ expected: this.token }));
3304
3536
  }
3305
3537
  return (this.extraRoutes ? base.route("/", this.extraRoutes) : base).route("/", gatewayRoutes);
@@ -3535,6 +3767,84 @@ var FunnelListenersClient = class {
3535
3767
  }
3536
3768
  };
3537
3769
  //#endregion
3770
+ //#region lib/gateway/funnel-debug.ts
3771
+ const isGatewayStatusResponse$1 = (value) => {
3772
+ if (value === null || typeof value !== "object") return false;
3773
+ if (!("clients" in value) || !Array.isArray(value.clients)) return false;
3774
+ if (!("listeners" in value) || !Array.isArray(value.listeners)) return false;
3775
+ return true;
3776
+ };
3777
+ const buildFunnelDebugReport = async (deps, channelFilter) => {
3778
+ const gatewayStatus = deps.gateway.getStatus();
3779
+ const report = {
3780
+ gateway: {
3781
+ running: gatewayStatus.running,
3782
+ pid: gatewayStatus.pid,
3783
+ port: gatewayStatus.running ? gatewayStatus.port : null,
3784
+ uptimeMs: null
3785
+ },
3786
+ channels: [],
3787
+ recentEvents: null
3788
+ };
3789
+ const allChannels = deps.channels.list();
3790
+ const filteredChannels = channelFilter ? allChannels.filter((ch) => ch.name === channelFilter) : allChannels;
3791
+ let gatewayData = null;
3792
+ if (gatewayStatus.running) {
3793
+ const res = await fetch(`http://127.0.0.1:${gatewayStatus.port}/status`).catch(() => null);
3794
+ if (res && res.ok) {
3795
+ const body = await res.json();
3796
+ if (isGatewayStatusResponse$1(body)) {
3797
+ gatewayData = body;
3798
+ report.gateway.uptimeMs = body.uptimeMs;
3799
+ }
3800
+ }
3801
+ }
3802
+ for (const ch of filteredChannels) {
3803
+ const listenerEntry = gatewayData?.listeners.find((l) => l.channelName === ch.name) ?? null;
3804
+ const listener = listenerEntry ? {
3805
+ alive: listenerEntry.alive,
3806
+ events: listenerEntry.events,
3807
+ errors: listenerEntry.errors,
3808
+ lastEventAt: listenerEntry.lastEventAt
3809
+ } : null;
3810
+ const claudeClients = (gatewayData?.clients ?? []).filter((cl) => !cl.tapAll && (cl.channelName === ch.name || cl.channel === ch.name));
3811
+ report.channels.push({
3812
+ name: ch.name,
3813
+ connectors: ch.connectors.map((conn) => conn.name),
3814
+ listener,
3815
+ claudeConnected: claudeClients.length > 0,
3816
+ claudeClientCount: claudeClients.length
3817
+ });
3818
+ }
3819
+ const rawPath = join(deps.tmpDir, "connector-raw.db");
3820
+ const processedPath = join(deps.tmpDir, "connector-processed.db");
3821
+ const connectionPath = join(deps.tmpDir, "connector-connection.db");
3822
+ if (existsSync(rawPath) && existsSync(processedPath) && existsSync(connectionPath)) {
3823
+ const reader = new ConnectorDiagnosticSqlReader({
3824
+ rawPath,
3825
+ processedPath,
3826
+ connectionPath
3827
+ });
3828
+ const filteredChannelId = channelFilter ? allChannels.find((ch) => ch.name === channelFilter)?.id ?? null : null;
3829
+ 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";
3830
+ const params = filteredChannelId ? [filteredChannelId] : [];
3831
+ const rows = reader.query(sql, params);
3832
+ reader.close();
3833
+ if (!(rows instanceof Error)) report.recentEvents = rows.map((row) => {
3834
+ const ts = typeof row.ts === "number" ? row.ts : 0;
3835
+ const outcome = typeof row.outcome === "string" ? row.outcome : "";
3836
+ const payload = typeof row.payload === "string" ? row.payload : null;
3837
+ return {
3838
+ ts,
3839
+ outcome,
3840
+ payload,
3841
+ preview: payload ? payload.slice(0, 120) : null
3842
+ };
3843
+ });
3844
+ }
3845
+ return report;
3846
+ };
3847
+ //#endregion
3538
3848
  //#region lib/funnel.ts
3539
3849
  const SANDBOX_DIR = "/sandbox/.funnel";
3540
3850
  const SANDBOX_TMP_DIR = "/sandbox/tmp";
@@ -3781,6 +4091,17 @@ var Funnel = class Funnel {
3781
4091
  extraRoutes: options.extraRoutes
3782
4092
  });
3783
4093
  }
4094
+ async debug(channelName) {
4095
+ return buildFunnelDebugReport({
4096
+ gateway: this.gateway,
4097
+ channels: this.channels,
4098
+ tmpDir: this.paths.tmpDir
4099
+ }, channelName ?? null);
4100
+ }
4101
+ gatewayClient() {
4102
+ const { port } = this.gateway.getStatus();
4103
+ return hc(`http://127.0.0.1:${port}`);
4104
+ }
3784
4105
  };
3785
4106
  //#endregion
3786
4107
  //#region lib/engine/mcp/channel-subscriber.ts
@@ -3874,14 +4195,44 @@ const readGatewayToken = (dir) => {
3874
4195
  //#endregion
3875
4196
  //#region lib/engine/mcp/usage-hint-for-type.ts
3876
4197
  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}";
4198
+ if (type === "slack") return [
4199
+ "Slack Web API.",
4200
+ "To reply in the same thread: method=POST path=chat.postMessage body={ channel: meta.channel_id, text: \"...\", thread_ts: meta.thread_ts }",
4201
+ "To react: method=POST path=reactions.add body={ channel: meta.channel_id, timestamp: meta.thread_ts, name: \"thumbsup\" }",
4202
+ "Use meta fields from the incoming event: channel_id (Slack channel), thread_ts (thread anchor), user_id (sender)."
4203
+ ].join(" ");
4204
+ if (type === "discord") return [
4205
+ "Discord REST API.",
4206
+ "To reply: method=POST path=/channels/<meta.channel_id>/messages body={ content: \"...\" }",
4207
+ "Use meta fields: channel_id (Discord channel), user_id (sender), guild_id."
4208
+ ].join(" ");
4209
+ if (type === "gh") return [
4210
+ "GitHub REST via gh CLI.",
4211
+ "To comment: method=POST path=repos/<meta.repository>/issues/<number>/comments body={ body: \"...\" }",
4212
+ "Parse <number> from meta.subject_url. meta fields: repository (owner/repo), subject_type, subject_url, reason."
4213
+ ].join(" ");
3880
4214
  return "Generic adapter call.";
3881
4215
  };
3882
4216
  //#endregion
3883
4217
  //#region lib/engine/mcp/channel-server.ts
3884
4218
  const DEFAULT_FUNNEL_DIR = join(homedir(), ".funnel");
4219
+ const BUILTIN_TOOL_NAMES = ["fnl_status", "fnl_debug"];
4220
+ const isBuiltinTool = (name) => BUILTIN_TOOL_NAMES.includes(name);
4221
+ const readAllChannels = (dir) => {
4222
+ const settingsPath = join(dir, "settings.json");
4223
+ if (!existsSync(settingsPath)) return [];
4224
+ try {
4225
+ const raw = JSON.parse(readFileSync(settingsPath, "utf-8"));
4226
+ const parsed = settingsSchema.safeParse(raw);
4227
+ if (!parsed.success) return [];
4228
+ return parsed.data.channels.map((c) => ({
4229
+ id: c.id,
4230
+ name: c.name
4231
+ }));
4232
+ } catch {
4233
+ return [];
4234
+ }
4235
+ };
3885
4236
  const startChannelServer = async (options = {}) => {
3886
4237
  const dir = options.dir ?? DEFAULT_FUNNEL_DIR;
3887
4238
  const gatewayBaseUrl = options.gatewayUrl ?? process.env.FUNNEL_GATEWAY_URL ?? `http://127.0.0.1:${resolveFunnelPort()}`;
@@ -3889,6 +4240,13 @@ const startChannelServer = async (options = {}) => {
3889
4240
  const channelId = options.channelId ?? process.env.FUNNEL_CHANNEL_ID;
3890
4241
  const channel = channelId ? readChannelConnectors(dir, channelId) : null;
3891
4242
  const token = options.token ?? readGatewayToken(dir);
4243
+ const allChannels = readAllChannels(dir);
4244
+ const currentChannelName = channel?.channelName ?? null;
4245
+ const channelContext = allChannels.length > 0 ? [
4246
+ "",
4247
+ "Configured channels (use as the `channel` argument to fnl_debug):",
4248
+ ...allChannels.map((ch) => ` ${ch.name}${ch.name === currentChannelName ? " ← this session" : ""}`)
4249
+ ].join("\n") : "";
3892
4250
  const server = new Server({
3893
4251
  name: FUNNEL_MCP_NAME,
3894
4252
  version: "1.0.0"
@@ -3898,13 +4256,35 @@ const startChannelServer = async (options = {}) => {
3898
4256
  tools: {}
3899
4257
  },
3900
4258
  instructions: [
3901
- `Events arrive inside <channel source="${FUNNEL_MCP_NAME}"> tags. Use meta.event_type to discriminate.`,
4259
+ `Events arrive as notifications (method: notifications/claude/channel) with two fields:`,
4260
+ ` content — the event payload as a JSON string (parse it to read the message)`,
4261
+ ` meta — key/value strings describing the event`,
4262
+ "",
4263
+ "meta fields by event_type:",
4264
+ " slack: event_type=slack channel_id=C… thread_ts=1234.5678 user_id=U… mentioned=true|false",
4265
+ " gh: event_type=gh repository=owner/repo subject_type=Issue|PullRequest subject_url=… reason=…",
4266
+ " discord: event_type=discord channel_id=… user_id=… guild_id=… mentioned=true|false",
4267
+ " schedule: event_type=schedule entry_id=…",
4268
+ "",
4269
+ "To reply to a Slack message in the same thread, call the connector tool with:",
4270
+ ` method: POST`,
4271
+ ` path: chat.postMessage`,
4272
+ ` body: { channel: meta.channel_id, text: "your reply", thread_ts: meta.thread_ts }`,
4273
+ "",
4274
+ "To comment on a GitHub issue/PR (extract from subject_url in meta):",
4275
+ ` method: POST`,
4276
+ ` path: repos/<meta.repository>/issues/<number>/comments (parse number from meta.subject_url)`,
4277
+ ` body: { body: "your reply" }`,
3902
4278
  "",
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."
4279
+ "Built-in diagnostic tools call proactively when events seem missing or delayed:",
4280
+ " fnl_status — gateway running state, all listeners alive/dead, Claude WS clients",
4281
+ " fnl_debug — per-channel diagnosis with last 10 events, rootCause, suggestedActions",
4282
+ " omit channel arg to diagnose all channels; check summary.suggestedActions first",
4283
+ channelContext
3904
4284
  ].join("\n")
3905
4285
  });
3906
4286
  server.setRequestHandler(ListToolsRequestSchema, async () => {
3907
- return { tools: (channel?.connectors ?? []).map((c) => ({
4287
+ const connectorTools = (channel?.connectors ?? []).map((c) => ({
3908
4288
  name: c.name,
3909
4289
  description: `Call the "${c.name}" (${c.type}) connector. ${usageHintForType(c.type)}`,
3910
4290
  inputSchema: {
@@ -3925,17 +4305,42 @@ const startChannelServer = async (options = {}) => {
3925
4305
  },
3926
4306
  required: ["method", "path"]
3927
4307
  }
3928
- })) };
4308
+ }));
4309
+ const channelEnum = allChannels.length > 0 ? allChannels.map((ch) => ch.name) : void 0;
4310
+ const builtinTools = [{
4311
+ name: "fnl_status",
4312
+ 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.",
4313
+ inputSchema: {
4314
+ type: "object",
4315
+ properties: {}
4316
+ }
4317
+ }, {
4318
+ name: "fnl_debug",
4319
+ 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.",
4320
+ inputSchema: {
4321
+ type: "object",
4322
+ properties: { channel: channelEnum ? {
4323
+ type: "string",
4324
+ description: `Channel name to inspect. One of: ${channelEnum.join(", ")}. Omit to get all channels.`,
4325
+ enum: channelEnum
4326
+ } : {
4327
+ type: "string",
4328
+ description: "Channel name to inspect. Omit to get all channels."
4329
+ } }
4330
+ }
4331
+ }];
4332
+ return { tools: [...connectorTools, ...builtinTools] };
3929
4333
  });
3930
4334
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
4335
+ const toolName = request.params.name;
4336
+ if (isBuiltinTool(toolName)) return handleBuiltinTool(toolName, request.params.arguments, gatewayBaseUrl, token, allChannels);
3931
4337
  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
4338
  const args = request.params.arguments ?? {};
3934
4339
  const method = typeof args.method === "string" ? args.method : "";
3935
4340
  const path = typeof args.path === "string" ? args.path : "";
3936
4341
  const body = args.body ?? {};
3937
4342
  if (!method || !path) throw new Error("`method` and `path` are required");
3938
- const url = `${gatewayBaseUrl}/channels/${encodeURIComponent(channel.channelName)}/connectors/${encodeURIComponent(connectorName)}/call`;
4343
+ const url = `${gatewayBaseUrl}/channels/${encodeURIComponent(channel.channelName)}/connectors/${encodeURIComponent(toolName)}/call`;
3939
4344
  const headers = { "content-type": "application/json" };
3940
4345
  if (token) headers.authorization = `Bearer ${token}`;
3941
4346
  const res = await fetch(url, {
@@ -3963,6 +4368,51 @@ const startChannelServer = async (options = {}) => {
3963
4368
  protocols: token ? [`funnel.token.${token}`] : void 0
3964
4369
  }).start();
3965
4370
  };
4371
+ const handleBuiltinTool = async (name, args, gatewayBaseUrl, token, allChannels) => {
4372
+ const headers = {};
4373
+ if (token) headers.authorization = `Bearer ${token}`;
4374
+ if (name === "fnl_status") {
4375
+ const res = await fetch(`${gatewayBaseUrl}/status`, { headers }).catch(() => null);
4376
+ if (!res) return { content: [{
4377
+ type: "text",
4378
+ text: JSON.stringify({
4379
+ running: false,
4380
+ error: "gateway unreachable",
4381
+ hint: "run: fnl gateway start",
4382
+ knownChannels: allChannels.map((ch) => ch.name)
4383
+ })
4384
+ }] };
4385
+ const body = await res.json();
4386
+ return { content: [{
4387
+ type: "text",
4388
+ text: JSON.stringify(body)
4389
+ }] };
4390
+ }
4391
+ const channelArg = typeof args?.channel === "string" ? args.channel : null;
4392
+ const url = channelArg ? `${gatewayBaseUrl}/debug?channel=${encodeURIComponent(channelArg)}` : `${gatewayBaseUrl}/debug`;
4393
+ const res = await fetch(url, { headers }).catch(() => null);
4394
+ if (!res) return { content: [{
4395
+ type: "text",
4396
+ text: JSON.stringify({
4397
+ gateway: { running: false },
4398
+ channels: allChannels.map((ch) => ({
4399
+ id: ch.id,
4400
+ name: ch.name,
4401
+ diagnosis: {
4402
+ status: "error",
4403
+ message: "gateway is not running",
4404
+ nextAction: "fnl gateway start",
4405
+ rootCause: null
4406
+ }
4407
+ }))
4408
+ })
4409
+ }] };
4410
+ const body = await res.json();
4411
+ return { content: [{
4412
+ type: "text",
4413
+ text: JSON.stringify(body)
4414
+ }] };
4415
+ };
3966
4416
  //#endregion
3967
4417
  //#region lib/engine/local-config/local-config-json-schema.ts
3968
4418
  /**
@@ -4506,87 +4956,6 @@ const takeRecent = (events, limit) => {
4506
4956
  return events.slice(-limit);
4507
4957
  };
4508
4958
  //#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
4959
  //#region lib/cli/factory.ts
4591
4960
  const factory = createFactory();
4592
4961
  //#endregion
@@ -4705,7 +5074,18 @@ const queryToCliArgs = (url, reservedKeys = []) => {
4705
5074
  return args;
4706
5075
  };
4707
5076
  //#endregion
4708
- //#region lib/cli/router/validator.ts
5077
+ //#region lib/cli/routes/channels.add.ts
5078
+ const help$17 = `funnel channels add — add a channel
5079
+
5080
+ usage: funnel channels add <name> [--delivery fanout|exclusive]
5081
+
5082
+ options:
5083
+ --delivery routing mode (default fanout):
5084
+ fanout every connected client receives every event
5085
+ exclusive each event delivered to exactly one client (round-robin)`;
5086
+ const channelsAddHelpHandler = factory.createHandlers((c) => c.text(help$17));
5087
+ //#endregion
5088
+ //#region lib/cli/router/validator.ts
4709
5089
  const zValidator$1 = (target, schema, helpText) => zValidator(target, schema, (result, c) => {
4710
5090
  if (helpText && c.req.query("help")) return c.text(helpText);
4711
5091
  if (result.success) return;
@@ -4716,18 +5096,10 @@ const zValidator$1 = (target, schema, helpText) => zValidator(target, schema, (r
4716
5096
  });
4717
5097
  //#endregion
4718
5098
  //#region lib/cli/routes/channels.add.$channel.ts
4719
- const addHelp$3 = `funnel channels add add a channel
4720
-
4721
- usage: funnel channels add <name> [--delivery fanout|exclusive]
4722
-
4723
- options:
4724
- --delivery routing mode (default fanout):
4725
- fanout every connected client receives every event
4726
- exclusive each event delivered to exactly one client (round-robin)`;
4727
- const channelsAddHandler = factory.createHandlers(zValidator$1("param", z.object({ channel: z.string() })), zValidator$1("query", z.object({ delivery: channelDeliveryModeSchema.optional() }), addHelp$3), (c) => {
5099
+ const channelsAddHandler = factory.createHandlers(zValidator$1("param", z.object({ channel: z.string() })), zValidator$1("query", z.object({ delivery: channelDeliveryModeSchema.optional() })), (c) => {
4728
5100
  const param = c.req.valid("param");
4729
5101
  const query = c.req.valid("query");
4730
- const created = c.var.funnel.channels.add({
5102
+ const created = c.env.funnel.channels.add({
4731
5103
  name: param.channel,
4732
5104
  delivery: query.delivery
4733
5105
  });
@@ -4737,14 +5109,14 @@ const channelsConnectorsGroupHandler = factory.createHandlers(zValidator$1("para
4737
5109
 
4738
5110
  usage: funnel channels <channel> connectors`), (c) => {
4739
5111
  const param = c.req.valid("param");
4740
- const channel = c.var.funnel.channels.get(param.channel);
5112
+ const channel = c.env.funnel.channels.get(param.channel);
4741
5113
  if (!channel) throw new HTTPException(404, { message: `channel "${param.channel}" not found` });
4742
5114
  if (channel.connectors.length === 0) return c.text(`no connectors in channel "${channel.name}"`);
4743
5115
  return c.text(channel.connectors.map((c) => `${c.name} (${c.type}, id: ${c.id})`).join("\n"));
4744
5116
  });
4745
5117
  //#endregion
4746
- //#region lib/cli/routes/channels.$channel.connectors.add.$connector.ts
4747
- const addHelp$2 = `funnel channels <channel> connectors add <connector> — add a connector to a channel
5118
+ //#region lib/cli/routes/channels.$channel.connectors.add.ts
5119
+ const help$16 = `funnel channels <channel> connectors add <connector> — add a connector to a channel
4748
5120
 
4749
5121
  usage:
4750
5122
  funnel channels <channel> connectors add <connector> --type=slack --bot-token=xoxb-... --app-token=xapp-...
@@ -4753,6 +5125,9 @@ usage:
4753
5125
  funnel channels <channel> connectors add <connector> --type=schedule
4754
5126
 
4755
5127
  Token uniqueness is enforced across all channels.`;
5128
+ const channelsConnectorsAddHelpHandler = factory.createHandlers((c) => c.text(help$16));
5129
+ //#endregion
5130
+ //#region lib/cli/routes/channels.$channel.connectors.add.$connector.ts
4756
5131
  const slackBody = z.object({
4757
5132
  type: z.literal("slack"),
4758
5133
  "bot-token": z.string().startsWith("xoxb-"),
@@ -4776,10 +5151,10 @@ const addBody = z.discriminatedUnion("type", [
4776
5151
  const channelsConnectorsAddHandler = factory.createHandlers(zValidator$1("param", z.object({
4777
5152
  channel: z.string(),
4778
5153
  connector: z.string()
4779
- })), zValidator$1("query", addBody, addHelp$2), async (c) => {
5154
+ })), zValidator$1("query", addBody), async (c) => {
4780
5155
  const param = c.req.valid("param");
4781
5156
  const query = c.req.valid("query");
4782
- const funnel = c.var.funnel;
5157
+ const funnel = c.env.funnel;
4783
5158
  if (query.type === "slack") {
4784
5159
  const created = funnel.channels.addConnector(param.channel, {
4785
5160
  type: "slack",
@@ -4817,28 +5192,34 @@ const channelsConnectorsAddHandler = factory.createHandlers(zValidator$1("param"
4817
5192
  return c.text(`added schedule connector "${created.name}" to channel "${param.channel}"`);
4818
5193
  });
4819
5194
  //#endregion
4820
- //#region lib/cli/routes/channels.$channel.connectors.remove.$connector.ts
4821
- const removeHelp$3 = `funnel channels <channel> connectors remove <connector> — remove a connector
5195
+ //#region lib/cli/routes/channels.$channel.connectors.remove.ts
5196
+ const help$15 = `funnel channels <channel> connectors remove <connector> — remove a connector
4822
5197
 
4823
5198
  usage: funnel channels <channel> connectors remove <connector>`;
5199
+ const channelsConnectorsRemoveHelpHandler = factory.createHandlers((c) => c.text(help$15));
5200
+ //#endregion
5201
+ //#region lib/cli/routes/channels.$channel.connectors.remove.$connector.ts
4824
5202
  const channelsConnectorsRemoveHandler = factory.createHandlers(zValidator$1("param", z.object({
4825
5203
  channel: z.string(),
4826
5204
  connector: z.string()
4827
- })), zValidator$1("query", z.object({}), removeHelp$3), async (c) => {
5205
+ })), zValidator$1("query", z.object({})), async (c) => {
4828
5206
  const param = c.req.valid("param");
4829
- const funnel = c.var.funnel;
5207
+ const funnel = c.env.funnel;
4830
5208
  await funnel.listeners.stop(param.channel, param.connector);
4831
5209
  funnel.channels.removeConnector(param.channel, param.connector);
4832
5210
  return c.text(`removed connector "${param.connector}" from channel "${param.channel}"`);
4833
5211
  });
4834
5212
  //#endregion
4835
- //#region lib/cli/routes/channels.$channel.connectors.set.$connector.ts
4836
- const setHelp$1 = `funnel channels <channel> connectors set <connector> — update connector fields
5213
+ //#region lib/cli/routes/channels.$channel.connectors.set.ts
5214
+ const help$14 = `funnel channels <channel> connectors set <connector> — update connector fields
4837
5215
 
4838
5216
  usage:
4839
5217
  funnel channels <ch> connectors set <conn> [--bot-token=...] [--app-token=...] # slack
4840
5218
  funnel channels <ch> connectors set <conn> [--bot-token=...] # discord
4841
5219
  funnel channels <ch> connectors set <conn> [--poll-interval=N] # gh`;
5220
+ const channelsConnectorsSetHelpHandler = factory.createHandlers((c) => c.text(help$14));
5221
+ //#endregion
5222
+ //#region lib/cli/routes/channels.$channel.connectors.set.$connector.ts
4842
5223
  const channelsConnectorsSetHandler = factory.createHandlers(zValidator$1("param", z.object({
4843
5224
  channel: z.string(),
4844
5225
  connector: z.string()
@@ -4846,10 +5227,10 @@ const channelsConnectorsSetHandler = factory.createHandlers(zValidator$1("param"
4846
5227
  "bot-token": z.string().optional(),
4847
5228
  "app-token": z.string().optional(),
4848
5229
  "poll-interval": z.coerce.number().int().positive().optional()
4849
- }).passthrough(), setHelp$1), async (c) => {
5230
+ }).passthrough()), async (c) => {
4850
5231
  const param = c.req.valid("param");
4851
5232
  const query = c.req.valid("query");
4852
- const funnel = c.var.funnel;
5233
+ const funnel = c.env.funnel;
4853
5234
  const existing = funnel.channels.getConnector(param.channel, param.connector);
4854
5235
  if (!existing) throw new HTTPException(404, { message: `connector "${param.connector}" not found in channel "${param.channel}"` });
4855
5236
  if (existing.type === "slack") funnel.channels.updateSlackConnector(param.channel, param.connector, {
@@ -4869,27 +5250,36 @@ const channelsConnectorsShowHandler = factory.createHandlers(zValidator$1("param
4869
5250
 
4870
5251
  usage: funnel channels <channel> connectors show <connector>`), (c) => {
4871
5252
  const param = c.req.valid("param");
4872
- const connector = c.var.funnel.channels.getConnector(param.channel, param.connector);
5253
+ const connector = c.env.funnel.channels.getConnector(param.channel, param.connector);
4873
5254
  if (!connector) throw new HTTPException(404, { message: `connector "${param.connector}" not found in channel "${param.channel}"` });
4874
5255
  return c.text(JSON.stringify(connector, null, 2));
4875
5256
  });
4876
5257
  //#endregion
4877
- //#region lib/cli/routes/channels.$channel.connectors.$connector.rename.$newName.ts
4878
- const renameHelp$2 = `funnel channels <channel> connectors rename <connector> <new-name>
5258
+ //#region lib/cli/routes/channels.$channel.connectors.rename.ts
5259
+ const help$13 = `funnel channels <channel> connectors rename <connector> <new-name>
4879
5260
 
4880
5261
  usage: funnel channels <channel> connectors rename <connector> <new-name>`;
5262
+ const channelsConnectorsRenameHelpHandler = factory.createHandlers((c) => c.text(help$13));
5263
+ //#endregion
5264
+ //#region lib/cli/routes/channels.$channel.connectors.$connector.rename.$newName.ts
4881
5265
  const channelsConnectorsRenameHandler = factory.createHandlers(zValidator$1("param", z.object({
4882
5266
  channel: z.string(),
4883
5267
  connector: z.string(),
4884
5268
  newName: z.string()
4885
- })), zValidator$1("query", z.object({}), renameHelp$2), async (c) => {
5269
+ })), zValidator$1("query", z.object({})), async (c) => {
4886
5270
  const param = c.req.valid("param");
4887
- const funnel = c.var.funnel;
5271
+ const funnel = c.env.funnel;
4888
5272
  await funnel.listeners.stop(param.channel, param.connector);
4889
5273
  funnel.channels.renameConnector(param.channel, param.connector, param.newName);
4890
5274
  await funnel.listeners.start(param.channel, param.newName);
4891
5275
  return c.text(`renamed connector "${param.connector}" to "${param.newName}"`);
4892
5276
  });
5277
+ //#endregion
5278
+ //#region lib/cli/routes/channels.$channel.connectors.$connector.rename.ts
5279
+ const help$12 = `funnel channels <channel> connectors rename <connector> <new-name>
5280
+
5281
+ usage: funnel channels <channel> connectors rename <connector> <new-name>`;
5282
+ const channelsConnectorRenameHelpHandler = factory.createHandlers((c) => c.text(help$12));
4893
5283
  const channelsConnectorsRequestHandler = factory.createHandlers(zValidator$1("param", z.object({
4894
5284
  channel: z.string(),
4895
5285
  connector: z.string()
@@ -4898,7 +5288,7 @@ const channelsConnectorsRequestHandler = factory.createHandlers(zValidator$1("pa
4898
5288
  usage: funnel channels <channel> connectors <connector> request --method=<api.method> [--key=value ...]`), async (c) => {
4899
5289
  const param = c.req.valid("param");
4900
5290
  const query = c.req.valid("query");
4901
- const funnel = c.var.funnel;
5291
+ const funnel = c.env.funnel;
4902
5292
  const passthrough = {};
4903
5293
  for (const [k, v] of new URL(c.req.url).searchParams) {
4904
5294
  if (k === "method") continue;
@@ -4918,15 +5308,18 @@ const channelsConnectorsSchedulesGroupHandler = factory.createHandlers(zValidato
4918
5308
 
4919
5309
  usage: funnel channels <ch> connectors <conn> schedules`), (c) => {
4920
5310
  const param = c.req.valid("param");
4921
- const entries = c.var.funnel.channels.listScheduleEntries(param.channel, param.connector);
5311
+ const entries = c.env.funnel.channels.listScheduleEntries(param.channel, param.connector);
4922
5312
  if (entries.length === 0) return c.text("no schedule entries");
4923
5313
  return c.text(entries.map((e) => `${e.id}\t${e.cron}\t${e.enabled ? "on" : "off"}\t${e.prompt}`).join("\n"));
4924
5314
  });
4925
5315
  //#endregion
4926
- //#region lib/cli/routes/channels.$channel.connectors.$connector.schedules.add.$id.ts
4927
- const addHelp$1 = `funnel channels <ch> connectors <conn> schedules add <id> — add a schedule entry
5316
+ //#region lib/cli/routes/channels.$channel.connectors.$connector.schedules.add.ts
5317
+ const help$11 = `funnel channels <ch> connectors <conn> schedules add <id> — add a schedule entry
4928
5318
 
4929
5319
  usage: funnel channels <ch> connectors <conn> schedules add <id> --cron="*/5 * * * *" --prompt="..." [--enabled=true] [--catchup-policy=latest|all|skip]`;
5320
+ const channelsConnectorSchedulesAddHelpHandler = factory.createHandlers((c) => c.text(help$11));
5321
+ //#endregion
5322
+ //#region lib/cli/routes/channels.$channel.connectors.$connector.schedules.add.$id.ts
4930
5323
  const channelsConnectorsSchedulesAddHandler = factory.createHandlers(zValidator$1("param", z.object({
4931
5324
  channel: z.string(),
4932
5325
  connector: z.string(),
@@ -4936,10 +5329,10 @@ const channelsConnectorsSchedulesAddHandler = factory.createHandlers(zValidator$
4936
5329
  prompt: z.string(),
4937
5330
  enabled: z.coerce.boolean().optional(),
4938
5331
  "catchup-policy": scheduleCatchupPolicySchema.optional()
4939
- }), addHelp$1), async (c) => {
5332
+ })), async (c) => {
4940
5333
  const param = c.req.valid("param");
4941
5334
  const query = c.req.valid("query");
4942
- const funnel = c.var.funnel;
5335
+ const funnel = c.env.funnel;
4943
5336
  const entry = funnel.channels.addScheduleEntry(param.channel, param.connector, {
4944
5337
  id: param.id,
4945
5338
  cron: query.cron,
@@ -4951,24 +5344,27 @@ const channelsConnectorsSchedulesAddHandler = factory.createHandlers(zValidator$
4951
5344
  return c.text(`added schedule entry "${entry.id}"`);
4952
5345
  });
4953
5346
  //#endregion
4954
- //#region lib/cli/routes/channels.$channel.connectors.$connector.schedules.remove.$id.ts
4955
- const removeHelp$2 = `funnel channels <ch> connectors <conn> schedules remove <id>
5347
+ //#region lib/cli/routes/channels.$channel.connectors.$connector.schedules.remove.ts
5348
+ const help$10 = `funnel channels <ch> connectors <conn> schedules remove <id>
4956
5349
 
4957
5350
  usage: funnel channels <ch> connectors <conn> schedules remove <id>`;
5351
+ const channelsConnectorSchedulesRemoveHelpHandler = factory.createHandlers((c) => c.text(help$10));
5352
+ //#endregion
5353
+ //#region lib/cli/routes/channels.$channel.connectors.$connector.schedules.remove.$id.ts
4958
5354
  const channelsConnectorsSchedulesRemoveHandler = factory.createHandlers(zValidator$1("param", z.object({
4959
5355
  channel: z.string(),
4960
5356
  connector: z.string(),
4961
5357
  id: z.string()
4962
- })), zValidator$1("query", z.object({}), removeHelp$2), async (c) => {
5358
+ })), zValidator$1("query", z.object({})), async (c) => {
4963
5359
  const param = c.req.valid("param");
4964
- const funnel = c.var.funnel;
5360
+ const funnel = c.env.funnel;
4965
5361
  funnel.channels.removeScheduleEntry(param.channel, param.connector, param.id);
4966
5362
  await funnel.listeners.restart(param.channel, param.connector);
4967
5363
  return c.text(`removed schedule entry "${param.id}"`);
4968
5364
  });
4969
5365
  //#endregion
4970
- //#region lib/cli/routes/channels.$channel.publish.ts
4971
- const publishHelp = `funnel channels <channel> publish — push arbitrary content into a channel
5366
+ //#region lib/cli/routes/channels.publish.ts
5367
+ const help$9 = `funnel channels <channel> publish — push arbitrary content into a channel
4972
5368
 
4973
5369
  usage: funnel channels <channel> publish --content="<text>" [--connector=<name>] [--meta-<key>=<value> ...]
4974
5370
 
@@ -4976,14 +5372,17 @@ options:
4976
5372
  --content Required. The event body delivered to subscribers.
4977
5373
  --connector Optional. Stamp the event with a connector name (resolved to id when found).
4978
5374
  --meta-<key> Optional. Repeatable. Added to meta. Example: --meta-source=cron`;
5375
+ const channelsPublishHelpHandler = factory.createHandlers((c) => c.text(help$9));
5376
+ //#endregion
5377
+ //#region lib/cli/routes/channels.$channel.publish.ts
4979
5378
  const querySchema = z.object({
4980
5379
  content: z.string().min(1, { message: "--content is required" }),
4981
5380
  connector: z.string().min(1).optional()
4982
5381
  }).passthrough();
4983
- const channelsPublishHandler = factory.createHandlers(zValidator$1("param", z.object({ channel: z.string() })), zValidator$1("query", querySchema, publishHelp), async (c) => {
5382
+ const channelsPublishHandler = factory.createHandlers(zValidator$1("param", z.object({ channel: z.string() })), zValidator$1("query", querySchema), async (c) => {
4984
5383
  const param = c.req.valid("param");
4985
5384
  const query = c.req.valid("query");
4986
- const funnel = c.var.funnel;
5385
+ const funnel = c.env.funnel;
4987
5386
  const meta = {};
4988
5387
  for (const [k, v] of new URL(c.req.url).searchParams) if (k.startsWith("meta-")) meta[k.slice(5)] = v;
4989
5388
  const result = await funnel.publisher.publish(param.channel, {
@@ -4996,28 +5395,42 @@ const channelsPublishHandler = factory.createHandlers(zValidator$1("param", z.ob
4996
5395
  return c.text(`published (offset=${result.offset})`);
4997
5396
  });
4998
5397
  //#endregion
4999
- //#region lib/cli/routes/channels.remove.$channel.ts
5000
- const removeHelp$1 = `funnel channels remove — remove a channel
5398
+ //#region lib/cli/routes/channels.remove.ts
5399
+ const help$8 = `funnel channels remove — remove a channel
5001
5400
 
5002
5401
  usage: funnel channels remove <name>`;
5003
- const channelsRemoveHandler = factory.createHandlers(zValidator$1("param", z.object({ channel: z.string() })), zValidator$1("query", z.object({}), removeHelp$1), (c) => {
5402
+ const channelsRemoveHelpHandler = factory.createHandlers((c) => c.text(help$8));
5403
+ //#endregion
5404
+ //#region lib/cli/routes/channels.remove.$channel.ts
5405
+ const channelsRemoveHandler = factory.createHandlers(zValidator$1("param", z.object({ channel: z.string() })), zValidator$1("query", z.object({})), (c) => {
5004
5406
  const param = c.req.valid("param");
5005
- c.var.funnel.channels.remove(param.channel);
5407
+ c.env.funnel.channels.remove(param.channel);
5006
5408
  return c.text(`removed channel "${param.channel}"`);
5007
5409
  });
5008
5410
  //#endregion
5009
- //#region lib/cli/routes/channels.$channel.rename.$newName.ts
5010
- const renameHelp$1 = `funnel channels rename — rename a channel
5411
+ //#region lib/cli/routes/channels.rename.ts
5412
+ const help$7 = `funnel channels rename — rename a channel
5011
5413
 
5012
5414
  usage:
5013
5415
  funnel channels rename <old> <new>
5014
5416
  funnel channels <old> rename <new>`;
5417
+ const channelsRenameHelpHandler = factory.createHandlers((c) => c.text(help$7));
5418
+ //#endregion
5419
+ //#region lib/cli/routes/channels.$channel.rename.ts
5420
+ const help$6 = `funnel channels rename — rename a channel
5421
+
5422
+ usage:
5423
+ funnel channels rename <old> <new>
5424
+ funnel channels <old> rename <new>`;
5425
+ const channelsChannelRenameHelpHandler = factory.createHandlers((c) => c.text(help$6));
5426
+ //#endregion
5427
+ //#region lib/cli/routes/channels.$channel.rename.$newName.ts
5015
5428
  const channelsRenameHandler = factory.createHandlers(zValidator$1("param", z.object({
5016
5429
  channel: z.string(),
5017
5430
  newName: z.string()
5018
- })), zValidator$1("query", z.object({}), renameHelp$1), (c) => {
5431
+ })), zValidator$1("query", z.object({})), (c) => {
5019
5432
  const param = c.req.valid("param");
5020
- c.var.funnel.channels.rename(param.channel, param.newName);
5433
+ c.env.funnel.channels.rename(param.channel, param.newName);
5021
5434
  return c.text(`renamed channel "${param.channel}" to "${param.newName}"`);
5022
5435
  });
5023
5436
  const channelsSetDeliveryHandler = factory.createHandlers(zValidator$1("param", z.object({
@@ -5034,12 +5447,12 @@ modes:
5034
5447
  tap=all clients (TUI dashboard, debugging) always receive regardless of mode.
5035
5448
  `), (c) => {
5036
5449
  const param = c.req.valid("param");
5037
- c.var.funnel.channels.setDelivery(param.channel, param.mode);
5450
+ c.env.funnel.channels.setDelivery(param.channel, param.mode);
5038
5451
  return c.text(`channel "${param.channel}" delivery set to ${param.mode}`);
5039
5452
  });
5040
5453
  const channelsShowHandler = factory.createHandlers(zValidator$1("param", z.object({ channel: z.string() })), zValidator$1("query", z.object({}), `funnel channels <name> — show channel details`), (c) => {
5041
5454
  const param = c.req.valid("param");
5042
- const channel = c.var.funnel.channels.get(param.channel);
5455
+ const channel = c.env.funnel.channels.get(param.channel);
5043
5456
  if (!channel) throw new HTTPException(404, { message: `channel "${param.channel}" not found` });
5044
5457
  const connectorLines = channel.connectors.length ? channel.connectors.map((c) => ` - ${c.name} (${c.type}, id: ${c.id})`) : [" (none)"];
5045
5458
  const lines = [
@@ -5051,9 +5464,16 @@ const channelsShowHandler = factory.createHandlers(zValidator$1("param", z.objec
5051
5464
  ];
5052
5465
  return c.text(lines.join("\n"));
5053
5466
  });
5054
- const channelsGroupHandler = factory.createHandlers(zValidator$1("query", z.object({}), `funnel channels — manage subscription boxes
5467
+ const channelsGroupHandler = factory.createHandlers(zValidator$1("query", z.object({ json: z.enum([
5468
+ "true",
5469
+ "false",
5470
+ ""
5471
+ ]).optional() }), `funnel channels — manage subscription boxes
5472
+
5473
+ usage: funnel channels [--json]
5055
5474
 
5056
- usage: funnel channels [subcommand]
5475
+ options:
5476
+ --json output as JSON array (machine-readable, useful for Claude)
5057
5477
 
5058
5478
  subcommands:
5059
5479
  (none) list
@@ -5064,19 +5484,132 @@ subcommands:
5064
5484
  <name> connectors add <c> --type=... add a connector
5065
5485
 
5066
5486
  examples:
5487
+ funnel channels
5488
+ funnel channels --json
5067
5489
  funnel channels add prod-inbox
5068
5490
  funnel channels prod-inbox connectors add prod-slack --type=slack --bot-token=xoxb-... --app-token=xapp-...
5069
5491
  funnel channels prod-inbox`), (c) => {
5070
- const channels = c.var.funnel.channels.list();
5492
+ const query = c.req.valid("query");
5493
+ const channels = c.env.funnel.channels.list();
5494
+ if (query.json === "true" || query.json === "") return c.json(channels.map((ch) => ({
5495
+ id: ch.id,
5496
+ name: ch.name,
5497
+ delivery: ch.delivery,
5498
+ connectors: ch.connectors.map((conn) => ({
5499
+ id: conn.id,
5500
+ name: conn.name,
5501
+ type: conn.type
5502
+ }))
5503
+ })));
5071
5504
  if (channels.length === 0) return c.text("no channels");
5072
5505
  const lines = channels.map((ch) => {
5073
- const names = ch.connectors.map((c) => c.name);
5506
+ const names = ch.connectors.map((conn) => conn.name);
5074
5507
  const connectors = names.length > 0 ? names.join(", ") : "(none)";
5075
5508
  return `${ch.name} [${connectors}]`;
5076
5509
  });
5077
5510
  return c.text(lines.join("\n"));
5078
5511
  });
5079
5512
  //#endregion
5513
+ //#region lib/cli/routes/channels.validate.ts
5514
+ const help$5 = `funnel channels <channel> validate — check connector configuration
5515
+
5516
+ usage: funnel channels <channel> validate [--json]
5517
+
5518
+ options:
5519
+ --json output as JSON
5520
+
5521
+ Checks that each connector has the required tokens and fields set.
5522
+ Does not make any network calls — static config check only.
5523
+
5524
+ examples:
5525
+ funnel channels open-karte validate
5526
+ funnel channels open-karte validate --json`;
5527
+ const channelsValidateHelpHandler = factory.createHandlers((c) => c.text(help$5));
5528
+ //#endregion
5529
+ //#region lib/cli/routes/channels.$channel.validate.ts
5530
+ const validateConnector = (connector) => {
5531
+ const issues = [];
5532
+ if (connector.type === "slack") {
5533
+ if (!(connector.botToken || connector.botTokenEnv)) issues.push({
5534
+ connector: connector.name,
5535
+ field: "botToken",
5536
+ message: "missing botToken (xoxb-...) or botTokenEnv"
5537
+ });
5538
+ if (!(connector.appToken || connector.appTokenEnv)) issues.push({
5539
+ connector: connector.name,
5540
+ field: "appToken",
5541
+ message: "missing appToken (xapp-...) or appTokenEnv"
5542
+ });
5543
+ if (connector.botToken && typeof connector.botToken === "string" && !connector.botToken.startsWith("xoxb-")) issues.push({
5544
+ connector: connector.name,
5545
+ field: "botToken",
5546
+ message: `botToken must start with xoxb- (got: ${connector.botToken.slice(0, 8)}...)`
5547
+ });
5548
+ if (connector.appToken && typeof connector.appToken === "string" && !connector.appToken.startsWith("xapp-")) issues.push({
5549
+ connector: connector.name,
5550
+ field: "appToken",
5551
+ message: `appToken must start with xapp- (got: ${connector.appToken.slice(0, 8)}...)`
5552
+ });
5553
+ }
5554
+ if (connector.type === "gh") {
5555
+ if (!(connector.token || connector.tokenEnv)) issues.push({
5556
+ connector: connector.name,
5557
+ field: "token",
5558
+ message: "missing token or tokenEnv for GitHub connector"
5559
+ });
5560
+ if (!connector.repo) issues.push({
5561
+ connector: connector.name,
5562
+ field: "repo",
5563
+ message: "missing repo (expected owner/repo format)"
5564
+ });
5565
+ }
5566
+ if (connector.type === "discord") {
5567
+ if (!(connector.botToken || connector.botTokenEnv)) issues.push({
5568
+ connector: connector.name,
5569
+ field: "botToken",
5570
+ message: "missing botToken or botTokenEnv for Discord connector"
5571
+ });
5572
+ }
5573
+ return issues;
5574
+ };
5575
+ const channelsValidateHandler = factory.createHandlers(zValidator$1("param", z.object({ channel: z.string() })), zValidator$1("query", z.object({ json: z.enum([
5576
+ "true",
5577
+ "false",
5578
+ ""
5579
+ ]).optional() })), (c) => {
5580
+ const param = c.req.valid("param");
5581
+ const query = c.req.valid("query");
5582
+ const funnel = c.env.funnel;
5583
+ const isJson = query.json === "true" || query.json === "";
5584
+ const channel = funnel.channels.get(param.channel);
5585
+ if (!channel) throw new HTTPException(404, { message: `channel "${param.channel}" not found` });
5586
+ if (channel.connectors.length === 0) {
5587
+ if (isJson) return c.json({
5588
+ channel: channel.name,
5589
+ valid: false,
5590
+ issues: [{
5591
+ connector: "(none)",
5592
+ field: "connectors",
5593
+ message: "no connectors configured"
5594
+ }]
5595
+ });
5596
+ return c.text(`⚠ ${channel.name}: no connectors configured`);
5597
+ }
5598
+ const allIssues = [];
5599
+ for (const connector of channel.connectors) {
5600
+ const issues = validateConnector(connector);
5601
+ allIssues.push(...issues);
5602
+ }
5603
+ if (isJson) return c.json({
5604
+ channel: channel.name,
5605
+ valid: allIssues.length === 0,
5606
+ issues: allIssues
5607
+ });
5608
+ if (allIssues.length === 0) return c.text(`✓ ${channel.name}: all connectors valid`);
5609
+ const lines = allIssues.map((issue) => `✗ ${channel.name}/${issue.connector}: ${issue.message}`);
5610
+ return c.text(lines.join("\n"));
5611
+ });
5612
+ //#endregion
5080
5613
  //#region lib/cli/routes/claude.ts
5081
5614
  const claudeHelp = `funnel claude — launch Claude Code
5082
5615
 
@@ -5108,7 +5641,7 @@ const claudeHandler = factory.createHandlers(zValidator$1("query", z.object({
5108
5641
  channel: z.string().optional()
5109
5642
  }).passthrough(), claudeHelp), async (c) => {
5110
5643
  const query = c.req.valid("query");
5111
- const funnel = c.var.funnel;
5644
+ const funnel = c.env.funnel;
5112
5645
  const userArgs = queryToCliArgs(c.req.url, RESERVED_KEYS$1);
5113
5646
  if (query.channel && !query.profile) {
5114
5647
  const exitCode = await funnel.claude.launch({
@@ -5161,12 +5694,697 @@ const claudeHandler = factory.createHandlers(zValidator$1("query", z.object({
5161
5694
  process.exit(exitCode);
5162
5695
  });
5163
5696
  //#endregion
5697
+ //#region lib/cli/routes/debug-row.ts
5698
+ const stringOrNull = (value) => typeof value === "string" && value.length > 0 ? value : null;
5699
+ const numberOrNull = (value) => typeof value === "number" ? value : null;
5700
+ const stringOr = (value, fallback) => typeof value === "string" ? value : fallback;
5701
+ /**
5702
+ * Parse a payload string as a JSON object. Returns null for non-strings,
5703
+ * malformed JSON, or any non-object JSON (arrays, primitives) — the callers
5704
+ * only ever want the object form.
5705
+ */
5706
+ const parsePayloadObject = (payload) => {
5707
+ if (payload === null) return null;
5708
+ try {
5709
+ const parsed = JSON.parse(payload);
5710
+ if (isStringKeyedObject(parsed)) return parsed;
5711
+ } catch {
5712
+ return null;
5713
+ }
5714
+ return null;
5715
+ };
5716
+ const isStringKeyedObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
5717
+ const truncate = (text, max) => text.length <= max ? text : `${text.slice(0, max)}…`;
5718
+ /**
5719
+ * A short human preview of a payload: the `text` field when the payload is a
5720
+ * JSON object that has one, otherwise the raw payload, both truncated.
5721
+ */
5722
+ const previewOf = (payload) => {
5723
+ if (typeof payload !== "string" || payload.length === 0) return null;
5724
+ const parsed = parsePayloadObject(payload);
5725
+ if (parsed !== null && "text" in parsed) return truncate(String(parsed.text), 60);
5726
+ return truncate(payload, 60);
5727
+ };
5728
+ /** Narrow one processed-table row into a `DebugEvent`. */
5729
+ const toDebugEvent = (row) => {
5730
+ const payload = stringOrNull(row.payload);
5731
+ return {
5732
+ seq: numberOrNull(row.seq),
5733
+ ts: numberOrNull(row.ts),
5734
+ type: stringOr(row.type, "?"),
5735
+ outcome: stringOr(row.outcome, "?"),
5736
+ eventId: stringOrNull(row.event_id),
5737
+ payload,
5738
+ payloadParsed: parsePayloadObject(payload),
5739
+ preview: previewOf(row.payload)
5740
+ };
5741
+ };
5742
+ /** Narrow one connection-table row into a `DebugConnectionError`. */
5743
+ const toDebugConnectionError = (row) => ({
5744
+ seq: numberOrNull(row.seq),
5745
+ ts: numberOrNull(row.ts),
5746
+ type: stringOr(row.type, "?"),
5747
+ status: stringOr(row.status, "?"),
5748
+ detail: stringOrNull(row.detail)
5749
+ });
5750
+ /**
5751
+ * Open a reader, run one query, and always close — the shared shape behind
5752
+ * every diagnostic lookup. Returns the rows or the reader's `Error`.
5753
+ */
5754
+ const queryRows = (reader, sql, params) => {
5755
+ try {
5756
+ return reader.query(sql, params);
5757
+ } finally {
5758
+ reader.close();
5759
+ }
5760
+ };
5761
+ //#endregion
5762
+ //#region lib/cli/routes/debug.ts
5763
+ const debugHelp = `funnel debug — diagnose why Claude is not receiving events
5764
+
5765
+ usage: funnel debug [subcommand] [--channel <name>] [--all] [--json]
5766
+
5767
+ subcommands:
5768
+ (none) full diagnosis (gateway + listener + Claude + last 5 events)
5769
+ events last N processed events with outcome and preview
5770
+ dropped events filtered out (skip:*) with payload detail
5771
+ errors listener connection errors (auth-failed, error)
5772
+
5773
+ options:
5774
+ --channel <name> channel to inspect (auto-selected when only one exists)
5775
+ --all diagnose all channels at once (JSON output)
5776
+ --limit <N> number of recent events to include (default: 5; events/dropped/errors default: 20)
5777
+ --json output as JSON (machine-readable, useful for Claude)
5778
+
5779
+ when a listener is dead the diagnosis includes rootCause — the most recent
5780
+ connection error detail pulled from the connection log automatically.
5781
+
5782
+ use --json when asking Claude to analyse the output — it returns structured
5783
+ data that Claude can parse without guessing at text formatting.
5784
+
5785
+ examples:
5786
+ funnel debug
5787
+ funnel debug --all --json
5788
+ funnel debug --channel open-karte
5789
+ funnel debug --channel open-karte --json
5790
+ funnel debug events --channel open-karte --limit 50
5791
+ funnel debug dropped --channel open-karte --json
5792
+ funnel debug errors`;
5793
+ const debugEventsHelp = `funnel debug events — last N processed events with outcome and preview
5794
+
5795
+ usage: funnel debug events [--channel <name>] [--limit <N>] [--json]
5796
+
5797
+ options:
5798
+ --channel <name> channel to inspect (auto-selected when only one exists)
5799
+ --limit <N> number of rows (default: 20)
5800
+ --json output as JSON
5801
+
5802
+ examples:
5803
+ funnel debug events
5804
+ funnel debug events --channel open-karte --limit 50
5805
+ funnel debug events --json`;
5806
+ const debugDroppedHelp = `funnel debug dropped — events filtered out (skip:*)
5807
+
5808
+ usage: funnel debug dropped [--channel <name>] [--limit <N>] [--json]
5809
+
5810
+ options:
5811
+ --channel <name> channel to inspect (auto-selected when only one exists)
5812
+ --limit <N> number of rows (default: 20)
5813
+ --json output as JSON
5814
+
5815
+ shows why events were skipped: skip:type, skip:subtype, skip:dedup,
5816
+ skip:self-user, skip:self-bot, skip:preprocess
5817
+
5818
+ examples:
5819
+ funnel debug dropped
5820
+ funnel debug dropped --channel open-karte --json`;
5821
+ const debugErrorsHelp = `funnel debug errors — listener connection errors
5822
+
5823
+ usage: funnel debug errors [--channel <name>] [--limit <N>] [--json]
5824
+
5825
+ options:
5826
+ --channel <name> channel to inspect (auto-selected when only one exists)
5827
+ --limit <N> number of rows (default: 20)
5828
+ --json output as JSON
5829
+
5830
+ shows auth-failed and error events from the connection lifecycle log.
5831
+ use this when a listener never connects or keeps disconnecting.
5832
+
5833
+ examples:
5834
+ funnel debug errors
5835
+ funnel debug errors --channel open-karte`;
5836
+ const isGatewayStatusResponse = (value) => {
5837
+ if (value === null || typeof value !== "object") return false;
5838
+ if (!("clients" in value) || !Array.isArray(value.clients)) return false;
5839
+ if (!("listeners" in value) || !Array.isArray(value.listeners)) return false;
5840
+ return true;
5841
+ };
5842
+ const formatUptime = (ms) => {
5843
+ const sec = Math.floor(ms / 1e3);
5844
+ const min = Math.floor(sec / 60);
5845
+ if (min >= 60) return `${Math.floor(min / 60)}h ${min % 60}m`;
5846
+ if (sec >= 60) return `${min}m ${sec % 60}s`;
5847
+ return `${sec}s`;
5848
+ };
5849
+ const formatTs = (epochMs) => {
5850
+ if (typeof epochMs !== "number") return "?";
5851
+ return new Date(epochMs).toISOString().slice(11, 19);
5852
+ };
5853
+ const buildDiagnosis = (report) => {
5854
+ const rootCause = (report.connectionErrors[report.connectionErrors.length - 1] ?? null)?.detail ?? null;
5855
+ if (!report.gateway.running) return {
5856
+ status: "error",
5857
+ message: "gateway is not running",
5858
+ nextActions: ["fnl gateway start"],
5859
+ rootCause: null
5860
+ };
5861
+ const channel = report.channel;
5862
+ if (!(report.listeners.length > 0)) return {
5863
+ status: "warn",
5864
+ message: "no connectors configured on this channel",
5865
+ nextActions: [`fnl channels ${channel} connectors add <name> --type=slack ...`],
5866
+ rootCause: null
5867
+ };
5868
+ const allDead = report.listeners.every((l) => !l.alive);
5869
+ const someDead = report.listeners.some((l) => !l.alive);
5870
+ if (allDead) return {
5871
+ status: "error",
5872
+ message: "all listeners are dead",
5873
+ nextActions: ["fnl gateway logs", "fnl gateway restart"],
5874
+ rootCause
5875
+ };
5876
+ if (someDead) return {
5877
+ status: "warn",
5878
+ message: "some listeners are dead",
5879
+ nextActions: ["fnl gateway logs"],
5880
+ rootCause
5881
+ };
5882
+ const hasErrors = report.listeners.some((l) => l.errors > 0);
5883
+ if (report.claudeClients === 0) return {
5884
+ status: "warn",
5885
+ message: "no Claude connected to this channel",
5886
+ nextActions: [`fnl claude --channel ${channel}`],
5887
+ rootCause: null
5888
+ };
5889
+ if (hasErrors) return {
5890
+ status: "warn",
5891
+ message: "listeners have errors",
5892
+ nextActions: ["fnl gateway logs"],
5893
+ rootCause
5894
+ };
5895
+ return {
5896
+ status: "ok",
5897
+ message: "everything looks healthy",
5898
+ nextActions: [],
5899
+ rootCause: null
5900
+ };
5901
+ };
5902
+ const renderText = (report) => {
5903
+ const lines = [];
5904
+ lines.push(`= funnel debug: ${report.channel} =`);
5905
+ lines.push("");
5906
+ const gw = report.gateway;
5907
+ if (!gw.running) lines.push("[gateway] ○ not running");
5908
+ else {
5909
+ const uptime = gw.uptimeMs !== null ? ` · up ${formatUptime(gw.uptimeMs)}` : "";
5910
+ lines.push(`[gateway] ● running pid ${gw.pid} · port ${gw.port}${uptime}`);
5911
+ }
5912
+ if (report.listeners.length === 0) lines.push("[listener] - no listener");
5913
+ else for (const listener of report.listeners) {
5914
+ const indicator = listener.alive ? "●" : "○";
5915
+ const state = listener.alive ? "alive " : "dead ";
5916
+ const eventsStr = `${listener.events} events`;
5917
+ const lastStr = listener.lastEventAt ? ` · last ${listener.lastEventAt.slice(11, 19)}` : "";
5918
+ const errStr = listener.errors > 0 ? ` · ⚠ ${listener.errors} errors` : "";
5919
+ lines.push(`[listener] ${indicator} ${state} ${eventsStr}${lastStr}${errStr}`);
5920
+ }
5921
+ const claudeCount = report.claudeClients;
5922
+ if (claudeCount === 0) lines.push("[claude] ○ not connected");
5923
+ else lines.push(`[claude] ● connected (${claudeCount} WS client${claudeCount > 1 ? "s" : ""})`);
5924
+ if (report.recentEvents.length === 0) lines.push("[events] no events recorded");
5925
+ else {
5926
+ lines.push(`[events] last ${report.recentEvents.length} event${report.recentEvents.length > 1 ? "s" : ""}:`);
5927
+ for (const event of report.recentEvents) {
5928
+ const time = formatTs(event.ts);
5929
+ const type = event.type.padEnd(8);
5930
+ const outcome = event.outcome.padEnd(20);
5931
+ const preview = event.preview ? ` "${event.preview}"` : "";
5932
+ const seq = event.seq !== null ? ` (seq=${event.seq})` : "";
5933
+ lines.push(` ${time} ${type} ${outcome}${preview}${seq}`);
5934
+ }
5935
+ }
5936
+ if (report.connectionErrors.length > 0) {
5937
+ lines.push("[conn errs] recent connection errors:");
5938
+ for (const err of report.connectionErrors) {
5939
+ const time = formatTs(err.ts);
5940
+ const status = err.status.padEnd(14);
5941
+ const detail = err.detail ? ` "${err.detail}"` : "";
5942
+ lines.push(` ${time} ${err.type.padEnd(8)} ${status}${detail}`);
5943
+ }
5944
+ }
5945
+ lines.push("");
5946
+ const diag = report.diagnosis;
5947
+ const icon = diag.status === "ok" ? "✓" : diag.status === "warn" ? "⚠" : "✗";
5948
+ lines.push(`diagnosis: ${icon} ${diag.message}`);
5949
+ if (diag.rootCause) lines.push(` root cause: ${diag.rootCause}`);
5950
+ for (const action of diag.nextActions) lines.push(` → ${action}`);
5951
+ return lines.join("\n");
5952
+ };
5953
+ const resolveStoreOrNull = () => {
5954
+ const tmpDir = funnelTmpDir();
5955
+ const rawPath = join(tmpDir, "connector-raw.db");
5956
+ const processedPath = join(tmpDir, "connector-processed.db");
5957
+ const connectionPath = join(tmpDir, "connector-connection.db");
5958
+ if (!existsSync(rawPath) || !existsSync(processedPath) || !existsSync(connectionPath)) return null;
5959
+ return {
5960
+ rawPath,
5961
+ processedPath,
5962
+ connectionPath
5963
+ };
5964
+ };
5965
+ /**
5966
+ * Resolve the connector name for a connector id on a channel, used to attribute
5967
+ * a replayed event back to its source connector. Returns undefined when the id
5968
+ * is null or no longer present (connectors can be removed after an event was
5969
+ * logged).
5970
+ */
5971
+ const connectorOf = (channel, connectorId) => {
5972
+ if (connectorId === null) return void 0;
5973
+ return channel.connectors?.find((connector) => connector.id === connectorId)?.name;
5974
+ };
5975
+ const resolveChannelId = (channels, channelName) => {
5976
+ if (channelName) {
5977
+ const match = channels.find((ch) => ch.name === channelName);
5978
+ if (match) return {
5979
+ found: true,
5980
+ channel: match
5981
+ };
5982
+ return {
5983
+ found: false,
5984
+ reason: "not-found",
5985
+ name: channelName
5986
+ };
5987
+ }
5988
+ if (channels.length === 1 && channels[0]) return {
5989
+ found: true,
5990
+ channel: channels[0]
5991
+ };
5992
+ if (channels.length === 0) return {
5993
+ found: false,
5994
+ reason: "none"
5995
+ };
5996
+ return {
5997
+ found: false,
5998
+ reason: "ambiguous",
5999
+ names: channels.map((ch) => ch.name)
6000
+ };
6001
+ };
6002
+ const debugEventsHandler = factory.createHandlers(zValidator$1("query", z.object({
6003
+ channel: z.string().optional(),
6004
+ limit: z.string().optional(),
6005
+ json: z.enum([
6006
+ "true",
6007
+ "false",
6008
+ ""
6009
+ ]).optional()
6010
+ }), debugEventsHelp), async (c) => {
6011
+ const query = c.req.valid("query");
6012
+ const channels = c.env.funnel.channels.list();
6013
+ const isJson = query.json === "true" || query.json === "";
6014
+ const limit = query.limit ? Math.max(1, Number(query.limit)) : 20;
6015
+ const store = resolveStoreOrNull();
6016
+ if (!store) {
6017
+ if (isJson) return c.json([]);
6018
+ return c.text("no diagnostic store yet (start the gateway first)");
6019
+ }
6020
+ const resolved = resolveChannelId(channels, query.channel);
6021
+ if (!resolved.found) {
6022
+ if (resolved.reason === "not-found") {
6023
+ if (isJson) return c.json({
6024
+ error: `channel not found: ${resolved.name}`,
6025
+ availableChannels: channels.map((ch) => ch.name)
6026
+ });
6027
+ return c.text(`channel not found: ${resolved.name}`);
6028
+ }
6029
+ if (resolved.reason === "ambiguous") {
6030
+ if (isJson) return c.json({
6031
+ error: "multiple channels — specify one with --channel",
6032
+ channels: resolved.names
6033
+ });
6034
+ return c.text(`multiple channels — specify one with --channel:\n${resolved.names.map((n) => ` - ${n}`).join("\n")}`);
6035
+ }
6036
+ if (isJson) return c.json([]);
6037
+ return c.text("no channels configured");
6038
+ }
6039
+ const channel = resolved.channel;
6040
+ const reader = new ConnectorDiagnosticSqlReader(store);
6041
+ const rows = channel ? queryRows(reader, "SELECT seq, ts, type, outcome, payload FROM processed WHERE channel_id = ? ORDER BY seq DESC LIMIT ?", [channel.id, limit]) : queryRows(reader, "SELECT seq, ts, type, outcome, payload FROM processed ORDER BY seq DESC LIMIT ?", [limit]);
6042
+ if (rows instanceof Error) return c.text(`error: ${rows.message}`);
6043
+ const events = rows.reverse().map(toDebugEvent);
6044
+ if (isJson) return c.json(events);
6045
+ if (events.length === 0) return c.text("no events recorded");
6046
+ const lines = events.map((ev) => {
6047
+ const time = formatTs(ev.ts);
6048
+ const type = ev.type.padEnd(8);
6049
+ const outcome = ev.outcome.padEnd(20);
6050
+ const preview = ev.preview ? ` "${ev.preview}"` : "";
6051
+ return `${time} ${type} ${outcome}${ev.seq !== null ? ` seq=${ev.seq}` : ""}${preview}`;
6052
+ });
6053
+ return c.text(lines.join("\n"));
6054
+ });
6055
+ const debugDroppedHandler = factory.createHandlers(zValidator$1("query", z.object({
6056
+ channel: z.string().optional(),
6057
+ limit: z.string().optional(),
6058
+ json: z.enum([
6059
+ "true",
6060
+ "false",
6061
+ ""
6062
+ ]).optional()
6063
+ }), debugDroppedHelp), async (c) => {
6064
+ const query = c.req.valid("query");
6065
+ const channels = c.env.funnel.channels.list();
6066
+ const isJson = query.json === "true" || query.json === "";
6067
+ const limit = query.limit ? Math.max(1, Number(query.limit)) : 20;
6068
+ const store = resolveStoreOrNull();
6069
+ if (!store) {
6070
+ if (isJson) return c.json([]);
6071
+ return c.text("no diagnostic store yet (start the gateway first)");
6072
+ }
6073
+ const resolvedDropped = resolveChannelId(channels, query.channel);
6074
+ if (!resolvedDropped.found) {
6075
+ if (resolvedDropped.reason === "not-found") {
6076
+ if (isJson) return c.json({
6077
+ error: `channel not found: ${resolvedDropped.name}`,
6078
+ availableChannels: channels.map((ch) => ch.name)
6079
+ });
6080
+ return c.text(`channel not found: ${resolvedDropped.name}`);
6081
+ }
6082
+ if (resolvedDropped.reason === "ambiguous") {
6083
+ if (isJson) return c.json({
6084
+ error: "multiple channels — specify one with --channel",
6085
+ channels: resolvedDropped.names
6086
+ });
6087
+ return c.text(`multiple channels — specify one with --channel:\n${resolvedDropped.names.map((n) => ` - ${n}`).join("\n")}`);
6088
+ }
6089
+ if (isJson) return c.json([]);
6090
+ return c.text("no channels configured");
6091
+ }
6092
+ const channel = resolvedDropped.channel;
6093
+ const reader = new ConnectorDiagnosticSqlReader(store);
6094
+ const rows = channel ? queryRows(reader, "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]) : queryRows(reader, "SELECT seq, ts, type, outcome, payload, event_id FROM processed WHERE outcome LIKE 'skip:%' ORDER BY seq DESC LIMIT ?", [limit]);
6095
+ if (rows instanceof Error) return c.text(`error: ${rows.message}`);
6096
+ const events = rows.reverse().map(toDebugEvent);
6097
+ if (isJson) return c.json(events);
6098
+ if (events.length === 0) return c.text("no dropped events recorded");
6099
+ const lines = events.map((ev) => {
6100
+ return `${formatTs(ev.ts)} ${ev.type.padEnd(8)} ${ev.outcome.padEnd(20)}${ev.seq !== null ? ` seq=${ev.seq}` : ""}${ev.eventId ? ` event_id=${ev.eventId.slice(0, 8)}` : ""}${ev.preview ? ` "${ev.preview}"` : ""}`;
6101
+ });
6102
+ return c.text(lines.join("\n"));
6103
+ });
6104
+ const debugErrorsHandler = factory.createHandlers(zValidator$1("query", z.object({
6105
+ channel: z.string().optional(),
6106
+ limit: z.string().optional(),
6107
+ json: z.enum([
6108
+ "true",
6109
+ "false",
6110
+ ""
6111
+ ]).optional()
6112
+ }), debugErrorsHelp), async (c) => {
6113
+ const query = c.req.valid("query");
6114
+ const channels = c.env.funnel.channels.list();
6115
+ const isJson = query.json === "true" || query.json === "";
6116
+ const limit = query.limit ? Math.max(1, Number(query.limit)) : 20;
6117
+ const store = resolveStoreOrNull();
6118
+ if (!store) {
6119
+ if (isJson) return c.json([]);
6120
+ return c.text("no diagnostic store yet (start the gateway first)");
6121
+ }
6122
+ const resolvedErrors = resolveChannelId(channels, query.channel);
6123
+ if (!resolvedErrors.found) {
6124
+ if (resolvedErrors.reason === "not-found") {
6125
+ if (isJson) return c.json({
6126
+ error: `channel not found: ${resolvedErrors.name}`,
6127
+ availableChannels: channels.map((ch) => ch.name)
6128
+ });
6129
+ return c.text(`channel not found: ${resolvedErrors.name}`);
6130
+ }
6131
+ if (resolvedErrors.reason === "ambiguous") {
6132
+ if (isJson) return c.json({
6133
+ error: "multiple channels — specify one with --channel",
6134
+ channels: resolvedErrors.names
6135
+ });
6136
+ return c.text(`multiple channels — specify one with --channel:\n${resolvedErrors.names.map((n) => ` - ${n}`).join("\n")}`);
6137
+ }
6138
+ if (isJson) return c.json([]);
6139
+ return c.text("no channels configured");
6140
+ }
6141
+ const channel = resolvedErrors.channel;
6142
+ const reader = new ConnectorDiagnosticSqlReader(store);
6143
+ const rows = channel ? queryRows(reader, "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]) : queryRows(reader, "SELECT seq, ts, type, status, detail FROM connection WHERE status IN ('auth-failed','error') ORDER BY seq DESC LIMIT ?", [limit]);
6144
+ if (rows instanceof Error) return c.text(`error: ${rows.message}`);
6145
+ const errors = rows.reverse().map(toDebugConnectionError);
6146
+ if (isJson) return c.json(errors);
6147
+ if (errors.length === 0) return c.text("no connection errors recorded");
6148
+ const lines = errors.map((ev) => {
6149
+ return `${formatTs(ev.ts)} ${ev.type.padEnd(8)} ${ev.status.padEnd(16)}${ev.detail ? ` "${ev.detail}"` : ""}`;
6150
+ });
6151
+ return c.text(lines.join("\n"));
6152
+ });
6153
+ const buildChannelReport = async (targetChannel, gatewayStatus, gatewayBodyOrNull, store, limit = 5) => {
6154
+ const targetChannelName = targetChannel.name;
6155
+ const baseReport = {
6156
+ channel: targetChannelName,
6157
+ channelId: targetChannel.id,
6158
+ gateway: {
6159
+ running: gatewayStatus.running,
6160
+ pid: gatewayStatus.pid,
6161
+ port: gatewayStatus.running ? gatewayStatus.port : null,
6162
+ uptimeMs: gatewayBodyOrNull?.uptimeMs ?? null
6163
+ },
6164
+ listeners: [],
6165
+ claudeClients: 0,
6166
+ recentEvents: [],
6167
+ connectionErrors: []
6168
+ };
6169
+ if (gatewayBodyOrNull) {
6170
+ baseReport.listeners = gatewayBodyOrNull.listeners.filter((l) => l.channelName === targetChannelName).map((l) => ({
6171
+ name: l.name,
6172
+ type: l.type,
6173
+ alive: l.alive,
6174
+ events: l.events,
6175
+ errors: l.errors,
6176
+ lastEventAt: l.lastEventAt
6177
+ }));
6178
+ baseReport.claudeClients = gatewayBodyOrNull.clients.filter((cl) => !cl.tapAll && cl.channelName === targetChannelName).length;
6179
+ }
6180
+ if (store) {
6181
+ const evRows = queryRows(new ConnectorDiagnosticSqlReader(store), "SELECT seq, ts, type, outcome, payload FROM processed WHERE channel_id = ? ORDER BY seq DESC LIMIT ?", [targetChannel.id, limit]);
6182
+ if (!(evRows instanceof Error)) baseReport.recentEvents = evRows.reverse().map(toDebugEvent);
6183
+ const hasDeadListeners = baseReport.listeners.some((l) => !l.alive);
6184
+ const hasListenerErrors = baseReport.listeners.some((l) => l.errors > 0);
6185
+ if (hasDeadListeners || hasListenerErrors) {
6186
+ const errRows = queryRows(new ConnectorDiagnosticSqlReader(store), "SELECT ts, type, status, detail FROM connection WHERE channel_id = ? AND status IN ('auth-failed','error') ORDER BY seq DESC LIMIT 3", [targetChannel.id]);
6187
+ if (!(errRows instanceof Error)) baseReport.connectionErrors = errRows.reverse().map(toDebugConnectionError);
6188
+ }
6189
+ }
6190
+ return {
6191
+ ...baseReport,
6192
+ diagnosis: buildDiagnosis(baseReport)
6193
+ };
6194
+ };
6195
+ const debugHandler = factory.createHandlers(zValidator$1("query", z.object({
6196
+ channel: z.string().optional(),
6197
+ all: z.enum([
6198
+ "true",
6199
+ "false",
6200
+ ""
6201
+ ]).optional(),
6202
+ json: z.enum([
6203
+ "true",
6204
+ "false",
6205
+ ""
6206
+ ]).optional(),
6207
+ limit: z.string().optional()
6208
+ }), debugHelp), async (c) => {
6209
+ const query = c.req.valid("query");
6210
+ const funnel = c.env.funnel;
6211
+ const channels = funnel.channels.list();
6212
+ const gatewayStatus = funnel.gateway.getStatus();
6213
+ const isJson = query.json === "true" || query.json === "";
6214
+ const isAll = query.all === "true" || query.all === "";
6215
+ const eventLimit = query.limit ? Math.max(1, Number(query.limit)) : 5;
6216
+ if (channels.length === 0) {
6217
+ if (isJson) return c.json({
6218
+ error: "no channels configured",
6219
+ nextAction: "fnl channels add <name>"
6220
+ });
6221
+ return c.text("no channels configured — run: fnl channels add <name>");
6222
+ }
6223
+ const token = funnel.gatewayToken.read();
6224
+ const headers = token ? { Authorization: `Bearer ${token}` } : {};
6225
+ let gatewayBodyOrNull = null;
6226
+ if (gatewayStatus.running) {
6227
+ const res = await fetch(`http://127.0.0.1:${gatewayStatus.port}/status`, { headers }).catch(() => null);
6228
+ if (res && res.ok) {
6229
+ const body = await res.json();
6230
+ if (isGatewayStatusResponse(body)) gatewayBodyOrNull = body;
6231
+ }
6232
+ }
6233
+ const store = resolveStoreOrNull();
6234
+ if (isAll) {
6235
+ const reports = await Promise.all(channels.map((ch) => buildChannelReport(ch, gatewayStatus, gatewayBodyOrNull, store, eventLimit)));
6236
+ const errorChannels = reports.filter((r) => r.diagnosis.status === "error").map((r) => r.channel);
6237
+ const warnChannels = reports.filter((r) => r.diagnosis.status === "warn").map((r) => r.channel);
6238
+ const okChannels = reports.filter((r) => r.diagnosis.status === "ok").map((r) => r.channel);
6239
+ const uniqueActions = [...new Set(reports.flatMap((r) => r.diagnosis.nextActions))];
6240
+ return c.json({
6241
+ summary: {
6242
+ total: reports.length,
6243
+ ok: okChannels.length,
6244
+ warn: warnChannels.length,
6245
+ error: errorChannels.length,
6246
+ criticalChannels: errorChannels,
6247
+ warnChannels,
6248
+ suggestedActions: uniqueActions
6249
+ },
6250
+ channels: reports
6251
+ });
6252
+ }
6253
+ let targetChannel = null;
6254
+ if (query.channel) {
6255
+ targetChannel = channels.find((ch) => ch.name === query.channel) ?? null;
6256
+ if (!targetChannel) {
6257
+ if (isJson) return c.json({
6258
+ error: `channel not found: ${query.channel}`,
6259
+ availableChannels: channels.map((ch) => ch.name)
6260
+ });
6261
+ return c.text(`channel not found: ${query.channel}`);
6262
+ }
6263
+ } else if (channels.length === 1 && channels[0]) targetChannel = channels[0];
6264
+ else {
6265
+ const names = channels.map((ch) => ch.name);
6266
+ if (isJson) return c.json({
6267
+ error: "multiple channels — specify one with --channel or use --all",
6268
+ channels: names,
6269
+ hint: "use --all for all channels at once"
6270
+ });
6271
+ return c.text(`multiple channels — specify one with --channel or use --all:\n${names.map((n) => ` - ${n}`).join("\n")}`);
6272
+ }
6273
+ const report = await buildChannelReport(targetChannel, gatewayStatus, gatewayBodyOrNull, store, eventLimit);
6274
+ if (isJson) return c.json(report);
6275
+ return c.text(renderText(report));
6276
+ });
6277
+ const debugReplayHandler = factory.createHandlers(zValidator$1("query", z.object({
6278
+ channel: z.string().optional(),
6279
+ seq: z.string().optional(),
6280
+ json: z.enum([
6281
+ "true",
6282
+ "false",
6283
+ ""
6284
+ ]).optional()
6285
+ }), `funnel debug replay — re-publish a past event into a channel
6286
+
6287
+ usage: funnel debug replay --channel <name> [--seq <N>] [--json]
6288
+
6289
+ options:
6290
+ --channel <name> channel to replay into (required when multiple channels exist)
6291
+ --seq <N> replay the event at this processed-table seq (default: most recent emitted)
6292
+ --json output result as JSON
6293
+
6294
+ Re-sends a past event from the diagnostic store through the publisher path,
6295
+ so subscribers receive it again. Useful to verify that Claude handles an event
6296
+ correctly without waiting for a real external trigger.
6297
+
6298
+ Gateway must be running. The event is injected via POST /channels/<name>/publish.
6299
+
6300
+ examples:
6301
+ fnl debug replay --channel open-karte
6302
+ fnl debug replay --channel open-karte --seq 412
6303
+ fnl debug replay --channel open-karte --json`), async (c) => {
6304
+ const query = c.req.valid("query");
6305
+ const funnel = c.env.funnel;
6306
+ const channels = funnel.channels.list();
6307
+ const isJson = query.json === "true" || query.json === "";
6308
+ const resolved = resolveChannelId(channels, query.channel);
6309
+ if (!resolved.found) {
6310
+ if (resolved.reason === "not-found") {
6311
+ if (isJson) return c.json({
6312
+ error: `channel not found: ${resolved.name}`,
6313
+ availableChannels: channels.map((ch) => ch.name)
6314
+ });
6315
+ return c.text(`channel not found: ${resolved.name}`);
6316
+ }
6317
+ if (resolved.reason === "ambiguous") {
6318
+ if (isJson) return c.json({
6319
+ error: "multiple channels — specify one with --channel",
6320
+ channels: resolved.names
6321
+ });
6322
+ return c.text(`multiple channels — specify one with --channel:\n${resolved.names.map((n) => ` - ${n}`).join("\n")}`);
6323
+ }
6324
+ if (isJson) return c.json({ error: "no channels configured" });
6325
+ return c.text("no channels configured");
6326
+ }
6327
+ const targetChannel = resolved.channel;
6328
+ const store = resolveStoreOrNull();
6329
+ if (!store) {
6330
+ if (isJson) return c.json({ error: "no diagnostic store yet (start the gateway first)" });
6331
+ return c.text("no diagnostic store yet (start the gateway first)");
6332
+ }
6333
+ const rows = query.seq ? queryRows(new ConnectorDiagnosticSqlReader(store), "SELECT seq, event_id, type, payload, connector_id, channel_id FROM processed WHERE channel_id = ? AND seq = ? LIMIT 1", [targetChannel.id, Number(query.seq)]) : queryRows(new ConnectorDiagnosticSqlReader(store), "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]);
6334
+ if (rows instanceof Error) {
6335
+ if (isJson) return c.json({ error: rows.message });
6336
+ return c.text(`error: ${rows.message}`);
6337
+ }
6338
+ const firstRow = rows[0];
6339
+ if (!firstRow) {
6340
+ if (isJson) return c.json({ error: "no matching event found" });
6341
+ return c.text("no matching event found");
6342
+ }
6343
+ const seq = typeof firstRow.seq === "number" ? firstRow.seq : null;
6344
+ const eventId = typeof firstRow.event_id === "string" ? firstRow.event_id : null;
6345
+ const connectorId = typeof firstRow.connector_id === "string" ? firstRow.connector_id : null;
6346
+ let content = typeof firstRow.payload === "string" ? firstRow.payload : null;
6347
+ if ((!content || content.length === 0) && eventId) {
6348
+ const rawRows = queryRows(new ConnectorDiagnosticSqlReader(store), "SELECT payload FROM raw WHERE event_id = ? LIMIT 1", [eventId]);
6349
+ const rawRow = rawRows instanceof Error ? null : rawRows[0];
6350
+ if (rawRow) content = typeof rawRow.payload === "string" ? rawRow.payload : null;
6351
+ }
6352
+ if (!content) {
6353
+ if (isJson) return c.json({ error: "event has no payload to replay" });
6354
+ return c.text("event has no payload to replay");
6355
+ }
6356
+ const connectorName = connectorOf(targetChannel, connectorId);
6357
+ const result = await funnel.publisher.publish(targetChannel.name, {
6358
+ content,
6359
+ connector: connectorName
6360
+ });
6361
+ if (result.state === "offline") {
6362
+ if (isJson) return c.json({
6363
+ error: "gateway daemon is not running",
6364
+ nextAction: "fnl gateway start"
6365
+ });
6366
+ return c.text("error: gateway daemon is not running — run: fnl gateway start");
6367
+ }
6368
+ if (result.state === "error") {
6369
+ if (isJson) return c.json({ error: result.reason });
6370
+ return c.text(`error: ${result.reason}`);
6371
+ }
6372
+ const preview = previewOf(content);
6373
+ if (isJson) return c.json({
6374
+ replayed: true,
6375
+ seq,
6376
+ offset: result.offset,
6377
+ preview
6378
+ });
6379
+ return c.text(`replayed seq=${seq ?? "?"} → offset=${result.offset}${preview ? ` "${preview}"` : ""}`);
6380
+ });
6381
+ //#endregion
5164
6382
  //#region lib/cli/routes/gateway.ts
5165
6383
  const groupHelp$1 = `funnel gateway — manage the funnel daemon
5166
6384
 
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.
6385
+ The gateway daemon hosts the WebSocket /ws (used by Claude MCP) and the
6386
+ listener supervisor that runs every connector. One daemon, one port (9743
6387
+ for the CLI, 9742 for programmatic use), one PID file.
5170
6388
 
5171
6389
  usage: funnel gateway [subcommand]
5172
6390
 
@@ -5177,20 +6395,49 @@ subcommands:
5177
6395
  restart stop then start
5178
6396
  run start in foreground (for developers)
5179
6397
  logs [-n <N>] tail the daemon diagnostic log (lifecycle, listener boot)
5180
- sql --query "<SQL>" query inbound connector traffic (raw + processed verdict)
6398
+ sql query inbound connector traffic (raw + processed verdict)
5181
6399
  listeners list running connector listeners (alive / dead)
5182
6400
 
5183
6401
  examples:
5184
- funnel gateway check status
5185
- funnel gateway restart restart`;
6402
+ funnel gateway check status
6403
+ funnel gateway restart restart after config changes
6404
+ funnel gateway logs stream the daemon log
6405
+ funnel gateway sql --preset recent inspect last 20 inbound events
6406
+
6407
+ see also: fnl debug --channel <name> (higher-level diagnosis with next-action hints)`;
5186
6408
  const renderGatewayStatus = async (c) => {
5187
- const status = c.var.funnel.gateway.getStatus();
6409
+ const status = c.env.funnel.gateway.getStatus();
5188
6410
  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);
6411
+ const res = await fetch(`http://127.0.0.1:${status.port}/status`).catch(() => null);
5190
6412
  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}`);
6413
+ const data = await res.json();
6414
+ const lines = [];
6415
+ lines.push(`funnel gateway: running (pid ${data.pid})`);
6416
+ lines.push(` port: ${status.port}`);
6417
+ const uptimeSec = Math.floor(data.uptimeMs / 1e3);
6418
+ const uptimeMin = Math.floor(uptimeSec / 60);
6419
+ const uptimeStr = uptimeMin >= 60 ? `${Math.floor(uptimeMin / 60)}h ${uptimeMin % 60}m` : uptimeSec >= 60 ? `${uptimeMin}m ${uptimeSec % 60}s` : `${uptimeSec}s`;
6420
+ lines.push(` uptime: ${uptimeStr}`);
6421
+ lines.push(` events: ${data.broadcaster.eventsBroadcast} broadcast`);
6422
+ if (data.listeners.length === 0) lines.push(` listeners: none`);
6423
+ else {
6424
+ lines.push(` listeners:`);
6425
+ for (const l of data.listeners) {
6426
+ const indicator = l.alive ? "●" : "○";
6427
+ const eventsStr = l.events > 0 ? ` (${l.events} events)` : "";
6428
+ const errStr = l.errors > 0 ? ` ⚠ ${l.errors} errors` : "";
6429
+ lines.push(` ${indicator} ${l.channelName}/${l.name} [${l.type}]${eventsStr}${errStr}`);
6430
+ }
6431
+ }
6432
+ if (data.clients.length === 0) lines.push(` clients: none`);
6433
+ else {
6434
+ lines.push(` clients: ${data.clients.length}`);
6435
+ for (const cl of data.clients) {
6436
+ const connectors = cl.connectors.length > 0 ? ` → ${cl.connectors.join(", ")}` : "";
6437
+ lines.push(` · ${cl.channel}${connectors}`);
6438
+ }
6439
+ }
6440
+ return c.text(lines.join("\n"));
5194
6441
  };
5195
6442
  const gatewayGroupHandler = factory.createHandlers(zValidator$1("query", z.object({}), groupHelp$1), renderGatewayStatus);
5196
6443
  const gatewayListenersHandler = factory.createHandlers(zValidator$1("query", z.object({}), `funnel gateway listeners — show running connector listeners
@@ -5201,7 +6448,7 @@ Reads /listeners from the running gateway daemon and prints the live registry.
5201
6448
 
5202
6449
  examples:
5203
6450
  funnel gateway listeners`), async (c) => {
5204
- const result = await c.var.funnel.listeners.list();
6451
+ const result = await c.env.funnel.listeners.list();
5205
6452
  if (result.state === "offline") throw new HTTPException(503, { message: "funnel gateway: not running" });
5206
6453
  if (result.state === "error") throw new HTTPException(503, { message: `funnel gateway: ${result.reason}` });
5207
6454
  if (result.listeners.length === 0) return c.text("funnel gateway: no running listeners");
@@ -5212,24 +6459,31 @@ examples:
5212
6459
  });
5213
6460
  //#endregion
5214
6461
  //#region lib/cli/routes/gateway.logs.ts
5215
- const logsHelp = `funnel gateway logs — tail diagnostic logs
6462
+ const logsHelp = `funnel gateway logs — tail the daemon diagnostic log
5216
6463
 
5217
- usage: funnel gateway logs [-n <N>]
6464
+ usage: funnel gateway logs [-n <N>] [--format <plain|json>]
5218
6465
 
5219
6466
  options:
5220
6467
  -n <N> number of trailing lines to show (default: 20)
6468
+ --format <plain|json> output format (default: plain)
5221
6469
 
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.
6470
+ Streams ${join(funnelTmpDir(), "funnel.log")} the daemon's diagnostic stream covering
6471
+ gateway lifecycle, listener start/stop/error, and WebSocket connect/disconnect.
6472
+ Exit with Ctrl-C.
5225
6473
 
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.
6474
+ plain format: HH:MM:SS LEVEL message key=value ...
6475
+ json format: raw JSON lines (pipe to jq for filtering)
6476
+
6477
+ This log does NOT contain inbound Slack/connector events. For those, use:
6478
+ fnl gateway sql --preset recent last 20 processed events
6479
+ fnl debug --channel <name> per-channel diagnosis with outcome summary
5229
6480
 
5230
6481
  examples:
5231
6482
  funnel gateway logs
5232
- funnel gateway logs -n 100`;
6483
+ funnel gateway logs -n 100
6484
+ funnel gateway logs --format json | jq 'select(.level == "error")'
6485
+
6486
+ see also: fnl debug, fnl gateway sql`;
5233
6487
  const logger = new NodeFunnelLogger();
5234
6488
  const tryParseJson = (line) => {
5235
6489
  try {
@@ -5245,11 +6499,27 @@ const isLogEntry = (value) => {
5245
6499
  if (!("message" in value) || typeof value.message !== "string") return false;
5246
6500
  return true;
5247
6501
  };
5248
- const gatewayLogsHandler = factory.createHandlers(zValidator$1("query", z.object({ n: z.string().optional() }), logsHelp), async (c) => {
6502
+ const formatMetaValue = (value) => {
6503
+ const str = typeof value === "string" ? value : JSON.stringify(value);
6504
+ return str.includes(" ") ? `"${str}"` : str;
6505
+ };
6506
+ const formatMeta = (meta) => {
6507
+ if (meta === null || typeof meta !== "object") return "";
6508
+ const pairs = Object.entries(meta).map(([k, v]) => `${k}=${formatMetaValue(v)}`).join(" ");
6509
+ return pairs ? ` ${pairs}` : "";
6510
+ };
6511
+ const formatPlain = (entry) => {
6512
+ return `${entry.time.slice(11, 19)} ${entry.level.toUpperCase().padEnd(5)} ${entry.message.padEnd(30)}${formatMeta(entry.meta)}\n`;
6513
+ };
6514
+ const gatewayLogsHandler = factory.createHandlers(zValidator$1("query", z.object({
6515
+ n: z.string().optional(),
6516
+ format: z.enum(["plain", "json"]).optional()
6517
+ }), logsHelp), async (c) => {
5249
6518
  const query = c.req.valid("query");
5250
6519
  const path = logger.file;
5251
6520
  if (!path || !existsSync(path)) return c.text("no logs");
5252
6521
  const lineCount = query.n ? Number(query.n) : 20;
6522
+ const format = query.format ?? "plain";
5253
6523
  const tail = Bun.spawn([
5254
6524
  "tail",
5255
6525
  "-f",
@@ -5268,7 +6538,6 @@ const gatewayLogsHandler = factory.createHandlers(zValidator$1("query", z.object
5268
6538
  const reader = tail.stdout.getReader();
5269
6539
  const decoder = new TextDecoder();
5270
6540
  let buffer = "";
5271
- logger.info("gateway.logs tail start", { file: path });
5272
6541
  while (true) {
5273
6542
  const result = await reader.read();
5274
6543
  if (result.done) break;
@@ -5282,13 +6551,8 @@ const gatewayLogsHandler = factory.createHandlers(zValidator$1("query", z.object
5282
6551
  process.stdout.write(`${line}\n`);
5283
6552
  continue;
5284
6553
  }
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)}`);
6554
+ if (format === "json") process.stdout.write(`${JSON.stringify(parsed)}\n`);
6555
+ else process.stdout.write(formatPlain(parsed));
5292
6556
  }
5293
6557
  }
5294
6558
  await tail.exited;
@@ -5296,47 +6560,78 @@ const gatewayLogsHandler = factory.createHandlers(zValidator$1("query", z.object
5296
6560
  });
5297
6561
  //#endregion
5298
6562
  //#region lib/cli/routes/gateway.sql.ts
6563
+ const PRESETS = {
6564
+ recent: "SELECT seq, ts, type, outcome FROM processed ORDER BY seq DESC LIMIT 20",
6565
+ skipped: "SELECT seq, ts, type, outcome, payload FROM processed WHERE outcome LIKE 'skip:%' ORDER BY seq DESC LIMIT 20",
6566
+ errors: "SELECT ts, status, detail FROM connection WHERE status IN ('auth-failed','error') ORDER BY seq DESC LIMIT 20",
6567
+ summary: "SELECT outcome, COUNT(*) AS count FROM processed GROUP BY outcome ORDER BY count DESC",
6568
+ "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"
6569
+ };
5299
6570
  const sqlHelp = `funnel gateway sql — query inbound connector traffic with SQL
5300
6571
 
5301
- usage: funnel gateway sql --query "<SELECT ...>"
6572
+ usage: funnel gateway sql --preset <name> [--channel <name|id>] [--limit <N>]
6573
+ funnel gateway sql --query "<SELECT ...>"
6574
+
6575
+ options:
6576
+ --preset <name> run a named preset (quickest starting point)
6577
+ --channel <name|id> filter preset results by channel name or id
6578
+ --limit <N> override the row limit for presets (default: 20)
6579
+ --query "<SQL>" run a custom SELECT; output is JSON
5302
6580
 
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?".
6581
+ quick-start presets (--preset <name>):
6582
+ recent last N processed events type, outcome, preview
6583
+ skipped last N events filtered out (skip:*) — see why events were dropped
6584
+ errors listener auth-failed or error events — start here for connection failures
6585
+ summary outcome counts grouped by type — high-level health snapshot (no limit)
6586
+ trace-dedup raw payload of events dropped as duplicates
5306
6587
 
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)
6588
+ Output is always JSON — pipe to jq or pass directly to Claude.
5311
6589
 
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
6590
+ three SQL views (for --query):
6591
+ raw every inbound event before filtering (payload = original JSON)
6592
+ processed filter verdict per event (outcome: emitted | skip:<reason>)
6593
+ connection listener lifecycle (status: connected | auth-failed | error | ...)
5328
6594
 
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.
6595
+ common join: SELECT r.payload FROM raw r JOIN processed p USING(event_id) WHERE p.outcome = 'skip:dedup'
6596
+
6597
+ skip reasons: skip:type skip:subtype skip:dedup skip:self-user skip:self-bot skip:preprocess
5332
6598
 
5333
6599
  examples:
5334
- funnel gateway sql --query "SELECT event_id, ts, type FROM raw ORDER BY seq DESC LIMIT 20"
6600
+ funnel gateway sql --preset recent
6601
+ funnel gateway sql --preset recent --limit 100
6602
+ funnel gateway sql --preset skipped --channel open-karte
6603
+ funnel gateway sql --preset errors
6604
+ funnel gateway sql --preset summary
5335
6605
  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;
6606
+
6607
+ tip: for a higher-level view without writing SQL, use: fnl debug --channel <name> --json
6608
+
6609
+ see also: fnl debug, fnl gateway logs`;
6610
+ const gatewaySqlHandler = factory.createHandlers(zValidator$1("query", z.object({
6611
+ query: z.string().optional(),
6612
+ preset: z.enum(Object.keys(PRESETS)).optional(),
6613
+ channel: z.string().optional(),
6614
+ limit: z.string().optional()
6615
+ }), sqlHelp), async (c) => {
6616
+ const query = c.req.valid("query");
6617
+ const funnel = c.env.funnel;
6618
+ let sql = null;
6619
+ let params = [];
6620
+ let resolvedChannelId = null;
6621
+ if (query.channel) resolvedChannelId = funnel.channels.list().find((ch) => ch.id === query.channel || ch.name === query.channel)?.id ?? query.channel;
6622
+ if (query.preset) {
6623
+ const base = PRESETS[query.preset] ?? null;
6624
+ if (!base) return c.text(sqlHelp);
6625
+ let applied = base;
6626
+ if (query.limit) {
6627
+ const n = Math.max(1, Number(query.limit));
6628
+ applied = applied.replace(/LIMIT \d+/, `LIMIT ${n}`);
6629
+ }
6630
+ if (resolvedChannelId) {
6631
+ sql = applied.replace(/FROM (raw|processed|connection)\b/, "FROM $1 WHERE channel_id = ?");
6632
+ params = [resolvedChannelId];
6633
+ } else sql = applied;
6634
+ } else if (query.query) sql = query.query;
5340
6635
  if (!sql) return c.text(sqlHelp);
5341
6636
  const tmpDir = funnelTmpDir();
5342
6637
  const rawPath = join(tmpDir, "connector-raw.db");
@@ -5350,7 +6645,7 @@ const gatewaySqlHandler = factory.createHandlers(zValidator$1("query", z.object(
5350
6645
  });
5351
6646
  const rows = (() => {
5352
6647
  try {
5353
- return reader.query(sql);
6648
+ return reader.query(sql, params);
5354
6649
  } finally {
5355
6650
  reader.close();
5356
6651
  }
@@ -5369,7 +6664,7 @@ examples:
5369
6664
  funnel gateway restart
5370
6665
  funnel gateway restart --no-caffeine`), async (c) => {
5371
6666
  const query = c.req.valid("query");
5372
- const result = await c.var.funnel.gateway.restart({ caffeinate: query["no-caffeine"] !== "true" });
6667
+ const result = await c.env.funnel.gateway.restart({ caffeinate: query["no-caffeine"] !== "true" });
5373
6668
  const lines = [];
5374
6669
  if (result.wasRunning) lines.push(result.stopped ? "funnel gateway: stopped" : "funnel gateway: failed to stop");
5375
6670
  if (result.stopped) lines.push(result.started ? "funnel gateway: started" : "funnel gateway: failed to start");
@@ -5390,7 +6685,7 @@ examples:
5390
6685
  funnel gateway run
5391
6686
  funnel gateway run --no-caffeine`), async (c) => {
5392
6687
  const query = c.req.valid("query");
5393
- const funnel = c.var.funnel;
6688
+ const funnel = c.env.funnel;
5394
6689
  const gatewayScript = resolveDaemonScript();
5395
6690
  const command = query["no-caffeine"] !== "true" && process.platform === "darwin" ? [
5396
6691
  "caffeinate",
@@ -5420,7 +6715,7 @@ examples:
5420
6715
  funnel gateway start --no-caffeine`;
5421
6716
  const gatewayStartHandler = factory.createHandlers(zValidator$1("query", z.object({ "no-caffeine": z.string().optional() }), startHelp), async (c) => {
5422
6717
  const query = c.req.valid("query");
5423
- const funnel = c.var.funnel;
6718
+ const funnel = c.env.funnel;
5424
6719
  if (funnel.gateway.isRunning()) {
5425
6720
  const status = funnel.gateway.getStatus();
5426
6721
  return c.text(`funnel gateway: already running (pid ${status.pid})`);
@@ -5428,15 +6723,41 @@ const gatewayStartHandler = factory.createHandlers(zValidator$1("query", z.objec
5428
6723
  if (!await funnel.gateway.start({ caffeinate: query["no-caffeine"] !== "true" })) throw new HTTPException(500, { message: "funnel gateway: failed to start" });
5429
6724
  return c.text("funnel gateway: started");
5430
6725
  });
5431
- const gatewayStatusHandler = factory.createHandlers(zValidator$1("query", z.object({}), `funnel gateway status — show gateway running status
6726
+ const gatewayStatusHandler = factory.createHandlers(zValidator$1("query", z.object({ json: z.enum([
6727
+ "true",
6728
+ "false",
6729
+ ""
6730
+ ]).optional() }), `funnel gateway status — show gateway running status
6731
+
6732
+ usage: funnel gateway status [--json]
5432
6733
 
5433
- usage: funnel gateway status
6734
+ options:
6735
+ --json output as JSON
5434
6736
 
5435
- When running, prints PID, port, and connected channel count. When not running, exits with 503.
6737
+ When running, prints PID, port, uptime, listeners (alive/dead), and WS clients.
6738
+ When not running, exits with 503.
5436
6739
 
5437
6740
  examples:
5438
6741
  funnel gateway status
5439
- funnel gateway`), renderGatewayStatus);
6742
+ funnel gateway status --json`), async (c) => {
6743
+ const query = c.req.valid("query");
6744
+ if (!(query.json === "true" || query.json === "")) return renderGatewayStatus(c);
6745
+ const status = c.env.funnel.gateway.getStatus();
6746
+ if (!status.running) throw new HTTPException(503, { message: "funnel gateway: not running" });
6747
+ const res = await fetch(`http://127.0.0.1:${status.port}/status`).catch(() => null);
6748
+ if (!res) return c.json({
6749
+ running: true,
6750
+ pid: status.pid,
6751
+ port: status.port,
6752
+ error: "health check failed"
6753
+ });
6754
+ const data = await res.json();
6755
+ return c.json({
6756
+ running: true,
6757
+ port: status.port,
6758
+ ...data
6759
+ });
6760
+ });
5440
6761
  const gatewayStopHandler = factory.createHandlers(zValidator$1("query", z.object({}), `funnel gateway stop — stop the gateway
5441
6762
 
5442
6763
  usage: funnel gateway stop
@@ -5445,12 +6766,29 @@ Terminates the process whose PID is stored in ~/.funnel/gateway.pid.
5445
6766
 
5446
6767
  examples:
5447
6768
  funnel gateway stop`), async (c) => {
5448
- const funnel = c.var.funnel;
6769
+ const funnel = c.env.funnel;
5449
6770
  if (!funnel.gateway.isRunning()) return c.text("funnel gateway: no running process");
5450
6771
  if (!await funnel.gateway.stop()) throw new HTTPException(500, { message: "funnel gateway: failed to stop" });
5451
6772
  return c.text("funnel gateway: stopped");
5452
6773
  });
5453
6774
  //#endregion
6775
+ //#region lib/cli/routes/profiles.add.ts
6776
+ const help$4 = `funnel profiles add — add a profile
6777
+
6778
+ usage: funnel profiles add <name> --path <path> --channel <channel-name> [recipe]
6779
+
6780
+ options:
6781
+ --path working directory passed to claude as cwd
6782
+ --channel channel name (resolved to channel id internally)
6783
+ --agent sub-agent name, prepended to the launch argv as --agent <name>
6784
+ --options extra launch argv as one whitespace-split string (e.g. "--brief")
6785
+ --env env vars layered under the process, as "KEY=VAL,KEY2=VAL2"
6786
+ --no-resume start a fresh claude session every launch (default resumes)
6787
+
6788
+ The launch recipe (--agent / --options / --env / --resume) lives on the
6789
+ profile; the channel only declares transport (connectors / delivery).`;
6790
+ const profilesAddHelpHandler = factory.createHandlers((c) => c.text(help$4));
6791
+ //#endregion
5454
6792
  //#region lib/cli/routes/parse-profile-recipe.ts
5455
6793
  /**
5456
6794
  * Turns the single-string CLI flags (`--agent`, `--options "<argv>"`,
@@ -5487,20 +6825,6 @@ const parseProfileRecipe = (query) => {
5487
6825
  };
5488
6826
  //#endregion
5489
6827
  //#region lib/cli/routes/profiles.add.$profile.ts
5490
- const addHelp = `funnel profiles add — add a profile
5491
-
5492
- usage: funnel profiles add <name> --path <path> --channel <channel-name> [recipe]
5493
-
5494
- options:
5495
- --path working directory passed to claude as cwd
5496
- --channel channel name (resolved to channel id internally)
5497
- --agent sub-agent name, prepended to the launch argv as --agent <name>
5498
- --options extra launch argv as one whitespace-split string (e.g. "--brief")
5499
- --env env vars layered under the process, as "KEY=VAL,KEY2=VAL2"
5500
- --no-resume start a fresh claude session every launch (default resumes)
5501
-
5502
- The launch recipe (--agent / --options / --env / --resume) lives on the
5503
- profile; the channel only declares transport (connectors / delivery).`;
5504
6828
  const profilesAddHandler = factory.createHandlers(zValidator$1("param", z.object({ profile: z.string() })), zValidator$1("query", z.object({
5505
6829
  path: z.string(),
5506
6830
  channel: z.string(),
@@ -5509,10 +6833,10 @@ const profilesAddHandler = factory.createHandlers(zValidator$1("param", z.object
5509
6833
  env: z.string().optional(),
5510
6834
  resume: z.string().optional(),
5511
6835
  "no-resume": z.string().optional()
5512
- }), addHelp), (c) => {
6836
+ })), (c) => {
5513
6837
  const param = c.req.valid("param");
5514
6838
  const query = c.req.valid("query");
5515
- const funnel = c.var.funnel;
6839
+ const funnel = c.env.funnel;
5516
6840
  const channel = funnel.channels.get(query.channel);
5517
6841
  if (!channel) throw new HTTPException(400, { message: `channel "${query.channel}" not found` });
5518
6842
  const recipe = parseProfileRecipe(query);
@@ -5532,22 +6856,33 @@ usage: funnel profiles <name> as-default
5532
6856
 
5533
6857
  the first profile in the list is treated as the default for fnl claude.`), (c) => {
5534
6858
  const param = c.req.valid("param");
5535
- c.var.funnel.profiles.asDefault(param.profile);
6859
+ c.env.funnel.profiles.asDefault(param.profile);
5536
6860
  return c.text(`profile "${param.profile}" is now the default`);
5537
6861
  });
5538
6862
  //#endregion
5539
- //#region lib/cli/routes/profiles.$profile.rename.$newName.ts
5540
- const renameHelp = `funnel profiles rename — rename a profile
6863
+ //#region lib/cli/routes/profiles.rename.ts
6864
+ const help$3 = `funnel profiles rename — rename a profile
5541
6865
 
5542
6866
  usage:
5543
6867
  funnel profiles rename <old> <new>
5544
6868
  funnel profiles <old> rename <new>`;
6869
+ const profilesRenameHelpHandler = factory.createHandlers((c) => c.text(help$3));
6870
+ //#endregion
6871
+ //#region lib/cli/routes/profiles.$profile.rename.ts
6872
+ const help$2 = `funnel profiles rename — rename a profile
6873
+
6874
+ usage:
6875
+ funnel profiles rename <old> <new>
6876
+ funnel profiles <old> rename <new>`;
6877
+ const profilesProfileRenameHelpHandler = factory.createHandlers((c) => c.text(help$2));
6878
+ //#endregion
6879
+ //#region lib/cli/routes/profiles.$profile.rename.$newName.ts
5545
6880
  const profilesRenameHandler = factory.createHandlers(zValidator$1("param", z.object({
5546
6881
  profile: z.string(),
5547
6882
  newName: z.string()
5548
- })), zValidator$1("query", z.object({}), renameHelp), (c) => {
6883
+ })), zValidator$1("query", z.object({})), (c) => {
5549
6884
  const param = c.req.valid("param");
5550
- c.var.funnel.profiles.rename(param.profile, param.newName);
6885
+ c.env.funnel.profiles.rename(param.profile, param.newName);
5551
6886
  return c.text(`renamed profile "${param.profile}" to "${param.newName}"`);
5552
6887
  });
5553
6888
  //#endregion
@@ -5559,7 +6894,7 @@ usage: funnel profiles <name> run [additional claude args...]
5559
6894
  const RESERVED_KEYS = [];
5560
6895
  const profilesLaunchHandler = factory.createHandlers(zValidator$1("param", z.object({ profile: z.string() })), zValidator$1("query", z.object({}).passthrough(), launchHelp), async (c) => {
5561
6896
  const param = c.req.valid("param");
5562
- const funnel = c.var.funnel;
6897
+ const funnel = c.env.funnel;
5563
6898
  const profile = funnel.profiles.get(param.profile);
5564
6899
  if (!profile) throw new HTTPException(404, { message: `profile "${param.profile}" not found` });
5565
6900
  const exitCode = await funnel.claude.launch({
@@ -5574,18 +6909,21 @@ const profilesLaunchHandler = factory.createHandlers(zValidator$1("param", z.obj
5574
6909
  process.exit(exitCode);
5575
6910
  });
5576
6911
  //#endregion
5577
- //#region lib/cli/routes/profiles.remove.$profile.ts
5578
- const removeHelp = `funnel profiles remove — remove a profile
6912
+ //#region lib/cli/routes/profiles.remove.ts
6913
+ const help$1 = `funnel profiles remove — remove a profile
5579
6914
 
5580
6915
  usage: funnel profiles remove <name>`;
5581
- const profilesRemoveHandler = factory.createHandlers(zValidator$1("param", z.object({ profile: z.string() })), zValidator$1("query", z.object({}), removeHelp), (c) => {
6916
+ const profilesRemoveHelpHandler = factory.createHandlers((c) => c.text(help$1));
6917
+ //#endregion
6918
+ //#region lib/cli/routes/profiles.remove.$profile.ts
6919
+ const profilesRemoveHandler = factory.createHandlers(zValidator$1("param", z.object({ profile: z.string() })), zValidator$1("query", z.object({})), (c) => {
5582
6920
  const param = c.req.valid("param");
5583
- c.var.funnel.profiles.remove(param.profile);
6921
+ c.env.funnel.profiles.remove(param.profile);
5584
6922
  return c.text(`removed profile "${param.profile}"`);
5585
6923
  });
5586
6924
  //#endregion
5587
- //#region lib/cli/routes/profiles.set.$profile.ts
5588
- const setHelp = `funnel profiles <name> set — update a profile
6925
+ //#region lib/cli/routes/profiles.set.ts
6926
+ const help = `funnel profiles <name> set — update a profile
5589
6927
 
5590
6928
  usage: funnel profiles <name> set [--path <path>] [--channel <channel-name>] [recipe]
5591
6929
 
@@ -5599,6 +6937,9 @@ options:
5599
6937
 
5600
6938
  Only the flags you pass are changed; --agent and --options together replace
5601
6939
  the profile's whole options list.`;
6940
+ const profilesSetHelpHandler = factory.createHandlers((c) => c.text(help));
6941
+ //#endregion
6942
+ //#region lib/cli/routes/profiles.set.$profile.ts
5602
6943
  const profilesSetHandler = factory.createHandlers(zValidator$1("param", z.object({ profile: z.string() })), zValidator$1("query", z.object({
5603
6944
  path: z.string().optional(),
5604
6945
  channel: z.string().optional(),
@@ -5607,10 +6948,10 @@ const profilesSetHandler = factory.createHandlers(zValidator$1("param", z.object
5607
6948
  env: z.string().optional(),
5608
6949
  resume: z.string().optional(),
5609
6950
  "no-resume": z.string().optional()
5610
- }), setHelp), (c) => {
6951
+ })), (c) => {
5611
6952
  const param = c.req.valid("param");
5612
6953
  const query = c.req.valid("query");
5613
- const funnel = c.var.funnel;
6954
+ const funnel = c.env.funnel;
5614
6955
  const channel = query.channel !== void 0 ? funnel.channels.get(query.channel) : null;
5615
6956
  if (query.channel !== void 0 && !channel) throw new HTTPException(400, { message: `channel "${query.channel}" not found` });
5616
6957
  const recipe = parseProfileRecipe(query);
@@ -5645,7 +6986,7 @@ examples:
5645
6986
  funnel profiles add cto --path /repo/myapp --channel prod-inbox --agent pm --options "--brief"
5646
6987
  funnel profiles cto as-default
5647
6988
  funnel profiles cto run`), (c) => {
5648
- const profiles = c.var.funnel.profiles.list();
6989
+ const profiles = c.env.funnel.profiles.list();
5649
6990
  if (profiles.length === 0) return c.text("no profiles");
5650
6991
  const lines = profiles.map((profile, index) => {
5651
6992
  const tag = index === 0 ? " (default)" : "";
@@ -5675,29 +7016,77 @@ can validate and autocomplete the config:
5675
7016
  });
5676
7017
  //#endregion
5677
7018
  //#region lib/cli/routes/status.ts
5678
- const statusHelp = `funnel status — show overall connection status
7019
+ const statusHelp = `funnel status — overall health at a glance
7020
+
7021
+ usage: funnel status [--watch] [--interval <N>]
7022
+
7023
+ options:
7024
+ --watch continuously refresh (Ctrl+C to stop)
7025
+ --interval <N> polling interval in seconds (used with --watch, default: 3)
7026
+
7027
+ Shows gateway running state (pid, port, uptime), per-channel listener health
7028
+ (● alive / ○ dead), and whether Claude is connected to each channel as a
7029
+ WebSocket client. Use this as the first step when debugging missing events.
5679
7030
 
5680
- usage: funnel status
7031
+ examples:
7032
+ funnel status
7033
+ funnel status --watch
7034
+ funnel status --watch --interval 5
5681
7035
 
5682
- Lists configured connectors / channels / profiles, gateway running status,
5683
- and active MCP WebSocket clients.`;
7036
+ see also: fnl debug --channel <name> (per-channel diagnosis with next steps)`;
5684
7037
  const isGatewayStatus = (value) => {
5685
7038
  if (value === null || typeof value !== "object") return false;
5686
7039
  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));
7040
+ if (!("listeners" in value) || !Array.isArray(value.listeners)) return false;
7041
+ return true;
5688
7042
  };
5689
- const statusHandler = factory.createHandlers(zValidator$1("query", z.object({}), statusHelp), async (c) => {
5690
- const funnel = c.var.funnel;
7043
+ const buildStatusLines = async (funnel) => {
5691
7044
  const channels = funnel.channels.list();
5692
7045
  const profiles = funnel.profiles.list();
5693
7046
  const gatewayStatus = funnel.gateway.getStatus();
5694
7047
  const lines = [];
5695
7048
  lines.push("= funnel status =");
5696
7049
  lines.push("");
7050
+ let gatewayData = null;
7051
+ if (!gatewayStatus.running) lines.push("gateway: not running");
7052
+ else {
7053
+ const res = await fetch(`http://127.0.0.1:${gatewayStatus.port}/status`).catch(() => null);
7054
+ let uptimeStr = "";
7055
+ if (res && res.ok) {
7056
+ const body = await res.json();
7057
+ if (isGatewayStatus(body)) {
7058
+ gatewayData = body;
7059
+ const uptimeSec = Math.floor(body.uptimeMs / 1e3);
7060
+ const uptimeMin = Math.floor(uptimeSec / 60);
7061
+ uptimeStr = uptimeMin >= 60 ? ` · ${Math.floor(uptimeMin / 60)}h ${uptimeMin % 60}m` : uptimeSec >= 60 ? ` · ${uptimeMin}m ${uptimeSec % 60}s` : ` · ${uptimeSec}s`;
7062
+ }
7063
+ }
7064
+ lines.push(`gateway: running (pid ${gatewayStatus.pid}, port ${gatewayStatus.port})${uptimeStr}`);
7065
+ }
7066
+ lines.push("");
7067
+ const clientsByChannel = /* @__PURE__ */ new Map();
7068
+ const listenerAliveByChannel = /* @__PURE__ */ new Map();
7069
+ if (gatewayData) {
7070
+ for (const client of gatewayData.clients) {
7071
+ if (client.tapAll) continue;
7072
+ const key = client.channelName ?? client.channel;
7073
+ clientsByChannel.set(key, (clientsByChannel.get(key) ?? 0) + 1);
7074
+ }
7075
+ for (const listener of gatewayData.listeners) {
7076
+ const current = listenerAliveByChannel.get(listener.channelName);
7077
+ listenerAliveByChannel.set(listener.channelName, current === void 0 ? listener.alive : current && listener.alive);
7078
+ }
7079
+ }
7080
+ const maxNameLen = Math.max(...channels.map((ch) => ch.name.length), 0);
5697
7081
  lines.push(`channels: ${channels.length}`);
5698
7082
  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}]`);
7083
+ const connectorLabel = ch.connectors.length > 0 ? ch.connectors.map((conn) => conn.type).join(", ") : "no connectors";
7084
+ const isAlive = listenerAliveByChannel.get(ch.name);
7085
+ const indicator = gatewayData === null ? "-" : isAlive === true ? "●" : isAlive === false ? "○" : "-";
7086
+ const claudeCount = clientsByChannel.get(ch.name) ?? 0;
7087
+ const claudeLabel = gatewayData === null ? "" : claudeCount === 0 ? " no Claude" : claudeCount === 1 ? " Claude connected (1 client)" : ` Claude connected (${claudeCount} clients)`;
7088
+ const paddedName = ch.name.padEnd(maxNameLen);
7089
+ lines.push(` ${indicator} ${paddedName} [${connectorLabel}]${claudeLabel}`);
5701
7090
  }
5702
7091
  lines.push("");
5703
7092
  lines.push(`profiles: ${profiles.length}`);
@@ -5707,23 +7096,41 @@ const statusHandler = factory.createHandlers(zValidator$1("query", z.object({}),
5707
7096
  const channelLabel = channel ? channel.name : `id:${profile.channelId}`;
5708
7097
  lines.push(` - ${profile.name}${tag} [path=${profile.path}, channel=${channelLabel}]`);
5709
7098
  }
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"));
7099
+ return lines;
7100
+ };
7101
+ const statusHandler = factory.createHandlers(zValidator$1("query", z.object({
7102
+ watch: z.enum([
7103
+ "true",
7104
+ "false",
7105
+ ""
7106
+ ]).optional(),
7107
+ interval: z.string().optional()
7108
+ }), statusHelp), async (c) => {
7109
+ const query = c.req.valid("query");
7110
+ const funnel = c.env.funnel;
7111
+ const isWatch = query.watch === "true" || query.watch === "";
7112
+ const intervalSec = Math.min(60, Math.max(1, query.interval ? Number(query.interval) : 3));
7113
+ if (!isWatch) {
7114
+ const lines = await buildStatusLines(funnel);
7115
+ return c.text(lines.join("\n"));
7116
+ }
7117
+ const render = async () => {
7118
+ const lines = await buildStatusLines(funnel);
7119
+ const ts = (/* @__PURE__ */ new Date()).toISOString().slice(11, 19);
7120
+ process.stdout.write("\x1B[2J\x1B[H");
7121
+ process.stdout.write(lines.join("\n"));
7122
+ process.stdout.write(`\n\n refreshing every ${intervalSec}s · ${ts} · Ctrl+C to stop\n`);
7123
+ };
7124
+ process.on("SIGINT", () => {
7125
+ process.stdout.write("\n");
7126
+ process.exit(0);
7127
+ });
7128
+ await render();
7129
+ const timer = setInterval(render, intervalSec * 1e3);
7130
+ await new Promise(() => {
7131
+ process.on("exit", () => clearInterval(timer));
7132
+ });
7133
+ return c.text("");
5727
7134
  });
5728
7135
  //#endregion
5729
7136
  //#region lib/cli/routes/update.ts
@@ -5745,30 +7152,9 @@ const updateHandler = factory.createHandlers(zValidator$1("query", z.object({}),
5745
7152
  });
5746
7153
  //#endregion
5747
7154
  //#region lib/cli/routes/index.ts
5748
- const helpRoute = (text) => factory.createHandlers((c) => c.text(text));
5749
- /**
5750
- * Build the CLI Hono app wired to a specific Funnel instance.
5751
- * Exposed so library consumers can mount the same routes their `fnl` CLI
5752
- * uses against a custom Funnel (e.g. one with sandboxed boundaries).
5753
- *
5754
- * All CLI verbs (`add` / `remove` / `set` / `rename` / `as-default` / `request`) map to POST in
5755
- * to-request.ts and stay in the URL as a literal segment. Read paths (list / show / launch) keep GET.
5756
- * Help shortcuts at parameterless URLs return the help text directly so `funnel <verb>` (no args) is
5757
- * informative instead of 404.
5758
- */
5759
- const createCliApp = (funnel) => {
5760
- const base = factory.createApp();
5761
- base.use((c, next) => {
5762
- c.set("funnel", funnel);
5763
- return next();
5764
- });
5765
- base.onError((error, c) => {
5766
- if (error instanceof HTTPException) return c.text(`error: ${error.message}`, error.status);
5767
- return c.text(`error: ${error instanceof Error ? error.message : String(error)}`, 400);
5768
- });
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);
5770
- };
5771
- /** CLI Hono app wired to a default `new Funnel()`. For embedding with a custom Funnel use `createCliApp`. */
5772
- const app = createCliApp(new Funnel());
7155
+ const routes = factory.createApp().onError((error, c) => {
7156
+ if (error instanceof HTTPException) return c.text(`error: ${error.message}`, error.status);
7157
+ return c.text(`error: ${error instanceof Error ? error.message : String(error)}`, 400);
7158
+ }).get("/claude", ...claudeHandler).get("/channels", ...channelsGroupHandler).post("/channels/add", ...channelsAddHelpHandler).post("/channels/add/:channel", ...channelsAddHandler).post("/channels/remove", ...channelsRemoveHelpHandler).post("/channels/remove/:channel", ...channelsRemoveHandler).post("/channels/rename/:channel/:newName", ...channelsRenameHandler).post("/channels/:channel/rename/:newName", ...channelsRenameHandler).post("/channels/rename", ...channelsRenameHelpHandler).post("/channels/:channel/rename", ...channelsChannelRenameHelpHandler).post("/channels/:channel/set/delivery/:mode", ...channelsSetDeliveryHandler).post("/channels/publish", ...channelsPublishHelpHandler).post("/channels/:channel/publish", ...channelsPublishHandler).get("/channels/:channel/validate", ...channelsValidateHandler).get("/channels/validate", ...channelsValidateHelpHandler).get("/channels/:channel", ...channelsShowHandler).get("/channels/:channel/connectors", ...channelsConnectorsGroupHandler).post("/channels/:channel/connectors/add", ...channelsConnectorsAddHelpHandler).post("/channels/:channel/connectors/add/:connector", ...channelsConnectorsAddHandler).post("/channels/:channel/connectors/remove", ...channelsConnectorsRemoveHelpHandler).post("/channels/:channel/connectors/remove/:connector", ...channelsConnectorsRemoveHandler).post("/channels/:channel/connectors/set", ...channelsConnectorsSetHelpHandler).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", ...channelsConnectorsRenameHelpHandler).post("/channels/:channel/connectors/:connector/rename", ...channelsConnectorRenameHelpHandler).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", ...channelsConnectorSchedulesAddHelpHandler).post("/channels/:channel/connectors/:connector/schedules/add/:id", ...channelsConnectorsSchedulesAddHandler).post("/channels/:channel/connectors/:connector/schedules/remove", ...channelsConnectorSchedulesRemoveHelpHandler).post("/channels/:channel/connectors/:connector/schedules/remove/:id", ...channelsConnectorsSchedulesRemoveHandler).get("/profiles", ...profilesGroupHandler).post("/profiles/add", ...profilesAddHelpHandler).post("/profiles/add/:profile", ...profilesAddHandler).post("/profiles/set", ...profilesSetHelpHandler).post("/profiles/set/:profile", ...profilesSetHandler).post("/profiles/remove", ...profilesRemoveHelpHandler).post("/profiles/remove/:profile", ...profilesRemoveHandler).post("/profiles/rename/:profile/:newName", ...profilesRenameHandler).post("/profiles/:profile/rename/:newName", ...profilesRenameHandler).post("/profiles/rename", ...profilesRenameHelpHandler).post("/profiles/:profile/rename", ...profilesProfileRenameHelpHandler).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);
5773
7159
  //#endregion
5774
- export { CONNECTOR_CONNECTION_STATUSES, ConnectorDiagnosticLog, ConnectorDiagnosticSqlReader, DEFAULT_GATEWAY_PORT, DEFAULT_GATEWAY_TOKEN_PATH, FUNNEL_DIR, FUNNEL_MCP_ARGS, FUNNEL_MCP_COMMAND, FUNNEL_MCP_NAME, Funnel, FunnelBroadcaster, FunnelChannelPublisher, FunnelChannels, FunnelClaude, FunnelClock, FunnelConnectorFactory, FunnelConnectorListener, FunnelEventLog, FunnelFileSystem, FunnelGateway, FunnelGatewayServer, FunnelGatewayToken, FunnelIdGenerator, FunnelListenerSupervisor, FunnelListenersClient, FunnelLocalConfig, FunnelLocalConfigSync, FunnelLocalConfigWriter, FunnelLogger, FunnelMcp, FunnelProcessRunner, FunnelProfiles, FunnelSettingsReader, FunnelSettingsStore, FunnelSlackEventProcessor, FunnelTokenPrompter, LOCAL_CONFIG_FILENAME, MemoryConnectorDiagnosticLog, MemoryFunnelClock, MemoryFunnelEventLog, MemoryFunnelFileSystem, MemoryFunnelIdGenerator, MemoryFunnelLogger, MemoryFunnelProcessRunner, MemoryFunnelTokenPrompter, MockFunnelSettingsReader, NodeFunnelClock, NodeFunnelFileSystem, NodeFunnelIdGenerator, NodeFunnelLogger, NodeFunnelProcessRunner, NodeFunnelTokenPrompter, NoopFunnelLogger, SETTINGS_PATH, SETTINGS_VERSION, SqliteConnectorDiagnosticLog, SqliteFunnelEventLog, channelConfigSchema, channelDeliveryModeSchema, channelSpecSchema, app as cliApp, connectorConfigSchema, connectorConnectionEventSchema, connectorProcessedEventSchema, connectorRawEventSchema, connectorSpecSchema, createCliApp, createSettings, discordConnectorSchema, factory, funnelEventSchema, funnelJsonSchema, ghConnectorSchema, localConfigSchema, profileConfigSchema, profileSpecSchema, publishRequestSchema, publishResponseSchema, queryToCliArgs, resolveFunnelDir, resolveFunnelPort, scheduleCatchupPolicySchema, scheduleConnectorSchema, scheduleEntrySchema, settingsSchema, slackConnectorSchema, startChannelServer, toRequest };
7160
+ export { CONNECTOR_CONNECTION_STATUSES, ConnectorDiagnosticLog, ConnectorDiagnosticSqlReader, DEFAULT_GATEWAY_PORT, DEFAULT_GATEWAY_TOKEN_PATH, FUNNEL_DIR, FUNNEL_MCP_ARGS, FUNNEL_MCP_COMMAND, FUNNEL_MCP_NAME, Funnel, FunnelBroadcaster, FunnelChannelPublisher, FunnelChannels, FunnelClaude, FunnelClock, FunnelConnectorFactory, FunnelConnectorListener, FunnelEventLog, FunnelFileSystem, FunnelGateway, FunnelGatewayServer, FunnelGatewayToken, FunnelIdGenerator, FunnelListenerSupervisor, FunnelListenersClient, FunnelLocalConfig, FunnelLocalConfigSync, FunnelLocalConfigWriter, FunnelLogger, FunnelMcp, FunnelProcessRunner, FunnelProfiles, FunnelSettingsReader, FunnelSettingsStore, FunnelSlackEventProcessor, FunnelTokenPrompter, LOCAL_CONFIG_FILENAME, MemoryConnectorDiagnosticLog, MemoryFunnelClock, MemoryFunnelEventLog, MemoryFunnelFileSystem, MemoryFunnelIdGenerator, MemoryFunnelLogger, MemoryFunnelProcessRunner, MemoryFunnelTokenPrompter, MockFunnelSettingsReader, NodeFunnelClock, NodeFunnelFileSystem, NodeFunnelIdGenerator, NodeFunnelLogger, NodeFunnelProcessRunner, NodeFunnelTokenPrompter, NoopFunnelLogger, SETTINGS_PATH, SETTINGS_VERSION, SqliteConnectorDiagnosticLog, SqliteFunnelEventLog, channelConfigSchema, channelDeliveryModeSchema, channelSpecSchema, routes as cliRoutes, connectorConfigSchema, connectorConnectionEventSchema, connectorProcessedEventSchema, connectorRawEventSchema, connectorSpecSchema, createSettings, discordConnectorSchema, factory, funnelEventSchema, funnelJsonSchema, ghConnectorSchema, localConfigSchema, profileConfigSchema, profileSpecSchema, publishRequestSchema, publishResponseSchema, queryToCliArgs, resolveFunnelDir, resolveFunnelPort, scheduleCatchupPolicySchema, scheduleConnectorSchema, scheduleEntrySchema, settingsSchema, slackConnectorSchema, startChannelServer, toRequest };