@interactive-inc/claude-funnel 0.53.0 → 0.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +3 -3
  2. package/dist/bin.js +1229 -486
  3. package/dist/claude.d.ts +22 -5
  4. package/dist/claude.js +455 -168
  5. package/dist/{connector-adapter-CePYBTgW.d.ts → connector-adapter-1PxjN-Uk.d.ts} +1 -1
  6. package/dist/{connector-adapter-D5Utumgz.js → connector-adapter-qwXLjQId.js} +1 -1
  7. package/dist/{connector-listener-DU54DN-f.js → connector-listener-CpHBecCj.js} +1 -1
  8. package/dist/connectors/discord.d.ts +6 -6
  9. package/dist/connectors/discord.js +2 -2
  10. package/dist/connectors/gh.d.ts +6 -6
  11. package/dist/connectors/gh.js +2 -2
  12. package/dist/connectors/schedule.d.ts +12 -2
  13. package/dist/connectors/schedule.js +2 -2
  14. package/dist/connectors/slack.d.ts +3 -3
  15. package/dist/connectors/slack.js +2 -2
  16. package/dist/{connector-diagnostic-log-yTOojKUR.d.ts → diagnostic-log-Bxe7Bbvw.d.ts} +2 -2
  17. package/dist/diagnostic-sql-reader-CzYgZpq2.js +83 -0
  18. package/dist/diagnostics.d.ts +2 -0
  19. package/dist/diagnostics.js +2 -0
  20. package/dist/{discord-connector-schema-CBDyGdOI.js → discord-connector-schema-B_N6IXLz.js} +1 -1
  21. package/dist/{discord-connector-schema-R0Uu-3ns.d.ts → discord-connector-schema-CPgcZkXh.d.ts} +1 -1
  22. package/dist/{discord-listener-_jSE3HsQ.js → discord-listener-C0MoKdQO.js} +6 -6
  23. package/dist/docs.d.ts +2 -0
  24. package/dist/docs.js +2 -0
  25. package/dist/doctor.d.ts +2 -0
  26. package/dist/doctor.js +2 -0
  27. package/dist/{file-process-guard-DMeLB6Zd.d.ts → file-process-guard-DI1742H5.d.ts} +5 -4
  28. package/dist/funnel-diagnostics-BpKYrMSu.js +300 -0
  29. package/dist/funnel-diagnostics-qWy5tPSq.d.ts +176 -0
  30. package/dist/funnel-docs-dXPokzr5.d.ts +18 -0
  31. package/dist/funnel-docs-ng5K8w4j.js +653 -0
  32. package/dist/funnel-doctor-BF3Rdgk0.d.ts +34 -0
  33. package/dist/funnel-doctor-CApCezTq.js +82 -0
  34. package/dist/funnel-recovery-BUBsu7WX.d.ts +101 -0
  35. package/dist/funnel-recovery-D9CxD5Zs.js +134 -0
  36. package/dist/gateway/daemon.js +838 -252
  37. package/dist/{gateway-base-url-ssk_He5G.js → gateway-base-url-6foMXfFf.js} +5 -5
  38. package/dist/gateway.d.ts +2 -2
  39. package/dist/gateway.js +2 -2
  40. package/dist/{gh-connector-schema-eoTtHbY6.d.ts → gh-connector-schema-CU1ojfIF.d.ts} +1 -1
  41. package/dist/{gh-connector-schema-o3Q1-ojL.js → gh-connector-schema-DUcZgN2Q.js} +1 -1
  42. package/dist/{gh-listener-DH-fClQm.js → gh-listener-Dsx6AmhH.js} +5 -5
  43. package/dist/{index-DF5VmCPJ.d.ts → index-CrngHrne.d.ts} +104 -607
  44. package/dist/index.d.ts +16 -11
  45. package/dist/index.js +509 -973
  46. package/dist/{local-config-json-schema-D8i-BogY.js → local-config-json-schema-DE1zkMcb.js} +12 -8
  47. package/dist/{local-config-sync-Cq39mT6p.d.ts → local-config-sync-B8b04LrZ.d.ts} +21 -16
  48. package/dist/local-config.d.ts +2 -2
  49. package/dist/local-config.js +2 -2
  50. package/dist/{memory-connector-diagnostic-log-COUWCsT_.js → memory-diagnostic-log-BbFVqDzz.js} +30 -95
  51. package/dist/{memory-token-prompter-CKV7VBM5.d.ts → memory-token-prompter-Lo3YRDzq.d.ts} +4 -4
  52. package/dist/{memory-token-prompter-Q7Snwsv2.js → memory-token-prompter-vBXxY20-.js} +2 -2
  53. package/dist/{profiles-f0mNmEyP.d.ts → profiles-EHTeCOqB.d.ts} +3 -2
  54. package/dist/profiles.d.ts +1 -1
  55. package/dist/profiles.js +1 -1
  56. package/dist/recovery.d.ts +2 -0
  57. package/dist/recovery.js +2 -0
  58. package/dist/{resolve-connector-token-BHmZLRrV.js → resolve-connector-token-CczqG_Ig.js} +1 -1
  59. package/dist/{schedule-connector-schema-iCI61gzU.js → schedule-connector-schema-B_xO5z5B.js} +1 -1
  60. package/dist/{schedule-listener-CUyUFFR1.d.ts → schedule-listener-DKh0hnkK.d.ts} +5 -5
  61. package/dist/{schedule-listener-ePAjians.js → schedule-listener-DP9Jhc6U.js} +14 -4
  62. package/dist/settings-reader-CBrgz01o.d.ts +18 -0
  63. package/dist/{settings-reader-BSU6JyvM.d.ts → settings-schema-zhnMIa8I.d.ts} +1 -16
  64. package/dist/{slack-connector-schema-BCNWluHM.js → slack-connector-schema-C1zEf4TG.js} +1 -1
  65. package/dist/{slack-listener-Bv5xI9gC.d.ts → slack-listener-COQA8wAZ.d.ts} +4 -4
  66. package/dist/{slack-listener-ClQuHhEF.js → slack-listener-DUKPcpJH.js} +7 -7
  67. package/dist/{mcp-QeNCBhOD.js → yaml-render-OhUN-qkS.js} +52 -34
  68. package/package.json +21 -1
  69. /package/dist/{file-system-BeOKXjlV.d.ts → file-system-Wub9Nto4.d.ts} +0 -0
  70. /package/dist/{process-runner-DfniuWVU.d.ts → process-runner-D5I_jhYQ.d.ts} +0 -0
  71. /package/dist/{profiles-wMRnjSid.js → profiles-MnXvYfZF.js} +0 -0
package/dist/index.js CHANGED
@@ -1,21 +1,26 @@
1
- import { r as FunnelDiscordAdapter, t as FunnelDiscordListener } from "./discord-listener-_jSE3HsQ.js";
2
- import { t as FunnelConnectorListener } from "./connector-listener-DU54DN-f.js";
1
+ import { r as FunnelDiscordAdapter, t as FunnelDiscordListener } from "./discord-listener-C0MoKdQO.js";
2
+ import { t as FunnelConnectorListener } from "./connector-listener-CpHBecCj.js";
3
3
  import { t as FunnelLogger } from "./logger-BP6SisKt.js";
4
- import { n as NodeFunnelProcessRunner, r as FunnelProcessRunner, t as ghConnectorSchema } from "./gh-connector-schema-o3Q1-ojL.js";
5
- import { n as FunnelGhAdapter, t as FunnelGhListener } from "./gh-listener-DH-fClQm.js";
6
- import { n as ScheduleStateStore, t as FunnelScheduleListener } from "./schedule-listener-ePAjians.js";
4
+ import { n as NodeFunnelProcessRunner, r as FunnelProcessRunner, t as ghConnectorSchema } from "./gh-connector-schema-DUcZgN2Q.js";
5
+ import { n as FunnelGhAdapter, t as FunnelGhListener } from "./gh-listener-Dsx6AmhH.js";
6
+ import { n as ScheduleStateStore, t as FunnelScheduleListener } from "./schedule-listener-DP9Jhc6U.js";
7
7
  import { t as FunnelFileSystem } from "./file-system-PWKKU7lA.js";
8
8
  import { t as NodeFunnelFileSystem } from "./node-file-system-BcrmWN9I.js";
9
- import { n as FunnelSlackEventProcessor, r as FunnelSlackAdapter, t as FunnelSlackListener } from "./slack-listener-ClQuHhEF.js";
9
+ import { n as FunnelSlackEventProcessor, r as FunnelSlackAdapter, t as FunnelSlackListener } from "./slack-listener-DUKPcpJH.js";
10
10
  import { n as FunnelIdGenerator, t as FunnelSettingsReader } from "./settings-reader-DPqrpV7s.js";
11
- import { a as SETTINGS_PATH, c as SETTINGS_VERSION, d as profileConfigSchema, f as settingsSchema, i as FunnelSettingsStore, l as channelConfigSchema, m as NodeFunnelIdGenerator, n as DEFAULT_GATEWAY_PORT, o as resolveFunnelDir, p as connectorConfigSchema, r as FUNNEL_DIR, s as resolveFunnelPort, t as gatewayLoopbackUrl, u as channelDeliveryModeSchema } from "./gateway-base-url-ssk_He5G.js";
12
- import { t as discordConnectorSchema } from "./discord-connector-schema-CBDyGdOI.js";
13
- import { n as scheduleConnectorSchema, r as scheduleEntrySchema, t as scheduleCatchupPolicySchema } from "./schedule-connector-schema-iCI61gzU.js";
14
- import { t as slackConnectorSchema } from "./slack-connector-schema-BCNWluHM.js";
15
- import { a as FileProcessGuard, i as FunnelMcp, o as FunnelClaude } from "./mcp-QeNCBhOD.js";
16
- import { a as FunnelLocalConfig, n as NodeFunnelTokenPrompter, r as FunnelLocalConfigSync, t as funnelJsonSchema } from "./local-config-json-schema-D8i-BogY.js";
17
- import { t as FunnelProfiles } from "./profiles-wMRnjSid.js";
18
- import { C as funnelTmpDir, S as publishResponseSchema, _ as FunnelEventLog, a as connectorConnectionEventSchema, b as FunnelChannelPublisher, c as MemoryFunnelEventLog, d as DEFAULT_GATEWAY_TOKEN_PATH, f as FunnelGatewayToken, g as SqliteFunnelEventLog, h as FunnelListenerSupervisor, i as ConnectorDiagnosticLog, l as channelWsProtocols, m as ConnectorDiagnosticSqlReader, n as SqliteConnectorDiagnosticLog, o as connectorProcessedEventSchema, p as FunnelGatewayServer, r as CONNECTOR_CONNECTION_STATUSES, s as connectorRawEventSchema, t as MemoryConnectorDiagnosticLog, u as channelWsUrl, v as funnelEventSchema, x as publishRequestSchema, y as FunnelBroadcaster } from "./memory-connector-diagnostic-log-COUWCsT_.js";
11
+ import { a as SETTINGS_PATH, c as SETTINGS_VERSION, d as profileConfigSchema, f as settingsSchema, i as FunnelSettingsStore, l as channelConfigSchema, m as NodeFunnelIdGenerator, n as DEFAULT_GATEWAY_PORT, o as resolveFunnelDir, p as connectorConfigSchema, r as FUNNEL_DIR, s as resolveFunnelPort, t as gatewayLoopbackUrl, u as channelDeliveryModeSchema } from "./gateway-base-url-6foMXfFf.js";
12
+ import { t as discordConnectorSchema } from "./discord-connector-schema-B_N6IXLz.js";
13
+ import { n as scheduleConnectorSchema, r as scheduleEntrySchema, t as scheduleCatchupPolicySchema } from "./schedule-connector-schema-B_xO5z5B.js";
14
+ import { t as slackConnectorSchema } from "./slack-connector-schema-C1zEf4TG.js";
15
+ import { a as FunnelMcp, o as FileProcessGuard, s as FunnelClaude, t as renderYaml } from "./yaml-render-OhUN-qkS.js";
16
+ import { a as toDiagnosticEvent, i as toDiagnosticConnectionError, n as previewOf, r as queryRows, t as FunnelDiagnostics } from "./funnel-diagnostics-BpKYrMSu.js";
17
+ import { t as ConnectorDiagnosticSqlReader } from "./diagnostic-sql-reader-CzYgZpq2.js";
18
+ import { t as FunnelDoctor } from "./funnel-doctor-CApCezTq.js";
19
+ import { t as FunnelDocs } from "./funnel-docs-ng5K8w4j.js";
20
+ import { a as FunnelLocalConfig, n as NodeFunnelTokenPrompter, r as FunnelLocalConfigSync, t as funnelJsonSchema } from "./local-config-json-schema-DE1zkMcb.js";
21
+ import { t as FunnelProfiles } from "./profiles-MnXvYfZF.js";
22
+ import { t as FunnelRecovery } from "./funnel-recovery-D9CxD5Zs.js";
23
+ import { C as funnelTmpDir, S as publishResponseSchema, _ as funnelEventSchema, a as connectorConnectionEventSchema, b as FunnelChannelPublisher, c as MemoryFunnelEventLog, d as DEFAULT_GATEWAY_TOKEN_PATH, f as FunnelGatewayToken, g as FunnelEventLog, h as SqliteFunnelEventLog, i as ConnectorDiagnosticLog, l as channelWsProtocols, m as FunnelListenerSupervisor, n as SqliteConnectorDiagnosticLog, o as connectorProcessedEventSchema, p as FunnelGatewayServer, r as CONNECTOR_CONNECTION_STATUSES, s as connectorRawEventSchema, t as MemoryConnectorDiagnosticLog, u as channelWsUrl, v as FunnelBroadcaster, x as publishRequestSchema, y as requireBearerToken } from "./memory-diagnostic-log-BbFVqDzz.js";
19
24
  import { dirname, join, resolve } from "node:path";
20
25
  import { hc } from "hono/client";
21
26
  import { appendFileSync, existsSync, mkdirSync } from "node:fs";
@@ -24,7 +29,8 @@ import { fileURLToPath } from "node:url";
24
29
  import { createFactory } from "hono/factory";
25
30
  import { HTTPException } from "hono/http-exception";
26
31
  import { zValidator } from "@hono/zod-validator";
27
- //#region lib/connectors/connector-factory.ts
32
+ import { Hono } from "hono";
33
+ //#region lib/engine/connectors/connector-factory.ts
28
34
  const defaultFs$1 = new NodeFunnelFileSystem();
29
35
  const defaultProcess$1 = new NodeFunnelProcessRunner();
30
36
  /**
@@ -994,84 +1000,6 @@ var FunnelListenersClient = class {
994
1000
  }
995
1001
  };
996
1002
  //#endregion
997
- //#region lib/gateway/funnel-debug.ts
998
- const isGatewayStatusResponse$1 = (value) => {
999
- if (value === null || typeof value !== "object") return false;
1000
- if (!("clients" in value) || !Array.isArray(value.clients)) return false;
1001
- if (!("listeners" in value) || !Array.isArray(value.listeners)) return false;
1002
- return true;
1003
- };
1004
- const buildFunnelDebugReport = async (deps, channelFilter) => {
1005
- const gatewayStatus = deps.gateway.getStatus();
1006
- const report = {
1007
- gateway: {
1008
- running: gatewayStatus.running,
1009
- pid: gatewayStatus.pid,
1010
- port: gatewayStatus.running ? gatewayStatus.port : null,
1011
- uptimeMs: null
1012
- },
1013
- channels: [],
1014
- recentEvents: null
1015
- };
1016
- const allChannels = deps.channels.list();
1017
- const filteredChannels = channelFilter ? allChannels.filter((ch) => ch.name === channelFilter) : allChannels;
1018
- let gatewayData = null;
1019
- if (gatewayStatus.running) {
1020
- const res = await fetch(`http://127.0.0.1:${gatewayStatus.port}/status`).catch(() => null);
1021
- if (res && res.ok) {
1022
- const body = await res.json();
1023
- if (isGatewayStatusResponse$1(body)) {
1024
- gatewayData = body;
1025
- report.gateway.uptimeMs = body.uptimeMs;
1026
- }
1027
- }
1028
- }
1029
- for (const ch of filteredChannels) {
1030
- const listenerEntry = gatewayData?.listeners.find((l) => l.channelName === ch.name) ?? null;
1031
- const listener = listenerEntry ? {
1032
- alive: listenerEntry.alive,
1033
- events: listenerEntry.events,
1034
- errors: listenerEntry.errors,
1035
- lastEventAt: listenerEntry.lastEventAt
1036
- } : null;
1037
- const claudeClients = (gatewayData?.clients ?? []).filter((cl) => cl.channelName === ch.name || cl.channel === ch.name);
1038
- report.channels.push({
1039
- name: ch.name,
1040
- connectors: ch.connectors.map((conn) => conn.name),
1041
- listener,
1042
- claudeConnected: claudeClients.length > 0,
1043
- claudeClientCount: claudeClients.length
1044
- });
1045
- }
1046
- const rawPath = join(deps.tmpDir, "connector-raw.db");
1047
- const processedPath = join(deps.tmpDir, "connector-processed.db");
1048
- const connectionPath = join(deps.tmpDir, "connector-connection.db");
1049
- if (existsSync(rawPath) && existsSync(processedPath) && existsSync(connectionPath)) {
1050
- const reader = new ConnectorDiagnosticSqlReader({
1051
- rawPath,
1052
- processedPath,
1053
- connectionPath
1054
- });
1055
- const filteredChannelId = channelFilter ? allChannels.find((ch) => ch.name === channelFilter)?.id ?? null : null;
1056
- 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";
1057
- const params = filteredChannelId ? [filteredChannelId] : [];
1058
- const rows = reader.query(sql, params);
1059
- reader.close();
1060
- if (!(rows instanceof Error)) report.recentEvents = rows.map((row) => {
1061
- const ts = typeof row.ts === "number" ? row.ts : 0;
1062
- const outcome = typeof row.outcome === "string" ? row.outcome : "";
1063
- const payload = typeof row.payload === "string" ? row.payload : null;
1064
- return {
1065
- ts,
1066
- outcome,
1067
- payload,
1068
- preview: payload ? payload.slice(0, 120) : null
1069
- };
1070
- });
1071
- }
1072
- return report;
1073
- };
1074
- //#endregion
1075
1003
  //#region lib/funnel.ts
1076
1004
  const SANDBOX_DIR = "/sandbox/.funnel";
1077
1005
  const SANDBOX_TMP_DIR = "/sandbox/tmp";
@@ -1105,6 +1033,10 @@ var Funnel = class Funnel {
1105
1033
  profiles;
1106
1034
  localConfig;
1107
1035
  localConfigSync;
1036
+ diagnostics;
1037
+ recovery;
1038
+ doctor;
1039
+ docs;
1108
1040
  fs;
1109
1041
  process;
1110
1042
  logger;
@@ -1193,6 +1125,23 @@ var Funnel = class Funnel {
1193
1125
  process,
1194
1126
  logger: this.logger
1195
1127
  });
1128
+ this.diagnostics = new FunnelDiagnostics({
1129
+ gateway: this.gateway,
1130
+ gatewayToken: this.gatewayToken,
1131
+ channels: this.channels,
1132
+ publisher: this.publisher,
1133
+ tmpDir
1134
+ });
1135
+ this.recovery = new FunnelRecovery({
1136
+ gateway: this.gateway,
1137
+ listeners: this.listeners,
1138
+ channels: this.channels
1139
+ });
1140
+ this.doctor = new FunnelDoctor({
1141
+ diagnostics: this.diagnostics,
1142
+ recovery: this.recovery
1143
+ });
1144
+ this.docs = new FunnelDocs();
1196
1145
  Object.freeze(this);
1197
1146
  }
1198
1147
  /**
@@ -1261,13 +1210,6 @@ var Funnel = class Funnel {
1261
1210
  ] : ["bun", gatewayScript];
1262
1211
  return this.process.attach(command);
1263
1212
  }
1264
- async debug(channelName) {
1265
- return buildFunnelDebugReport({
1266
- gateway: this.gateway,
1267
- channels: this.channels,
1268
- tmpDir: this.paths.tmpDir
1269
- }, channelName ?? null);
1270
- }
1271
1213
  gatewayClient() {
1272
1214
  const { port } = this.gateway.getStatus();
1273
1215
  return hc(`http://127.0.0.1:${port}`);
@@ -1314,6 +1256,67 @@ var NoopFunnelLogger = class extends FunnelLogger {
1314
1256
  error() {}
1315
1257
  };
1316
1258
  //#endregion
1259
+ //#region lib/gateway/service-routes.ts
1260
+ /**
1261
+ * Mountable Hono app that exposes the service layer (`FunnelDiagnostics` +
1262
+ * `FunnelDoctor`) over loopback HTTP. The MCP server, which lives in a
1263
+ * different process, calls these endpoints to drive the autonomous
1264
+ * troubleshooting loop. The CLI bypasses HTTP and calls the same services
1265
+ * directly through the in-process funnel facade, so CLI and MCP share one
1266
+ * code path.
1267
+ */
1268
+ const buildServiceRoutes = (deps) => {
1269
+ const app = new Hono();
1270
+ if (deps.token) {
1271
+ app.use("/diagnostics", requireBearerToken({ expected: deps.token }));
1272
+ app.use("/diagnostics/*", requireBearerToken({ expected: deps.token }));
1273
+ app.use("/doctor", requireBearerToken({ expected: deps.token }));
1274
+ }
1275
+ app.get("/diagnostics", async (c) => {
1276
+ const channel = c.req.query("channel");
1277
+ if (c.req.query("all") !== void 0) {
1278
+ const report = await deps.diagnostics.diagnoseAll();
1279
+ return c.json(report);
1280
+ }
1281
+ const report = await deps.diagnostics.diagnose(channel ?? void 0);
1282
+ if (!report) return c.json({ error: "channel not found" }, 404);
1283
+ return c.json(report);
1284
+ });
1285
+ app.get("/diagnostics/events", async (c) => {
1286
+ const channel = c.req.query("channel") ?? null;
1287
+ const limit = Number(c.req.query("limit") ?? "20");
1288
+ const events = await deps.diagnostics.recentEvents(channel, limit);
1289
+ return c.json(events);
1290
+ });
1291
+ app.get("/diagnostics/dropped", async (c) => {
1292
+ const channel = c.req.query("channel") ?? null;
1293
+ const limit = Number(c.req.query("limit") ?? "20");
1294
+ const events = await deps.diagnostics.droppedEvents(channel, limit);
1295
+ return c.json(events);
1296
+ });
1297
+ app.get("/diagnostics/errors", async (c) => {
1298
+ const channel = c.req.query("channel") ?? null;
1299
+ const limit = Number(c.req.query("limit") ?? "20");
1300
+ const errors = await deps.diagnostics.connectionErrors(channel, limit);
1301
+ return c.json(errors);
1302
+ });
1303
+ app.post("/diagnostics/replay", async (c) => {
1304
+ const body = await c.req.json().catch(() => ({}));
1305
+ const channel = typeof body.channel === "string" ? body.channel : null;
1306
+ const seq = typeof body.seq === "number" ? body.seq : void 0;
1307
+ if (!channel) return c.json({ error: "channel is required" }, 400);
1308
+ const result = await deps.diagnostics.replay(channel, seq);
1309
+ return c.json(result);
1310
+ });
1311
+ app.post("/doctor", async (c) => {
1312
+ const body = await c.req.json().catch(() => ({}));
1313
+ const mode = body.mode === "safe" || body.mode === "aggressive" || body.mode === "off" ? body.mode : "off";
1314
+ const report = await deps.doctor.run(mode);
1315
+ return c.json(report);
1316
+ });
1317
+ return app;
1318
+ };
1319
+ //#endregion
1317
1320
  //#region lib/cli/factory.ts
1318
1321
  const factory = createFactory();
1319
1322
  //#endregion
@@ -1604,13 +1607,15 @@ const channelsConnectorsSetHandler = factory.createHandlers(zValidator$1("param"
1604
1607
  const channelsConnectorsShowHandler = factory.createHandlers(zValidator$1("param", z.object({
1605
1608
  channel: z.string(),
1606
1609
  connector: z.string()
1607
- })), zValidator$1("query", z.object({}), `funnel channels <channel> connectors show <connector> show connector config
1610
+ })), zValidator$1("query", z.object({}), `funnel channels <channel> connectors show <connector> / show connector config
1611
+
1612
+ usage / funnel channels <channel> connectors show <connector>
1608
1613
 
1609
- usage: funnel channels <channel> connectors show <connector>`), (c) => {
1614
+ output / valid YAML`), (c) => {
1610
1615
  const param = c.req.valid("param");
1611
1616
  const connector = c.env.funnel.channels.getConnector(param.channel, param.connector);
1612
1617
  if (!connector) throw new HTTPException(404, { message: `connector "${param.connector}" not found in channel "${param.channel}"` });
1613
- return c.text(JSON.stringify(connector, null, 2));
1618
+ return c.text(renderYaml(connector));
1614
1619
  });
1615
1620
  //#endregion
1616
1621
  //#region lib/cli/routes/channels.$channel.connectors.rename.ts
@@ -1641,23 +1646,32 @@ const channelsConnectorRenameHelpHandler = factory.createHandlers((c) => c.text(
1641
1646
  const channelsConnectorsRequestHandler = factory.createHandlers(zValidator$1("param", z.object({
1642
1647
  channel: z.string(),
1643
1648
  connector: z.string()
1644
- })), zValidator$1("query", z.object({ method: z.string() }).passthrough(), `funnel channels <channel> connectors <connector> request — call a connector's outbound API
1649
+ })), zValidator$1("query", z.object({
1650
+ method: z.string(),
1651
+ path: z.string().optional()
1652
+ }).passthrough(), `funnel channels <channel> connectors <connector> request / call a connector's outbound API
1645
1653
 
1646
- usage: funnel channels <channel> connectors <connector> request --method=<api.method> [--key=value ...]`), async (c) => {
1654
+ usage / funnel channels <channel> connectors <connector> request --method=<m> [--path=<p>] [--key=value ...]
1655
+
1656
+ options:
1657
+ --method / slack: API method (e.g. chat.postMessage). gh/discord: HTTP verb (GET/POST/...).
1658
+ --path / gh/discord: endpoint (e.g. repos/o/r/issues). Omit for slack (defaults to --method).
1659
+
1660
+ output / valid YAML (or raw text when the adapter returns text)`), async (c) => {
1647
1661
  const param = c.req.valid("param");
1648
1662
  const query = c.req.valid("query");
1649
1663
  const funnel = c.env.funnel;
1650
1664
  const passthrough = {};
1651
1665
  for (const [k, v] of new URL(c.req.url).searchParams) {
1652
- if (k === "method") continue;
1666
+ if (k === "method" || k === "path") continue;
1653
1667
  passthrough[k] = v;
1654
1668
  }
1655
1669
  const response = await funnel.channels.call(param.channel, param.connector, {
1656
1670
  method: query.method,
1657
- path: query.method,
1671
+ path: query.path ?? query.method,
1658
1672
  body: passthrough
1659
1673
  });
1660
- return c.text(typeof response === "string" ? response : JSON.stringify(response, null, 2));
1674
+ return c.text(typeof response === "string" ? response : renderYaml(response));
1661
1675
  });
1662
1676
  const channelsConnectorsSchedulesGroupHandler = factory.createHandlers(zValidator$1("param", z.object({
1663
1677
  channel: z.string(),
@@ -1685,7 +1699,7 @@ const channelsConnectorsSchedulesAddHandler = factory.createHandlers(zValidator$
1685
1699
  })), zValidator$1("query", z.object({
1686
1700
  cron: z.string(),
1687
1701
  prompt: z.string(),
1688
- enabled: z.coerce.boolean().optional(),
1702
+ enabled: z.enum(["true", "false"]).optional(),
1689
1703
  "catchup-policy": scheduleCatchupPolicySchema.optional()
1690
1704
  })), async (c) => {
1691
1705
  const param = c.req.valid("param");
@@ -1695,7 +1709,7 @@ const channelsConnectorsSchedulesAddHandler = factory.createHandlers(zValidator$
1695
1709
  id: param.id,
1696
1710
  cron: query.cron,
1697
1711
  prompt: query.prompt,
1698
- ...query.enabled !== void 0 ? { enabled: query.enabled } : {},
1712
+ ...query.enabled !== void 0 ? { enabled: query.enabled === "true" } : {},
1699
1713
  ...query["catchup-policy"] !== void 0 ? { catchupPolicy: query["catchup-policy"] } : {}
1700
1714
  });
1701
1715
  await funnel.listeners.restart(param.channel, param.connector);
@@ -1807,48 +1821,46 @@ modes:
1807
1821
  c.env.funnel.channels.setDelivery(param.channel, param.mode);
1808
1822
  return c.text(`channel "${param.channel}" delivery set to ${param.mode}`);
1809
1823
  });
1810
- const channelsShowHandler = factory.createHandlers(zValidator$1("param", z.object({ channel: z.string() })), zValidator$1("query", z.object({}), `funnel channels <name> show channel details`), (c) => {
1824
+ const channelsShowHandler = factory.createHandlers(zValidator$1("param", z.object({ channel: z.string() })), zValidator$1("query", z.object({}), `funnel channels <name> / show channel details
1825
+
1826
+ output / valid YAML`), (c) => {
1811
1827
  const param = c.req.valid("param");
1812
1828
  const channel = c.env.funnel.channels.get(param.channel);
1813
1829
  if (!channel) throw new HTTPException(404, { message: `channel "${param.channel}" not found` });
1814
- const connectorLines = channel.connectors.length ? channel.connectors.map((c) => ` - ${c.name} (${c.type}, id: ${c.id})`) : [" (none)"];
1815
- const lines = [
1816
- `id: ${channel.id}`,
1817
- `name: ${channel.name}`,
1818
- `delivery: ${channel.delivery}`,
1819
- `connectors:`,
1820
- ...connectorLines
1821
- ];
1822
- return c.text(lines.join("\n"));
1830
+ return c.text(renderYaml({
1831
+ id: channel.id,
1832
+ name: channel.name,
1833
+ delivery: channel.delivery,
1834
+ connectors: channel.connectors.map((conn) => ({
1835
+ id: conn.id,
1836
+ name: conn.name,
1837
+ type: conn.type
1838
+ }))
1839
+ }));
1823
1840
  });
1824
- const channelsGroupHandler = factory.createHandlers(zValidator$1("query", z.object({ json: z.enum([
1825
- "true",
1826
- "false",
1827
- ""
1828
- ]).optional() }), `funnel channels — manage subscription boxes
1829
-
1830
- usage: funnel channels [--json]
1841
+ const channelsGroupHandler = factory.createHandlers(zValidator$1("query", z.object({}), `funnel channels / manage subscription boxes
1831
1842
 
1832
- options:
1833
- --json output as JSON array (machine-readable, useful for Claude)
1843
+ usage / funnel channels [subcommand]
1834
1844
 
1835
1845
  subcommands:
1836
- (none) list
1837
- add <name> add
1838
- remove <name> remove
1839
- <name> show details
1840
- <name> connectors list connectors
1841
- <name> connectors add <c> --type=... add a connector
1846
+ (none) / list every channel with its connectors
1847
+ add <name> / create a channel
1848
+ remove <name> / delete a channel
1849
+ <name> / show one channel
1850
+ <name> connectors / list connectors
1851
+ <name> connectors add <c> --type=... / add a connector
1852
+
1853
+ output / valid YAML
1854
+
1855
+ programmable / funnel.channels.list() / .add() / .remove() / .addConnector() / .removeConnector()
1842
1856
 
1843
1857
  examples:
1844
1858
  funnel channels
1845
- funnel channels --json
1846
1859
  funnel channels add prod-inbox
1847
1860
  funnel channels prod-inbox connectors add prod-slack --type=slack --bot-token=xoxb-... --app-token=xapp-...
1848
1861
  funnel channels prod-inbox`), (c) => {
1849
- const query = c.req.valid("query");
1850
1862
  const channels = c.env.funnel.channels.list();
1851
- if (query.json === "true" || query.json === "") return c.json(channels.map((ch) => ({
1863
+ return c.text(renderYaml({ channels: channels.map((ch) => ({
1852
1864
  id: ch.id,
1853
1865
  name: ch.name,
1854
1866
  delivery: ch.delivery,
@@ -1857,14 +1869,7 @@ examples:
1857
1869
  name: conn.name,
1858
1870
  type: conn.type
1859
1871
  }))
1860
- })));
1861
- if (channels.length === 0) return c.text("no channels");
1862
- const lines = channels.map((ch) => {
1863
- const names = ch.connectors.map((conn) => conn.name);
1864
- const connectors = names.length > 0 ? names.join(", ") : "(none)";
1865
- return `${ch.name} [${connectors}]`;
1866
- });
1867
- return c.text(lines.join("\n"));
1872
+ })) }));
1868
1873
  });
1869
1874
  //#endregion
1870
1875
  //#region lib/cli/routes/channels.validate.ts
@@ -1929,42 +1934,29 @@ const validateConnector = (connector) => {
1929
1934
  }
1930
1935
  return issues;
1931
1936
  };
1932
- const channelsValidateHandler = factory.createHandlers(zValidator$1("param", z.object({ channel: z.string() })), zValidator$1("query", z.object({ json: z.enum([
1933
- "true",
1934
- "false",
1935
- ""
1936
- ]).optional() })), (c) => {
1937
+ const channelsValidateHandler = factory.createHandlers(zValidator$1("param", z.object({ channel: z.string() })), zValidator$1("query", z.object({})), (c) => {
1937
1938
  const param = c.req.valid("param");
1938
- const query = c.req.valid("query");
1939
- const funnel = c.env.funnel;
1940
- const isJson = query.json === "true" || query.json === "";
1941
- const channel = funnel.channels.get(param.channel);
1939
+ const channel = c.env.funnel.channels.get(param.channel);
1942
1940
  if (!channel) throw new HTTPException(404, { message: `channel "${param.channel}" not found` });
1943
- if (channel.connectors.length === 0) {
1944
- if (isJson) return c.json({
1945
- channel: channel.name,
1946
- valid: false,
1947
- issues: [{
1948
- connector: "(none)",
1949
- field: "connectors",
1950
- message: "no connectors configured"
1951
- }]
1952
- });
1953
- return c.text(`⚠ ${channel.name}: no connectors configured`);
1954
- }
1941
+ if (channel.connectors.length === 0) return c.text(renderYaml({
1942
+ channel: channel.name,
1943
+ valid: false,
1944
+ issues: [{
1945
+ connector: null,
1946
+ field: "connectors",
1947
+ message: "no connectors configured"
1948
+ }]
1949
+ }));
1955
1950
  const allIssues = [];
1956
1951
  for (const connector of channel.connectors) {
1957
1952
  const issues = validateConnector(connector);
1958
1953
  allIssues.push(...issues);
1959
1954
  }
1960
- if (isJson) return c.json({
1955
+ return c.text(renderYaml({
1961
1956
  channel: channel.name,
1962
1957
  valid: allIssues.length === 0,
1963
1958
  issues: allIssues
1964
- });
1965
- if (allIssues.length === 0) return c.text(`✓ ${channel.name}: all connectors valid`);
1966
- const lines = allIssues.map((issue) => `✗ ${channel.name}/${issue.connector}: ${issue.message}`);
1967
- return c.text(lines.join("\n"));
1959
+ }));
1968
1960
  });
1969
1961
  //#endregion
1970
1962
  //#region lib/cli/routes/claude.ts
@@ -1991,7 +1983,14 @@ funnel-specific options (everything else passes through to claude verbatim):
1991
1983
 
1992
1984
  Positional args, unknown short flags (e.g. -c, -r), and claude's own flags
1993
1985
  (--agent, --resume, --model, --print, --output-format ...) are all forwarded.
1994
- On launch the FUNNEL_CHANNEL_ID env var is set and MCP connects to the gateway.`;
1986
+ On launch the FUNNEL_CHANNEL_ID env var is set and MCP connects to the gateway.
1987
+
1988
+ see also:
1989
+ fnl docs claude full resolution order, side effects, double-launch guard
1990
+ fnl docs mcp what the MCP server exposes once Claude is up
1991
+ fnl docs debugging the diagnose → recover → verify loop
1992
+
1993
+ programmable: funnel.claude.launch({ profileId | channelId, options, env, resume })`;
1995
1994
  const RESERVED_KEYS$1 = ["profile", "channel"];
1996
1995
  const claudeHandler = factory.createHandlers(zValidator$1("query", z.object({
1997
1996
  profile: z.string().optional(),
@@ -2051,768 +2050,310 @@ const claudeHandler = factory.createHandlers(zValidator$1("query", z.object({
2051
2050
  process.exit(exitCode);
2052
2051
  });
2053
2052
  //#endregion
2054
- //#region lib/cli/routes/debug-row.ts
2055
- const stringOrNull = (value) => typeof value === "string" && value.length > 0 ? value : null;
2056
- const numberOrNull = (value) => typeof value === "number" ? value : null;
2057
- const stringOr = (value, fallback) => typeof value === "string" ? value : fallback;
2058
- /**
2059
- * Parse a payload string as a JSON object. Returns null for non-strings,
2060
- * malformed JSON, or any non-object JSON (arrays, primitives) — the callers
2061
- * only ever want the object form.
2062
- */
2063
- const parsePayloadObject = (payload) => {
2064
- if (payload === null) return null;
2065
- try {
2066
- const parsed = JSON.parse(payload);
2067
- if (isStringKeyedObject(parsed)) return parsed;
2068
- } catch {
2069
- return null;
2070
- }
2071
- return null;
2072
- };
2073
- const isStringKeyedObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
2074
- const truncate = (text, max) => text.length <= max ? text : `${text.slice(0, max)}…`;
2075
- /**
2076
- * A short human preview of a payload: the `text` field when the payload is a
2077
- * JSON object that has one, otherwise the raw payload, both truncated.
2078
- */
2079
- const previewOf = (payload) => {
2080
- if (typeof payload !== "string" || payload.length === 0) return null;
2081
- const parsed = parsePayloadObject(payload);
2082
- if (parsed !== null && "text" in parsed) return truncate(String(parsed.text), 60);
2083
- return truncate(payload, 60);
2084
- };
2085
- /** Narrow one processed-table row into a `DebugEvent`. */
2086
- const toDebugEvent = (row) => {
2087
- const payload = stringOrNull(row.payload);
2088
- return {
2089
- seq: numberOrNull(row.seq),
2090
- ts: numberOrNull(row.ts),
2091
- type: stringOr(row.type, "?"),
2092
- outcome: stringOr(row.outcome, "?"),
2093
- eventId: stringOrNull(row.event_id),
2094
- payload,
2095
- payloadParsed: parsePayloadObject(payload),
2096
- preview: previewOf(row.payload)
2097
- };
2098
- };
2099
- /** Narrow one connection-table row into a `DebugConnectionError`. */
2100
- const toDebugConnectionError = (row) => ({
2101
- seq: numberOrNull(row.seq),
2102
- ts: numberOrNull(row.ts),
2103
- type: stringOr(row.type, "?"),
2104
- status: stringOr(row.status, "?"),
2105
- detail: stringOrNull(row.detail)
2106
- });
2107
- /**
2108
- * Open a reader, run one query, and always close — the shared shape behind
2109
- * every diagnostic lookup. Returns the rows or the reader's `Error`.
2110
- */
2111
- const queryRows = (reader, sql, params) => {
2112
- try {
2113
- return reader.query(sql, params);
2114
- } finally {
2115
- reader.close();
2116
- }
2117
- };
2118
- //#endregion
2119
2053
  //#region lib/cli/routes/debug.ts
2120
- const debugHelp = `funnel debug diagnose why Claude is not receiving events
2054
+ const debugHelp = `funnel debug / per-channel inspection (events, drops, connection errors, replay)
2121
2055
 
2122
- usage: funnel debug [subcommand] [--channel <name>] [--all] [--json]
2056
+ usage / funnel debug [subcommand] [--channel <name>] [--all] [--limit <N>]
2123
2057
 
2124
2058
  subcommands:
2125
- (none) full diagnosis (gateway + listener + Claude + last 5 events)
2126
- events last N processed events with outcome and preview
2127
- dropped events filtered out (skip:*) with payload detail
2128
- errors listener connection errors (auth-failed, error)
2059
+ (none) / full diagnosis for one channel (or --all for every channel)
2060
+ events / last N processed events with outcome
2061
+ dropped / events filtered out (skip:*) with payload
2062
+ errors / listener auth-failed and error events
2063
+ replay / re-send a past event into a channel
2129
2064
 
2130
2065
  options:
2131
- --channel <name> channel to inspect (auto-selected when only one exists)
2132
- --all diagnose all channels at once (JSON output)
2133
- --limit <N> number of recent events to include (default: 5; events/dropped/errors default: 20)
2134
- --json output as JSON (machine-readable, useful for Claude)
2066
+ --channel <name> / channel to inspect (auto-selected when only one exists)
2067
+ --all / diagnose every channel
2068
+ --limit <N> / number of rows (default 5 for diagnosis, 20 for subcommands)
2135
2069
 
2136
- when a listener is dead the diagnosis includes rootCause the most recent
2137
- connection error detail pulled from the connection log automatically.
2070
+ For the common case, prefer fnl doctor it runs a full diagnosis and can apply
2071
+ safe fixes in one shot. fnl debug is the lower-level view.
2138
2072
 
2139
- use --json when asking Claude to analyse the output it returns structured
2140
- data that Claude can parse without guessing at text formatting.
2073
+ output / valid YAML
2074
+
2075
+ programmable / funnel.diagnostics.diagnose() / .diagnoseAll() / .recentEvents() / .droppedEvents() / .connectionErrors() / .replay()
2141
2076
 
2142
2077
  examples:
2143
2078
  funnel debug
2144
- funnel debug --all --json
2145
- funnel debug --channel open-karte
2146
- funnel debug --channel open-karte --json
2147
- funnel debug events --channel open-karte --limit 50
2148
- funnel debug dropped --channel open-karte --json
2149
- funnel debug errors`;
2150
- const debugEventsHelp = `funnel debug events — last N processed events with outcome and preview
2079
+ funnel debug --all
2080
+ funnel debug --channel ops
2081
+ funnel debug events --channel ops --limit 50
2082
+ funnel debug dropped --channel ops`;
2083
+ const debugEventsHelp = `funnel debug events / last N processed events
2151
2084
 
2152
- usage: funnel debug events [--channel <name>] [--limit <N>] [--json]
2085
+ usage / funnel debug events [--channel <name>] [--limit <N>]
2153
2086
 
2154
- options:
2155
- --channel <name> channel to inspect (auto-selected when only one exists)
2156
- --limit <N> number of rows (default: 20)
2157
- --json output as JSON
2087
+ programmable / funnel.diagnostics.recentEvents(channel, limit)`;
2088
+ const debugDroppedHelp = `funnel debug dropped / events filtered out (skip:*)
2158
2089
 
2159
- examples:
2160
- funnel debug events
2161
- funnel debug events --channel open-karte --limit 50
2162
- funnel debug events --json`;
2163
- const debugDroppedHelp = `funnel debug dropped — events filtered out (skip:*)
2164
-
2165
- usage: funnel debug dropped [--channel <name>] [--limit <N>] [--json]
2090
+ usage / funnel debug dropped [--channel <name>] [--limit <N>]
2166
2091
 
2167
- options:
2168
- --channel <name> channel to inspect (auto-selected when only one exists)
2169
- --limit <N> number of rows (default: 20)
2170
- --json output as JSON
2092
+ shows skip reasons: skip:type / skip:subtype / skip:dedup / skip:self-user /
2093
+ skip:self-bot / skip:preprocess
2171
2094
 
2172
- shows why events were skipped: skip:type, skip:subtype, skip:dedup,
2173
- skip:self-user, skip:self-bot, skip:preprocess
2174
-
2175
- examples:
2176
- funnel debug dropped
2177
- funnel debug dropped --channel open-karte --json`;
2178
- const debugErrorsHelp = `funnel debug errors — listener connection errors
2095
+ programmable / funnel.diagnostics.droppedEvents(channel, limit)`;
2096
+ const debugErrorsHelp = `funnel debug errors / listener auth-failed and error events
2179
2097
 
2180
- usage: funnel debug errors [--channel <name>] [--limit <N>] [--json]
2098
+ usage / funnel debug errors [--channel <name>] [--limit <N>]
2181
2099
 
2182
- options:
2183
- --channel <name> channel to inspect (auto-selected when only one exists)
2184
- --limit <N> number of rows (default: 20)
2185
- --json output as JSON
2100
+ programmable / funnel.diagnostics.connectionErrors(channel, limit)`;
2101
+ const debugReplayHelp = `funnel debug replay / re-publish a past event into a channel
2186
2102
 
2187
- shows auth-failed and error events from the connection lifecycle log.
2188
- use this when a listener never connects or keeps disconnecting.
2103
+ usage / funnel debug replay --channel <name> [--seq <N>]
2189
2104
 
2190
- examples:
2191
- funnel debug errors
2192
- funnel debug errors --channel open-karte`;
2193
- const isGatewayStatusResponse = (value) => {
2194
- if (value === null || typeof value !== "object") return false;
2195
- if (!("clients" in value) || !Array.isArray(value.clients)) return false;
2196
- if (!("listeners" in value) || !Array.isArray(value.listeners)) return false;
2197
- return true;
2198
- };
2199
- const formatUptime = (ms) => {
2200
- const sec = Math.floor(ms / 1e3);
2201
- const min = Math.floor(sec / 60);
2202
- if (min >= 60) return `${Math.floor(min / 60)}h ${min % 60}m`;
2203
- if (sec >= 60) return `${min}m ${sec % 60}s`;
2204
- return `${sec}s`;
2205
- };
2206
- const formatTs = (epochMs) => {
2207
- if (typeof epochMs !== "number") return "?";
2208
- return new Date(epochMs).toISOString().slice(11, 19);
2209
- };
2210
- const buildDiagnosis = (report) => {
2211
- const rootCause = (report.connectionErrors[report.connectionErrors.length - 1] ?? null)?.detail ?? null;
2212
- if (!report.gateway.running) return {
2213
- status: "error",
2214
- message: "gateway is not running",
2215
- nextActions: ["fnl gateway start"],
2216
- rootCause: null
2217
- };
2218
- const channel = report.channel;
2219
- if (!(report.listeners.length > 0)) return {
2220
- status: "warn",
2221
- message: "no connectors configured on this channel",
2222
- nextActions: [`fnl channels ${channel} connectors add <name> --type=slack ...`],
2223
- rootCause: null
2224
- };
2225
- const allDead = report.listeners.every((l) => !l.alive);
2226
- const someDead = report.listeners.some((l) => !l.alive);
2227
- if (allDead) return {
2228
- status: "error",
2229
- message: "all listeners are dead",
2230
- nextActions: ["fnl gateway logs", "fnl gateway restart"],
2231
- rootCause
2232
- };
2233
- if (someDead) return {
2234
- status: "warn",
2235
- message: "some listeners are dead",
2236
- nextActions: ["fnl gateway logs"],
2237
- rootCause
2238
- };
2239
- const hasErrors = report.listeners.some((l) => l.errors > 0);
2240
- if (report.claudeClients === 0) return {
2241
- status: "warn",
2242
- message: "no Claude connected to this channel",
2243
- nextActions: [`fnl claude --channel ${channel}`],
2244
- rootCause: null
2245
- };
2246
- if (hasErrors) return {
2247
- status: "warn",
2248
- message: "listeners have errors",
2249
- nextActions: ["fnl gateway logs"],
2250
- rootCause
2251
- };
2252
- return {
2253
- status: "ok",
2254
- message: "everything looks healthy",
2255
- nextActions: [],
2256
- rootCause: null
2257
- };
2258
- };
2259
- const renderText = (report) => {
2260
- const lines = [];
2261
- lines.push(`= funnel debug: ${report.channel} =`);
2262
- lines.push("");
2263
- const gw = report.gateway;
2264
- if (!gw.running) lines.push("[gateway] ○ not running");
2265
- else {
2266
- const uptime = gw.uptimeMs !== null ? ` · up ${formatUptime(gw.uptimeMs)}` : "";
2267
- lines.push(`[gateway] ● running pid ${gw.pid} · port ${gw.port}${uptime}`);
2268
- }
2269
- if (report.listeners.length === 0) lines.push("[listener] - no listener");
2270
- else for (const listener of report.listeners) {
2271
- const indicator = listener.alive ? "●" : "○";
2272
- const state = listener.alive ? "alive " : "dead ";
2273
- const eventsStr = `${listener.events} events`;
2274
- const lastStr = listener.lastEventAt ? ` · last ${listener.lastEventAt.slice(11, 19)}` : "";
2275
- const errStr = listener.errors > 0 ? ` · ⚠ ${listener.errors} errors` : "";
2276
- lines.push(`[listener] ${indicator} ${state} ${eventsStr}${lastStr}${errStr}`);
2277
- }
2278
- const claudeCount = report.claudeClients;
2279
- if (claudeCount === 0) lines.push("[claude] ○ not connected");
2280
- else lines.push(`[claude] ● connected (${claudeCount} WS client${claudeCount > 1 ? "s" : ""})`);
2281
- if (report.recentEvents.length === 0) lines.push("[events] no events recorded");
2282
- else {
2283
- lines.push(`[events] last ${report.recentEvents.length} event${report.recentEvents.length > 1 ? "s" : ""}:`);
2284
- for (const event of report.recentEvents) {
2285
- const time = formatTs(event.ts);
2286
- const type = event.type.padEnd(8);
2287
- const outcome = event.outcome.padEnd(20);
2288
- const preview = event.preview ? ` "${event.preview}"` : "";
2289
- const seq = event.seq !== null ? ` (seq=${event.seq})` : "";
2290
- lines.push(` ${time} ${type} ${outcome}${preview}${seq}`);
2291
- }
2292
- }
2293
- if (report.connectionErrors.length > 0) {
2294
- lines.push("[conn errs] recent connection errors:");
2295
- for (const err of report.connectionErrors) {
2296
- const time = formatTs(err.ts);
2297
- const status = err.status.padEnd(14);
2298
- const detail = err.detail ? ` "${err.detail}"` : "";
2299
- lines.push(` ${time} ${err.type.padEnd(8)} ${status}${detail}`);
2300
- }
2301
- }
2302
- lines.push("");
2303
- const diag = report.diagnosis;
2304
- const icon = diag.status === "ok" ? "✓" : diag.status === "warn" ? "⚠" : "✗";
2305
- lines.push(`diagnosis: ${icon} ${diag.message}`);
2306
- if (diag.rootCause) lines.push(` root cause: ${diag.rootCause}`);
2307
- for (const action of diag.nextActions) lines.push(` → ${action}`);
2308
- return lines.join("\n");
2309
- };
2310
- const resolveStoreOrNull = () => {
2311
- const tmpDir = funnelTmpDir();
2312
- const rawPath = join(tmpDir, "connector-raw.db");
2313
- const processedPath = join(tmpDir, "connector-processed.db");
2314
- const connectionPath = join(tmpDir, "connector-connection.db");
2315
- if (!existsSync(rawPath) || !existsSync(processedPath) || !existsSync(connectionPath)) return null;
2316
- return {
2317
- rawPath,
2318
- processedPath,
2319
- connectionPath
2320
- };
2321
- };
2322
- /**
2323
- * Resolve the connector name for a connector id on a channel, used to attribute
2324
- * a replayed event back to its source connector. Returns undefined when the id
2325
- * is null or no longer present (connectors can be removed after an event was
2326
- * logged).
2327
- */
2328
- const connectorOf = (channel, connectorId) => {
2329
- if (connectorId === null) return void 0;
2330
- return channel.connectors?.find((connector) => connector.id === connectorId)?.name;
2331
- };
2332
- const resolveChannelId = (channels, channelName) => {
2333
- if (channelName) {
2334
- const match = channels.find((ch) => ch.name === channelName);
2335
- if (match) return {
2336
- found: true,
2337
- channel: match
2105
+ programmable / funnel.diagnostics.replay(channel, seq?)`;
2106
+ const channelLimitQuery = z.object({
2107
+ channel: z.string().optional(),
2108
+ limit: z.string().optional()
2109
+ });
2110
+ const resolveTargetChannel = (c, channelArg) => {
2111
+ const channels = c.env.funnel.channels.list();
2112
+ if (channelArg) {
2113
+ const match = channels.find((ch) => ch.name === channelArg);
2114
+ if (!match) return {
2115
+ kind: "error",
2116
+ payload: {
2117
+ error: `channel not found: ${channelArg}`,
2118
+ availableChannels: channels.map((ch) => ch.name)
2119
+ }
2338
2120
  };
2339
2121
  return {
2340
- found: false,
2341
- reason: "not-found",
2342
- name: channelName
2122
+ kind: "ok",
2123
+ name: match.name
2343
2124
  };
2344
2125
  }
2345
- if (channels.length === 1 && channels[0]) return {
2346
- found: true,
2347
- channel: channels[0]
2348
- };
2349
2126
  if (channels.length === 0) return {
2350
- found: false,
2351
- reason: "none"
2127
+ kind: "ok",
2128
+ name: null
2129
+ };
2130
+ if (channels.length === 1) return {
2131
+ kind: "ok",
2132
+ name: channels[0]?.name ?? null
2352
2133
  };
2353
2134
  return {
2354
- found: false,
2355
- reason: "ambiguous",
2356
- names: channels.map((ch) => ch.name)
2135
+ kind: "error",
2136
+ payload: {
2137
+ error: "multiple channels specify one with --channel",
2138
+ channels: channels.map((ch) => ch.name)
2139
+ }
2357
2140
  };
2358
2141
  };
2359
- const debugEventsHandler = factory.createHandlers(zValidator$1("query", z.object({
2142
+ const debugHandler = factory.createHandlers(zValidator$1("query", z.object({
2360
2143
  channel: z.string().optional(),
2361
- limit: z.string().optional(),
2362
- json: z.enum([
2144
+ all: z.enum([
2363
2145
  "true",
2364
2146
  "false",
2365
2147
  ""
2366
- ]).optional()
2367
- }), debugEventsHelp), async (c) => {
2148
+ ]).optional(),
2149
+ limit: z.string().optional()
2150
+ }), debugHelp), async (c) => {
2368
2151
  const query = c.req.valid("query");
2369
- const channels = c.env.funnel.channels.list();
2370
- const isJson = query.json === "true" || query.json === "";
2152
+ const funnel = c.env.funnel;
2153
+ if (query.all === "true" || query.all === "") {
2154
+ const report = await funnel.diagnostics.diagnoseAll();
2155
+ return c.text(renderYaml(report));
2156
+ }
2157
+ const allChannels = funnel.channels.list();
2158
+ if (allChannels.length === 0) return c.text(renderYaml({
2159
+ error: "no channels configured",
2160
+ nextAction: "fnl channels add <name>"
2161
+ }));
2162
+ let targetName = null;
2163
+ if (query.channel) {
2164
+ const match = allChannels.find((ch) => ch.name === query.channel);
2165
+ if (!match) return c.text(renderYaml({
2166
+ error: `channel not found: ${query.channel}`,
2167
+ availableChannels: allChannels.map((ch) => ch.name)
2168
+ }));
2169
+ targetName = match.name;
2170
+ } else if (allChannels.length === 1) targetName = allChannels[0]?.name ?? null;
2171
+ else return c.text(renderYaml({
2172
+ error: "multiple channels — specify one with --channel or use --all",
2173
+ channels: allChannels.map((ch) => ch.name),
2174
+ hint: "use --all for all channels at once"
2175
+ }));
2176
+ const report = await funnel.diagnostics.diagnose(targetName ?? void 0);
2177
+ if (!report) return c.text(renderYaml({ error: "channel not resolvable" }));
2178
+ return c.text(renderYaml(report));
2179
+ });
2180
+ const debugEventsHandler = factory.createHandlers(zValidator$1("query", channelLimitQuery, debugEventsHelp), async (c) => {
2181
+ const query = c.req.valid("query");
2182
+ const funnel = c.env.funnel;
2371
2183
  const limit = query.limit ? Math.max(1, Number(query.limit)) : 20;
2372
- const store = resolveStoreOrNull();
2373
- if (!store) {
2374
- if (isJson) return c.json([]);
2375
- return c.text("no diagnostic store yet (start the gateway first)");
2376
- }
2377
- const resolved = resolveChannelId(channels, query.channel);
2378
- if (!resolved.found) {
2379
- if (resolved.reason === "not-found") {
2380
- if (isJson) return c.json({
2381
- error: `channel not found: ${resolved.name}`,
2382
- availableChannels: channels.map((ch) => ch.name)
2383
- });
2384
- return c.text(`channel not found: ${resolved.name}`);
2385
- }
2386
- if (resolved.reason === "ambiguous") {
2387
- if (isJson) return c.json({
2388
- error: "multiple channels — specify one with --channel",
2389
- channels: resolved.names
2390
- });
2391
- return c.text(`multiple channels — specify one with --channel:\n${resolved.names.map((n) => ` - ${n}`).join("\n")}`);
2392
- }
2393
- if (isJson) return c.json([]);
2394
- return c.text("no channels configured");
2395
- }
2396
- const channel = resolved.channel;
2397
- const reader = new ConnectorDiagnosticSqlReader(store);
2398
- 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]);
2399
- if (rows instanceof Error) return c.text(`error: ${rows.message}`);
2400
- const events = rows.reverse().map(toDebugEvent);
2401
- if (isJson) return c.json(events);
2402
- if (events.length === 0) return c.text("no events recorded");
2403
- const lines = events.map((ev) => {
2404
- const time = formatTs(ev.ts);
2405
- const type = ev.type.padEnd(8);
2406
- const outcome = ev.outcome.padEnd(20);
2407
- const preview = ev.preview ? ` "${ev.preview}"` : "";
2408
- return `${time} ${type} ${outcome}${ev.seq !== null ? ` seq=${ev.seq}` : ""}${preview}`;
2409
- });
2410
- return c.text(lines.join("\n"));
2184
+ const resolved = resolveTargetChannel(c, query.channel);
2185
+ if (resolved.kind === "error") return c.text(renderYaml(resolved.payload));
2186
+ const events = await funnel.diagnostics.recentEvents(resolved.name, limit);
2187
+ return c.text(renderYaml({ events }));
2411
2188
  });
2412
- const debugDroppedHandler = factory.createHandlers(zValidator$1("query", z.object({
2413
- channel: z.string().optional(),
2414
- limit: z.string().optional(),
2415
- json: z.enum([
2416
- "true",
2417
- "false",
2418
- ""
2419
- ]).optional()
2420
- }), debugDroppedHelp), async (c) => {
2189
+ const debugDroppedHandler = factory.createHandlers(zValidator$1("query", channelLimitQuery, debugDroppedHelp), async (c) => {
2421
2190
  const query = c.req.valid("query");
2422
- const channels = c.env.funnel.channels.list();
2423
- const isJson = query.json === "true" || query.json === "";
2191
+ const funnel = c.env.funnel;
2424
2192
  const limit = query.limit ? Math.max(1, Number(query.limit)) : 20;
2425
- const store = resolveStoreOrNull();
2426
- if (!store) {
2427
- if (isJson) return c.json([]);
2428
- return c.text("no diagnostic store yet (start the gateway first)");
2429
- }
2430
- const resolvedDropped = resolveChannelId(channels, query.channel);
2431
- if (!resolvedDropped.found) {
2432
- if (resolvedDropped.reason === "not-found") {
2433
- if (isJson) return c.json({
2434
- error: `channel not found: ${resolvedDropped.name}`,
2435
- availableChannels: channels.map((ch) => ch.name)
2436
- });
2437
- return c.text(`channel not found: ${resolvedDropped.name}`);
2438
- }
2439
- if (resolvedDropped.reason === "ambiguous") {
2440
- if (isJson) return c.json({
2441
- error: "multiple channels — specify one with --channel",
2442
- channels: resolvedDropped.names
2443
- });
2444
- return c.text(`multiple channels — specify one with --channel:\n${resolvedDropped.names.map((n) => ` - ${n}`).join("\n")}`);
2445
- }
2446
- if (isJson) return c.json([]);
2447
- return c.text("no channels configured");
2448
- }
2449
- const channel = resolvedDropped.channel;
2450
- const reader = new ConnectorDiagnosticSqlReader(store);
2451
- 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]);
2452
- if (rows instanceof Error) return c.text(`error: ${rows.message}`);
2453
- const events = rows.reverse().map(toDebugEvent);
2454
- if (isJson) return c.json(events);
2455
- if (events.length === 0) return c.text("no dropped events recorded");
2456
- const lines = events.map((ev) => {
2457
- 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}"` : ""}`;
2458
- });
2459
- return c.text(lines.join("\n"));
2193
+ const resolved = resolveTargetChannel(c, query.channel);
2194
+ if (resolved.kind === "error") return c.text(renderYaml(resolved.payload));
2195
+ const events = await funnel.diagnostics.droppedEvents(resolved.name, limit);
2196
+ return c.text(renderYaml({ dropped: events }));
2460
2197
  });
2461
- const debugErrorsHandler = factory.createHandlers(zValidator$1("query", z.object({
2462
- channel: z.string().optional(),
2463
- limit: z.string().optional(),
2464
- json: z.enum([
2465
- "true",
2466
- "false",
2467
- ""
2468
- ]).optional()
2469
- }), debugErrorsHelp), async (c) => {
2198
+ const debugErrorsHandler = factory.createHandlers(zValidator$1("query", channelLimitQuery, debugErrorsHelp), async (c) => {
2470
2199
  const query = c.req.valid("query");
2471
- const channels = c.env.funnel.channels.list();
2472
- const isJson = query.json === "true" || query.json === "";
2200
+ const funnel = c.env.funnel;
2473
2201
  const limit = query.limit ? Math.max(1, Number(query.limit)) : 20;
2474
- const store = resolveStoreOrNull();
2475
- if (!store) {
2476
- if (isJson) return c.json([]);
2477
- return c.text("no diagnostic store yet (start the gateway first)");
2478
- }
2479
- const resolvedErrors = resolveChannelId(channels, query.channel);
2480
- if (!resolvedErrors.found) {
2481
- if (resolvedErrors.reason === "not-found") {
2482
- if (isJson) return c.json({
2483
- error: `channel not found: ${resolvedErrors.name}`,
2484
- availableChannels: channels.map((ch) => ch.name)
2485
- });
2486
- return c.text(`channel not found: ${resolvedErrors.name}`);
2487
- }
2488
- if (resolvedErrors.reason === "ambiguous") {
2489
- if (isJson) return c.json({
2490
- error: "multiple channels — specify one with --channel",
2491
- channels: resolvedErrors.names
2492
- });
2493
- return c.text(`multiple channels — specify one with --channel:\n${resolvedErrors.names.map((n) => ` - ${n}`).join("\n")}`);
2494
- }
2495
- if (isJson) return c.json([]);
2496
- return c.text("no channels configured");
2497
- }
2498
- const channel = resolvedErrors.channel;
2499
- const reader = new ConnectorDiagnosticSqlReader(store);
2500
- 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]);
2501
- if (rows instanceof Error) return c.text(`error: ${rows.message}`);
2502
- const errors = rows.reverse().map(toDebugConnectionError);
2503
- if (isJson) return c.json(errors);
2504
- if (errors.length === 0) return c.text("no connection errors recorded");
2505
- const lines = errors.map((ev) => {
2506
- return `${formatTs(ev.ts)} ${ev.type.padEnd(8)} ${ev.status.padEnd(16)}${ev.detail ? ` "${ev.detail}"` : ""}`;
2507
- });
2508
- return c.text(lines.join("\n"));
2202
+ const resolved = resolveTargetChannel(c, query.channel);
2203
+ if (resolved.kind === "error") return c.text(renderYaml(resolved.payload));
2204
+ const errors = await funnel.diagnostics.connectionErrors(resolved.name, limit);
2205
+ return c.text(renderYaml({ errors }));
2509
2206
  });
2510
- const buildChannelReport = async (targetChannel, gatewayStatus, gatewayBodyOrNull, store, limit = 5) => {
2511
- const targetChannelName = targetChannel.name;
2512
- const baseReport = {
2513
- channel: targetChannelName,
2514
- channelId: targetChannel.id,
2515
- gateway: {
2516
- running: gatewayStatus.running,
2517
- pid: gatewayStatus.pid,
2518
- port: gatewayStatus.running ? gatewayStatus.port : null,
2519
- uptimeMs: gatewayBodyOrNull?.uptimeMs ?? null
2520
- },
2521
- listeners: [],
2522
- claudeClients: 0,
2523
- recentEvents: [],
2524
- connectionErrors: []
2525
- };
2526
- if (gatewayBodyOrNull) {
2527
- baseReport.listeners = gatewayBodyOrNull.listeners.filter((l) => l.channelName === targetChannelName).map((l) => ({
2528
- name: l.name,
2529
- type: l.type,
2530
- alive: l.alive,
2531
- events: l.events,
2532
- errors: l.errors,
2533
- lastEventAt: l.lastEventAt
2534
- }));
2535
- baseReport.claudeClients = gatewayBodyOrNull.clients.filter((cl) => cl.channelName === targetChannelName).length;
2536
- }
2537
- if (store) {
2538
- 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]);
2539
- if (!(evRows instanceof Error)) baseReport.recentEvents = evRows.reverse().map(toDebugEvent);
2540
- const hasDeadListeners = baseReport.listeners.some((l) => !l.alive);
2541
- const hasListenerErrors = baseReport.listeners.some((l) => l.errors > 0);
2542
- if (hasDeadListeners || hasListenerErrors) {
2543
- 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]);
2544
- if (!(errRows instanceof Error)) baseReport.connectionErrors = errRows.reverse().map(toDebugConnectionError);
2545
- }
2546
- }
2547
- return {
2548
- ...baseReport,
2549
- diagnosis: buildDiagnosis(baseReport)
2550
- };
2551
- };
2552
- const debugHandler = factory.createHandlers(zValidator$1("query", z.object({
2207
+ const debugReplayHandler = factory.createHandlers(zValidator$1("query", z.object({
2553
2208
  channel: z.string().optional(),
2554
- all: z.enum([
2555
- "true",
2556
- "false",
2557
- ""
2558
- ]).optional(),
2559
- json: z.enum([
2209
+ seq: z.string().optional()
2210
+ }), debugReplayHelp), async (c) => {
2211
+ const query = c.req.valid("query");
2212
+ const funnel = c.env.funnel;
2213
+ const resolved = resolveTargetChannel(c, query.channel);
2214
+ if (resolved.kind === "error") return c.text(renderYaml(resolved.payload));
2215
+ if (!resolved.name) return c.text(renderYaml({ error: "no channels configured" }));
2216
+ const seq = query.seq ? Number(query.seq) : void 0;
2217
+ const result = await funnel.diagnostics.replay(resolved.name, seq);
2218
+ return c.text(renderYaml(result));
2219
+ });
2220
+ const docsIndexHandler = factory.createHandlers(zValidator$1("query", z.object({}), `funnel docs / embedded documentation
2221
+
2222
+ usage / funnel docs [topic]
2223
+
2224
+ with no topic, lists topics as YAML. with a topic, prints the doc text.
2225
+
2226
+ output / valid YAML (topic listing) or plain text (topic body)
2227
+
2228
+ programmable / funnel.docs.list() / funnel.docs.get(topic)
2229
+
2230
+ examples:
2231
+ funnel docs
2232
+ funnel docs architecture
2233
+ funnel docs debugging`), async (c) => {
2234
+ const docs = c.env.funnel.docs;
2235
+ return c.text(renderYaml({ topics: docs.list() }));
2236
+ });
2237
+ const docsTopicHandler = factory.createHandlers(zValidator$1("param", z.object({ topic: z.string() })), async (c) => {
2238
+ const param = c.req.valid("param");
2239
+ const docs = c.env.funnel.docs;
2240
+ const text = docs.get(param.topic);
2241
+ if (text === null) return c.text(renderYaml({
2242
+ error: `unknown topic: ${param.topic}`,
2243
+ availableTopics: docs.topics()
2244
+ }));
2245
+ return c.text(text);
2246
+ });
2247
+ const doctorHandler = factory.createHandlers(zValidator$1("query", z.object({
2248
+ fix: z.enum([
2560
2249
  "true",
2561
2250
  "false",
2562
2251
  ""
2563
2252
  ]).optional(),
2564
- limit: z.string().optional()
2565
- }), debugHelp), async (c) => {
2566
- const query = c.req.valid("query");
2567
- const funnel = c.env.funnel;
2568
- const channels = funnel.channels.list();
2569
- const gatewayStatus = funnel.gateway.getStatus();
2570
- const isJson = query.json === "true" || query.json === "";
2571
- const isAll = query.all === "true" || query.all === "";
2572
- const eventLimit = query.limit ? Math.max(1, Number(query.limit)) : 5;
2573
- if (channels.length === 0) {
2574
- if (isJson) return c.json({
2575
- error: "no channels configured",
2576
- nextAction: "fnl channels add <name>"
2577
- });
2578
- return c.text("no channels configured — run: fnl channels add <name>");
2579
- }
2580
- const token = funnel.gatewayToken.read();
2581
- const headers = token ? { Authorization: `Bearer ${token}` } : {};
2582
- let gatewayBodyOrNull = null;
2583
- if (gatewayStatus.running) {
2584
- const res = await fetch(`http://127.0.0.1:${gatewayStatus.port}/status`, { headers }).catch(() => null);
2585
- if (res && res.ok) {
2586
- const body = await res.json();
2587
- if (isGatewayStatusResponse(body)) gatewayBodyOrNull = body;
2588
- }
2589
- }
2590
- const store = resolveStoreOrNull();
2591
- if (isAll) {
2592
- const reports = await Promise.all(channels.map((ch) => buildChannelReport(ch, gatewayStatus, gatewayBodyOrNull, store, eventLimit)));
2593
- const errorChannels = reports.filter((r) => r.diagnosis.status === "error").map((r) => r.channel);
2594
- const warnChannels = reports.filter((r) => r.diagnosis.status === "warn").map((r) => r.channel);
2595
- const okChannels = reports.filter((r) => r.diagnosis.status === "ok").map((r) => r.channel);
2596
- const uniqueActions = [...new Set(reports.flatMap((r) => r.diagnosis.nextActions))];
2597
- return c.json({
2598
- summary: {
2599
- total: reports.length,
2600
- ok: okChannels.length,
2601
- warn: warnChannels.length,
2602
- error: errorChannels.length,
2603
- criticalChannels: errorChannels,
2604
- warnChannels,
2605
- suggestedActions: uniqueActions
2606
- },
2607
- channels: reports
2608
- });
2609
- }
2610
- let targetChannel = null;
2611
- if (query.channel) {
2612
- targetChannel = channels.find((ch) => ch.name === query.channel) ?? null;
2613
- if (!targetChannel) {
2614
- if (isJson) return c.json({
2615
- error: `channel not found: ${query.channel}`,
2616
- availableChannels: channels.map((ch) => ch.name)
2617
- });
2618
- return c.text(`channel not found: ${query.channel}`);
2619
- }
2620
- } else if (channels.length === 1 && channels[0]) targetChannel = channels[0];
2621
- else {
2622
- const names = channels.map((ch) => ch.name);
2623
- if (isJson) return c.json({
2624
- error: "multiple channels — specify one with --channel or use --all",
2625
- channels: names,
2626
- hint: "use --all for all channels at once"
2627
- });
2628
- return c.text(`multiple channels — specify one with --channel or use --all:\n${names.map((n) => ` - ${n}`).join("\n")}`);
2629
- }
2630
- const report = await buildChannelReport(targetChannel, gatewayStatus, gatewayBodyOrNull, store, eventLimit);
2631
- if (isJson) return c.json(report);
2632
- return c.text(renderText(report));
2633
- });
2634
- const debugReplayHandler = factory.createHandlers(zValidator$1("query", z.object({
2635
- channel: z.string().optional(),
2636
- seq: z.string().optional(),
2637
- json: z.enum([
2253
+ aggressive: z.enum([
2638
2254
  "true",
2639
2255
  "false",
2640
2256
  ""
2641
2257
  ]).optional()
2642
- }), `funnel debug replay re-publish a past event into a channel
2258
+ }), `funnel doctor / diagnose every channel; --fix applies safe self-healing
2643
2259
 
2644
- usage: funnel debug replay --channel <name> [--seq <N>] [--json]
2260
+ usage / funnel doctor [--fix] [--aggressive]
2645
2261
 
2646
- options:
2647
- --channel <name> channel to replay into (required when multiple channels exist)
2648
- --seq <N> replay the event at this processed-table seq (default: most recent emitted)
2649
- --json output result as JSON
2262
+ modes:
2263
+ (default) / read-only diagnosis, safe to run anytime
2264
+ --fix / start the gateway if down, restart dead listeners (idempotent)
2265
+ --fix --aggressive / also restart the gateway when safe fixes do not return things to ok
2650
2266
 
2651
- Re-sends a past event from the diagnostic store through the publisher path,
2652
- so subscribers receive it again. Useful to verify that Claude handles an event
2653
- correctly without waiting for a real external trigger.
2267
+ output / valid YAML, parseable by yq
2654
2268
 
2655
- Gateway must be running. The event is injected via POST /channels/<name>/publish.
2269
+ programmable / await funnel.doctor.run() / await funnel.doctor.run("safe") / await funnel.doctor.run("aggressive")
2656
2270
 
2657
2271
  examples:
2658
- fnl debug replay --channel open-karte
2659
- fnl debug replay --channel open-karte --seq 412
2660
- fnl debug replay --channel open-karte --json`), async (c) => {
2272
+ funnel doctor
2273
+ funnel doctor --fix
2274
+ funnel doctor --fix --aggressive`), async (c) => {
2661
2275
  const query = c.req.valid("query");
2662
- const funnel = c.env.funnel;
2663
- const channels = funnel.channels.list();
2664
- const isJson = query.json === "true" || query.json === "";
2665
- const resolved = resolveChannelId(channels, query.channel);
2666
- if (!resolved.found) {
2667
- if (resolved.reason === "not-found") {
2668
- if (isJson) return c.json({
2669
- error: `channel not found: ${resolved.name}`,
2670
- availableChannels: channels.map((ch) => ch.name)
2671
- });
2672
- return c.text(`channel not found: ${resolved.name}`);
2673
- }
2674
- if (resolved.reason === "ambiguous") {
2675
- if (isJson) return c.json({
2676
- error: "multiple channels — specify one with --channel",
2677
- channels: resolved.names
2678
- });
2679
- return c.text(`multiple channels — specify one with --channel:\n${resolved.names.map((n) => ` - ${n}`).join("\n")}`);
2680
- }
2681
- if (isJson) return c.json({ error: "no channels configured" });
2682
- return c.text("no channels configured");
2683
- }
2684
- const targetChannel = resolved.channel;
2685
- const store = resolveStoreOrNull();
2686
- if (!store) {
2687
- if (isJson) return c.json({ error: "no diagnostic store yet (start the gateway first)" });
2688
- return c.text("no diagnostic store yet (start the gateway first)");
2689
- }
2690
- 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]);
2691
- if (rows instanceof Error) {
2692
- if (isJson) return c.json({ error: rows.message });
2693
- return c.text(`error: ${rows.message}`);
2694
- }
2695
- const firstRow = rows[0];
2696
- if (!firstRow) {
2697
- if (isJson) return c.json({ error: "no matching event found" });
2698
- return c.text("no matching event found");
2699
- }
2700
- const seq = typeof firstRow.seq === "number" ? firstRow.seq : null;
2701
- const eventId = typeof firstRow.event_id === "string" ? firstRow.event_id : null;
2702
- const connectorId = typeof firstRow.connector_id === "string" ? firstRow.connector_id : null;
2703
- let content = typeof firstRow.payload === "string" ? firstRow.payload : null;
2704
- if ((!content || content.length === 0) && eventId) {
2705
- const rawRows = queryRows(new ConnectorDiagnosticSqlReader(store), "SELECT payload FROM raw WHERE event_id = ? LIMIT 1", [eventId]);
2706
- const rawRow = rawRows instanceof Error ? null : rawRows[0];
2707
- if (rawRow) content = typeof rawRow.payload === "string" ? rawRow.payload : null;
2708
- }
2709
- if (!content) {
2710
- if (isJson) return c.json({ error: "event has no payload to replay" });
2711
- return c.text("event has no payload to replay");
2712
- }
2713
- const connectorName = connectorOf(targetChannel, connectorId);
2714
- const result = await funnel.publisher.publish(targetChannel.name, {
2715
- content,
2716
- connector: connectorName
2717
- });
2718
- if (result.state === "offline") {
2719
- if (isJson) return c.json({
2720
- error: "gateway daemon is not running",
2721
- nextAction: "fnl gateway start"
2722
- });
2723
- return c.text("error: gateway daemon is not running — run: fnl gateway start");
2724
- }
2725
- if (result.state === "error") {
2726
- if (isJson) return c.json({ error: result.reason });
2727
- return c.text(`error: ${result.reason}`);
2728
- }
2729
- const preview = previewOf(content);
2730
- if (isJson) return c.json({
2731
- replayed: true,
2732
- seq,
2733
- offset: result.offset,
2734
- preview
2735
- });
2736
- return c.text(`replayed seq=${seq ?? "?"} → offset=${result.offset}${preview ? ` "${preview}"` : ""}`);
2276
+ const wantsFix = query.fix === "true" || query.fix === "";
2277
+ const wantsAggressive = query.aggressive === "true" || query.aggressive === "";
2278
+ const mode = wantsFix ? wantsAggressive ? "aggressive" : "safe" : "off";
2279
+ const report = await c.env.funnel.doctor.run(mode);
2280
+ return c.text(renderYaml(report));
2737
2281
  });
2738
2282
  //#endregion
2739
2283
  //#region lib/cli/routes/gateway.ts
2740
- const groupHelp$1 = `funnel gateway manage the funnel daemon
2284
+ const groupHelp$1 = `funnel gateway / manage the funnel daemon
2741
2285
 
2742
2286
  The gateway daemon hosts the WebSocket /ws (used by Claude MCP) and the
2743
2287
  listener supervisor that runs every connector. One daemon, one port (9743
2744
2288
  for the CLI, 9742 for programmatic use), one PID file.
2745
2289
 
2746
- usage: funnel gateway [subcommand]
2290
+ usage / funnel gateway [subcommand]
2747
2291
 
2748
2292
  subcommands:
2749
- status show running status (default)
2750
- start start in background
2751
- stop stop
2752
- restart stop then start
2753
- run start in foreground (for developers)
2754
- logs [-n <N>] tail the daemon diagnostic log (lifecycle, listener boot)
2755
- sql query inbound connector traffic (raw + processed verdict)
2756
- listeners list running connector listeners (alive / dead)
2293
+ status / show running status (default)
2294
+ start / start in background
2295
+ stop / stop
2296
+ restart / stop then start
2297
+ run / start in foreground (for developers)
2298
+ logs [-n <N>] / tail the daemon diagnostic log
2299
+ sql / query inbound connector traffic
2300
+ listeners / list running connector listeners
2757
2301
 
2758
- examples:
2759
- funnel gateway check status
2760
- funnel gateway restart restart after config changes
2761
- funnel gateway logs stream the daemon log
2762
- funnel gateway sql --preset recent inspect last 20 inbound events
2302
+ output / valid YAML
2303
+
2304
+ see also / fnl doctor --fix (one command does diagnose + restart) / fnl debug --channel <name> (per-channel inspection)
2763
2305
 
2764
- see also: fnl debug --channel <name> (higher-level diagnosis with next-action hints)`;
2306
+ programmable / funnel.gateway.start() / .stop() / .restart() / .getStatus() / funnel.doctor.run("safe")
2307
+
2308
+ examples:
2309
+ funnel gateway
2310
+ funnel gateway restart
2311
+ funnel gateway logs
2312
+ funnel gateway sql --preset recent`;
2765
2313
  const renderGatewayStatus = async (c) => {
2766
2314
  const status = c.env.funnel.gateway.getStatus();
2767
- if (!status.running) throw new HTTPException(503, { message: "funnel gateway: not running" });
2315
+ if (!status.running) return c.text(renderYaml({ running: false }), 503);
2768
2316
  const res = await fetch(`http://127.0.0.1:${status.port}/status`).catch(() => null);
2769
- if (!res) return c.text(`funnel gateway: running (pid ${status.pid}) — health check failed`);
2317
+ if (!res) return c.text(renderYaml({
2318
+ running: true,
2319
+ pid: status.pid,
2320
+ port: status.port,
2321
+ error: "health check failed"
2322
+ }));
2770
2323
  const data = await res.json();
2771
- const lines = [];
2772
- lines.push(`funnel gateway: running (pid ${data.pid})`);
2773
- lines.push(` port: ${status.port}`);
2774
- const uptimeSec = Math.floor(data.uptimeMs / 1e3);
2775
- const uptimeMin = Math.floor(uptimeSec / 60);
2776
- const uptimeStr = uptimeMin >= 60 ? `${Math.floor(uptimeMin / 60)}h ${uptimeMin % 60}m` : uptimeSec >= 60 ? `${uptimeMin}m ${uptimeSec % 60}s` : `${uptimeSec}s`;
2777
- lines.push(` uptime: ${uptimeStr}`);
2778
- lines.push(` events: ${data.broadcaster.eventsBroadcast} broadcast`);
2779
- if (data.listeners.length === 0) lines.push(` listeners: none`);
2780
- else {
2781
- lines.push(` listeners:`);
2782
- for (const l of data.listeners) {
2783
- const indicator = l.alive ? "●" : "○";
2784
- const eventsStr = l.events > 0 ? ` (${l.events} events)` : "";
2785
- const errStr = l.errors > 0 ? ` ⚠ ${l.errors} errors` : "";
2786
- lines.push(` ${indicator} ${l.channelName}/${l.name} [${l.type}]${eventsStr}${errStr}`);
2787
- }
2788
- }
2789
- if (data.clients.length === 0) lines.push(` clients: none`);
2790
- else {
2791
- lines.push(` clients: ${data.clients.length}`);
2792
- for (const cl of data.clients) {
2793
- const connectors = cl.connectors.length > 0 ? ` → ${cl.connectors.join(", ")}` : "";
2794
- lines.push(` · ${cl.channel}${connectors}`);
2795
- }
2796
- }
2797
- return c.text(lines.join("\n"));
2324
+ return c.text(renderYaml({
2325
+ running: true,
2326
+ pid: data.pid,
2327
+ port: status.port,
2328
+ uptimeMs: data.uptimeMs,
2329
+ events: data.broadcaster.eventsBroadcast,
2330
+ listeners: data.listeners.map((l) => ({
2331
+ channel: l.channelName,
2332
+ name: l.name,
2333
+ type: l.type,
2334
+ alive: l.alive,
2335
+ events: l.events,
2336
+ errors: l.errors,
2337
+ lastEventAt: l.lastEventAt
2338
+ })),
2339
+ clients: data.clients.map((cl) => ({
2340
+ channel: cl.channel,
2341
+ connectors: cl.connectors
2342
+ }))
2343
+ }));
2798
2344
  };
2799
2345
  const gatewayGroupHandler = factory.createHandlers(zValidator$1("query", z.object({}), groupHelp$1), renderGatewayStatus);
2800
- const gatewayListenersHandler = factory.createHandlers(zValidator$1("query", z.object({}), `funnel gateway listeners show running connector listeners
2346
+ const gatewayListenersHandler = factory.createHandlers(zValidator$1("query", z.object({}), `funnel gateway listeners / show running connector listeners
2801
2347
 
2802
- usage: funnel gateway listeners
2348
+ usage / funnel gateway listeners
2803
2349
 
2804
- Reads /listeners from the running gateway daemon and prints the live registry.
2350
+ output / valid YAML
2805
2351
 
2806
- examples:
2807
- funnel gateway listeners`), async (c) => {
2352
+ programmable / funnel.listeners.list() / funnel.recovery.restartAllDeadListeners()`), async (c) => {
2808
2353
  const result = await c.env.funnel.listeners.list();
2809
2354
  if (result.state === "offline") throw new HTTPException(503, { message: "funnel gateway: not running" });
2810
2355
  if (result.state === "error") throw new HTTPException(503, { message: `funnel gateway: ${result.reason}` });
2811
- if (result.listeners.length === 0) return c.text("funnel gateway: no running listeners");
2812
- const lines = result.listeners.map((entry) => {
2813
- return ` [${(entry.alive ? "alive" : "dead").padEnd(5)}] ${entry.type.padEnd(8)} ${entry.name}`;
2814
- });
2815
- return c.text(`funnel gateway: running listeners\n${lines.join("\n")}`);
2356
+ return c.text(renderYaml({ listeners: result.listeners }));
2816
2357
  });
2817
2358
  //#endregion
2818
2359
  //#region lib/cli/routes/gateway.logs.ts
@@ -2840,7 +2381,10 @@ examples:
2840
2381
  funnel gateway logs -n 100
2841
2382
  funnel gateway logs --format json | jq 'select(.level == "error")'
2842
2383
 
2843
- see also: fnl debug, fnl gateway sql`;
2384
+ see also: fnl debug, fnl gateway sql
2385
+
2386
+ programmable: this command tails a file directly. For structured introspection,
2387
+ prefer funnel.diagnostics.diagnoseAll() / .recentEvents() in code.`;
2844
2388
  const logger = new NodeFunnelLogger();
2845
2389
  const tryParseJson = (line) => {
2846
2390
  try {
@@ -2924,6 +2468,13 @@ const PRESETS = {
2924
2468
  summary: "SELECT outcome, COUNT(*) AS count FROM processed GROUP BY outcome ORDER BY count DESC",
2925
2469
  "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"
2926
2470
  };
2471
+ const PRESETS_BY_CHANNEL = {
2472
+ recent: "SELECT seq, ts, type, outcome FROM processed WHERE channel_id = ? ORDER BY seq DESC LIMIT 20",
2473
+ skipped: "SELECT seq, ts, type, outcome, payload FROM processed WHERE channel_id = ? AND outcome LIKE 'skip:%' ORDER BY seq DESC LIMIT 20",
2474
+ errors: "SELECT ts, status, detail FROM connection WHERE channel_id = ? AND status IN ('auth-failed','error') ORDER BY seq DESC LIMIT 20",
2475
+ summary: "SELECT outcome, COUNT(*) AS count FROM processed WHERE channel_id = ? GROUP BY outcome ORDER BY count DESC",
2476
+ "trace-dedup": "SELECT r.seq, r.ts, r.event_id, r.payload FROM raw r JOIN processed p USING(event_id) WHERE r.channel_id = ? AND p.outcome='skip:dedup' ORDER BY r.seq DESC LIMIT 20"
2477
+ };
2927
2478
  const sqlHelp = `funnel gateway sql — query inbound connector traffic with SQL
2928
2479
 
2929
2480
  usage: funnel gateway sql --preset <name> [--channel <name|id>] [--limit <N>]
@@ -2963,7 +2514,12 @@ examples:
2963
2514
 
2964
2515
  tip: for a higher-level view without writing SQL, use: fnl debug --channel <name> --json
2965
2516
 
2966
- see also: fnl debug, fnl gateway logs`;
2517
+ see also: fnl debug, fnl gateway logs
2518
+
2519
+ programmable: const reader = new ConnectorDiagnosticSqlReader({ rawPath, processedPath, connectionPath })
2520
+ reader.query("SELECT …")
2521
+ — but for most cases, funnel.diagnostics.recentEvents() / .droppedEvents()
2522
+ / .connectionErrors() are higher level and don't need SQL.`;
2967
2523
  const gatewaySqlHandler = factory.createHandlers(zValidator$1("query", z.object({
2968
2524
  query: z.string().optional(),
2969
2525
  preset: z.enum(Object.keys(PRESETS)).optional(),
@@ -2977,17 +2533,15 @@ const gatewaySqlHandler = factory.createHandlers(zValidator$1("query", z.object(
2977
2533
  let resolvedChannelId = null;
2978
2534
  if (query.channel) resolvedChannelId = funnel.channels.list().find((ch) => ch.id === query.channel || ch.name === query.channel)?.id ?? query.channel;
2979
2535
  if (query.preset) {
2980
- const base = PRESETS[query.preset] ?? null;
2536
+ const base = resolvedChannelId ? PRESETS_BY_CHANNEL[query.preset] ?? null : PRESETS[query.preset] ?? null;
2981
2537
  if (!base) return c.text(sqlHelp);
2982
2538
  let applied = base;
2983
2539
  if (query.limit) {
2984
2540
  const n = Math.max(1, Number(query.limit));
2985
2541
  applied = applied.replace(/LIMIT \d+/, `LIMIT ${n}`);
2986
2542
  }
2987
- if (resolvedChannelId) {
2988
- sql = applied.replace(/FROM (raw|processed|connection)\b/, "FROM $1 WHERE channel_id = ?");
2989
- params = [resolvedChannelId];
2990
- } else sql = applied;
2543
+ sql = applied;
2544
+ params = resolvedChannelId ? [resolvedChannelId] : [];
2991
2545
  } else if (query.query) sql = query.query;
2992
2546
  if (!sql) return c.text(sqlHelp);
2993
2547
  const tmpDir = funnelTmpDir();
@@ -3007,8 +2561,8 @@ const gatewaySqlHandler = factory.createHandlers(zValidator$1("query", z.object(
3007
2561
  reader.close();
3008
2562
  }
3009
2563
  })();
3010
- if (rows instanceof Error) return c.text(`error: ${rows.message}`);
3011
- return c.text(JSON.stringify(rows, null, 2));
2564
+ if (rows instanceof Error) return c.text(renderYaml({ error: rows.message }));
2565
+ return c.text(renderYaml({ rows }));
3012
2566
  });
3013
2567
  const gatewayRestartHandler = factory.createHandlers(zValidator$1("query", z.object({ "no-caffeine": z.string().optional() }), `funnel gateway restart — restart the gateway
3014
2568
 
@@ -3019,7 +2573,10 @@ On macOS wraps with caffeinate -is by default. Use --no-caffeine to disable.
3019
2573
 
3020
2574
  examples:
3021
2575
  funnel gateway restart
3022
- funnel gateway restart --no-caffeine`), async (c) => {
2576
+ funnel gateway restart --no-caffeine
2577
+
2578
+ programmable: funnel.gateway.restart({ caffeinate })
2579
+ funnel.recovery.restartGateway()`), async (c) => {
3023
2580
  const query = c.req.valid("query");
3024
2581
  const result = await c.env.funnel.gateway.restart({ caffeinate: query["no-caffeine"] !== "true" });
3025
2582
  const lines = [];
@@ -3040,7 +2597,9 @@ For normal usage prefer funnel gateway start.
3040
2597
 
3041
2598
  examples:
3042
2599
  funnel gateway run
3043
- funnel gateway run --no-caffeine`), async (c) => {
2600
+ funnel gateway run --no-caffeine
2601
+
2602
+ programmable: funnel.runGatewayForeground({ caffeinate })`), async (c) => {
3044
2603
  const query = c.req.valid("query");
3045
2604
  const exitCode = await c.env.funnel.runGatewayForeground({ caffeinate: query["no-caffeine"] !== "true" });
3046
2605
  process.exit(exitCode);
@@ -3061,7 +2620,9 @@ log: ${join(funnelTmpDir(), "gateway.log")}
3061
2620
 
3062
2621
  examples:
3063
2622
  funnel gateway start
3064
- funnel gateway start --no-caffeine`;
2623
+ funnel gateway start --no-caffeine
2624
+
2625
+ programmable: funnel.gateway.start({ caffeinate })`;
3065
2626
  const gatewayStartHandler = factory.createHandlers(zValidator$1("query", z.object({ "no-caffeine": z.string().optional() }), startHelp), async (c) => {
3066
2627
  const query = c.req.valid("query");
3067
2628
  const funnel = c.env.funnel;
@@ -3072,41 +2633,13 @@ const gatewayStartHandler = factory.createHandlers(zValidator$1("query", z.objec
3072
2633
  if (!await funnel.gateway.start({ caffeinate: query["no-caffeine"] !== "true" })) throw new HTTPException(500, { message: "funnel gateway: failed to start" });
3073
2634
  return c.text("funnel gateway: started");
3074
2635
  });
3075
- const gatewayStatusHandler = factory.createHandlers(zValidator$1("query", z.object({ json: z.enum([
3076
- "true",
3077
- "false",
3078
- ""
3079
- ]).optional() }), `funnel gateway status — show gateway running status
2636
+ const gatewayStatusHandler = factory.createHandlers(zValidator$1("query", z.object({}), `funnel gateway status / show gateway running status
3080
2637
 
3081
- usage: funnel gateway status [--json]
3082
-
3083
- options:
3084
- --json output as JSON
2638
+ usage / funnel gateway status
3085
2639
 
3086
- When running, prints PID, port, uptime, listeners (alive/dead), and WS clients.
3087
- When not running, exits with 503.
2640
+ output / valid YAML
3088
2641
 
3089
- examples:
3090
- funnel gateway status
3091
- funnel gateway status --json`), async (c) => {
3092
- const query = c.req.valid("query");
3093
- if (!(query.json === "true" || query.json === "")) return renderGatewayStatus(c);
3094
- const status = c.env.funnel.gateway.getStatus();
3095
- if (!status.running) throw new HTTPException(503, { message: "funnel gateway: not running" });
3096
- const res = await fetch(`http://127.0.0.1:${status.port}/status`).catch(() => null);
3097
- if (!res) return c.json({
3098
- running: true,
3099
- pid: status.pid,
3100
- port: status.port,
3101
- error: "health check failed"
3102
- });
3103
- const data = await res.json();
3104
- return c.json({
3105
- running: true,
3106
- port: status.port,
3107
- ...data
3108
- });
3109
- });
2642
+ programmable / funnel.gateway.getStatus()`), async (c) => renderGatewayStatus(c));
3110
2643
  const gatewayStopHandler = factory.createHandlers(zValidator$1("query", z.object({}), `funnel gateway stop — stop the gateway
3111
2644
 
3112
2645
  usage: funnel gateway stop
@@ -3322,39 +2855,42 @@ const profilesSetHandler = factory.createHandlers(zValidator$1("param", z.object
3322
2855
  });
3323
2856
  return c.text(`updated profile "${param.profile}"`);
3324
2857
  });
3325
- const profilesGroupHandler = factory.createHandlers(zValidator$1("query", z.object({}), `funnel profiles manage launch profiles
2858
+ const profilesGroupHandler = factory.createHandlers(zValidator$1("query", z.object({}), `funnel profiles / manage launch profiles
3326
2859
 
3327
- usage: funnel profiles [subcommand]
2860
+ usage / funnel profiles [subcommand]
3328
2861
 
3329
2862
  subcommands:
3330
- (none) list (first entry is the default)
2863
+ (none) / list (first entry is the default)
3331
2864
  add <name> --path <path> --channel <channel> [--agent ...] [--options ...] [--env ...] [--no-resume]
3332
2865
  <name> set [--path ...] [--channel ...] [--agent ...] [--options ...] [--env ...] [--resume|--no-resume]
3333
- <name> as-default move profile to the front (becomes default)
3334
- rename <old> <new> rename
3335
- remove <name> remove
3336
- <name> run launch (sugar for fnl claude -p <name>)
3337
- <name> launch (alias for run)
2866
+ <name> as-default / move profile to the front
2867
+ rename <old> <new> / rename
2868
+ remove <name> / remove
2869
+ <name> run / launch (sugar for fnl claude -p <name>)
2870
+ <name> / launch (alias for run)
3338
2871
 
3339
- A profile carries the launch recipe — \`--agent\` / \`--options\` prepended to
3340
- the claude argv, \`--env\` layered under the process, \`--resume\` toggling
3341
- session reuse. The channel it points at only declares transport (connectors).
2872
+ A profile carries the launch recipe — --agent / --options prepended to the
2873
+ claude argv, --env layered under the process, --resume toggling session
2874
+ reuse. The channel it points at only declares transport (connectors).
2875
+
2876
+ output / valid YAML
2877
+
2878
+ programmable / funnel.profiles.list() / .add() / .remove() / .rename() / .setDefault()
3342
2879
 
3343
2880
  examples:
3344
2881
  funnel profiles add cto --path /repo/myapp --channel prod-inbox --agent pm --options "--brief"
3345
2882
  funnel profiles cto as-default
3346
2883
  funnel profiles cto run`), (c) => {
3347
- c.env.funnel;
3348
2884
  const { profiles } = c.env;
3349
2885
  const profileList = profiles.list();
3350
- if (profileList.length === 0) return c.text("no profiles");
3351
- const lines = profileList.map((profile, index) => {
3352
- const tag = index === 0 ? " (default)" : "";
3353
- const recipe = profile.options.length > 0 ? `, options=${profile.options.join(" ")}` : "";
3354
- const session = profile.resume ? "" : ", resume=false";
3355
- return `${profile.name}${tag} [path=${profile.path}, channel=${profile.channelId}${recipe}${session}]`;
3356
- });
3357
- return c.text(lines.join("\n"));
2886
+ return c.text(renderYaml({ profiles: profileList.map((profile, index) => ({
2887
+ name: profile.name,
2888
+ default: index === 0,
2889
+ path: profile.path,
2890
+ channelId: profile.channelId,
2891
+ options: profile.options,
2892
+ resume: profile.resume
2893
+ })) }));
3358
2894
  });
3359
2895
  const schemaHandler = factory.createHandlers(zValidator$1("query", z.object({}), `funnel schema — print the JSON Schema for funnel.json
3360
2896
 
@@ -3370,60 +2906,51 @@ can validate and autocomplete the config:
3370
2906
  {
3371
2907
  "$schema": "./funnel.schema.json",
3372
2908
  "channel": "ops"
3373
- }`), async (c) => {
2909
+ }
2910
+
2911
+ programmable: import { funnelJsonSchema } from "@interactive-inc/claude-funnel/local-config"
2912
+ funnelJsonSchema() // returns the same object as the CLI prints`), async (c) => {
3374
2913
  const schema = funnelJsonSchema();
3375
2914
  return c.text(`${JSON.stringify(schema, null, 2)}\n`);
3376
2915
  });
3377
2916
  //#endregion
3378
2917
  //#region lib/cli/routes/status.ts
3379
- const statusHelp = `funnel status overall health at a glance
2918
+ const statusHelp = `funnel status / overall health snapshot
3380
2919
 
3381
- usage: funnel status [--watch] [--interval <N>]
2920
+ usage / funnel status [--watch] [--interval <N>]
3382
2921
 
3383
2922
  options:
3384
- --watch continuously refresh (Ctrl+C to stop)
3385
- --interval <N> polling interval in seconds (used with --watch, default: 3)
2923
+ --watch / continuously refresh (Ctrl+C to stop)
2924
+ --interval <N> / polling interval in seconds (default 3)
2925
+
2926
+ output / valid YAML
3386
2927
 
3387
- Shows gateway running state (pid, port, uptime), per-channel listener health
3388
- (● alive / ○ dead), and whether Claude is connected to each channel as a
3389
- WebSocket client. Use this as the first step when debugging missing events.
2928
+ For a richer diagnosis with rootCause + nextActions, prefer fnl doctor.
2929
+
2930
+ programmable / funnel.gateway.getStatus() / funnel.doctor.run()
3390
2931
 
3391
2932
  examples:
3392
2933
  funnel status
3393
2934
  funnel status --watch
3394
- funnel status --watch --interval 5
3395
-
3396
- see also: fnl debug --channel <name> (per-channel diagnosis with next steps)`;
2935
+ funnel status --watch --interval 5`;
3397
2936
  const isGatewayStatus = (value) => {
3398
2937
  if (value === null || typeof value !== "object") return false;
3399
2938
  if (!("clients" in value) || !Array.isArray(value.clients)) return false;
3400
2939
  if (!("listeners" in value) || !Array.isArray(value.listeners)) return false;
3401
2940
  return true;
3402
2941
  };
3403
- const buildStatusLines = async (funnel, profiles) => {
2942
+ const buildStatusReport = async (funnel, profiles) => {
3404
2943
  const channels = funnel.channels.list();
3405
2944
  const profileList = profiles.list();
3406
2945
  const gatewayStatus = funnel.gateway.getStatus();
3407
- const lines = [];
3408
- lines.push("= funnel status =");
3409
- lines.push("");
3410
2946
  let gatewayData = null;
3411
- if (!gatewayStatus.running) lines.push("gateway: not running");
3412
- else {
2947
+ if (gatewayStatus.running) {
3413
2948
  const res = await fetch(`http://127.0.0.1:${gatewayStatus.port}/status`).catch(() => null);
3414
- let uptimeStr = "";
3415
2949
  if (res && res.ok) {
3416
2950
  const body = await res.json();
3417
- if (isGatewayStatus(body)) {
3418
- gatewayData = body;
3419
- const uptimeSec = Math.floor(body.uptimeMs / 1e3);
3420
- const uptimeMin = Math.floor(uptimeSec / 60);
3421
- uptimeStr = uptimeMin >= 60 ? ` · ${Math.floor(uptimeMin / 60)}h ${uptimeMin % 60}m` : uptimeSec >= 60 ? ` · ${uptimeMin}m ${uptimeSec % 60}s` : ` · ${uptimeSec}s`;
3422
- }
2951
+ if (isGatewayStatus(body)) gatewayData = body;
3423
2952
  }
3424
- lines.push(`gateway: running (pid ${gatewayStatus.pid}, port ${gatewayStatus.port})${uptimeStr}`);
3425
2953
  }
3426
- lines.push("");
3427
2954
  const clientsByChannel = /* @__PURE__ */ new Map();
3428
2955
  const listenerAliveByChannel = /* @__PURE__ */ new Map();
3429
2956
  if (gatewayData) {
@@ -3436,26 +2963,33 @@ const buildStatusLines = async (funnel, profiles) => {
3436
2963
  listenerAliveByChannel.set(listener.channelName, current === void 0 ? listener.alive : current && listener.alive);
3437
2964
  }
3438
2965
  }
3439
- const maxNameLen = Math.max(...channels.map((ch) => ch.name.length), 0);
3440
- lines.push(`channels: ${channels.length}`);
3441
- for (const ch of channels) {
3442
- const connectorLabel = ch.connectors.length > 0 ? ch.connectors.map((conn) => conn.type).join(", ") : "no connectors";
3443
- const isAlive = listenerAliveByChannel.get(ch.name);
3444
- const indicator = gatewayData === null ? "-" : isAlive === true ? "●" : isAlive === false ? "○" : "-";
3445
- const claudeCount = clientsByChannel.get(ch.name) ?? 0;
3446
- const claudeLabel = gatewayData === null ? "" : claudeCount === 0 ? " no Claude" : claudeCount === 1 ? " Claude connected (1 client)" : ` Claude connected (${claudeCount} clients)`;
3447
- const paddedName = ch.name.padEnd(maxNameLen);
3448
- lines.push(` ${indicator} ${paddedName} [${connectorLabel}]${claudeLabel}`);
3449
- }
3450
- lines.push("");
3451
- lines.push(`profiles: ${profileList.length}`);
3452
- for (const [index, profile] of profileList.entries()) {
3453
- const tag = index === 0 ? " (default)" : "";
3454
- const channel = funnel.channels.getById(profile.channelId);
3455
- const channelLabel = channel ? channel.name : `id:${profile.channelId}`;
3456
- lines.push(` - ${profile.name}${tag} [path=${profile.path}, channel=${channelLabel}]`);
3457
- }
3458
- return lines;
2966
+ return {
2967
+ gateway: gatewayStatus.running ? {
2968
+ running: true,
2969
+ pid: gatewayStatus.pid,
2970
+ port: gatewayStatus.port,
2971
+ uptimeMs: gatewayData?.uptimeMs ?? null
2972
+ } : { running: false },
2973
+ channels: channels.map((ch) => ({
2974
+ name: ch.name,
2975
+ connectors: ch.connectors.map((conn) => ({
2976
+ name: conn.name,
2977
+ type: conn.type
2978
+ })),
2979
+ listenerAlive: gatewayData === null ? null : listenerAliveByChannel.get(ch.name) ?? null,
2980
+ claudeClients: clientsByChannel.get(ch.name) ?? 0
2981
+ })),
2982
+ profiles: profileList.map((profile, index) => {
2983
+ const channel = funnel.channels.getById(profile.channelId);
2984
+ return {
2985
+ name: profile.name,
2986
+ default: index === 0,
2987
+ path: profile.path,
2988
+ channel: channel ? channel.name : null,
2989
+ channelId: channel ? void 0 : profile.channelId
2990
+ };
2991
+ })
2992
+ };
3459
2993
  };
3460
2994
  const statusHandler = factory.createHandlers(zValidator$1("query", z.object({
3461
2995
  watch: z.enum([
@@ -3470,15 +3004,14 @@ const statusHandler = factory.createHandlers(zValidator$1("query", z.object({
3470
3004
  const isWatch = query.watch === "true" || query.watch === "";
3471
3005
  const intervalSec = Math.min(60, Math.max(1, query.interval ? Number(query.interval) : 3));
3472
3006
  if (!isWatch) {
3473
- const lines = await buildStatusLines(funnel, c.env.profiles);
3474
- return c.text(lines.join("\n"));
3007
+ const report = await buildStatusReport(funnel, c.env.profiles);
3008
+ return c.text(renderYaml(report));
3475
3009
  }
3476
3010
  const render = async () => {
3477
- const lines = await buildStatusLines(funnel, c.env.profiles);
3478
- const ts = (/* @__PURE__ */ new Date()).toISOString().slice(11, 19);
3011
+ const report = await buildStatusReport(funnel, c.env.profiles);
3479
3012
  process.stdout.write("\x1B[2J\x1B[H");
3480
- process.stdout.write(lines.join("\n"));
3481
- process.stdout.write(`\n\n refreshing every ${intervalSec}s · ${ts} · Ctrl+C to stop\n`);
3013
+ process.stdout.write(renderYaml(report));
3014
+ process.stdout.write(`\n# refreshing every ${intervalSec}s; Ctrl+C to stop\n`);
3482
3015
  };
3483
3016
  process.on("SIGINT", () => {
3484
3017
  process.stdout.write("\n");
@@ -3497,7 +3030,10 @@ const updateHelp = `funnel update — update funnel to the latest version
3497
3030
 
3498
3031
  usage: funnel update
3499
3032
 
3500
- Runs "bun i -g @interactive-inc/claude-funnel".`;
3033
+ Runs "bun i -g @interactive-inc/claude-funnel".
3034
+
3035
+ This command has no programmable equivalent — package management belongs to
3036
+ the host (npm / bun / yarn install in the host's own way).`;
3501
3037
  const PACKAGE = "@interactive-inc/claude-funnel";
3502
3038
  const updateHandler = factory.createHandlers(zValidator$1("query", z.object({}), updateHelp), async (c) => {
3503
3039
  const exitCode = await new NodeFunnelProcessRunner().attach([
@@ -3512,8 +3048,8 @@ const updateHandler = factory.createHandlers(zValidator$1("query", z.object({}),
3512
3048
  //#endregion
3513
3049
  //#region lib/cli/routes/index.ts
3514
3050
  const routes = factory.createApp().onError((error, c) => {
3515
- if (error instanceof HTTPException) return c.text(`error: ${error.message}`, error.status);
3051
+ if (error instanceof HTTPException) return c.text(error.message, error.status);
3516
3052
  return c.text(`error: ${error instanceof Error ? error.message : String(error)}`, 400);
3517
- }).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);
3053
+ }).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("/docs", ...docsIndexHandler).get("/docs/:topic", ...docsTopicHandler).get("/doctor", ...doctorHandler).get("/schema", ...schemaHandler).get("/status", ...statusHandler).get("/update", ...updateHandler);
3518
3054
  //#endregion
3519
- export { CONNECTOR_CONNECTION_STATUSES, ConnectorDiagnosticLog, ConnectorDiagnosticSqlReader, DEFAULT_GATEWAY_PORT, DEFAULT_GATEWAY_TOKEN_PATH, FUNNEL_DIR, Funnel, FunnelBroadcaster, FunnelChannelPublisher, FunnelChannels, FunnelClock, FunnelConnectorFactory, FunnelConnectorListener, FunnelEventLog, FunnelFileSystem, FunnelGateway, FunnelGatewayServer, FunnelGatewayToken, FunnelIdGenerator, FunnelListenerSupervisor, FunnelListenersClient, FunnelLogger, FunnelProcessRunner, FunnelSettingsReader, FunnelSettingsStore, FunnelSlackEventProcessor, MemoryConnectorDiagnosticLog, MemoryFunnelClock, MemoryFunnelEventLog, MemoryFunnelFileSystem, MemoryFunnelIdGenerator, MemoryFunnelLogger, MemoryFunnelProcessRunner, MockFunnelSettingsReader, NodeFunnelClock, NodeFunnelFileSystem, NodeFunnelIdGenerator, NodeFunnelLogger, NodeFunnelProcessRunner, NoopFunnelLogger, SETTINGS_PATH, SETTINGS_VERSION, SqliteConnectorDiagnosticLog, SqliteFunnelEventLog, channelConfigSchema, channelDeliveryModeSchema, channelWsProtocols, channelWsUrl, routes as cliRoutes, connectorConfigSchema, connectorConnectionEventSchema, connectorProcessedEventSchema, connectorRawEventSchema, createSettings, discordConnectorSchema, factory, funnelEventSchema, gatewayLoopbackUrl, ghConnectorSchema, profileConfigSchema, publishRequestSchema, publishResponseSchema, queryToCliArgs, resolveFunnelDir, resolveFunnelPort, scheduleCatchupPolicySchema, scheduleConnectorSchema, scheduleEntrySchema, settingsSchema, slackConnectorSchema, toRequest };
3055
+ export { CONNECTOR_CONNECTION_STATUSES, ConnectorDiagnosticLog, ConnectorDiagnosticSqlReader, DEFAULT_GATEWAY_PORT, DEFAULT_GATEWAY_TOKEN_PATH, FUNNEL_DIR, Funnel, FunnelBroadcaster, FunnelChannelPublisher, FunnelChannels, FunnelClock, FunnelConnectorFactory, FunnelConnectorListener, FunnelDiagnostics, FunnelDocs, FunnelDoctor, FunnelEventLog, FunnelFileSystem, FunnelGateway, FunnelGatewayServer, FunnelGatewayToken, FunnelIdGenerator, FunnelListenerSupervisor, FunnelListenersClient, FunnelLogger, FunnelProcessRunner, FunnelRecovery, FunnelSettingsReader, FunnelSettingsStore, FunnelSlackEventProcessor, MemoryConnectorDiagnosticLog, MemoryFunnelClock, MemoryFunnelEventLog, MemoryFunnelFileSystem, MemoryFunnelIdGenerator, MemoryFunnelLogger, MemoryFunnelProcessRunner, MockFunnelSettingsReader, NodeFunnelClock, NodeFunnelFileSystem, NodeFunnelIdGenerator, NodeFunnelLogger, NodeFunnelProcessRunner, NoopFunnelLogger, SETTINGS_PATH, SETTINGS_VERSION, SqliteConnectorDiagnosticLog, SqliteFunnelEventLog, buildServiceRoutes, channelConfigSchema, channelDeliveryModeSchema, channelWsProtocols, channelWsUrl, routes as cliRoutes, connectorConfigSchema, connectorConnectionEventSchema, connectorProcessedEventSchema, connectorRawEventSchema, createSettings, discordConnectorSchema, factory, funnelEventSchema, gatewayLoopbackUrl, ghConnectorSchema, previewOf, profileConfigSchema, publishRequestSchema, publishResponseSchema, queryRows, queryToCliArgs, resolveFunnelDir, resolveFunnelPort, scheduleCatchupPolicySchema, scheduleConnectorSchema, scheduleEntrySchema, settingsSchema, slackConnectorSchema, toDiagnosticConnectionError, toDiagnosticEvent, toRequest };