@interactive-inc/claude-funnel 0.41.0 → 0.50.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 (55) hide show
  1. package/README.md +34 -9
  2. package/dist/bin.js +255 -256
  3. package/dist/claude-CB1WkV77.d.ts +115 -0
  4. package/dist/claude.d.ts +59 -0
  5. package/dist/claude.js +322 -0
  6. package/dist/{connector-diagnostic-log-OPpPi9V9.d.ts → connector-diagnostic-log-yTOojKUR.d.ts} +14 -14
  7. package/dist/{logger-Czli2OKh.js → connector-listener-DU54DN-f.js} +1 -9
  8. package/dist/connectors/discord.d.ts +3 -3
  9. package/dist/connectors/discord.js +2 -1
  10. package/dist/connectors/gh.d.ts +4 -3
  11. package/dist/connectors/gh.js +2 -1
  12. package/dist/connectors/schedule.d.ts +1 -1
  13. package/dist/connectors/schedule.js +2 -1
  14. package/dist/connectors/slack.d.ts +2 -2
  15. package/dist/connectors/slack.js +2 -1
  16. package/dist/discord-connector-schema-CBDyGdOI.js +21 -0
  17. package/dist/{discord-connector-schema-BeThExJp.js → discord-listener-_jSE3HsQ.js} +2 -22
  18. package/dist/file-system-BeOKXjlV.d.ts +26 -0
  19. package/dist/file-system-PWKKU7lA.js +9 -0
  20. package/dist/gateway/daemon.js +151 -152
  21. package/dist/gateway.d.ts +3 -0
  22. package/dist/gateway.js +2 -0
  23. package/dist/gh-connector-schema-eoTtHbY6.d.ts +14 -0
  24. package/dist/{gh-connector-schema-eYE4g77K.js → gh-connector-schema-o3Q1-ojL.js} +1 -176
  25. package/dist/gh-listener-DH-fClQm.js +178 -0
  26. package/dist/index-ChomoTZ5.d.ts +3404 -0
  27. package/dist/index.d.ts +11 -4214
  28. package/dist/index.js +195 -3869
  29. package/dist/local-config-json-schema-8IHjS4Q7.js +439 -0
  30. package/dist/local-config-sync-BdsrDZOu.d.ts +381 -0
  31. package/dist/local-config.d.ts +3 -0
  32. package/dist/local-config.js +3 -0
  33. package/dist/logger-BP6SisKt.js +9 -0
  34. package/dist/mcp-Dr-nIBwN.js +253 -0
  35. package/dist/memory-connector-diagnostic-log-CrW1ltLM.js +2245 -0
  36. package/dist/memory-token-prompter-B5FFCsGP.d.ts +57 -0
  37. package/dist/memory-token-prompter-CLerGsgM.js +61 -0
  38. package/dist/node-file-system-BcrmWN9I.js +48 -0
  39. package/dist/{gh-connector-schema-CQmEWzdV.d.ts → process-runner-DfniuWVU.d.ts} +1 -14
  40. package/dist/profiles-f0mNmEyP.d.ts +64 -0
  41. package/dist/profiles-wMRnjSid.js +129 -0
  42. package/dist/profiles.d.ts +2 -0
  43. package/dist/profiles.js +2 -0
  44. package/dist/schedule-connector-schema-iCI61gzU.js +31 -0
  45. package/dist/{schedule-listener-3M6WkH1Y.d.ts → schedule-listener-CUyUFFR1.d.ts} +22 -46
  46. package/dist/{schedule-connector-schema-CM-sRkac.js → schedule-listener-ePAjians.js} +3 -86
  47. package/dist/settings-reader-BSU6JyvM.d.ts +167 -0
  48. package/dist/settings-reader-DPqrpV7s.js +11 -0
  49. package/dist/settings-store-D2XSXTyt.js +186 -0
  50. package/dist/slack-connector-schema-BCNWluHM.js +32 -0
  51. package/dist/{slack-listener-9UdAn_ui.d.ts → slack-listener-Bv5xI9gC.d.ts} +31 -31
  52. package/dist/{slack-connector-schema-DDbSGPZn.js → slack-listener-ClQuHhEF.js} +2 -32
  53. package/package.json +16 -1
  54. /package/dist/{connector-adapter-VA6undzc.d.ts → connector-adapter-DKgsVuMH.d.ts} +0 -0
  55. /package/dist/{discord-connector-schema-DF4pL3Sc.d.ts → discord-connector-schema-R0Uu-3ns.d.ts} +0 -0
package/dist/index.js CHANGED
@@ -1,212 +1,32 @@
1
- import { i as FunnelDiscordAdapter, n as FunnelDiscordListener, t as discordConnectorSchema } from "./discord-connector-schema-BeThExJp.js";
2
- import { n as FunnelConnectorListener, t as FunnelLogger } from "./logger-Czli2OKh.js";
3
- import { a as FunnelProcessRunner, i as NodeFunnelProcessRunner, n as FunnelGhListener, r as FunnelGhAdapter, t as ghConnectorSchema } from "./gh-connector-schema-eYE4g77K.js";
4
- import { a as ScheduleStateStore, i as FunnelScheduleListener, n as scheduleConnectorSchema, o as NodeFunnelFileSystem, r as scheduleEntrySchema, s as FunnelFileSystem, t as scheduleCatchupPolicySchema } from "./schedule-connector-schema-CM-sRkac.js";
5
- import { i as FunnelSlackAdapter, n as FunnelSlackListener, r as FunnelSlackEventProcessor, t as slackConnectorSchema } from "./slack-connector-schema-DDbSGPZn.js";
1
+ import { r as FunnelDiscordAdapter, t as FunnelDiscordListener } from "./discord-listener-_jSE3HsQ.js";
2
+ import { t as FunnelConnectorListener } from "./connector-listener-DU54DN-f.js";
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";
7
+ import { t as FunnelFileSystem } from "./file-system-PWKKU7lA.js";
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";
10
+ import { n as FunnelIdGenerator, t as FunnelSettingsReader } from "./settings-reader-DPqrpV7s.js";
11
+ import { a as resolveFunnelDir, c as channelConfigSchema, d as settingsSchema, f as connectorConfigSchema, i as SETTINGS_PATH, l as channelDeliveryModeSchema, n as FUNNEL_DIR, o as resolveFunnelPort, p as NodeFunnelIdGenerator, r as FunnelSettingsStore, s as SETTINGS_VERSION, t as DEFAULT_GATEWAY_PORT, u as profileConfigSchema } from "./settings-store-D2XSXTyt.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-Dr-nIBwN.js";
16
+ import { a as FunnelLocalConfig, n as NodeFunnelTokenPrompter, r as FunnelLocalConfigSync, t as funnelJsonSchema } from "./local-config-json-schema-8IHjS4Q7.js";
17
+ import { t as FunnelProfiles } from "./profiles-wMRnjSid.js";
18
+ import { _ as FunnelBroadcaster, a as connectorConnectionEventSchema, b as publishResponseSchema, c as MemoryFunnelEventLog, d as FunnelGatewayServer, f as ConnectorDiagnosticSqlReader, g as funnelEventSchema, h as FunnelEventLog, i as ConnectorDiagnosticLog, l as DEFAULT_GATEWAY_TOKEN_PATH, m as SqliteFunnelEventLog, n as SqliteConnectorDiagnosticLog, o as connectorProcessedEventSchema, p as FunnelListenerSupervisor, r as CONNECTOR_CONNECTION_STATUSES, s as connectorRawEventSchema, t as MemoryConnectorDiagnosticLog, u as FunnelGatewayToken, v as FunnelChannelPublisher, x as funnelTmpDir, y as publishRequestSchema } from "./memory-connector-diagnostic-log-CrW1ltLM.js";
6
19
  import { dirname, join, resolve } from "node:path";
7
20
  import { hc } from "hono/client";
8
- import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync } from "node:fs";
21
+ import { appendFileSync, existsSync, mkdirSync } from "node:fs";
9
22
  import { z } from "zod";
10
- import { homedir, tmpdir } from "node:os";
11
- import { stderr, stdin } from "node:process";
12
23
  import { fileURLToPath } from "node:url";
13
- import { timingSafeEqual } from "node:crypto";
14
24
  import { createFactory } from "hono/factory";
15
- import { Database } from "bun:sqlite";
16
25
  import { HTTPException } from "hono/http-exception";
17
26
  import { zValidator } from "@hono/zod-validator";
18
- import { Server } from "@modelcontextprotocol/sdk/server/index.js";
19
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
20
- import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
21
- //#region lib/engine/id/id-generator.ts
22
- /**
23
- * ID generator boundary. Default NodeFunnelIdGenerator wraps `crypto.randomUUID()`;
24
- * MemoryFunnelIdGenerator emits `<prefix>-1, <prefix>-2, ...` for deterministic tests.
25
- */
26
- var FunnelIdGenerator = class {};
27
- //#endregion
28
- //#region lib/engine/id/node-id-generator.ts
29
- var NodeFunnelIdGenerator = class extends FunnelIdGenerator {
30
- generate() {
31
- return crypto.randomUUID();
32
- }
33
- };
34
- //#endregion
35
- //#region lib/engine/settings/settings-reader.ts
36
- var FunnelSettingsReader = class {};
37
- //#endregion
38
- //#region lib/connectors/connector-config-schema.ts
39
- const connectorConfigSchema = z.discriminatedUnion("type", [
40
- slackConnectorSchema,
41
- ghConnectorSchema,
42
- discordConnectorSchema,
43
- scheduleConnectorSchema
44
- ]);
45
- //#endregion
46
- //#region lib/engine/settings/settings-schema.ts
47
- /**
48
- * Routing mode when multiple WS clients are subscribed to the same channel.
49
- *
50
- * - `fanout` (default): every connected client receives every event. Right when each
51
- * subscriber has its own job (e.g., TUI mirrors, distinct Claude profiles each running
52
- * their own pipeline against the same source).
53
- * - `exclusive`: each event is delivered to exactly one connected client, picked
54
- * round-robin per channel. Right when subscribers are interchangeable workers and you
55
- * want each event handled once. Tap=all clients (TUI dashboard) always receive,
56
- * regardless of mode, so they can passively observe.
57
- */
58
- const channelDeliveryModeSchema = z.enum(["fanout", "exclusive"]);
59
- const channelConfigSchema = z.object({
60
- id: z.string(),
61
- name: z.string(),
62
- delivery: channelDeliveryModeSchema.default("fanout"),
63
- connectors: z.array(connectorConfigSchema).default([])
64
- });
65
- const profileConfigSchema = z.object({
66
- /** Stable identity (uuid). The primary key everything internal resolves to;
67
- * survives renames. CLI surfaces still address profiles by `name`. */
68
- id: z.string(),
69
- /** Human-facing label used only at the CLI/TUI surface (`--profile <name>`).
70
- * Renameable; never used as a storage key. */
71
- name: z.string(),
72
- path: z.string(),
73
- channelId: z.string(),
74
- /** Args prepended to the claude argv on every launch through this profile. */
75
- options: z.array(z.string()).default([]),
76
- /** Env vars layered under the launched claude process. process.env wins on collision. */
77
- env: z.record(z.string(), z.string()).default({}),
78
- /**
79
- * When true (the default), funnel resumes this profile's previous claude
80
- * session via `--session-id`/`--resume`. The id lives in `sessionId` below,
81
- * scoped to this profile so an unrelated session in the same repo can't bleed
82
- * in. Set to false for profiles that should always start fresh.
83
- */
84
- resume: z.boolean().default(true),
85
- /**
86
- * Execution state, not config: the claude session id this profile last
87
- * launched. Written by the launcher, read on the next resume. Absent until
88
- * the first launch; kept inside the profile (rather than a separate file) so
89
- * the session belongs to the profile by identity and the transport layer
90
- * (channels) never has to know profiles exist.
91
- */
92
- sessionId: z.string().optional()
93
- });
94
- const SETTINGS_VERSION = 1;
95
- const settingsSchema = z.object({
96
- /** Schema version. New files always write the current version; older files without one are read as v1. */
97
- version: z.literal(1).default(1),
98
- channels: z.array(channelConfigSchema).default([]),
99
- profiles: z.array(profileConfigSchema).default([])
100
- });
101
- //#endregion
102
- //#region lib/engine/settings/settings-store.ts
103
- /**
104
- * Resolves the funnel home dir. Defaults to `~/.funnel`, overridable via
105
- * `FUNNEL_DIR` so a funnel.json-scoped launch can point everything (settings,
106
- * gateway pid/token, claude pids) at a repo-local `<repo>/.funnel` and never
107
- * touch the global home. Read at call time, not module load, so a daemon
108
- * spawned with the env set resolves the override.
109
- */
110
- function resolveFunnelDir() {
111
- const override = process.env.FUNNEL_DIR;
112
- if (override && override.length > 0) return override;
113
- return join(homedir(), ".funnel");
114
- }
115
- const DEFAULT_GATEWAY_PORT = 9742;
116
- /**
117
- * Resolves the gateway port. Defaults to 9742 — the port a programmatically
118
- * hosted gateway (`new Funnel().gatewayServer()`) uses. The `funnel` CLI entry
119
- * sets `FUNNEL_PORT` to a distinct default so a CLI launch never collides with
120
- * an embedding app's gateway on 9742. Read at call time so a daemon spawned
121
- * with the env set resolves the override.
122
- */
123
- function resolveFunnelPort() {
124
- return Number(process.env.FUNNEL_PORT) || 9742;
125
- }
126
- const FUNNEL_DIR = join(homedir(), ".funnel");
127
- const SETTINGS_PATH = join(FUNNEL_DIR, "settings.json");
128
- const defaultFs$5 = new NodeFunnelFileSystem();
129
- const defaultIdGenerator$2 = new NodeFunnelIdGenerator();
130
- var FunnelSettingsStore = class extends FunnelSettingsReader {
131
- path;
132
- fs;
133
- idGenerator;
134
- constructor(deps = {}) {
135
- super();
136
- this.path = deps.path ?? SETTINGS_PATH;
137
- this.fs = deps.fs ?? defaultFs$5;
138
- this.idGenerator = deps.idGenerator ?? defaultIdGenerator$2;
139
- Object.freeze(this);
140
- }
141
- read() {
142
- if (!this.fs.existsSync(this.path)) return {
143
- version: 1,
144
- channels: [],
145
- profiles: []
146
- };
147
- const content = this.fs.readFileSync(this.path);
148
- const parsed = JSON.parse(content);
149
- if (this.looksLikeLegacy(parsed)) throw new Error(`legacy settings.json detected at ${this.path}. The schema changed (channel.connectors are now nested objects with ids; profile fields renamed). Migration is intentionally not provided. Back up and remove the old file:\n mv ${this.path} ${this.path}.bak`);
150
- if (parsed && typeof parsed === "object" && "version" in parsed && parsed.version !== 1) throw new Error(`unsupported settings.json version (${this.path}): expected 1, got ${String(parsed.version)}`);
151
- const minted = this.backfillProfileIds(parsed);
152
- const result = settingsSchema.safeParse(parsed);
153
- if (!result.success) throw new Error(`invalid settings.json (${this.path}): ${result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(", ")}`);
154
- if (minted) this.write(result.data);
155
- return result.data;
156
- }
157
- looksLikeLegacy(parsed) {
158
- if (!parsed || typeof parsed !== "object") return false;
159
- const obj = parsed;
160
- if (Array.isArray(obj.channels)) for (const channel of obj.channels) {
161
- if (!channel || typeof channel !== "object") continue;
162
- const ch = channel;
163
- if (Array.isArray(ch.connectors) && ch.connectors.some((x) => typeof x === "string")) return true;
164
- if (!("id" in ch) && "name" in ch) return true;
165
- }
166
- if (Array.isArray(obj.connectors)) return true;
167
- if (Array.isArray(obj.repositories)) return true;
168
- if (Array.isArray(obj.profiles)) for (const profile of obj.profiles) {
169
- if (!profile || typeof profile !== "object") continue;
170
- const p = profile;
171
- if ("repository" in p || "envFiles" in p || "channel" in p && !("channelId" in p)) return true;
172
- }
173
- return false;
174
- }
175
- /**
176
- * Non-destructive migration for profiles written before `id` existed. Mints a
177
- * uuid for each profile lacking one and returns whether anything was minted, so
178
- * `read` can persist it immediately — a profile id must be STABLE across reads,
179
- * otherwise `setSessionId` (a second read) sees a different id and can't match
180
- * the one the launch used. Mutates `parsed` in place (freshly JSON-parsed).
181
- */
182
- backfillProfileIds(parsed) {
183
- if (!parsed || typeof parsed !== "object") return false;
184
- const obj = parsed;
185
- if (!Array.isArray(obj.profiles)) return false;
186
- let minted = false;
187
- for (const profile of obj.profiles) {
188
- if (!profile || typeof profile !== "object") continue;
189
- const p = profile;
190
- if (typeof p.id !== "string") {
191
- p.id = this.idGenerator.generate();
192
- minted = true;
193
- }
194
- }
195
- return minted;
196
- }
197
- write(settings) {
198
- this.fs.mkdirSync(dirname(this.path), { recursive: true });
199
- const versioned = {
200
- ...settings,
201
- version: 1
202
- };
203
- this.fs.writeSecretFileSync(this.path, `${JSON.stringify(versioned, null, 2)}\n`);
204
- }
205
- };
206
- //#endregion
207
27
  //#region lib/connectors/connector-factory.ts
208
- const defaultFs$4 = new NodeFunnelFileSystem();
209
- const defaultProcess$3 = new NodeFunnelProcessRunner();
28
+ const defaultFs$1 = new NodeFunnelFileSystem();
29
+ const defaultProcess$1 = new NodeFunnelProcessRunner();
210
30
  /**
211
31
  * Pure factory for per-type listeners and adapters. The factory has no CRUD
212
32
  * responsibility — connector configs live inside settings.json under their
@@ -228,8 +48,8 @@ var FunnelConnectorFactory = class {
228
48
  slackListenerOptions;
229
49
  scheduleListenerOptions;
230
50
  constructor(deps = {}) {
231
- this.fs = deps.fs ?? defaultFs$4;
232
- this.process = deps.process ?? defaultProcess$3;
51
+ this.fs = deps.fs ?? defaultFs$1;
52
+ this.process = deps.process ?? defaultProcess$1;
233
53
  this.logger = deps.logger;
234
54
  this.diagnosticLog = deps.diagnosticLog;
235
55
  this.dir = deps.dir ?? FUNNEL_DIR;
@@ -364,7 +184,7 @@ const slotFields = (literalKey, envKey, fields, current) => {
364
184
  return result;
365
185
  };
366
186
  const defaultClock$1 = new NodeFunnelClock();
367
- const defaultIdGenerator$1 = new NodeFunnelIdGenerator();
187
+ const defaultIdGenerator = new NodeFunnelIdGenerator();
368
188
  /**
369
189
  * Channels own their connectors. Each channel has a stable id (UUID); the
370
190
  * `name` is the human-facing label used by the CLI. Connectors live nested
@@ -382,9 +202,9 @@ var FunnelChannels = class {
382
202
  constructor(deps) {
383
203
  this.store = deps.store;
384
204
  this.factory = deps.factory;
385
- this.profileChecker = deps.profileChecker;
205
+ this.profileChecker = deps.profileChecker ?? null;
386
206
  this.clock = deps.clock ?? defaultClock$1;
387
- this.idGenerator = deps.idGenerator ?? defaultIdGenerator$1;
207
+ this.idGenerator = deps.idGenerator ?? defaultIdGenerator;
388
208
  Object.freeze(this);
389
209
  }
390
210
  list() {
@@ -420,7 +240,7 @@ var FunnelChannels = class {
420
240
  const index = settings.channels.findIndex((c) => c.name === name);
421
241
  if (index < 0) throw new Error(`channel "${name}" not found`);
422
242
  const channel = settings.channels[index];
423
- if (channel && this.profileChecker.hasChannelRef(channel.id)) throw new Error(`channel "${name}" is referenced by a profile`);
243
+ if (channel && this.profileChecker?.hasChannelRef(channel.id)) throw new Error(`channel "${name}" is referenced by a profile`);
424
244
  settings.channels.splice(index, 1);
425
245
  this.store.write(settings);
426
246
  }
@@ -638,175 +458,6 @@ var FunnelChannels = class {
638
458
  }
639
459
  };
640
460
  //#endregion
641
- //#region lib/engine/claude/claude.ts
642
- const defaultProcess$2 = new NodeFunnelProcessRunner();
643
- const defaultFs$3 = new NodeFunnelFileSystem();
644
- const defaultIdGenerator = new NodeFunnelIdGenerator();
645
- /**
646
- * Launches Claude Code with funnel pre-wired: ensures the gateway is running,
647
- * installs the funnel MCP into the target repo's `.mcp.json` if missing,
648
- * injects `FUNNEL_CHANNEL_ID` into the child env, and writes a per-profile
649
- * PID file to enforce singleton launches.
650
- */
651
- var FunnelClaude = class {
652
- channels;
653
- mcp;
654
- gateway;
655
- profiles;
656
- process;
657
- fs;
658
- idGenerator;
659
- logger;
660
- pidDir;
661
- constructor(deps) {
662
- this.channels = deps.channels;
663
- this.mcp = deps.mcp;
664
- this.gateway = deps.gateway;
665
- this.profiles = deps.profiles;
666
- this.process = deps.process ?? defaultProcess$2;
667
- this.fs = deps.fs ?? defaultFs$3;
668
- this.idGenerator = deps.idGenerator ?? defaultIdGenerator;
669
- this.logger = deps.logger;
670
- this.pidDir = join(deps.dir ?? FUNNEL_DIR, "claude");
671
- Object.freeze(this);
672
- }
673
- async launch(options) {
674
- const channel = this.channels.get(options.channel) ?? this.channels.getById(options.channel);
675
- if (!channel) throw new Error(`channel "${options.channel}" not found`);
676
- if (options.profileId && this.isRunning(options.profileId)) throw new Error(`profile "${options.profileId}" is already running`);
677
- const cwd = options.cwd ?? globalThis.process.cwd();
678
- if ((options.installMcp ?? true) && !this.mcp.findInstalledName(cwd)) {
679
- this.mcp.install(cwd);
680
- this.logger?.info(`added funnel MCP to .mcp.json`, { cwd });
681
- }
682
- if (!this.gateway.isRunning()) {
683
- this.logger?.info(`starting gateway automatically`);
684
- await this.gateway.start();
685
- }
686
- if (options.profileId) {
687
- this.writePidFile(options.profileId);
688
- this.installCleanup(options.profileId);
689
- }
690
- const session = (options.resume ?? false) && options.profileId ? this.resolveSession(options.profileId, cwd, options.userArgs ?? [], options.env ?? {}) : null;
691
- const claudeArgs = this.buildArgs(options.options ?? [], options.userArgs ?? [], cwd, session);
692
- const env = this.buildEnv(channel.id, options.env ?? {});
693
- this.logger?.info(`claude launch`, {
694
- channel: options.channel,
695
- channelId: channel.id,
696
- cwd
697
- });
698
- try {
699
- return await this.process.attach(["claude", ...claudeArgs], {
700
- cwd,
701
- env,
702
- onSpawned: options.onSpawned
703
- });
704
- } finally {
705
- if (options.profileId) this.removePidFile(options.profileId);
706
- }
707
- }
708
- isRunning(profileId) {
709
- const pid = this.readPid(profileId);
710
- if (!pid) return false;
711
- return this.isProcessAlive(pid);
712
- }
713
- pidPath(profileId) {
714
- return join(this.pidDir, `${profileId}.pid`);
715
- }
716
- readPid(profileId) {
717
- const path = this.pidPath(profileId);
718
- if (!this.fs.existsSync(path)) return null;
719
- try {
720
- const content = this.fs.readFileSync(path).trim();
721
- const pid = Number(content);
722
- if (!pid || pid <= 0) return null;
723
- return pid;
724
- } catch {
725
- return null;
726
- }
727
- }
728
- writePidFile(profileId) {
729
- this.fs.mkdirSync(this.pidDir, { recursive: true });
730
- this.fs.writeFileSync(this.pidPath(profileId), String(globalThis.process.pid));
731
- }
732
- removePidFile(profileId) {
733
- const path = this.pidPath(profileId);
734
- if (this.fs.existsSync(path)) this.fs.unlink(path);
735
- }
736
- installCleanup(profileId) {
737
- globalThis.process.once("exit", () => this.removePidFile(profileId));
738
- }
739
- isProcessAlive(pid) {
740
- return this.process.isAlive(pid);
741
- }
742
- buildArgs(recipeOptions, userArgs, cwd, session) {
743
- const result = [...recipeOptions, ...userArgs];
744
- if (session !== null) if (session.mode === "resume") result.push("--resume", session.id);
745
- else result.push("--session-id", session.id);
746
- const mcpName = this.mcp.findInstalledName(cwd);
747
- if (mcpName && !result.includes("--dangerously-load-development-channels") && !result.includes("--channels")) result.push("--dangerously-load-development-channels", `server:${mcpName}`);
748
- return result;
749
- }
750
- /**
751
- * Decides whether funnel should resume an existing claude session or start
752
- * a freshly minted one. Backs off when the user already passed a
753
- * session-shaping flag, since combining them would either confuse claude
754
- * or override the explicit user intent.
755
- *
756
- * The session is owned by the profile (by id), not by cwd: two profiles
757
- * pointing at the same repo each keep their own conversation, and a launch
758
- * with no profile never resumes — so an unrelated session in the same repo
759
- * can't bleed in. The channel never enters into it; sessions belong to the
760
- * launch layer (profiles), keeping the transport layer ignorant of them.
761
- *
762
- * A persisted id is only resumed when its session jsonl still exists on
763
- * disk. claude errors out on `--resume <id>` for a missing conversation, and
764
- * a persisted id can outlive its jsonl (claude pruned it, or the very first
765
- * launch was aborted after the id was written but before the jsonl
766
- * appeared). When the file is gone we mint a fresh session instead, which
767
- * overwrites the dangling entry — so the store self-heals.
768
- */
769
- resolveSession(profileId, cwd, userArgs, recipeEnv) {
770
- for (const arg of userArgs) {
771
- if (arg === "-c" || arg === "--continue") return null;
772
- if (arg === "--resume" || arg.startsWith("--resume=")) return null;
773
- if (arg === "--session-id" || arg.startsWith("--session-id=")) return null;
774
- }
775
- const existing = this.profiles.getSessionId(profileId);
776
- if (existing !== null && this.sessionFileExists(cwd, existing, recipeEnv)) return {
777
- id: existing,
778
- mode: "resume"
779
- };
780
- const fresh = this.idGenerator.generate();
781
- this.profiles.setSessionId(profileId, fresh);
782
- return {
783
- id: fresh,
784
- mode: "new"
785
- };
786
- }
787
- /**
788
- * Mirrors claude's session storage path
789
- * (`<config-dir>/projects/<cwd-with-slashes-as-dashes>/<id>.jsonl`) to check
790
- * whether a recorded session still exists AND is non-empty. Reads the same
791
- * `CLAUDE_CONFIG_DIR` the child will run under so the check matches reality; a
792
- * wrong guess can only ever produce a false negative (start fresh), never a
793
- * bad resume.
794
- */
795
- sessionFileExists(cwd, sessionId, recipeEnv) {
796
- const path = join(recipeEnv.CLAUDE_CONFIG_DIR ?? globalThis.process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude"), "projects", cwd.replace(/\//g, "-"), `${sessionId}.jsonl`);
797
- if (!this.fs.existsSync(path)) return false;
798
- return this.fs.readFileSync(path).trim().length > 0;
799
- }
800
- buildEnv(channelId, recipeEnv) {
801
- const env = {};
802
- for (const [key, value] of Object.entries(recipeEnv)) env[key] = value;
803
- for (const [key, value] of Object.entries(globalThis.process.env)) if (typeof value === "string") env[key] = value;
804
- env.FUNNEL_CHANNEL_ID = channelId;
805
- env.FUNNEL_PORT = String(resolveFunnelPort());
806
- return env;
807
- }
808
- };
809
- //#endregion
810
461
  //#region lib/engine/fs/memory-file-system.ts
811
462
  const SECRET_MODE = 384;
812
463
  var MemoryFunnelFileSystem = class extends FunnelFileSystem {
@@ -895,397 +546,6 @@ var MemoryFunnelIdGenerator = class extends FunnelIdGenerator {
895
546
  }
896
547
  };
897
548
  //#endregion
898
- //#region lib/engine/local-config/local-config-schema.ts
899
- /**
900
- * Per-repo launch config (`funnel.json`).
901
- *
902
- * `fnl claude` reads this when no global --profile preset is used. It picks one
903
- * of the declared channels (`--channel <name>` selects by name; otherwise the
904
- * first entry wins) and materializes its transport (connectors / delivery) into
905
- * the repo's scoped settings (`~/.funnel/projects/<id>/settings.json`) on launch.
906
- * Connectors carry no tokens here — a token absent from settings is prompted for
907
- * at launch (TTY) and saved there, never in the repo.
908
- *
909
- * The launch recipe (`options` / `env` / `resume`) lives on `profiles[]`, not on
910
- * the channel: a channel only describes where events come from. `fnl claude`
911
- * applies the first profile bound to the chosen channel; the recipe is passed
912
- * straight to the launcher and is not persisted into the global profile list.
913
- * These profiles are selected by their `channel` binding, not by name.
914
- */
915
- const slackConnectorSpecSchema = z.object({
916
- type: z.literal("slack"),
917
- name: z.string(),
918
- /** Shrink raw Slack events before fanout. Defaults to true. */
919
- minify: z.boolean().optional()
920
- });
921
- const discordConnectorSpecSchema = z.object({
922
- type: z.literal("discord"),
923
- name: z.string()
924
- });
925
- const ghConnectorSpecSchema = z.object({
926
- type: z.literal("gh"),
927
- name: z.string(),
928
- pollInterval: z.number().int().positive().optional()
929
- });
930
- const scheduleConnectorSpecSchema = z.object({
931
- type: z.literal("schedule"),
932
- name: z.string()
933
- });
934
- const connectorSpecSchema = z.discriminatedUnion("type", [
935
- slackConnectorSpecSchema,
936
- discordConnectorSpecSchema,
937
- ghConnectorSpecSchema,
938
- scheduleConnectorSpecSchema
939
- ]);
940
- const channelSpecSchema = z.object({
941
- name: z.string(),
942
- connectors: z.array(connectorSpecSchema).optional()
943
- });
944
- const profileSpecSchema = z.object({
945
- /** Handle for `fnl claude --profile <name>`. A profile is only launchable by this name. */
946
- name: z.string(),
947
- /** Name of the channel (declared in `channels[]`) this profile binds. The profile depends on the channel, never the reverse. */
948
- channel: z.string(),
949
- /** Args prepended to the claude argv on every launch through this profile. */
950
- options: z.array(z.string()).optional(),
951
- /** Env vars layered under the launched claude process. process.env wins on collision. */
952
- env: z.record(z.string(), z.string()).optional(),
953
- /**
954
- * When true (the default), funnel injects `--session-id <uuid>` so that
955
- * relaunching from the same cwd resumes the previous claude session
956
- * without bleeding into other channels or workspaces. Set to false for
957
- * profiles that should always start a fresh session.
958
- */
959
- resume: z.boolean().optional()
960
- });
961
- const localConfigSchema = z.object({
962
- $schema: z.string().optional(),
963
- /**
964
- * Stable per-repo identifier. funnel writes this on first launch when absent;
965
- * all funnel state for this repo lives under `~/.funnel/projects/<id>/`, so the
966
- * repo itself never holds settings or tokens. Committed alongside funnel.json.
967
- */
968
- id: z.string().optional(),
969
- /** Declared channels (transport only). First entry is the default; --channel <name> selects by name. */
970
- channels: z.array(channelSpecSchema).min(1),
971
- /** Launch presets bound to a channel. First entry bound to the chosen channel is the default. */
972
- profiles: z.array(profileSpecSchema).optional()
973
- });
974
- const LOCAL_CONFIG_FILENAME = "funnel.json";
975
- //#endregion
976
- //#region lib/engine/local-config/local-config.ts
977
- /**
978
- * Reads `funnel.json` from a directory. Returns `null` when the file is
979
- * absent so callers can fall through to other resolution paths (default
980
- * profile, help). Throws on present-but-invalid files so misconfiguration
981
- * surfaces loudly instead of silently launching the wrong channel.
982
- */
983
- var FunnelLocalConfig = class {
984
- fs;
985
- constructor(deps) {
986
- this.fs = deps.fs;
987
- Object.freeze(this);
988
- }
989
- read(cwd) {
990
- const path = join(cwd, LOCAL_CONFIG_FILENAME);
991
- if (!this.fs.existsSync(path)) return null;
992
- const raw = this.fs.readFileSync(path);
993
- const parsed = (() => {
994
- try {
995
- return JSON.parse(raw);
996
- } catch (error) {
997
- const message = error instanceof Error ? error.message : String(error);
998
- throw new Error(`${LOCAL_CONFIG_FILENAME} is not valid JSON: ${message}`);
999
- }
1000
- })();
1001
- const result = localConfigSchema.safeParse(parsed);
1002
- if (!result.success) throw new Error(`${LOCAL_CONFIG_FILENAME} is invalid: ${result.error.message}`);
1003
- this.assertProfilesValid(result.data);
1004
- return result.data;
1005
- }
1006
- assertProfilesValid(config) {
1007
- const profiles = config.profiles ?? [];
1008
- if (profiles.length === 0) return;
1009
- const channelNames = new Set(config.channels.map((channel) => channel.name));
1010
- const seenNames = /* @__PURE__ */ new Set();
1011
- for (const profile of profiles) {
1012
- if (!channelNames.has(profile.channel)) throw new Error(`${LOCAL_CONFIG_FILENAME} is invalid: profile "${profile.name}" binds channel "${profile.channel}", which is not declared in channels[]`);
1013
- if (seenNames.has(profile.name)) throw new Error(`${LOCAL_CONFIG_FILENAME} is invalid: more than one profile is named "${profile.name}" — names must be unique`);
1014
- seenNames.add(profile.name);
1015
- }
1016
- }
1017
- };
1018
- //#endregion
1019
- //#region lib/engine/token-prompter/token-prompter.ts
1020
- /**
1021
- * Asks the user for a secret value on stdin. Used as a last resort when a
1022
- * funnel.json token field is absent and not present in `~/.funnel`. The Node
1023
- * implementation refuses to prompt when stdin is not a TTY so non-interactive
1024
- * launches (CI, agent spawning agent, daemons) fail fast instead of hanging.
1025
- */
1026
- var FunnelTokenPrompter = class {};
1027
- //#endregion
1028
- //#region lib/engine/local-config/local-config-sync.ts
1029
- /**
1030
- * Reconciles a single funnel.json channel spec with `~/.funnel/settings.json`.
1031
- * The spec is the source of truth for the channel it declares:
1032
- *
1033
- * - missing channel → created
1034
- * - declared connector matched by name → tokens reconciled
1035
- * - declared connector matched by token in the same channel under a
1036
- * different name → renamed in place (then tokens reconciled)
1037
- * - declared connector with no match → added
1038
- * - any connector left in the channel that the spec did not touch → removed
1039
- *
1040
- * Removal only fires when the channel spec has a `connectors` field. An
1041
- * absent field means "do not manage connectors from here" and leaves
1042
- * everything in `~/.funnel` alone. Other channels in funnel.json (not
1043
- * passed to this call) are untouched.
1044
- *
1045
- * Returns the per-connector change set so callers (e.g. the claude launcher)
1046
- * can drive listener hot-reload on the gateway after settings are written.
1047
- */
1048
- var FunnelLocalConfigSync = class {
1049
- channels;
1050
- prompter;
1051
- constructor(deps) {
1052
- this.channels = deps.channels;
1053
- this.prompter = deps.prompter;
1054
- Object.freeze(this);
1055
- }
1056
- async ensure(channel) {
1057
- if (!this.channels.get(channel.name)) this.channels.add({ name: channel.name });
1058
- if (channel.connectors === void 0) return {
1059
- touched: [],
1060
- removed: []
1061
- };
1062
- const touched = [];
1063
- const touchedIds = /* @__PURE__ */ new Set();
1064
- for (const spec of channel.connectors) {
1065
- const outcome = await this.ensureConnector(channel.name, spec);
1066
- touched.push({
1067
- name: outcome.name,
1068
- changed: outcome.changed
1069
- });
1070
- touchedIds.add(outcome.id);
1071
- }
1072
- return {
1073
- touched,
1074
- removed: this.removeExtras(channel.name, touchedIds)
1075
- };
1076
- }
1077
- async ensureConnector(channelName, spec) {
1078
- if (spec.type === "slack") return await this.ensureSlack(channelName, spec);
1079
- if (spec.type === "discord") return await this.ensureDiscord(channelName, spec);
1080
- if (spec.type === "gh") return this.ensureGh(channelName, spec);
1081
- return this.ensureSchedule(channelName, spec);
1082
- }
1083
- async ensureSlack(channelName, spec) {
1084
- const byName = this.findExistingSlack(channelName, spec.name);
1085
- const bot = await this.resolveSlot({
1086
- label: `${spec.name}.botToken`,
1087
- existingLiteral: byName?.botToken,
1088
- existingEnv: byName?.botTokenEnv
1089
- });
1090
- const app = await this.resolveSlot({
1091
- label: `${spec.name}.appToken`,
1092
- existingLiteral: byName?.appToken,
1093
- existingEnv: byName?.appTokenEnv
1094
- });
1095
- const update = {
1096
- botToken: bot.token,
1097
- botTokenEnv: bot.tokenEnv,
1098
- appToken: app.token,
1099
- appTokenEnv: app.tokenEnv
1100
- };
1101
- if (byName) {
1102
- if (!(byName.botToken === bot.token && byName.botTokenEnv === bot.tokenEnv && byName.appToken === app.token && byName.appTokenEnv === app.tokenEnv)) {
1103
- this.channels.updateSlackConnector(channelName, spec.name, update);
1104
- return {
1105
- id: byName.id,
1106
- name: spec.name,
1107
- changed: true
1108
- };
1109
- }
1110
- return {
1111
- id: byName.id,
1112
- name: spec.name,
1113
- changed: false
1114
- };
1115
- }
1116
- return {
1117
- id: this.channels.addConnector(channelName, {
1118
- type: "slack",
1119
- name: spec.name,
1120
- ...update,
1121
- ...spec.minify !== void 0 ? { minify: spec.minify } : {}
1122
- }).id,
1123
- name: spec.name,
1124
- changed: true
1125
- };
1126
- }
1127
- async ensureDiscord(channelName, spec) {
1128
- const byName = this.findExistingDiscord(channelName, spec.name);
1129
- const bot = await this.resolveSlot({
1130
- label: `${spec.name}.botToken`,
1131
- existingLiteral: byName?.botToken,
1132
- existingEnv: byName?.botTokenEnv
1133
- });
1134
- const update = {
1135
- botToken: bot.token,
1136
- botTokenEnv: bot.tokenEnv
1137
- };
1138
- if (byName) {
1139
- if (byName.botToken !== bot.token || byName.botTokenEnv !== bot.tokenEnv) {
1140
- this.channels.updateDiscordConnector(channelName, spec.name, update);
1141
- return {
1142
- id: byName.id,
1143
- name: spec.name,
1144
- changed: true
1145
- };
1146
- }
1147
- return {
1148
- id: byName.id,
1149
- name: spec.name,
1150
- changed: false
1151
- };
1152
- }
1153
- return {
1154
- id: this.channels.addConnector(channelName, {
1155
- type: "discord",
1156
- name: spec.name,
1157
- ...update
1158
- }).id,
1159
- name: spec.name,
1160
- changed: true
1161
- };
1162
- }
1163
- ensureGh(channelName, spec) {
1164
- const existing = this.channels.getConnector(channelName, spec.name);
1165
- if (existing && existing.type !== "gh") throw new Error(`connector "${spec.name}" exists in channel "${channelName}" with type "${existing.type}", funnel.json declares "gh"`);
1166
- if (existing && existing.type === "gh") {
1167
- if (spec.pollInterval !== void 0 && existing.pollInterval !== spec.pollInterval) {
1168
- this.channels.updateGhConnector(channelName, spec.name, { pollInterval: spec.pollInterval });
1169
- return {
1170
- id: existing.id,
1171
- name: spec.name,
1172
- changed: true
1173
- };
1174
- }
1175
- return {
1176
- id: existing.id,
1177
- name: spec.name,
1178
- changed: false
1179
- };
1180
- }
1181
- return {
1182
- id: this.channels.addConnector(channelName, {
1183
- type: "gh",
1184
- name: spec.name,
1185
- ...spec.pollInterval !== void 0 ? { pollInterval: spec.pollInterval } : {}
1186
- }).id,
1187
- name: spec.name,
1188
- changed: true
1189
- };
1190
- }
1191
- ensureSchedule(channelName, spec) {
1192
- const existing = this.channels.getConnector(channelName, spec.name);
1193
- if (existing && existing.type !== "schedule") throw new Error(`connector "${spec.name}" exists in channel "${channelName}" with type "${existing.type}", funnel.json declares "schedule"`);
1194
- if (existing && existing.type === "schedule") return {
1195
- id: existing.id,
1196
- name: spec.name,
1197
- changed: false
1198
- };
1199
- return {
1200
- id: this.channels.addConnector(channelName, {
1201
- type: "schedule",
1202
- name: spec.name
1203
- }).id,
1204
- name: spec.name,
1205
- changed: true
1206
- };
1207
- }
1208
- findExistingSlack(channelName, connectorName) {
1209
- const existing = this.channels.getConnector(channelName, connectorName);
1210
- if (!existing) return null;
1211
- if (existing.type !== "slack") throw new Error(`connector "${connectorName}" exists in channel "${channelName}" with type "${existing.type}", funnel.json declares "slack"`);
1212
- return existing;
1213
- }
1214
- findExistingDiscord(channelName, connectorName) {
1215
- const existing = this.channels.getConnector(channelName, connectorName);
1216
- if (!existing) return null;
1217
- if (existing.type !== "discord") throw new Error(`connector "${connectorName}" exists in channel "${channelName}" with type "${existing.type}", funnel.json declares "discord"`);
1218
- return existing;
1219
- }
1220
- removeExtras(channelName, touched) {
1221
- const channel = this.channels.get(channelName);
1222
- if (!channel) return [];
1223
- const stale = channel.connectors.filter((c) => !touched.has(c.id));
1224
- for (const connector of stale) this.channels.removeConnector(channelName, connector.name);
1225
- return stale.map((c) => c.name);
1226
- }
1227
- /**
1228
- * Decides how a single token slot is stored in settings.json. funnel.json
1229
- * never carries tokens, so the only sources are a value already in
1230
- * settings.json (carried over verbatim, whichever form it was — literal or an
1231
- * `env`-var reference set via the CLI) or, on first sync, a TTY prompt for a
1232
- * literal (throws when stdin is not a TTY). Either way the secret lands in the
1233
- * repo-scoped settings, never in the repo itself.
1234
- */
1235
- async resolveSlot(input) {
1236
- if (input.existingEnv !== void 0) return {
1237
- token: void 0,
1238
- tokenEnv: input.existingEnv
1239
- };
1240
- if (input.existingLiteral !== void 0) return {
1241
- token: input.existingLiteral,
1242
- tokenEnv: void 0
1243
- };
1244
- return {
1245
- token: await this.prompter.promptSecret(input.label),
1246
- tokenEnv: void 0
1247
- };
1248
- }
1249
- };
1250
- //#endregion
1251
- //#region lib/engine/local-config/local-config-writer.ts
1252
- const isRecord = (value) => {
1253
- return typeof value === "object" && value !== null && !Array.isArray(value);
1254
- };
1255
- const withIdFirst = (config, id) => {
1256
- const ordered = {};
1257
- if (config.$schema !== void 0) ordered.$schema = config.$schema;
1258
- ordered.id = id;
1259
- for (const key of Object.keys(config)) {
1260
- if (key === "$schema" || key === "id") continue;
1261
- ordered[key] = config[key];
1262
- }
1263
- return ordered;
1264
- };
1265
- /**
1266
- * The one path that mutates the repo-committed funnel.json, and it only ever
1267
- * inserts `id`. On first launch a repo has no `id`; funnel generates one and
1268
- * writes it back here so future launches resolve the same `~/.funnel/projects/<id>/`.
1269
- * Idempotent — a no-op once `id` is present. Kept separate from the read-only
1270
- * FunnelLocalConfig so reads stay side-effect free.
1271
- */
1272
- var FunnelLocalConfigWriter = class {
1273
- fs;
1274
- constructor(deps) {
1275
- this.fs = deps.fs;
1276
- Object.freeze(this);
1277
- }
1278
- ensureId(cwd, id) {
1279
- const path = join(cwd, LOCAL_CONFIG_FILENAME);
1280
- if (!this.fs.existsSync(path)) return;
1281
- const parsed = JSON.parse(this.fs.readFileSync(path));
1282
- if (!isRecord(parsed)) return;
1283
- if (typeof parsed.id === "string" && parsed.id !== "") return;
1284
- const ordered = withIdFirst(parsed, id);
1285
- this.fs.writeFileSync(path, `${JSON.stringify(ordered, null, 2)}\n`);
1286
- }
1287
- };
1288
- //#endregion
1289
549
  //#region lib/engine/logger/memory-logger.ts
1290
550
  var MemoryFunnelLogger = class extends FunnelLogger {
1291
551
  file = null;
@@ -1316,93 +576,6 @@ var MemoryFunnelLogger = class extends FunnelLogger {
1316
576
  }
1317
577
  };
1318
578
  //#endregion
1319
- //#region lib/engine/mcp/mcp.ts
1320
- const FUNNEL_MCP_COMMAND = "bun";
1321
- const FUNNEL_MCP_ARGS = ["funnel", "mcp"];
1322
- const FUNNEL_MCP_NAME = "funnel";
1323
- const mcpEntrySchema = z.object({
1324
- command: z.string().optional(),
1325
- args: z.array(z.string()).optional()
1326
- });
1327
- const mcpConfigSchema = z.object({ mcpServers: z.record(z.string(), mcpEntrySchema).optional() });
1328
- const defaultFs$2 = new NodeFunnelFileSystem();
1329
- /**
1330
- * Installs/uninstalls the funnel MCP entry into a target repository's
1331
- * `.mcp.json`. Detects an existing entry by command match so renaming is
1332
- * preserved across re-installs.
1333
- */
1334
- var FunnelMcp = class {
1335
- fs;
1336
- constructor(deps = {}) {
1337
- this.fs = deps.fs ?? defaultFs$2;
1338
- Object.freeze(this);
1339
- }
1340
- install(repoPath) {
1341
- if (!this.fs.existsSync(repoPath)) throw new Error(`repository does not exist: ${repoPath}`);
1342
- const config = this.readConfig(repoPath);
1343
- const servers = config.mcpServers ?? {};
1344
- const targetName = this.findServerName(servers) ?? "funnel";
1345
- servers[targetName] = {
1346
- command: "bun",
1347
- args: FUNNEL_MCP_ARGS
1348
- };
1349
- this.writeConfig(repoPath, {
1350
- ...config,
1351
- mcpServers: servers
1352
- });
1353
- }
1354
- uninstall(repoPath) {
1355
- if (!this.fs.existsSync(repoPath)) return;
1356
- const config = this.readConfig(repoPath);
1357
- const servers = config.mcpServers ?? {};
1358
- const name = this.findServerName(servers);
1359
- if (!name) return;
1360
- const next = { ...servers };
1361
- delete next[name];
1362
- this.writeConfig(repoPath, {
1363
- ...config,
1364
- mcpServers: next
1365
- });
1366
- }
1367
- findInstalledName(cwd) {
1368
- const config = this.readConfig(cwd);
1369
- return this.findServerName(config.mcpServers ?? {});
1370
- }
1371
- findServerName(servers) {
1372
- for (const entry of Object.entries(servers)) {
1373
- const name = entry[0];
1374
- const value = entry[1];
1375
- if (this.isFunnelEntry(value)) return name;
1376
- }
1377
- return null;
1378
- }
1379
- isFunnelEntry(value) {
1380
- if (!value) return false;
1381
- if (value.command === "bun" && value.args?.[0] === "funnel") return true;
1382
- if (value.command === "funnel") return true;
1383
- return false;
1384
- }
1385
- readConfig(repoPath) {
1386
- const mcpPath = join(repoPath, ".mcp.json");
1387
- if (!this.fs.existsSync(mcpPath)) return {};
1388
- const content = this.fs.readFileSync(mcpPath).trim();
1389
- if (!content) return {};
1390
- let parsed;
1391
- try {
1392
- parsed = JSON.parse(content);
1393
- } catch (error) {
1394
- throw new Error(`invalid .mcp.json (${mcpPath}): ${error instanceof Error ? error.message : String(error)}`);
1395
- }
1396
- const result = mcpConfigSchema.safeParse(parsed);
1397
- if (!result.success) throw new Error(`invalid .mcp.json (${mcpPath}): ${result.error.message}`);
1398
- return result.data;
1399
- }
1400
- writeConfig(repoPath, config) {
1401
- const mcpPath = join(repoPath, ".mcp.json");
1402
- this.fs.writeFileSync(mcpPath, `${JSON.stringify(config, null, 2)}\n`);
1403
- }
1404
- };
1405
- //#endregion
1406
579
  //#region lib/engine/process/memory-process-runner.ts
1407
580
  const empty = {
1408
581
  exitCode: 0,
@@ -1503,182 +676,6 @@ var MemoryFunnelProcessRunner = class extends FunnelProcessRunner {
1503
676
  }
1504
677
  };
1505
678
  //#endregion
1506
- //#region lib/engine/profiles/profiles.ts
1507
- /**
1508
- * Named launch presets for `fnl claude`. Each profile bundles a working
1509
- * directory, the channel id its Claude instance subscribes to, and the launch
1510
- * recipe (`options` prepended to the claude argv, `env` layered under the
1511
- * process, `resume` toggling session reuse). Implements ProfileChannelChecker
1512
- * so FunnelChannels can refuse to remove a channel that is still referenced.
1513
- *
1514
- * Each profile has a stable `id` (uuid) minted at `add`. That id is the unit
1515
- * everything internal keys on — the PID file, the resumable session id — so a
1516
- * rename never strands either. `name` is purely the CLI/TUI handle; the CRUD
1517
- * methods here take it because that is what the user types, but resolve to the
1518
- * id before touching id-keyed state. The first array entry is the default
1519
- * profile; `asDefault` reorders to put one first.
1520
- *
1521
- * `channelId` always stores the channel's stable id (uuid). CLI surfaces
1522
- * resolve channel name → id before calling `add`/`update` here.
1523
- */
1524
- var FunnelProfiles = class {
1525
- store;
1526
- idGenerator;
1527
- constructor(deps) {
1528
- this.store = deps.store;
1529
- this.idGenerator = deps.idGenerator;
1530
- Object.freeze(this);
1531
- }
1532
- list() {
1533
- return this.store.read().profiles;
1534
- }
1535
- get(name) {
1536
- return this.list().find((p) => p.name === name) ?? null;
1537
- }
1538
- getById(id) {
1539
- return this.list().find((p) => p.id === id) ?? null;
1540
- }
1541
- getDefault() {
1542
- return this.list()[0] ?? null;
1543
- }
1544
- add(input) {
1545
- const settings = this.store.read();
1546
- if (settings.profiles.some((p) => p.name === input.name)) throw new Error(`profile "${input.name}" already exists`);
1547
- if (!settings.channels.some((c) => c.id === input.channelId)) throw new Error(`channel id "${input.channelId}" not found`);
1548
- settings.profiles.push({
1549
- id: this.idGenerator.generate(),
1550
- name: input.name,
1551
- path: input.path,
1552
- channelId: input.channelId,
1553
- options: input.options ?? [],
1554
- env: input.env ?? {},
1555
- resume: input.resume ?? true
1556
- });
1557
- this.store.write(settings);
1558
- }
1559
- remove(name) {
1560
- const settings = this.store.read();
1561
- const index = settings.profiles.findIndex((p) => p.name === name);
1562
- if (index < 0) throw new Error(`profile "${name}" not found`);
1563
- settings.profiles.splice(index, 1);
1564
- this.store.write(settings);
1565
- }
1566
- rename(oldName, newName) {
1567
- const settings = this.store.read();
1568
- const profile = settings.profiles.find((p) => p.name === oldName);
1569
- if (!profile) throw new Error(`profile "${oldName}" not found`);
1570
- if (settings.profiles.some((p) => p.name === newName)) throw new Error(`profile "${newName}" already exists`);
1571
- profile.name = newName;
1572
- this.store.write(settings);
1573
- }
1574
- asDefault(name) {
1575
- const settings = this.store.read();
1576
- const index = settings.profiles.findIndex((p) => p.name === name);
1577
- if (index < 0) throw new Error(`profile "${name}" not found`);
1578
- if (index === 0) return;
1579
- const [profile] = settings.profiles.splice(index, 1);
1580
- if (!profile) return;
1581
- settings.profiles.unshift(profile);
1582
- this.store.write(settings);
1583
- }
1584
- hasChannelRef(channelId) {
1585
- return this.store.read().profiles.some((p) => p.channelId === channelId);
1586
- }
1587
- /** Resumable claude session id last launched by this profile (by id), or null. */
1588
- getSessionId(id) {
1589
- return this.getById(id)?.sessionId ?? null;
1590
- }
1591
- /** Records the claude session id this profile launched, overwriting any prior one. */
1592
- setSessionId(id, sessionId) {
1593
- const settings = this.store.read();
1594
- const profile = settings.profiles.find((p) => p.id === id);
1595
- if (!profile) throw new Error(`profile id "${id}" not found`);
1596
- profile.sessionId = sessionId;
1597
- this.store.write(settings);
1598
- }
1599
- update(name, fields) {
1600
- const settings = this.store.read();
1601
- const profile = settings.profiles.find((p) => p.name === name);
1602
- if (!profile) throw new Error(`profile "${name}" not found`);
1603
- if (fields.channelId !== void 0) {
1604
- if (!settings.channels.some((c) => c.id === fields.channelId)) throw new Error(`channel id "${fields.channelId}" not found`);
1605
- profile.channelId = fields.channelId;
1606
- }
1607
- if (fields.path !== void 0) profile.path = fields.path;
1608
- if (fields.options !== void 0) profile.options = fields.options;
1609
- if (fields.env !== void 0) profile.env = fields.env;
1610
- if (fields.resume !== void 0) profile.resume = fields.resume;
1611
- this.store.write(settings);
1612
- }
1613
- };
1614
- //#endregion
1615
- //#region lib/engine/token-prompter/node-token-prompter.ts
1616
- const STAR = "*";
1617
- const CR = "\r";
1618
- const LF = "\n";
1619
- const BACKSPACE = String.fromCharCode(8);
1620
- const DEL = String.fromCharCode(127);
1621
- const CTRL_C = String.fromCharCode(3);
1622
- const CTRL_D = String.fromCharCode(4);
1623
- /**
1624
- * Reads a secret from stdin in raw mode. Echoes a `*` per byte so the user
1625
- * can see progress without exposing the token. Refuses to prompt when stdin
1626
- * is not a TTY — callers should surface the resulting error with a hint
1627
- * pointing at the corresponding env var or CLI command.
1628
- */
1629
- var NodeFunnelTokenPrompter = class extends FunnelTokenPrompter {
1630
- async promptSecret(label) {
1631
- if (!stdin.isTTY) throw new Error(`cannot prompt for "${label}": stdin is not a TTY. Set the matching env var or run \`fnl channels <ch> connectors add ...\` first.`);
1632
- stderr.write(`${label}: `);
1633
- const wasRaw = stdin.isRaw;
1634
- stdin.setRawMode(true);
1635
- stdin.resume();
1636
- try {
1637
- return await this.readSecret();
1638
- } finally {
1639
- stdin.setRawMode(wasRaw);
1640
- stdin.pause();
1641
- stderr.write(LF);
1642
- }
1643
- }
1644
- readSecret() {
1645
- return new Promise((resolve, reject) => {
1646
- let buffer = "";
1647
- const onData = (chunk) => {
1648
- for (const byte of chunk) {
1649
- const char = String.fromCharCode(byte);
1650
- if (char === LF || char === CR) {
1651
- stdin.off("data", onData);
1652
- resolve(buffer);
1653
- return;
1654
- }
1655
- if (char === CTRL_C) {
1656
- stdin.off("data", onData);
1657
- reject(/* @__PURE__ */ new Error("prompt cancelled"));
1658
- return;
1659
- }
1660
- if (char === CTRL_D) {
1661
- stdin.off("data", onData);
1662
- if (buffer.length === 0) reject(/* @__PURE__ */ new Error("prompt cancelled"));
1663
- else resolve(buffer);
1664
- return;
1665
- }
1666
- if (char === BACKSPACE || char === DEL) {
1667
- if (buffer.length > 0) {
1668
- buffer = buffer.slice(0, -1);
1669
- stderr.write("\b \b");
1670
- }
1671
- continue;
1672
- }
1673
- buffer += char;
1674
- stderr.write(STAR);
1675
- }
1676
- };
1677
- stdin.on("data", onData);
1678
- });
1679
- }
1680
- };
1681
- //#endregion
1682
679
  //#region lib/engine/settings/mock-settings-reader.ts
1683
680
  const createSettings = (partial = {}) => ({
1684
681
  version: 1,
@@ -1700,18 +697,6 @@ var MockFunnelSettingsReader = class extends FunnelSettingsReader {
1700
697
  }
1701
698
  };
1702
699
  //#endregion
1703
- //#region lib/engine/settings/tmp-dir.ts
1704
- /**
1705
- * Resolves the funnel temp/log root for the current OS. Defaults to
1706
- * `<os.tmpdir()>/funnel` so Windows lands under `%TEMP%\funnel` and POSIX
1707
- * lands under `/tmp/funnel`. Callers may override via `FUNNEL_TMP_DIR`.
1708
- */
1709
- function funnelTmpDir() {
1710
- const override = process.env.FUNNEL_TMP_DIR;
1711
- if (override && override.length > 0) return override;
1712
- return join(tmpdir(), "funnel");
1713
- }
1714
- //#endregion
1715
700
  //#region lib/engine/time/memory-clock.ts
1716
701
  var MemoryFunnelClock = class extends FunnelClock {
1717
702
  current;
@@ -1730,85 +715,6 @@ var MemoryFunnelClock = class extends FunnelClock {
1730
715
  }
1731
716
  };
1732
717
  //#endregion
1733
- //#region lib/gateway/publish-schema.ts
1734
- /**
1735
- * Shared schema for `POST /channels/:channel/publish` — used by both the
1736
- * gateway route handler (input validation) and the CLI / programmable client
1737
- * (request shape). The route resolves `channel` from the path; this body
1738
- * covers everything else.
1739
- */
1740
- const publishRequestSchema = z.object({
1741
- content: z.string().min(1),
1742
- meta: z.record(z.string(), z.string()).optional(),
1743
- connector: z.string().min(1).optional(),
1744
- /**
1745
- * Address the event to a single subscriber. When set, only the WS client that
1746
- * declared this id at upgrade time (`?id=<subscriberId>`) receives it among the
1747
- * channel's regular subscribers; tap=all observers still see it. Omit for the
1748
- * default fanout. The route surfaces it to subscribers as `meta.target`.
1749
- */
1750
- target: z.string().min(1).optional()
1751
- });
1752
- const publishResponseSchema = z.object({
1753
- ok: z.literal(true),
1754
- offset: z.number().int().nonnegative()
1755
- });
1756
- //#endregion
1757
- //#region lib/gateway/channel-publisher.ts
1758
- const OFFLINE$1 = { state: "offline" };
1759
- /**
1760
- * HTTP client for `POST /channels/:channel/publish` on a running gateway
1761
- * daemon. Returns `{ state: "offline" }` when the daemon isn't up so callers
1762
- * can branch without exceptions, mirroring `FunnelListenersClient`.
1763
- */
1764
- var FunnelChannelPublisher = class {
1765
- port;
1766
- isDaemonRunning;
1767
- getToken;
1768
- constructor(deps) {
1769
- this.port = deps.port;
1770
- this.isDaemonRunning = deps.isDaemonRunning;
1771
- this.getToken = deps.getToken ?? (() => null);
1772
- Object.freeze(this);
1773
- }
1774
- async publish(channelName, request) {
1775
- if (!this.isDaemonRunning()) return OFFLINE$1;
1776
- try {
1777
- const url = `http://127.0.0.1:${this.port}/channels/${encodeURIComponent(channelName)}/publish`;
1778
- const res = await fetch(url, {
1779
- method: "POST",
1780
- headers: {
1781
- ...this.authHeaders(),
1782
- "content-type": "application/json"
1783
- },
1784
- body: JSON.stringify(request)
1785
- });
1786
- if (!res.ok) return {
1787
- state: "error",
1788
- reason: await res.text() || `HTTP ${res.status}`
1789
- };
1790
- const parsed = publishResponseSchema.safeParse(await res.json());
1791
- if (!parsed.success) return {
1792
- state: "error",
1793
- reason: "malformed daemon response"
1794
- };
1795
- return {
1796
- state: "ok",
1797
- offset: parsed.data.offset
1798
- };
1799
- } catch (error) {
1800
- return {
1801
- state: "error",
1802
- reason: error instanceof Error ? error.message : String(error)
1803
- };
1804
- }
1805
- }
1806
- authHeaders() {
1807
- const token = this.getToken();
1808
- return token ? { authorization: `Bearer ${token}` } : {};
1809
- }
1810
- };
1811
- //#endregion
1812
718
  //#region lib/gateway/resolve-daemon-script.ts
1813
719
  /**
1814
720
  * Locate the daemon entry script. Works in both dev (running from source)
@@ -1840,10 +746,10 @@ const STARTUP_TIMEOUT_MS = 5e3;
1840
746
  const SIGTERM_TIMEOUT_MS = 2e3;
1841
747
  const POLL_INTERVAL_MS = 100;
1842
748
  const SIGKILL_GRACE_MS = 200;
1843
- const defaultProcess$1 = new NodeFunnelProcessRunner();
1844
- const defaultFs$1 = new NodeFunnelFileSystem();
749
+ const defaultProcess = new NodeFunnelProcessRunner();
750
+ const defaultFs = new NodeFunnelFileSystem();
1845
751
  const defaultClock = new NodeFunnelClock();
1846
- const defaultSleep$1 = (ms) => new Promise((r) => {
752
+ const defaultSleep = (ms) => new Promise((r) => {
1847
753
  setTimeout(r, ms);
1848
754
  });
1849
755
  /**
@@ -1862,15 +768,15 @@ var FunnelGateway = class {
1862
768
  port;
1863
769
  sleep;
1864
770
  constructor(deps = {}) {
1865
- this.process = deps.process ?? defaultProcess$1;
1866
- this.fs = deps.fs ?? defaultFs$1;
771
+ this.process = deps.process ?? defaultProcess;
772
+ this.fs = deps.fs ?? defaultFs;
1867
773
  this.clock = deps.clock ?? defaultClock;
1868
774
  this.dir = deps.dir ?? FUNNEL_DIR;
1869
775
  this.tmpDir = deps.tmpDir ?? funnelTmpDir();
1870
776
  this.pidFile = join(this.dir, "gateway.pid");
1871
777
  this.gatewayLog = join(this.tmpDir, "gateway.log");
1872
778
  this.port = deps.port ?? resolveFunnelPort();
1873
- this.sleep = deps.sleep ?? defaultSleep$1;
779
+ this.sleep = deps.sleep ?? defaultSleep;
1874
780
  Object.freeze(this);
1875
781
  }
1876
782
  isRunning() {
@@ -1986,1712 +892,13 @@ var FunnelGateway = class {
1986
892
  return null;
1987
893
  }
1988
894
  }
1989
- removePid() {
1990
- this.fs.unlink(this.pidFile);
1991
- }
1992
- isProcessAlive(pid) {
1993
- return this.process.isAlive(pid);
1994
- }
1995
- };
1996
- //#endregion
1997
- //#region lib/gateway/auth-middleware.ts
1998
- /**
1999
- * Verifies `Authorization: Bearer <token>` against the daemon's gateway token.
2000
- * Mounted on the routes that mutate listener state or expose detailed status.
2001
- * `/health` is intentionally left unauthenticated so the daemon manager can
2002
- * probe liveness without needing the token.
2003
- */
2004
- const requireBearerToken = (deps) => {
2005
- return async (c, next) => {
2006
- if (!constantTimeEqual((c.req.header("authorization") ?? "").match(/^Bearer\s+(.+)$/i)?.[1] ?? "", deps.expected)) return c.text("unauthorized", 401);
2007
- return await next();
2008
- };
2009
- };
2010
- const constantTimeEqual = (a, b) => {
2011
- const bufA = Buffer.from(a, "utf-8");
2012
- const bufB = Buffer.from(b, "utf-8");
2013
- const maxLen = Math.max(bufA.length, bufB.length, 1);
2014
- const padA = Buffer.alloc(maxLen);
2015
- const padB = Buffer.alloc(maxLen);
2016
- bufA.copy(padA);
2017
- bufB.copy(padB);
2018
- return timingSafeEqual(padA, padB) && bufA.length === bufB.length;
2019
- };
2020
- //#endregion
2021
- //#region lib/gateway/factory.ts
2022
- const factory$1 = createFactory();
2023
- //#endregion
2024
- //#region lib/gateway/broadcaster.ts
2025
- const byteLengthOf = (event) => {
2026
- let bytes = Buffer.byteLength(event.content, "utf-8");
2027
- if (event.meta) for (const [k, v] of Object.entries(event.meta)) bytes += Buffer.byteLength(k, "utf-8") + Buffer.byteLength(v, "utf-8");
2028
- return bytes;
2029
- };
2030
- const DEFAULT_MAX_BUFFERED_BYTES = 1024 * 1024;
2031
- const DEFAULT_REPLAY_BUFFER_SIZE = 200;
2032
- const DEFAULT_REPLAY_BUFFER_MAX_BYTES = 4 * 1024 * 1024;
2033
- const defaultOnError$2 = () => {};
2034
- /**
2035
- * In-process pub/sub for connector events.
2036
- *
2037
- * Two outbound paths:
2038
- * - WS clients connected via the gateway's `/ws` endpoint, scoped per channel
2039
- * - In-process subscribers registered via `subscribe()` (programmable API)
2040
- *
2041
- * Backpressure: if a WS client's `bufferedAmount` exceeds `maxBufferedBytes`
2042
- * (default 1 MiB), the client is closed with code 1009 and dropped from the
2043
- * registry to keep one slow consumer from blocking the daemon.
2044
- *
2045
- * Replay: every emitted event gets a strictly increasing `offset`. The latest
2046
- * `replayBufferSize` events are kept in memory; reconnecting WS clients can
2047
- * pass `?since=<offset>` and the broadcaster resends matching events before
2048
- * resuming the live stream. The in-memory ring covers short reconnects;
2049
- * older history is served from the event log wired in as `persistentReplay`.
2050
- */
2051
- var FunnelBroadcaster = class {
2052
- clients = /* @__PURE__ */ new Map();
2053
- subscribers = /* @__PURE__ */ new Set();
2054
- logger;
2055
- onError;
2056
- maxBufferedBytes;
2057
- now;
2058
- replayBufferSize;
2059
- replayBufferMaxBytes;
2060
- replayBuffer = [];
2061
- persistentReplay;
2062
- exclusiveCursor = /* @__PURE__ */ new Map();
2063
- replayBufferBytes = 0;
2064
- eventsBroadcast = 0;
2065
- droppedSlowClients = 0;
2066
- lastBroadcastAt = null;
2067
- latestOffset = 0;
2068
- constructor(deps = {}) {
2069
- this.logger = deps.logger;
2070
- this.onError = deps.onError ?? defaultOnError$2;
2071
- this.maxBufferedBytes = deps.maxBufferedBytes ?? DEFAULT_MAX_BUFFERED_BYTES;
2072
- this.now = deps.now ?? (() => Date.now());
2073
- this.replayBufferSize = Math.max(0, deps.replayBufferSize ?? DEFAULT_REPLAY_BUFFER_SIZE);
2074
- this.replayBufferMaxBytes = Math.max(0, deps.replayBufferMaxBytes ?? DEFAULT_REPLAY_BUFFER_MAX_BYTES);
2075
- this.persistentReplay = deps.persistentReplay ?? null;
2076
- }
2077
- getMetrics() {
2078
- return {
2079
- clients: this.clients.size,
2080
- subscribers: this.subscribers.size,
2081
- eventsBroadcast: this.eventsBroadcast,
2082
- droppedSlowClients: this.droppedSlowClients,
2083
- lastBroadcastAt: this.lastBroadcastAt ? new Date(this.lastBroadcastAt).toISOString() : null,
2084
- latestOffset: this.latestOffset,
2085
- oldestReplayableOffset: this.replayBuffer[0]?.offset ?? null
2086
- };
2087
- }
2088
- /**
2089
- * Returns events with offset > since, filtered by the connector subscription
2090
- * rules of `data`. Used at WS upgrade time when the client passes `?since=<offset>`.
2091
- *
2092
- * Two-tier lookup:
2093
- * 1. The in-memory ring buffer (covers short reconnects, last `replayBufferSize` events).
2094
- * 2. If `since` predates the oldest in-memory entry and a persistent replay source
2095
- * is wired in (SQLite by default), the gap is filled from it. This covers reconnects
2096
- * across daemon restarts where the in-memory buffer was lost.
2097
- *
2098
- * Result is sorted ascending by offset and de-duplicated against the in-memory buffer.
2099
- */
2100
- replaySince(since, data) {
2101
- const oldestInMemory = this.replayBuffer[0]?.offset;
2102
- const needFallback = this.persistentReplay && (oldestInMemory === void 0 || since < oldestInMemory - 1);
2103
- const fromMemory = this.replayBuffer.filter((event) => event.offset > since && this.matchesClient(event, data));
2104
- if (!needFallback) return fromMemory;
2105
- const persisted = this.persistentReplay ? this.persistentReplay.loadSince(since).filter((event) => this.matchesClient(event, data)) : [];
2106
- const cutoff = oldestInMemory ?? Number.POSITIVE_INFINITY;
2107
- return [...persisted.filter((event) => event.offset < cutoff), ...fromMemory];
2108
- }
2109
- matchesClient(event, data) {
2110
- if (data.tapAll) return true;
2111
- const target = event.meta?.target;
2112
- if (target && target !== data.subscriberId) return false;
2113
- const channelId = event.meta?.channelId;
2114
- if (channelId && channelId !== data.channel) return false;
2115
- const connector = event.meta?.connector;
2116
- if (!connector) return true;
2117
- return data.connectors.includes(connector);
2118
- }
2119
- /**
2120
- * Returns the list of WS clients that should receive `event`. Tap=all clients always
2121
- * receive (passive observation). For each per-channel group:
2122
- * - fanout → every matching client receives
2123
- * - exclusive → exactly one client receives, picked round-robin per channel
2124
- *
2125
- * `meta.target` narrows the regular (non-tap) recipient set first via
2126
- * `matchesClient`: only the subscriber whose `subscriberId` equals `target`
2127
- * stays in the running, so a targeted event reaches one named instance while
2128
- * still being observable by tap=all clients.
2129
- */
2130
- pickRecipients(event) {
2131
- const exclusiveByChannel = /* @__PURE__ */ new Map();
2132
- const recipients = [];
2133
- for (const [ws, data] of this.clients) {
2134
- if (!this.matchesClient(event, data)) continue;
2135
- if (data.tapAll) {
2136
- recipients.push(ws);
2137
- continue;
2138
- }
2139
- if (data.delivery === "exclusive") {
2140
- const list = exclusiveByChannel.get(data.channel) ?? [];
2141
- list.push(ws);
2142
- exclusiveByChannel.set(data.channel, list);
2143
- continue;
2144
- }
2145
- recipients.push(ws);
2146
- }
2147
- for (const [channel, candidates] of exclusiveByChannel) {
2148
- if (candidates.length === 0) continue;
2149
- const cursor = this.exclusiveCursor.get(channel) ?? 0;
2150
- const picked = candidates[cursor % candidates.length];
2151
- if (picked) recipients.push(picked);
2152
- this.exclusiveCursor.set(channel, cursor + 1);
2153
- }
2154
- return recipients;
2155
- }
2156
- addClient(ws, data) {
2157
- this.clients.set(ws, data);
2158
- }
2159
- removeClient(ws) {
2160
- this.clients.delete(ws);
2161
- }
2162
- getClientCount() {
2163
- return this.clients.size;
2164
- }
2165
- listChannels() {
2166
- return [...this.clients.values()].map((d) => ({ ...d }));
2167
- }
2168
- subscribe(handler) {
2169
- this.subscribers.add(handler);
2170
- return () => {
2171
- this.subscribers.delete(handler);
2172
- };
2173
- }
2174
- broadcast(content, meta) {
2175
- this.latestOffset += 1;
2176
- const event = {
2177
- content,
2178
- meta,
2179
- offset: this.latestOffset
2180
- };
2181
- const payload = JSON.stringify(event);
2182
- meta?.connector;
2183
- this.eventsBroadcast += 1;
2184
- this.lastBroadcastAt = this.now();
2185
- if (this.replayBufferSize > 0) {
2186
- const eventBytes = byteLengthOf(event);
2187
- this.replayBuffer.push(event);
2188
- this.replayBufferBytes += eventBytes;
2189
- while ((this.replayBuffer.length > this.replayBufferSize || this.replayBufferBytes > this.replayBufferMaxBytes) && this.replayBuffer.length > 0) {
2190
- const dropped = this.replayBuffer.shift();
2191
- if (dropped) this.replayBufferBytes -= byteLengthOf(dropped);
2192
- }
2193
- }
2194
- const recipients = this.pickRecipients(event);
2195
- for (const ws of recipients) {
2196
- const buffered = ws.getBufferedAmount();
2197
- if (buffered > this.maxBufferedBytes) {
2198
- const data = this.clients.get(ws);
2199
- this.logger?.warn("dropping slow WS client (backpressure)", {
2200
- channel: data?.channel,
2201
- buffered,
2202
- max: this.maxBufferedBytes
2203
- });
2204
- try {
2205
- ws.close(1009, "backpressure");
2206
- } catch {}
2207
- this.clients.delete(ws);
2208
- this.droppedSlowClients += 1;
2209
- continue;
2210
- }
2211
- ws.send(payload);
2212
- }
2213
- for (const handler of this.subscribers) try {
2214
- handler(event);
2215
- } catch (error) {
2216
- const err = error instanceof Error ? error : new Error(String(error));
2217
- this.logger?.error("broadcast subscriber threw", { error: err.message });
2218
- this.onError(err, {
2219
- component: "broadcaster.subscriber",
2220
- offset: event.offset,
2221
- connector: event.meta?.connector ?? null,
2222
- channel: event.meta?.channel ?? null
2223
- });
2224
- }
2225
- return event;
2226
- }
2227
- /** Forward-seed the offset counter (used at startup from the persisted event store). */
2228
- seedLatestOffset(offset) {
2229
- if (offset > this.latestOffset) this.latestOffset = offset;
2230
- }
2231
- };
2232
- //#endregion
2233
- //#region lib/gateway/funnel-event-log.ts
2234
- /**
2235
- * Replayable event payload persisted by the gateway. Domain events the
2236
- * broadcaster emits to WS clients land here so reconnects across daemon
2237
- * restarts can be served from disk. System events (gateway start, channel
2238
- * connected, etc.) are routed to `FunnelLogger` instead — they never go
2239
- * through this log, which keeps the offset space clean for replay.
2240
- */
2241
- const funnelEventSchema = z.object({
2242
- type: z.string(),
2243
- content: z.string(),
2244
- channel_id: z.string().nullable(),
2245
- connector_id: z.string().nullable(),
2246
- meta: z.record(z.string(), z.string()).nullable()
2247
- });
2248
- /**
2249
- * Durable, append-only log of broadcaster events keyed by the offset the
2250
- * broadcaster assigns. The gateway persists every domain event here, and
2251
- * across restarts it both seeds the broadcaster's offset counter
2252
- * (`findMaxOffset`) and serves reconnect replay (`loadSince`) from it.
2253
- *
2254
- * `loadSince` is the only method the broadcaster itself needs, which makes
2255
- * any implementation assignable to the broadcaster's narrow `ReplaySource`.
2256
- *
2257
- * Implementations:
2258
- * - `SqliteFunnelEventLog` — the default; durable across daemon restarts.
2259
- * - `MemoryFunnelEventLog` — an in-process double for tests and embedders
2260
- * that do not need durability (replay is lost when the process exits).
2261
- */
2262
- var FunnelEventLog = class {};
2263
- //#endregion
2264
- //#region lib/logger/leuco-logger-sqlite-sink.ts
2265
- /** Conservative whitelist for column names interpolated into SQL. */
2266
- const COLUMN_NAME_RE = /^[a-z_][a-z0-9_]*$/;
2267
- /** How many inserts between on-disk size checks (see insertsSinceByteCheck). */
2268
- const BYTE_CHECK_INTERVAL = 500;
2269
- const RESERVED_COLUMNS = new Set([
2270
- "seq",
2271
- "ts",
2272
- "type",
2273
- "event"
2274
- ]);
2275
- /**
2276
- * Schema versions. Each entry is the list of DDL statements that take the
2277
- * database from version i to version i + 1. Migrations run in a transaction
2278
- * so a partial failure rolls back. Adding a new version is append-only —
2279
- * never edit a published one. Caller-defined index columns are added
2280
- * dynamically on construct (independent of versioned migrations) because
2281
- * they are configuration, not schema evolution.
2282
- */
2283
- const MIGRATIONS = [[
2284
- "CREATE TABLE IF NOT EXISTS leuco_log (seq INTEGER PRIMARY KEY, ts INTEGER NOT NULL, type TEXT, event TEXT NOT NULL)",
2285
- "CREATE INDEX IF NOT EXISTS idx_leuco_log_ts ON leuco_log (ts)",
2286
- "CREATE INDEX IF NOT EXISTS idx_leuco_log_type ON leuco_log (type)"
2287
- ]];
2288
- /**
2289
- * SQLite-backed sink built on `bun:sqlite`. Implements both primary and
2290
- * relay roles so the same instance can own seq generation for one bus and
2291
- * mirror records from another (e.g. cross-process replication, restore
2292
- * from a backup stream).
2293
- *
2294
- * Concurrency model: seq is `INTEGER PRIMARY KEY`, so SQLite assigns it
2295
- * atomically via `lastInsertRowid`. Two `LeucoLogger` instances pointed
2296
- * at the same database file therefore see one monotonically increasing
2297
- * seq stream without any bus-level coordination — the database itself is
2298
- * the synchronization point.
2299
- *
2300
- * Schema is version-managed via `PRAGMA user_version`. Migrations are
2301
- * append-only and run in a transaction on every construct so a partial
2302
- * upgrade rolls back cleanly. Caller-defined `indexes` are layered on top
2303
- * via `ALTER TABLE ADD COLUMN` + `CREATE INDEX IF NOT EXISTS`, so adding
2304
- * a new index to an existing database is a no-downtime operation.
2305
- *
2306
- * Type safety: the second generic parameter `I` is the literal tuple of
2307
- * index column names. `extractIndexes` and `getRecords({ where })` are
2308
- * both type-checked against this tuple, so a typo at the call site is a
2309
- * compile-time error rather than a silent miss at runtime.
2310
- *
2311
- * Retention is bounded by `maxRows` and/or `maxAgeMs`. Both run on every
2312
- * insert as a single indexed DELETE that no-ops below the cap.
2313
- *
2314
- * Bulk inserts use `insertMany`, which wraps the batch in one transaction
2315
- * for ~10–100x throughput at the cost of one fsync per batch instead of
2316
- * one per row.
2317
- */
2318
- var LeucoLoggerSqliteSink = class {
2319
- db;
2320
- maxRows;
2321
- maxAgeMs;
2322
- maxBytes;
2323
- targetBytes;
2324
- now;
2325
- indexes;
2326
- extractIndexes;
2327
- insertStmt;
2328
- insertWithSeqStmt;
2329
- maxSeqStmt;
2330
- countStmt;
2331
- trimRowsStmt;
2332
- trimAgeStmt;
2333
- trimOldestStmt;
2334
- insertsSinceByteCheck = 0;
2335
- constructor(props) {
2336
- this.db = new Database(props.path);
2337
- this.db.run("PRAGMA journal_mode = WAL");
2338
- this.migrate();
2339
- this.maxRows = props.maxRows ?? null;
2340
- this.maxAgeMs = props.maxAgeMs ?? null;
2341
- this.maxBytes = props.maxBytes ?? null;
2342
- this.targetBytes = props.targetBytes ?? (props.maxBytes !== void 0 ? Math.floor(props.maxBytes / 4) : null);
2343
- this.now = props.now ?? (() => Date.now());
2344
- this.indexes = props.indexes ?? [];
2345
- if (this.indexes.length > 0) {
2346
- validateIndexNames(this.indexes);
2347
- this.extractIndexes = props.extractIndexes ?? null;
2348
- this.syncIndexColumns();
2349
- } else this.extractIndexes = null;
2350
- const cols = [
2351
- "ts",
2352
- "type",
2353
- "event",
2354
- ...this.indexes
2355
- ];
2356
- const placeholders = cols.map(() => "?").join(", ");
2357
- this.insertStmt = this.db.prepare(`INSERT INTO leuco_log (${cols.join(", ")}) VALUES (${placeholders})`);
2358
- const colsWithSeq = ["seq", ...cols];
2359
- const placeholdersWithSeq = colsWithSeq.map(() => "?").join(", ");
2360
- this.insertWithSeqStmt = this.db.prepare(`INSERT INTO leuco_log (${colsWithSeq.join(", ")}) VALUES (${placeholdersWithSeq})`);
2361
- this.maxSeqStmt = this.db.prepare("SELECT COALESCE(MAX(seq), 0) AS max FROM leuco_log");
2362
- this.countStmt = this.db.prepare("SELECT COUNT(*) AS n FROM leuco_log");
2363
- this.trimRowsStmt = this.db.prepare("DELETE FROM leuco_log WHERE seq <= (SELECT seq FROM leuco_log ORDER BY seq DESC LIMIT 1 OFFSET ?)");
2364
- this.trimAgeStmt = this.db.prepare("DELETE FROM leuco_log WHERE ts < ?");
2365
- this.trimOldestStmt = this.db.prepare("DELETE FROM leuco_log WHERE seq IN (SELECT seq FROM leuco_log ORDER BY seq ASC LIMIT ?)");
2366
- }
2367
- insert(input) {
2368
- try {
2369
- const params = this.buildInsertParams(input.ts, input.event);
2370
- const result = this.insertStmt.run(...params);
2371
- const seq = Number(result.lastInsertRowid);
2372
- this.trim();
2373
- return {
2374
- seq,
2375
- ts: input.ts,
2376
- event: input.event
2377
- };
2378
- } catch (e) {
2379
- return e instanceof Error ? e : new Error(String(e));
2380
- }
2381
- }
2382
- insertMany(inputs) {
2383
- if (inputs.length === 0) return [];
2384
- try {
2385
- const records = [];
2386
- this.db.transaction((batch) => {
2387
- for (const input of batch) {
2388
- const params = this.buildInsertParams(input.ts, input.event);
2389
- const result = this.insertStmt.run(...params);
2390
- records.push({
2391
- seq: Number(result.lastInsertRowid),
2392
- ts: input.ts,
2393
- event: input.event
2394
- });
2395
- }
2396
- })(inputs);
2397
- this.trim();
2398
- return records;
2399
- } catch (e) {
2400
- return e instanceof Error ? e : new Error(String(e));
2401
- }
2402
- }
2403
- write(record) {
2404
- try {
2405
- const params = [record.seq, ...this.buildInsertParams(record.ts, record.event)];
2406
- this.insertWithSeqStmt.run(...params);
2407
- this.trim();
2408
- } catch (e) {
2409
- return e instanceof Error ? e : new Error(String(e));
2410
- }
2411
- }
2412
- getMaxSeq() {
2413
- const row = this.maxSeqStmt.get();
2414
- return row ? row.max : 0;
2415
- }
2416
- getRecords(props = {}) {
2417
- const conditions = ["seq > ?"];
2418
- const params = [props.sinceSeq ?? 0];
2419
- if (typeof props.type === "string") {
2420
- conditions.push("type = ?");
2421
- params.push(props.type);
2422
- }
2423
- if (props.where) this.appendWhereConditions(props.where, conditions, params);
2424
- const limit = props.limit ?? 1e3;
2425
- params.push(limit);
2426
- const dir = props.order === "desc" ? "DESC" : "ASC";
2427
- const sql = `SELECT seq, ts, type, event FROM leuco_log WHERE ${conditions.join(" AND ")} ORDER BY seq ${dir} LIMIT ?`;
2428
- const rows = this.db.prepare(sql).all(...params);
2429
- if (dir === "DESC") rows.reverse();
2430
- return rows.map(toRecord);
2431
- }
2432
- /**
2433
- * Current schema version. Useful for diagnostics and for tests that want
2434
- * to verify migrations ran. Reads `PRAGMA user_version` once per call.
2435
- */
2436
- getSchemaVersion() {
2437
- return this.db.prepare("PRAGMA user_version").get()?.user_version ?? 0;
2438
- }
2439
- close() {
2440
- this.db.close();
2441
- }
2442
- buildInsertParams(ts, event) {
2443
- const type = extractType(event);
2444
- const json = JSON.stringify(event);
2445
- if (this.indexes.length === 0) return [
2446
- ts,
2447
- type,
2448
- json
2449
- ];
2450
- const values = this.extractIndexes ? this.extractIndexes(event) : null;
2451
- return [
2452
- ts,
2453
- type,
2454
- json,
2455
- ...this.indexes.map((col) => values?.[col] ?? null)
2456
- ];
2457
- }
2458
- appendWhereConditions(where, conditions, params) {
2459
- const widened = where;
2460
- for (const col of this.indexes) {
2461
- const value = widened[col];
2462
- if (value === void 0) continue;
2463
- if (value === null) conditions.push(`${col} IS NULL`);
2464
- else {
2465
- conditions.push(`${col} = ?`);
2466
- params.push(value);
2467
- }
2468
- }
2469
- }
2470
- trim() {
2471
- if (this.maxRows !== null) {
2472
- const row = this.countStmt.get();
2473
- if (row && row.n > this.maxRows) this.trimRowsStmt.run(this.maxRows);
2474
- }
2475
- if (this.maxAgeMs !== null) this.trimAgeStmt.run(this.now() - this.maxAgeMs);
2476
- this.maybeTrimBytes();
2477
- }
2478
- /**
2479
- * Throttled byte-size enforcement. Only every BYTE_CHECK_INTERVAL inserts do
2480
- * we measure the file; on overflow we estimate how many of the oldest rows to
2481
- * drop to land near targetBytes (by the byte/row ratio), delete them in one
2482
- * statement, then VACUUM once to return the freed pages to the filesystem (a
2483
- * plain DELETE only frees pages inside the file). One DELETE + one VACUUM per
2484
- * overflow keeps the expensive rewrite rare — the file must refill the whole
2485
- * maxBytes→targetBytes delta before the next overflow can trigger.
2486
- */
2487
- maybeTrimBytes() {
2488
- if (this.maxBytes === null || this.targetBytes === null) return;
2489
- this.insertsSinceByteCheck += 1;
2490
- if (this.insertsSinceByteCheck < BYTE_CHECK_INTERVAL) return;
2491
- this.insertsSinceByteCheck = 0;
2492
- const bytes = this.byteSize();
2493
- if (bytes <= this.maxBytes) return;
2494
- const rows = this.countStmt.get()?.n ?? 0;
2495
- if (rows === 0) return;
2496
- const bytesToFree = bytes - this.targetBytes;
2497
- const bytesPerRow = bytes / rows;
2498
- const rowsToDrop = Math.min(rows, Math.ceil(bytesToFree / bytesPerRow));
2499
- this.trimOldestStmt.run(rowsToDrop);
2500
- this.db.run("VACUUM");
2501
- }
2502
- byteSize() {
2503
- return (this.db.prepare("PRAGMA page_count").get()?.n ?? 0) * (this.db.prepare("PRAGMA page_size").get()?.n ?? 0);
2504
- }
2505
- /** Drop every row and reclaim the file space. Used by `<log>.clear()`. */
2506
- clear() {
2507
- this.db.run("DELETE FROM leuco_log");
2508
- this.db.run("VACUUM");
2509
- this.insertsSinceByteCheck = 0;
2510
- }
2511
- syncIndexColumns() {
2512
- const existing = new Set(this.db.prepare("PRAGMA table_info(leuco_log)").all().map((r) => r.name));
2513
- for (const col of this.indexes) {
2514
- if (!existing.has(col)) this.db.run(`ALTER TABLE leuco_log ADD COLUMN ${col} TEXT`);
2515
- this.db.run(`CREATE INDEX IF NOT EXISTS idx_leuco_log_${col} ON leuco_log (${col})`);
2516
- }
2517
- }
2518
- migrate() {
2519
- const current = this.db.prepare("PRAGMA user_version").get()?.user_version ?? 0;
2520
- if (current >= MIGRATIONS.length) return;
2521
- const pending = MIGRATIONS.slice(current);
2522
- let version = current;
2523
- for (const stmts of pending) {
2524
- version += 1;
2525
- this.db.transaction(() => {
2526
- for (const stmt of stmts) this.db.run(stmt);
2527
- this.db.run(`PRAGMA user_version = ${version}`);
2528
- })();
2529
- }
2530
- }
2531
- };
2532
- function validateIndexNames(names) {
2533
- for (const name of names) {
2534
- if (!COLUMN_NAME_RE.test(name)) throw new Error(`invalid index column name: ${name}`);
2535
- if (RESERVED_COLUMNS.has(name)) throw new Error(`reserved index column name: ${name}`);
2536
- }
2537
- }
2538
- function extractType(event) {
2539
- if (typeof event !== "object" || event === null) return null;
2540
- if (!("type" in event)) return null;
2541
- const t = event.type;
2542
- return typeof t === "string" ? t : null;
2543
- }
2544
- function toRecord(row) {
2545
- return {
2546
- seq: row.seq,
2547
- ts: row.ts,
2548
- event: JSON.parse(row.event)
2549
- };
2550
- }
2551
- //#endregion
2552
- //#region lib/gateway/sqlite-funnel-event-log.ts
2553
- const MAX_CONTENT_CHARS = 2e3;
2554
- /**
2555
- * SQLite-backed `FunnelEventLog`. One indexed table holds every broadcaster
2556
- * event with `channel_id` and `connector_id` as dedicated columns, so
2557
- * per-channel and per-connector replay is an indexed range scan.
2558
- *
2559
- * Concurrency: `seq` is `INTEGER PRIMARY KEY`, so SQLite assigns it
2560
- * atomically. The broadcaster owns its own offset counter at runtime
2561
- * (seeded from `findMaxOffset()` at startup); each broadcaster event
2562
- * flows in here via `record()` with that pre-assigned offset, which the
2563
- * sink stores via `write()` — PK uniqueness catches double-emit bugs.
2564
- *
2565
- * System events (gateway lifecycle, channel connect/disconnect, etc.) do
2566
- * NOT go through this store. They are diagnostic only and live in
2567
- * `FunnelLogger`'s file so the seq space here stays exclusive to
2568
- * broadcaster traffic. This is what makes the broadcaster's seq seeding
2569
- * (`getMaxSeq()` at startup) correct without per-event coordination.
2570
- */
2571
- var SqliteFunnelEventLog = class extends FunnelEventLog {
2572
- sink;
2573
- now;
2574
- constructor(props) {
2575
- super();
2576
- this.now = props.now ?? (() => Date.now());
2577
- this.sink = new LeucoLoggerSqliteSink({
2578
- path: props.path,
2579
- indexes: ["channel_id", "connector_id"],
2580
- extractIndexes: (event) => ({
2581
- channel_id: event.channel_id,
2582
- connector_id: event.connector_id
2583
- }),
2584
- now: this.now,
2585
- ...props.maxRows !== void 0 ? { maxRows: props.maxRows } : {},
2586
- ...props.maxAgeMs !== void 0 ? { maxAgeMs: props.maxAgeMs } : {},
2587
- ...props.maxBytes !== void 0 ? { maxBytes: props.maxBytes } : {},
2588
- ...props.targetBytes !== void 0 ? { targetBytes: props.targetBytes } : {}
2589
- });
2590
- }
2591
- /**
2592
- * Persist a broadcaster-driven event with its assigned offset. Caller
2593
- * (the gateway-server) supplies the offset from `broadcaster.broadcast()`
2594
- * so this store and the broadcaster's in-memory ring stay aligned.
2595
- */
2596
- record(record) {
2597
- const event = {
2598
- type: record.meta?.event_type ?? "unknown",
2599
- content: truncate$1(record.content),
2600
- channel_id: record.channelId,
2601
- connector_id: record.connectorId,
2602
- meta: record.meta
2603
- };
2604
- this.sink.write({
2605
- seq: record.offset,
2606
- ts: this.now(),
2607
- event
2608
- });
2609
- }
2610
- /**
2611
- * Returns events with offset > since. Filtering by channel/connector is
2612
- * the broadcaster's responsibility (it knows the client's subscription),
2613
- * so this returns the full slice and lets the caller filter.
2614
- */
2615
- loadSince(since) {
2616
- const records = this.sink.getRecords({ sinceSeq: since });
2617
- const out = [];
2618
- for (const record of records) out.push({
2619
- content: record.event.content,
2620
- meta: record.event.meta ?? void 0,
2621
- offset: record.seq
2622
- });
2623
- return out;
2624
- }
2625
- /**
2626
- * Returns events for one channel (and optionally one connector). Used
2627
- * by the gateway logs CLI for scoped queries. Channel/connector filters
2628
- * are indexed columns, so this is an indexed range scan.
2629
- */
2630
- loadForChannel(props) {
2631
- const where = { channel_id: props.channelId };
2632
- if (props.connectorId !== void 0) where.connector_id = props.connectorId;
2633
- const records = this.sink.getRecords({
2634
- where,
2635
- ...props.sinceSeq !== void 0 ? { sinceSeq: props.sinceSeq } : {},
2636
- ...props.limit !== void 0 ? { limit: props.limit } : {}
2637
- });
2638
- const out = [];
2639
- for (const record of records) out.push({
2640
- content: record.event.content,
2641
- meta: record.event.meta ?? void 0,
2642
- offset: record.seq
2643
- });
2644
- return out;
2645
- }
2646
- findMaxOffset() {
2647
- return this.sink.getMaxSeq();
2648
- }
2649
- clear() {
2650
- this.sink.clear();
2651
- }
2652
- close() {
2653
- this.sink.close();
2654
- }
2655
- };
2656
- function truncate$1(content) {
2657
- if (content.length <= MAX_CONTENT_CHARS) return content;
2658
- return `${content.slice(0, MAX_CONTENT_CHARS)}...`;
2659
- }
2660
- //#endregion
2661
- //#region lib/gateway/listener-supervisor.ts
2662
- const defaultOnError$1 = () => {};
2663
- const DEFAULT_HEALTH_INTERVAL_MS = 3e4;
2664
- const DEFAULT_MAX_BACKOFF_MS = 6e4;
2665
- const defaultSleep = (ms) => new Promise((r) => {
2666
- setTimeout(r, ms);
2667
- });
2668
- /**
2669
- * Owns the running listener instances and their lifecycle.
2670
- *
2671
- * Lives in the gateway process and is the only place that calls
2672
- * `listener.start()` / `listener.stop()`. Each entry is keyed by
2673
- * `${channelName}/${connectorName}` so the same connector name can exist in
2674
- * multiple channels without colliding.
2675
- *
2676
- * Periodically polls each running listener's `isAlive()` and auto-restarts
2677
- * dead listeners with exponential backoff (1s, 2s, 4s, ... capped). Resets
2678
- * the backoff counter on successful restart.
2679
- */
2680
- var FunnelListenerSupervisor = class FunnelListenerSupervisor {
2681
- channels;
2682
- notify;
2683
- logger;
2684
- onError;
2685
- running = /* @__PURE__ */ new Map();
2686
- failureCounts = /* @__PURE__ */ new Map();
2687
- stats = /* @__PURE__ */ new Map();
2688
- healthCheckIntervalMs;
2689
- maxBackoffMs;
2690
- sleep;
2691
- now;
2692
- healthCheckTimer = null;
2693
- healthCheckInFlight = false;
2694
- constructor(deps) {
2695
- this.channels = deps.channels;
2696
- this.notify = deps.notify;
2697
- this.logger = deps.logger;
2698
- this.onError = deps.onError ?? defaultOnError$1;
2699
- this.healthCheckIntervalMs = deps.healthCheckIntervalMs ?? DEFAULT_HEALTH_INTERVAL_MS;
2700
- this.maxBackoffMs = deps.maxBackoffMs ?? DEFAULT_MAX_BACKOFF_MS;
2701
- this.sleep = deps.sleep ?? defaultSleep;
2702
- this.now = deps.now ?? (() => Date.now());
2703
- }
2704
- static keyOf(channelName, connectorName) {
2705
- return `${channelName}/${connectorName}`;
2706
- }
2707
- isRunning(channelName, connectorName) {
2708
- return this.running.has(FunnelListenerSupervisor.keyOf(channelName, connectorName));
2709
- }
2710
- list() {
2711
- return [...this.running.entries()].map(([key, entry]) => {
2712
- const stats = this.stats.get(key);
2713
- return {
2714
- channelName: entry.channelName,
2715
- channelId: entry.channelId,
2716
- name: entry.config.name,
2717
- type: entry.config.type,
2718
- alive: entry.listener.isAlive(),
2719
- events: stats?.events ?? 0,
2720
- errors: stats?.errors ?? 0,
2721
- failureCount: this.failureCounts.get(key) ?? 0,
2722
- lastEventAt: stats?.lastEventAt ?? null
2723
- };
2724
- });
2725
- }
2726
- async start(channelName, connectorName) {
2727
- const key = FunnelListenerSupervisor.keyOf(channelName, connectorName);
2728
- if (this.running.has(key)) return {
2729
- ok: true,
2730
- reason: "already running"
2731
- };
2732
- const created = this.channels.createListener(channelName, connectorName);
2733
- if (!created) return {
2734
- ok: false,
2735
- reason: `connector "${connectorName}" not found in channel "${channelName}"`
2736
- };
2737
- const bind = async (content, meta) => {
2738
- try {
2739
- await this.notify(channelName, connectorName, content, meta);
2740
- this.recordEvent(key);
2741
- } catch (error) {
2742
- this.recordError(key);
2743
- throw error;
2744
- }
2745
- };
2746
- try {
2747
- await created.listener.start(bind);
2748
- this.running.set(key, {
2749
- config: created.config,
2750
- channelName,
2751
- channelId: created.channelId,
2752
- listener: created.listener
2753
- });
2754
- this.ensureStats(key);
2755
- this.logger?.info(`${created.config.type} listener started`, {
2756
- channel: channelName,
2757
- connector: connectorName
2758
- });
2759
- return { ok: true };
2760
- } catch (error) {
2761
- const err = error instanceof Error ? error : new Error(String(error));
2762
- this.logger?.error(`${created.config.type} listener failed to start`, {
2763
- channel: channelName,
2764
- connector: connectorName,
2765
- error: err.message
2766
- });
2767
- this.onError(err, {
2768
- component: "listener-supervisor.start",
2769
- channel: channelName,
2770
- connector: connectorName,
2771
- type: created.config.type
2772
- });
2773
- return {
2774
- ok: false,
2775
- reason: err.message
2776
- };
2777
- }
2778
- }
2779
- async stop(channelName, connectorName) {
2780
- const key = FunnelListenerSupervisor.keyOf(channelName, connectorName);
2781
- const entry = this.running.get(key);
2782
- if (!entry) return {
2783
- ok: true,
2784
- reason: "not running"
2785
- };
2786
- try {
2787
- await entry.listener.stop();
2788
- this.running.delete(key);
2789
- this.failureCounts.delete(key);
2790
- this.logger?.info(`${entry.config.type} listener stopped`, {
2791
- channel: channelName,
2792
- connector: connectorName
2793
- });
2794
- return { ok: true };
2795
- } catch (error) {
2796
- const err = error instanceof Error ? error : new Error(String(error));
2797
- this.logger?.error(`${entry.config.type} listener failed to stop`, {
2798
- channel: channelName,
2799
- connector: connectorName,
2800
- error: err.message
2801
- });
2802
- this.onError(err, {
2803
- component: "listener-supervisor.stop",
2804
- channel: channelName,
2805
- connector: connectorName,
2806
- type: entry.config.type
2807
- });
2808
- return {
2809
- ok: false,
2810
- reason: err.message
2811
- };
2812
- }
2813
- }
2814
- async restart(channelName, connectorName) {
2815
- const stopped = await this.stop(channelName, connectorName);
2816
- if (!stopped.ok) return stopped;
2817
- return await this.start(channelName, connectorName);
2818
- }
2819
- async startAll() {
2820
- const all = this.channels.listAllConnectors();
2821
- for (const view of all) await this.start(view.channelName, view.name);
2822
- this.startHealthCheck();
2823
- }
2824
- async stopAll() {
2825
- this.stopHealthCheck();
2826
- for (const [, entry] of [...this.running.entries()]) await this.stop(entry.channelName, entry.config.name);
2827
- }
2828
- ensureStats(key) {
2829
- const existing = this.stats.get(key);
2830
- if (existing) return existing;
2831
- const fresh = {
2832
- events: 0,
2833
- errors: 0,
2834
- failureCount: 0,
2835
- lastEventAt: null
2836
- };
2837
- this.stats.set(key, fresh);
2838
- return fresh;
2839
- }
2840
- recordEvent(key) {
2841
- const stats = this.ensureStats(key);
2842
- stats.events += 1;
2843
- stats.lastEventAt = new Date(this.now()).toISOString();
2844
- }
2845
- recordError(key) {
2846
- this.ensureStats(key).errors += 1;
2847
- }
2848
- startHealthCheck() {
2849
- if (this.healthCheckTimer) return;
2850
- this.healthCheckTimer = setInterval(() => {
2851
- this.runHealthCheck();
2852
- }, this.healthCheckIntervalMs);
2853
- this.healthCheckTimer.unref();
2854
- }
2855
- stopHealthCheck() {
2856
- if (!this.healthCheckTimer) return;
2857
- clearInterval(this.healthCheckTimer);
2858
- this.healthCheckTimer = null;
2859
- }
2860
- async runHealthCheck() {
2861
- if (this.healthCheckInFlight) return;
2862
- this.healthCheckInFlight = true;
2863
- try {
2864
- for (const [key, entry] of [...this.running.entries()]) {
2865
- if (entry.listener.isAlive()) {
2866
- this.failureCounts.delete(key);
2867
- continue;
2868
- }
2869
- await this.recoverDead(entry.channelName, entry.config.name, entry.config.type);
2870
- }
2871
- } finally {
2872
- this.healthCheckInFlight = false;
2873
- }
2874
- }
2875
- async recoverDead(channelName, connectorName, type) {
2876
- const key = FunnelListenerSupervisor.keyOf(channelName, connectorName);
2877
- const failureCount = this.failureCounts.get(key) ?? 0;
2878
- const backoffMs = Math.min(1e3 * 2 ** failureCount, this.maxBackoffMs);
2879
- this.logger?.warn(`${type} listener unhealthy, restarting`, {
2880
- channel: channelName,
2881
- connector: connectorName,
2882
- attempt: failureCount + 1,
2883
- backoffMs
2884
- });
2885
- await this.stop(channelName, connectorName);
2886
- await this.sleep(backoffMs);
2887
- if ((await this.start(channelName, connectorName)).ok) {
2888
- this.failureCounts.delete(key);
2889
- this.logger?.info(`${type} listener recovered`, {
2890
- channel: channelName,
2891
- connector: connectorName
2892
- });
2893
- } else this.failureCounts.set(key, failureCount + 1);
2894
- }
2895
- };
2896
- //#endregion
2897
- //#region lib/gateway/kill-competing-slack-gateways.ts
2898
- const defaultProcess = new NodeFunnelProcessRunner();
2899
- const titleFor = (dir) => `funnel-gateway[${dir}]`;
2900
- /**
2901
- * Kills other funnel daemon processes that share the SAME funnel home dir,
2902
- * which is the only situation that causes a real conflict (duplicate Slack
2903
- * Socket Mode connections with the same tokens). Daemons rooted at a
2904
- * different `~/.funnel/` are left alone — they hold different tokens and
2905
- * speak to different Slack apps. The daemon advertises its dir via the
2906
- * `funnel-gateway[<dir>]` marker appended to argv (also assigned to
2907
- * `process.title` on POSIX). `FunnelProcessRunner.listProcessesContaining`
2908
- * absorbs the POSIX/Windows enumeration difference behind the marker match.
2909
- */
2910
- const killCompetingSlackGateways = async (props) => {
2911
- const runner = props.process ?? defaultProcess;
2912
- const logger = props.logger;
2913
- const expectedTitle = titleFor(props.dir);
2914
- const snapshots = runner.listProcessesContaining(expectedTitle);
2915
- const killed = [];
2916
- for (const snapshot of snapshots) {
2917
- if (snapshot.pid === props.selfPid) continue;
2918
- runner.kill(snapshot.pid, "SIGTERM");
2919
- killed.push(snapshot.pid);
2920
- logger?.info("killed competing Slack gateway process", {
2921
- pid: snapshot.pid,
2922
- args: snapshot.command.slice(0, 160)
2923
- });
2924
- }
2925
- return killed;
2926
- };
2927
- //#endregion
2928
- //#region lib/gateway/routes/validator.ts
2929
- /**
2930
- * Path-param validator for gateway routes. On failure it answers with the same
2931
- * `{ ok: false, reason }` shape the listener routes already use, so
2932
- * `FunnelListenersClient` can surface the message without special-casing.
2933
- */
2934
- const zParam = (schema) => zValidator("param", schema, (result, c) => {
2935
- if (result.success) return;
2936
- const issue = result.error.issues[0];
2937
- const reason = issue ? `${issue.path.join(".")}: ${issue.message}` : "invalid request";
2938
- return c.json({
2939
- ok: false,
2940
- reason
2941
- }, 400);
2942
- });
2943
- //#endregion
2944
- //#region lib/gateway/routes/channels.connectors.call.ts
2945
- const bodySchema = z.object({
2946
- method: z.string().min(1),
2947
- path: z.string().min(1),
2948
- body: z.unknown().optional()
2949
- });
2950
- /**
2951
- * POST /channels/:channel/connectors/:connector/call
2952
- *
2953
- * Generic adapter call. Used by the funnel MCP server (running in the Claude
2954
- * Code process) to send replies/reactions/etc. without spawning a CLI
2955
- * subprocess. Mirrors the CLI's `funnel channels <c> connectors <conn> request
2956
- * --method=...` but with a structured JSON body and no shell.
2957
- */
2958
- const channelsConnectorsCallHandler = factory$1.createHandlers(zParam(z.object({
2959
- channel: z.string().min(1),
2960
- connector: z.string().min(1)
2961
- })), async (c) => {
2962
- const param = c.req.valid("param");
2963
- const raw = await c.req.json().catch(() => null);
2964
- const parsed = bodySchema.safeParse(raw);
2965
- if (!parsed.success) throw new HTTPException(400, { message: parsed.error.issues[0]?.message ?? "invalid body" });
2966
- const result = await c.var.deps.channels.call(param.channel, param.connector, {
2967
- method: parsed.data.method,
2968
- path: parsed.data.path,
2969
- body: parsed.data.body ?? {}
2970
- });
2971
- return c.json({
2972
- ok: true,
2973
- result
2974
- });
2975
- });
2976
- //#endregion
2977
- //#region lib/gateway/routes/channels.publish.ts
2978
- /**
2979
- * POST /channels/:channel/publish
2980
- *
2981
- * Inject arbitrary content into a channel. Mirrors the connector-driven `notify`
2982
- * path: events go through `broadcaster.broadcast` + `eventLog.record`, so
2983
- * subscribers see them exactly as if a listener had produced them.
2984
- *
2985
- * Body validation is Zod-shared with the client (`publishRequestSchema`); the
2986
- * response (`publishResponseSchema`) carries the assigned offset so callers can
2987
- * correlate with the persistent event store.
2988
- */
2989
- const channelsPublishHandler$1 = factory$1.createHandlers(zParam(z.object({ channel: z.string().min(1) })), zValidator("json", publishRequestSchema, (result, c) => {
2990
- if (result.success) return;
2991
- const issue = result.error.issues[0];
2992
- const reason = issue ? `${issue.path.join(".")}: ${issue.message}` : "invalid body";
2993
- return c.json({
2994
- ok: false,
2995
- reason
2996
- }, 400);
2997
- }), (c) => {
2998
- const param = c.req.valid("param");
2999
- const body = c.req.valid("json");
3000
- const meta = body.target ? {
3001
- ...body.meta,
3002
- target: body.target
3003
- } : body.meta;
3004
- const response = {
3005
- ok: true,
3006
- offset: c.var.deps.emit({
3007
- channel: param.channel,
3008
- connector: body.connector,
3009
- content: body.content,
3010
- meta
3011
- }).offset
3012
- };
3013
- return c.json(response);
3014
- });
3015
- //#endregion
3016
- //#region lib/gateway/connector-diagnostic-sql-reader.ts
3017
- /**
3018
- * Read-only SQL surface over the three diagnostic tables, for Claude to query
3019
- * the log with arbitrary `SELECT`s. It opens all files read-only and exposes
3020
- * three views — `raw`, `processed`, `connection` — that hide the storage
3021
- * details (the physical table is `leuco_log` and each row's columns live
3022
- * inside a JSON `event` blob): the views surface the columns as plain fields,
3023
- * with `payload` already pulled out of the nested JSON.
3024
- *
3025
- * The tables are separate files. `raw` and `processed` share an `event_id`,
3026
- * so a `JOIN` answers "the event arrived, but what verdict did it get?";
3027
- * `connection` answers the other half — "did the listener ever connect at
3028
- * all?". Writes are impossible: the connection is read-only and `query`
3029
- * rejects anything but a single `SELECT`.
3030
- */
3031
- var ConnectorDiagnosticSqlReader = class {
3032
- db;
3033
- constructor(props) {
3034
- const db = new Database(props.rawPath, { readonly: true });
3035
- try {
3036
- db.run("PRAGMA busy_timeout = 500");
3037
- db.prepare("ATTACH DATABASE ? AS processeddb").run(props.processedPath);
3038
- db.prepare("ATTACH DATABASE ? AS connectiondb").run(props.connectionPath);
3039
- db.run(rawViewSql);
3040
- db.run(processedViewSql);
3041
- db.run(connectionViewSql);
3042
- } catch (error) {
3043
- db.close();
3044
- throw error;
3045
- }
3046
- this.db = db;
3047
- Object.freeze(this);
3048
- }
3049
- /**
3050
- * Run one read-only `SELECT` and return the rows. Returns an `Error` (rather
3051
- * than throwing) for a non-SELECT statement or a SQL error, so the caller
3052
- * can surface the message without a stack trace.
3053
- */
3054
- query(sql, params = []) {
3055
- const trimmed = sql.trim().replace(/;$/, "").trim();
3056
- if (!/^select\b/i.test(trimmed)) return /* @__PURE__ */ new Error("only a single SELECT statement is allowed");
3057
- if (trimmed.includes(";")) return /* @__PURE__ */ new Error("only a single statement is allowed (remove the ';')");
3058
- try {
3059
- return this.db.prepare(trimmed).all(...params);
3060
- } catch (error) {
3061
- return error instanceof Error ? error : new Error(String(error));
3062
- }
3063
- }
3064
- close() {
3065
- this.db.close();
3066
- }
3067
- };
3068
- const rawViewSql = `CREATE TEMP VIEW raw AS SELECT
3069
- seq,
3070
- ts,
3071
- json_extract(event, '$.event_id') AS event_id,
3072
- json_extract(event, '$.type') AS type,
3073
- json_extract(event, '$.connector_id') AS connector_id,
3074
- json_extract(event, '$.channel_id') AS channel_id,
3075
- json_extract(event, '$.payload') AS payload
3076
- FROM main.leuco_log`;
3077
- const processedViewSql = `CREATE TEMP VIEW processed AS SELECT
3078
- seq,
3079
- ts,
3080
- json_extract(event, '$.event_id') AS event_id,
3081
- json_extract(event, '$.type') AS type,
3082
- json_extract(event, '$.connector_id') AS connector_id,
3083
- json_extract(event, '$.channel_id') AS channel_id,
3084
- json_extract(event, '$.outcome') AS outcome,
3085
- json_extract(event, '$.payload') AS payload
3086
- FROM processeddb.leuco_log`;
3087
- const connectionViewSql = `CREATE TEMP VIEW connection AS SELECT
3088
- seq,
3089
- ts,
3090
- json_extract(event, '$.type') AS type,
3091
- json_extract(event, '$.connector_id') AS connector_id,
3092
- json_extract(event, '$.channel_id') AS channel_id,
3093
- json_extract(event, '$.status') AS status,
3094
- json_extract(event, '$.detail') AS detail
3095
- FROM connectiondb.leuco_log`;
3096
- //#endregion
3097
- //#region lib/gateway/routes/debug.ts
3098
- const extractPreview = (payload) => {
3099
- if (typeof payload !== "string" || payload.length === 0) return null;
3100
- try {
3101
- const parsed = JSON.parse(payload);
3102
- if (parsed !== null && typeof parsed === "object" && "text" in parsed) {
3103
- const text = String(parsed.text);
3104
- return text.length > 80 ? `${text.slice(0, 80)}…` : text;
3105
- }
3106
- } catch {
3107
- return payload.length > 80 ? `${payload.slice(0, 80)}…` : payload;
3108
- }
3109
- return payload.length > 80 ? `${payload.slice(0, 80)}…` : payload;
3110
- };
3111
- const buildChannelDiagnosis = (channel) => {
3112
- const rootCause = (channel.connectionErrors[channel.connectionErrors.length - 1] ?? null)?.detail ?? null;
3113
- if (channel.connectors.length === 0) return {
3114
- status: "warn",
3115
- message: "no connectors configured on this channel",
3116
- nextActions: [`fnl channels ${channel.name} connectors add <name> --type=slack ...`],
3117
- rootCause: null
3118
- };
3119
- if (!channel.listener) return {
3120
- status: "error",
3121
- message: "no listener running for this channel",
3122
- nextActions: ["fnl gateway restart"],
3123
- rootCause
3124
- };
3125
- if (!channel.listener.alive) return {
3126
- status: "error",
3127
- message: "listener is dead",
3128
- nextActions: ["fnl gateway logs", "fnl gateway restart"],
3129
- rootCause
3130
- };
3131
- if (channel.claudeClients === 0) return {
3132
- status: "warn",
3133
- message: "no Claude connected to this channel",
3134
- nextActions: [`fnl claude --channel ${channel.name}`],
3135
- rootCause: null
3136
- };
3137
- if (channel.listener.errors > 0) return {
3138
- status: "warn",
3139
- message: "listener has errors",
3140
- nextActions: ["fnl gateway logs"],
3141
- rootCause
3142
- };
3143
- return {
3144
- status: "ok",
3145
- message: "healthy",
3146
- nextActions: [],
3147
- rootCause: null
3148
- };
3149
- };
3150
- /** GET /debug[?channel=<name>] — per-channel diagnosis with recent events. Used by MCP fnl_debug tool. */
3151
- const debugHandler$1 = factory$1.createHandlers(async (c) => {
3152
- const deps = c.var.deps;
3153
- const channelFilter = c.req.query("channel") ?? null;
3154
- const allChannels = deps.channels.list();
3155
- const targetChannels = channelFilter ? allChannels.filter((ch) => ch.name === channelFilter || ch.id === channelFilter) : allChannels;
3156
- const gatewayListeners = deps.supervisor.list();
3157
- const gatewayClients = deps.broadcaster.listChannels();
3158
- const metrics = deps.broadcaster.getMetrics();
3159
- const tmpDir = funnelTmpDir();
3160
- const rawPath = join(tmpDir, "connector-raw.db");
3161
- const processedPath = join(tmpDir, "connector-processed.db");
3162
- const connectionPath = join(tmpDir, "connector-connection.db");
3163
- const hasStore = existsSync(rawPath) && existsSync(processedPath) && existsSync(connectionPath);
3164
- const channels = targetChannels.map((ch) => {
3165
- const listenerEntry = gatewayListeners.find((l) => l.channelName === ch.name) ?? null;
3166
- const listener = listenerEntry ? {
3167
- alive: listenerEntry.alive,
3168
- events: listenerEntry.events,
3169
- errors: listenerEntry.errors,
3170
- lastEventAt: listenerEntry.lastEventAt
3171
- } : null;
3172
- const claudeClients = gatewayClients.filter((cl) => cl.channel === ch.id || cl.channel === ch.name).length;
3173
- const recentEvents = [];
3174
- const connectionErrors = [];
3175
- if (hasStore) {
3176
- const reader = new ConnectorDiagnosticSqlReader({
3177
- rawPath,
3178
- processedPath,
3179
- connectionPath
3180
- });
3181
- const rows = (() => {
3182
- try {
3183
- return reader.query("SELECT seq, ts, type, outcome, payload FROM processed WHERE channel_id = ? ORDER BY seq DESC LIMIT 10", [ch.id]);
3184
- } finally {
3185
- reader.close();
3186
- }
3187
- })();
3188
- if (!(rows instanceof Error)) for (const row of [...rows].reverse()) {
3189
- const rawPayload = typeof row.payload === "string" ? row.payload : null;
3190
- let payloadParsed = null;
3191
- if (rawPayload) try {
3192
- const parsed = JSON.parse(rawPayload);
3193
- if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) payloadParsed = parsed;
3194
- } catch {
3195
- payloadParsed = null;
3196
- }
3197
- recentEvents.push({
3198
- seq: typeof row.seq === "number" ? row.seq : null,
3199
- ts: typeof row.ts === "number" ? row.ts : null,
3200
- type: typeof row.type === "string" ? row.type : "?",
3201
- outcome: typeof row.outcome === "string" ? row.outcome : "?",
3202
- payload: rawPayload,
3203
- payloadParsed,
3204
- preview: extractPreview(row.payload)
3205
- });
3206
- }
3207
- if (listener && (!listener.alive || listener.errors > 0) || !listener) {
3208
- const errReader = new ConnectorDiagnosticSqlReader({
3209
- rawPath,
3210
- processedPath,
3211
- connectionPath
3212
- });
3213
- const errRows = (() => {
3214
- try {
3215
- return errReader.query("SELECT ts, type, status, detail FROM connection WHERE channel_id = ? AND status IN ('auth-failed','error') ORDER BY seq DESC LIMIT 3", [ch.id]);
3216
- } finally {
3217
- errReader.close();
3218
- }
3219
- })();
3220
- if (!(errRows instanceof Error)) for (const row of [...errRows].reverse()) connectionErrors.push({
3221
- ts: typeof row.ts === "number" ? row.ts : null,
3222
- type: typeof row.type === "string" ? row.type : "?",
3223
- status: typeof row.status === "string" ? row.status : "?",
3224
- detail: typeof row.detail === "string" && row.detail.length > 0 ? row.detail : null
3225
- });
3226
- }
3227
- }
3228
- const base = {
3229
- id: ch.id,
3230
- name: ch.name,
3231
- connectors: ch.connectors.map((conn) => conn.name),
3232
- listener,
3233
- claudeClients,
3234
- recentEvents,
3235
- connectionErrors
3236
- };
3237
- return {
3238
- ...base,
3239
- diagnosis: buildChannelDiagnosis(base)
3240
- };
3241
- });
3242
- return c.json({
3243
- pid: deps.selfPid,
3244
- uptimeMs: deps.uptimeMs(),
3245
- eventsBroadcast: metrics.eventsBroadcast,
3246
- channels
3247
- });
3248
- });
3249
- //#endregion
3250
- //#region lib/gateway/routes/health.ts
3251
- /** GET /health — liveness + listener registry snapshot. */
3252
- const healthHandler = factory$1.createHandlers((c) => {
3253
- const deps = c.var.deps;
3254
- return c.json({
3255
- ok: true,
3256
- pid: deps.selfPid,
3257
- clients: deps.broadcaster.getClientCount(),
3258
- listeners: deps.supervisor.list()
3259
- });
3260
- });
3261
- //#endregion
3262
- //#region lib/gateway/routes/listeners.list.ts
3263
- /** GET /listeners — running connector listeners with alive/dead status. */
3264
- const listenersListHandler = factory$1.createHandlers((c) => {
3265
- return c.json({ listeners: c.var.deps.supervisor.list() });
3266
- });
3267
- //#endregion
3268
- //#region lib/gateway/routes/listeners.restart.ts
3269
- /** POST /listeners/:channel/:connector/restart — stop + start a connector listener. */
3270
- const listenersRestartHandler = factory$1.createHandlers(zParam(z.object({
3271
- channel: z.string().min(1),
3272
- connector: z.string().min(1)
3273
- })), async (c) => {
3274
- const param = c.req.valid("param");
3275
- const result = await c.var.deps.supervisor.restart(param.channel, param.connector);
3276
- return c.json(result, result.ok ? 200 : 400);
3277
- });
3278
- //#endregion
3279
- //#region lib/gateway/routes/listeners.start.ts
3280
- /** POST /listeners/:channel/:connector/start — start a connector listener. */
3281
- const listenersStartHandler = factory$1.createHandlers(zParam(z.object({
3282
- channel: z.string().min(1),
3283
- connector: z.string().min(1)
3284
- })), async (c) => {
3285
- const param = c.req.valid("param");
3286
- const result = await c.var.deps.supervisor.start(param.channel, param.connector);
3287
- return c.json(result, result.ok ? 200 : 400);
3288
- });
3289
- //#endregion
3290
- //#region lib/gateway/routes/listeners.stop.ts
3291
- /** DELETE /listeners/:channel/:connector — stop a connector listener. */
3292
- const listenersStopHandler = factory$1.createHandlers(zParam(z.object({
3293
- channel: z.string().min(1),
3294
- connector: z.string().min(1)
3295
- })), async (c) => {
3296
- const param = c.req.valid("param");
3297
- const result = await c.var.deps.supervisor.stop(param.channel, param.connector);
3298
- return c.json(result, result.ok ? 200 : 400);
3299
- });
3300
- //#endregion
3301
- //#region lib/gateway/routes/status.ts
3302
- /** GET /status — listener registry, connected channels, and broadcaster metrics. */
3303
- const statusHandler$1 = factory$1.createHandlers((c) => {
3304
- const deps = c.var.deps;
3305
- return c.json({
3306
- ok: true,
3307
- pid: deps.selfPid,
3308
- uptimeMs: deps.uptimeMs(),
3309
- clients: deps.broadcaster.listChannels(),
3310
- listeners: deps.supervisor.list(),
3311
- broadcaster: deps.broadcaster.getMetrics()
3312
- });
3313
- });
3314
- //#endregion
3315
- //#region lib/gateway/routes/index.ts
3316
- function buildGatewayRoutes() {
3317
- return factory$1.createApp().get("/health", ...healthHandler).get("/status", ...statusHandler$1).get("/debug", ...debugHandler$1).get("/listeners", ...listenersListHandler).post("/listeners/:channel/:connector/start", ...listenersStartHandler).delete("/listeners/:channel/:connector", ...listenersStopHandler).post("/listeners/:channel/:connector/restart", ...listenersRestartHandler).post("/channels/:channel/connectors/:connector/call", ...channelsConnectorsCallHandler).post("/channels/:channel/publish", ...channelsPublishHandler$1);
3318
- }
3319
- const gatewayRoutes = buildGatewayRoutes();
3320
- //#endregion
3321
- //#region lib/gateway/gateway-server.ts
3322
- const DEFAULT_HOST = "127.0.0.1";
3323
- const LOOPBACK_HOSTS = new Set([
3324
- "127.0.0.1",
3325
- "localhost",
3326
- "::1",
3327
- "::ffff:127.0.0.1"
3328
- ]);
3329
- const defaultDbPath = () => join(funnelTmpDir(), "events.db");
3330
- const defaultOnError = () => {};
3331
- /**
3332
- * In-process gateway: runs `Bun.serve` (HTTP + WebSocket /ws), boots connector
3333
- * listeners through `FunnelListenerSupervisor`, fans events out via
3334
- * `FunnelBroadcaster`, and persists them via a `FunnelEventLog` (SQLite by default).
3335
- * System events (gateway lifecycle, connect/disconnect) flow to `FunnelLogger`
3336
- * instead — keeping the SQLite seq space exclusive to broadcaster traffic so
3337
- * the broadcaster's offset counter and `getMaxSeq()` stay aligned without
3338
- * per-event coordination. Exposes `/listeners` HTTP for runtime
3339
- * start/stop/restart of individual connectors.
3340
- */
3341
- var FunnelGatewayServer = class {
3342
- channels;
3343
- settings;
3344
- port;
3345
- hostname;
3346
- dbPath;
3347
- process;
3348
- logger;
3349
- onError;
3350
- selfPid;
3351
- dir;
3352
- killCompetingSlack;
3353
- token;
3354
- broadcaster;
3355
- eventLog;
3356
- supervisor;
3357
- nowMs;
3358
- extraRoutes;
3359
- startedAt = null;
3360
- server = null;
3361
- constructor(deps) {
3362
- this.channels = deps.channels;
3363
- this.settings = deps.settings;
3364
- this.port = deps.port ?? resolveFunnelPort();
3365
- this.hostname = deps.hostname ?? DEFAULT_HOST;
3366
- this.dbPath = deps.dbPath ?? defaultDbPath();
3367
- this.process = deps.process;
3368
- this.logger = deps.logger;
3369
- this.onError = deps.onError ?? defaultOnError;
3370
- this.selfPid = deps.selfPid ?? globalThis.process.pid;
3371
- this.dir = deps.dir ?? FUNNEL_DIR;
3372
- this.killCompetingSlack = deps.killCompetingSlack ?? true;
3373
- this.token = deps.token ?? "";
3374
- this.extraRoutes = deps.extraRoutes ?? null;
3375
- const clock = deps.clock;
3376
- this.nowMs = clock ? () => clock.millis() : () => Date.now();
3377
- if (deps.eventLog) this.eventLog = deps.eventLog;
3378
- else {
3379
- const dbDir = dirname(this.dbPath);
3380
- if (!existsSync(dbDir)) mkdirSync(dbDir, { recursive: true });
3381
- this.eventLog = new SqliteFunnelEventLog({
3382
- path: this.dbPath,
3383
- now: this.nowMs
3384
- });
3385
- }
3386
- this.broadcaster = new FunnelBroadcaster({
3387
- logger: this.logger,
3388
- onError: this.onError,
3389
- now: this.nowMs,
3390
- persistentReplay: this.eventLog
3391
- });
3392
- this.broadcaster.seedLatestOffset(this.eventLog.findMaxOffset());
3393
- this.supervisor = new FunnelListenerSupervisor({
3394
- channels: this.channels,
3395
- logger: this.logger,
3396
- onError: this.onError,
3397
- notify: async (channelName, connectorName, content, meta) => {
3398
- this.emit({
3399
- channel: channelName,
3400
- connector: connectorName,
3401
- content,
3402
- meta
3403
- });
3404
- },
3405
- now: this.nowMs
3406
- });
3407
- }
3408
- async start() {
3409
- if (this.server) return this.server;
3410
- if (!this.token && !LOOPBACK_HOSTS.has(this.hostname)) this.logger?.warn("gateway auth is disabled on a non-loopback bind — every endpoint is reachable without a token", { hostname: this.hostname });
3411
- const app = this.buildApp();
3412
- this.startedAt = this.nowMs();
3413
- this.server = Bun.serve({
3414
- port: this.port,
3415
- hostname: this.hostname,
3416
- development: false,
3417
- fetch: (request, server) => this.handleFetch(request, server, app),
3418
- websocket: {
3419
- open: (ws) => this.handleWsOpen(ws),
3420
- close: (ws) => this.handleWsClose(ws),
3421
- message() {}
3422
- }
3423
- });
3424
- this.logServerStarted();
3425
- await this.bootListeners();
3426
- return this.server;
3427
- }
3428
- async stop() {
3429
- await this.supervisor.stopAll();
3430
- if (this.server) {
3431
- this.server.stop();
3432
- this.server = null;
3433
- }
3434
- }
3435
- getStatus() {
3436
- return {
3437
- clients: this.broadcaster.getClientCount(),
3438
- channels: this.broadcaster.listChannels()
3439
- };
3440
- }
3441
- getBroadcaster() {
3442
- return this.broadcaster;
3443
- }
3444
- getSupervisor() {
3445
- return this.supervisor;
3446
- }
3447
- getEventLog() {
3448
- return this.eventLog;
3449
- }
3450
- /**
3451
- * Register an in-process observer for every broadcast event. Fires after
3452
- * the event is fanned out to WS clients and recorded in the event log.
3453
- * Returns an unsubscribe function. Only meaningful in-process (embedded
3454
- * hosts / `new Funnel(...)` running their own gateway-server); a separate
3455
- * daemon process cannot be observed this way — use a WS client for that.
3456
- */
3457
- onEvent(handler) {
3458
- return this.broadcaster.subscribe(handler);
3459
- }
3460
- handleFetch(request, server, app) {
3461
- const url = new URL(request.url);
3462
- if (url.pathname === "/ws" && request.headers.get("upgrade") === "websocket") {
3463
- if (this.token && !this.tokenMatchesUpgrade(request)) return new Response("unauthorized", { status: 401 });
3464
- const tapAll = url.searchParams.get("tap") === "all";
3465
- const requestedChannel = tapAll ? "" : url.searchParams.get("channel") ?? "";
3466
- const channel = !tapAll && requestedChannel ? this.resolveChannel(requestedChannel) : null;
3467
- const channelId = tapAll ? "" : channel?.id ?? requestedChannel;
3468
- const channelName = tapAll ? null : channel?.name ?? null;
3469
- const connectors = channel?.connectors ?? [];
3470
- const delivery = channel?.delivery ?? "fanout";
3471
- const sinceRaw = url.searchParams.get("since");
3472
- const sinceParsed = sinceRaw === null ? NaN : Number.parseInt(sinceRaw, 10);
3473
- const since = Number.isFinite(sinceParsed) && sinceParsed >= 0 ? sinceParsed : void 0;
3474
- const subscriberId = url.searchParams.get("id") ?? void 0;
3475
- if (server.upgrade(request, { data: {
3476
- channel: channelId,
3477
- channelName,
3478
- connectors,
3479
- tapAll,
3480
- delivery,
3481
- subscriberId,
3482
- since
3483
- } })) return void 0;
3484
- return new Response("WebSocket upgrade failed", { status: 400 });
3485
- }
3486
- return app.fetch(request);
3487
- }
3488
- handleWsOpen(ws) {
3489
- if (typeof ws.data.since === "number") {
3490
- const replay = this.broadcaster.replaySince(ws.data.since, ws.data);
3491
- for (const event of replay) ws.send(JSON.stringify(event));
3492
- }
3493
- this.broadcaster.addClient(ws, ws.data);
3494
- if (ws.data.channelName) {
3495
- const meta = {
3496
- event_type: "system",
3497
- action: "channel_connect",
3498
- channel: ws.data.channelName,
3499
- channelId: ws.data.channel,
3500
- connectors: ws.data.connectors.join(","),
3501
- total: String(this.broadcaster.getClientCount())
3502
- };
3503
- this.logger?.info("channel connected", meta);
3504
- } else this.logger?.info("tap-all client connected", {
3505
- event_type: "system",
3506
- action: "tap_connect",
3507
- total: String(this.broadcaster.getClientCount())
3508
- });
3509
- }
3510
- handleWsClose(ws) {
3511
- this.broadcaster.removeClient(ws);
3512
- if (ws.data.channelName) this.logger?.info("channel disconnected", {
3513
- event_type: "system",
3514
- action: "channel_disconnect",
3515
- channel: ws.data.channelName,
3516
- channelId: ws.data.channel,
3517
- total: String(this.broadcaster.getClientCount())
3518
- });
3519
- else this.logger?.info("tap-all client disconnected", {
3520
- event_type: "system",
3521
- action: "tap_disconnect",
3522
- total: String(this.broadcaster.getClientCount())
3523
- });
3524
- }
3525
- logServerStarted() {
3526
- this.logger?.info("gateway started", {
3527
- event_type: "system",
3528
- action: "gateway_start",
3529
- port: String(this.port),
3530
- pid: String(this.selfPid)
3531
- });
3532
- this.logger?.info("funnel gateway listening", {
3533
- url: `http://localhost:${this.port}`,
3534
- websocket: `ws://localhost:${this.port}/ws`,
3535
- health: `http://localhost:${this.port}/health`
3536
- });
3537
- }
3538
- buildApp() {
3539
- const base = factory$1.createApp();
3540
- base.use((c, next) => {
3541
- c.set("deps", {
3542
- selfPid: this.selfPid,
3543
- broadcaster: this.broadcaster,
3544
- supervisor: this.supervisor,
3545
- channels: this.channels,
3546
- uptimeMs: () => this.startedAt ? this.nowMs() - this.startedAt : 0,
3547
- emit: (input) => this.emit(input)
3548
- });
3549
- return next();
3550
- });
3551
- if (this.token) {
3552
- base.use("/listeners/*", requireBearerToken({ expected: this.token }));
3553
- base.use("/status", requireBearerToken({ expected: this.token }));
3554
- base.use("/debug", requireBearerToken({ expected: this.token }));
3555
- base.use("/channels/*", requireBearerToken({ expected: this.token }));
3556
- }
3557
- return (this.extraRoutes ? base.route("/", this.extraRoutes) : base).route("/", gatewayRoutes);
3558
- }
3559
- /**
3560
- * Reads the bearer token from the WebSocket upgrade request. Accepts:
3561
- * - `Sec-WebSocket-Protocol: funnel.token.<value>` (preferred — header, never logged in URLs)
3562
- * - `Authorization: Bearer <value>` (also header-based)
3563
- * Returns true on a constant-time match against the daemon token.
3564
- */
3565
- tokenMatchesUpgrade(request) {
3566
- const protocols = (request.headers.get("sec-websocket-protocol") ?? "").split(",").map((p) => p.trim()).filter((p) => p.length > 0);
3567
- for (const proto of protocols) if (proto.startsWith("funnel.token.") && constantTimeEqual(proto.slice(13), this.token)) return true;
3568
- const match = (request.headers.get("authorization") ?? "").match(/^Bearer\s+(.+)$/i);
3569
- if (match && constantTimeEqual(match[1] ?? "", this.token)) return true;
3570
- return false;
3571
- }
3572
- resolveChannel(requested) {
3573
- const channel = this.settings.read()?.channels.find((c) => c.id === requested || c.name === requested);
3574
- if (!channel) return null;
3575
- return {
3576
- id: channel.id,
3577
- name: channel.name,
3578
- connectors: channel.connectors.map((c) => c.name),
3579
- delivery: channel.delivery
3580
- };
3581
- }
3582
- async bootListeners() {
3583
- const allConnectors = this.channels.listAllConnectors();
3584
- if (this.killCompetingSlack && allConnectors.some((c) => c.type === "slack")) {
3585
- const killed = await killCompetingSlackGateways({
3586
- selfPid: this.selfPid,
3587
- dir: this.dir,
3588
- process: this.process,
3589
- logger: this.logger
3590
- });
3591
- if (killed.length > 0) this.logger?.info("killed competing Slack gateway processes", {
3592
- event_type: "system",
3593
- action: "kill_competing",
3594
- pids: killed.join(",")
3595
- });
3596
- }
3597
- await this.supervisor.startAll();
3598
- for (const entry of this.supervisor.list()) this.logger?.info(`${entry.type} listener started: ${entry.name}`, {
3599
- event_type: "system",
3600
- action: `${entry.type}_connect`,
3601
- channel: entry.channelName,
3602
- connector: entry.name
3603
- });
3604
- this.logger?.info(`event store: ${this.dbPath}`);
3605
- this.logger?.info("funnel gateway running");
3606
- }
3607
- /**
3608
- * Broadcast `content` to subscribers of `channel`, persisting the event in
3609
- * the SQLite store and stamping `meta.channel{,Id}` / `meta.connector{,Id}`
3610
- * when they resolve. Used by both the connector-listener path (via the
3611
- * supervisor's `notify` callback) and the public `/channels/:channel/publish`
3612
- * route. Returns the assigned event offset.
3613
- */
3614
- emit(input) {
3615
- const channelId = this.lookupChannelId(input.channel);
3616
- const connectorId = channelId && input.connector ? this.lookupConnectorId(channelId, input.connector) : null;
3617
- const enriched = {
3618
- ...input.meta,
3619
- channel: input.channel
3620
- };
3621
- if (input.connector) enriched.connector = input.connector;
3622
- if (channelId) enriched.channelId = channelId;
3623
- if (connectorId) enriched.connectorId = connectorId;
3624
- const event = this.broadcaster.broadcast(input.content, enriched);
3625
- this.eventLog.record({
3626
- content: input.content,
3627
- channelId: channelId ?? null,
3628
- connectorId: connectorId ?? null,
3629
- meta: enriched,
3630
- offset: event.offset
3631
- });
3632
- return { offset: event.offset };
3633
- }
3634
- lookupChannelId(channelName) {
3635
- return this.settings.read().channels.find((c) => c.name === channelName)?.id ?? null;
3636
- }
3637
- lookupConnectorId(channelId, connectorName) {
3638
- return (this.settings.read().channels.find((c) => c.id === channelId)?.connectors.find((c) => c.name === connectorName))?.id ?? null;
3639
- }
3640
- };
3641
- //#endregion
3642
- //#region lib/gateway/gateway-token.ts
3643
- const TOKEN_FILE_NAME = "gateway.token";
3644
- const TOKEN_BYTES = 32;
3645
- const defaultFs = new NodeFunnelFileSystem();
3646
- const defaultGenerate = () => {
3647
- const buf = new Uint8Array(TOKEN_BYTES);
3648
- crypto.getRandomValues(buf);
3649
- return [...buf].map((b) => b.toString(16).padStart(2, "0")).join("");
3650
- };
3651
- /**
3652
- * Reads / generates the gateway daemon token used to authenticate
3653
- * `/listeners*`, `/status`, and `/ws` connections.
3654
- *
3655
- * Token file: `<dir>/gateway.token` (default `~/.funnel/gateway.token`),
3656
- * written with mode 0600. Clients on the same machine as the daemon read
3657
- * the file directly; the token never leaves the user's home directory.
3658
- */
3659
- var FunnelGatewayToken = class {
3660
- fs;
3661
- path;
3662
- generate;
3663
- constructor(deps = {}) {
3664
- this.fs = deps.fs ?? defaultFs;
3665
- this.path = join(deps.dir ?? FUNNEL_DIR, TOKEN_FILE_NAME);
3666
- this.generate = deps.generate ?? defaultGenerate;
3667
- Object.freeze(this);
3668
- }
3669
- read() {
3670
- if (!this.fs.existsSync(this.path)) return null;
3671
- const value = this.fs.readFileSync(this.path).trim();
3672
- return value.length > 0 ? value : null;
3673
- }
3674
- /**
3675
- * Returns the existing token or, if missing, generates one and writes it with mode 0600.
3676
- *
3677
- * NOTE: not atomic — two concurrent `ensure()` calls (e.g., `fnl gateway start` racing
3678
- * itself before the PID lock is acquired) could each generate independent tokens. The
3679
- * gateway PID file makes this practically a non-issue; if you need stronger guarantees,
3680
- * take a file lock around this call externally.
3681
- */
3682
- ensure() {
3683
- const existing = this.read();
3684
- if (existing) return existing;
3685
- const token = this.generate();
3686
- this.fs.mkdirSync(dirname(this.path), { recursive: true });
3687
- this.fs.writeSecretFileSync(this.path, `${token}\n`);
3688
- return token;
895
+ removePid() {
896
+ this.fs.unlink(this.pidFile);
3689
897
  }
3690
- getPath() {
3691
- return this.path;
898
+ isProcessAlive(pid) {
899
+ return this.process.isAlive(pid);
3692
900
  }
3693
901
  };
3694
- const DEFAULT_GATEWAY_TOKEN_PATH = join(homedir(), ".funnel", TOKEN_FILE_NAME);
3695
902
  //#endregion
3696
903
  //#region lib/gateway/listeners-client.ts
3697
904
  const listenerEntrySchema = z.object({
@@ -3827,7 +1034,7 @@ const buildFunnelDebugReport = async (deps, channelFilter) => {
3827
1034
  errors: listenerEntry.errors,
3828
1035
  lastEventAt: listenerEntry.lastEventAt
3829
1036
  } : null;
3830
- const claudeClients = (gatewayData?.clients ?? []).filter((cl) => !cl.tapAll && (cl.channelName === ch.name || cl.channel === ch.name));
1037
+ const claudeClients = (gatewayData?.clients ?? []).filter((cl) => cl.channelName === ch.name || cl.channel === ch.name);
3831
1038
  report.channels.push({
3832
1039
  name: ch.name,
3833
1040
  connectors: ch.connectors.map((conn) => conn.name),
@@ -3870,15 +1077,14 @@ const SANDBOX_DIR = "/sandbox/.funnel";
3870
1077
  const SANDBOX_TMP_DIR = "/sandbox/tmp";
3871
1078
  const noopOnError = () => {};
3872
1079
  /**
3873
- * Facade exposing every funnel facet as a getter.
1080
+ * Facade that wires every funnel facet together and exposes the public surface.
3874
1081
  *
3875
- * The same `Funnel` is used by the CLI and as a programmable library.
3876
- * All side-effecting boundaries (filesystem, process, logger, clock, id, paths) are
3877
- * injectable via `Props` — passing memory implementations gives a fully sandboxed
1082
+ * All side-effecting boundaries (filesystem, process, logger, clock, id, paths)
1083
+ * are injected via Props passing memory implementations gives a fully sandboxed
3878
1084
  * Funnel that touches no real disk, processes, or wall-clock time.
3879
1085
  *
3880
- * Connectors live nested inside their owning channel (channels[].connectors[]),
3881
- * so connector CRUD is reached via `funnel.channels.addConnector(...)` etc.
1086
+ * Fully immutable: all fields are resolved in the constructor and frozen.
1087
+ * No lazy initialisation — every dependency is wired at construction time.
3882
1088
  *
3883
1089
  * @example
3884
1090
  * ```ts
@@ -3889,9 +1095,104 @@ const noopOnError = () => {};
3889
1095
  * ```
3890
1096
  */
3891
1097
  var Funnel = class Funnel {
3892
- memos = {};
1098
+ paths;
1099
+ channels;
1100
+ gateway;
1101
+ gatewayToken;
1102
+ publisher;
1103
+ listeners;
1104
+ claude;
1105
+ profiles;
1106
+ localConfig;
1107
+ localConfigSync;
1108
+ fs;
1109
+ process;
1110
+ logger;
1111
+ clock;
1112
+ onError;
3893
1113
  constructor(props = {}) {
3894
- this.props = props;
1114
+ const dir = props.dir ?? resolveFunnelDir();
1115
+ const tmpDir = props.tmpDir ?? funnelTmpDir();
1116
+ const fs = props.fs ?? new NodeFunnelFileSystem();
1117
+ const process = props.process ?? new NodeFunnelProcessRunner();
1118
+ const clock = props.clock ?? new NodeFunnelClock();
1119
+ const idGenerator = props.idGenerator ?? new NodeFunnelIdGenerator();
1120
+ this.paths = {
1121
+ dir,
1122
+ tmpDir,
1123
+ settings: join(dir, "settings.json")
1124
+ };
1125
+ this.fs = fs;
1126
+ this.process = process;
1127
+ this.logger = props.logger;
1128
+ this.clock = clock;
1129
+ this.onError = props.onError ?? noopOnError;
1130
+ const store = props.store ?? new FunnelSettingsStore({
1131
+ path: this.paths.settings,
1132
+ fs,
1133
+ idGenerator
1134
+ });
1135
+ const factory = new FunnelConnectorFactory({
1136
+ fs,
1137
+ process,
1138
+ logger: this.logger,
1139
+ diagnosticLog: props.diagnosticLog,
1140
+ dir,
1141
+ slackListenerOptions: props.slackListenerOptions,
1142
+ scheduleListenerOptions: props.scheduleListenerOptions
1143
+ });
1144
+ this.channels = new FunnelChannels({
1145
+ store,
1146
+ factory,
1147
+ clock,
1148
+ idGenerator
1149
+ });
1150
+ this.gateway = new FunnelGateway({
1151
+ fs,
1152
+ process,
1153
+ clock,
1154
+ dir,
1155
+ tmpDir,
1156
+ port: props.port
1157
+ });
1158
+ this.gatewayToken = new FunnelGatewayToken({
1159
+ fs,
1160
+ dir
1161
+ });
1162
+ this.publisher = new FunnelChannelPublisher({
1163
+ port: this.gateway.getPort(),
1164
+ isDaemonRunning: () => this.gateway.isRunning(),
1165
+ getToken: () => this.gatewayToken.read()
1166
+ });
1167
+ this.listeners = new FunnelListenersClient({
1168
+ port: this.gateway.getPort(),
1169
+ isDaemonRunning: () => this.gateway.isRunning(),
1170
+ getToken: () => this.gatewayToken.read()
1171
+ });
1172
+ const mcp = new FunnelMcp({ fs });
1173
+ this.profiles = new FunnelProfiles({
1174
+ store,
1175
+ idGenerator,
1176
+ fs
1177
+ });
1178
+ this.localConfig = new FunnelLocalConfig({ fs });
1179
+ this.localConfigSync = new FunnelLocalConfigSync({
1180
+ channels: this.channels,
1181
+ prompter: props.tokenPrompter ?? new NodeFunnelTokenPrompter()
1182
+ });
1183
+ this.claude = new FunnelClaude({
1184
+ channels: this.channels,
1185
+ mcp,
1186
+ gateway: this.gateway,
1187
+ sessions: this.profiles,
1188
+ guard: new FileProcessGuard({
1189
+ fs,
1190
+ process,
1191
+ dir
1192
+ }),
1193
+ process,
1194
+ logger: this.logger
1195
+ });
3895
1196
  Object.freeze(this);
3896
1197
  }
3897
1198
  /**
@@ -3901,6 +1202,7 @@ var Funnel = class Funnel {
3901
1202
  */
3902
1203
  static inMemory(props = {}) {
3903
1204
  return new Funnel({
1205
+ ...props,
3904
1206
  store: props.store ?? new MockFunnelSettingsReader(),
3905
1207
  fs: props.fs ?? new MemoryFunnelFileSystem(),
3906
1208
  process: props.process ?? new MemoryFunnelProcessRunner(),
@@ -3911,184 +1213,6 @@ var Funnel = class Funnel {
3911
1213
  tmpDir: props.tmpDir ?? SANDBOX_TMP_DIR
3912
1214
  });
3913
1215
  }
3914
- /** Resolved on-disk paths the facade will read/write when methods are called. Pure compute, not memoized. */
3915
- get paths() {
3916
- const dir = this.props.dir ?? resolveFunnelDir();
3917
- return {
3918
- dir,
3919
- tmpDir: this.props.tmpDir ?? funnelTmpDir(),
3920
- settings: join(dir, "settings.json")
3921
- };
3922
- }
3923
- /** Filesystem boundary. Defaults to NodeFunnelFileSystem. */
3924
- get fs() {
3925
- if (!this.memos.fs) this.memos.fs = this.props.fs ?? new NodeFunnelFileSystem();
3926
- return this.memos.fs;
3927
- }
3928
- /** Process runner boundary. Defaults to NodeFunnelProcessRunner. */
3929
- get process() {
3930
- if (!this.memos.process) this.memos.process = this.props.process ?? new NodeFunnelProcessRunner();
3931
- return this.memos.process;
3932
- }
3933
- /** Logger boundary. Optional — when no logger is injected, every facet's `this.logger?.x` call is a silent no-op. Production entry points (cli, daemon) inject a NodeFunnelLogger. */
3934
- get logger() {
3935
- return this.props.logger;
3936
- }
3937
- /** Clock boundary. Defaults to NodeFunnelClock. */
3938
- get clock() {
3939
- if (!this.memos.clock) this.memos.clock = this.props.clock ?? new NodeFunnelClock();
3940
- return this.memos.clock;
3941
- }
3942
- /**
3943
- * Error hook. Forwards Funnel-internal exceptions that would otherwise be
3944
- * swallowed. Defaults to a no-op when no host hook was passed.
3945
- */
3946
- get onError() {
3947
- return this.props.onError ?? noopOnError;
3948
- }
3949
- /** ID generator boundary. Defaults to NodeFunnelIdGenerator. */
3950
- get idGenerator() {
3951
- if (!this.memos.idGenerator) this.memos.idGenerator = this.props.idGenerator ?? new NodeFunnelIdGenerator();
3952
- return this.memos.idGenerator;
3953
- }
3954
- /** Settings reader. If not injected, a FunnelSettingsStore rooted at `dir` is created. */
3955
- get store() {
3956
- if (!this.memos.store) this.memos.store = this.props.store ?? new FunnelSettingsStore({
3957
- path: this.paths.settings,
3958
- fs: this.fs,
3959
- idGenerator: this.idGenerator
3960
- });
3961
- return this.memos.store;
3962
- }
3963
- /** Pure factory that constructs per-type listeners and adapters from connector configs. */
3964
- get factory() {
3965
- if (!this.memos.factory) this.memos.factory = new FunnelConnectorFactory({
3966
- fs: this.fs,
3967
- process: this.process,
3968
- logger: this.logger,
3969
- diagnosticLog: this.props.diagnosticLog,
3970
- dir: this.paths.dir,
3971
- slackListenerOptions: this.props.slackListenerOptions,
3972
- scheduleListenerOptions: this.props.scheduleListenerOptions
3973
- });
3974
- return this.memos.factory;
3975
- }
3976
- /** Channel CRUD + nested connector CRUD + schedule entries + listener/adapter dispatch. */
3977
- get channels() {
3978
- if (!this.memos.channels) this.memos.channels = new FunnelChannels({
3979
- store: this.store,
3980
- factory: this.factory,
3981
- profileChecker: this.profiles,
3982
- clock: this.clock,
3983
- idGenerator: this.idGenerator
3984
- });
3985
- return this.memos.channels;
3986
- }
3987
- /** Launch profiles (named presets for `fnl claude`: path + sub-agent + channel id). */
3988
- get profiles() {
3989
- if (!this.memos.profiles) this.memos.profiles = new FunnelProfiles({
3990
- store: this.store,
3991
- idGenerator: this.idGenerator
3992
- });
3993
- return this.memos.profiles;
3994
- }
3995
- /** Reads `funnel.json` from a cwd. `fnl claude` consults it before falling back to the default profile. */
3996
- get localConfig() {
3997
- if (!this.memos.localConfig) this.memos.localConfig = new FunnelLocalConfig({ fs: this.fs });
3998
- return this.memos.localConfig;
3999
- }
4000
- /** Writes the stable `id` into funnel.json on first launch so state can be scoped to `~/.funnel/projects/<id>/`. */
4001
- get localConfigWriter() {
4002
- if (!this.memos.localConfigWriter) this.memos.localConfigWriter = new FunnelLocalConfigWriter({ fs: this.fs });
4003
- return this.memos.localConfigWriter;
4004
- }
4005
- /** Secret prompter. Defaults to a TTY-only stdin reader; tests inject MemoryFunnelTokenPrompter. */
4006
- get tokenPrompter() {
4007
- if (!this.memos.tokenPrompter) this.memos.tokenPrompter = this.props.tokenPrompter ?? new NodeFunnelTokenPrompter();
4008
- return this.memos.tokenPrompter;
4009
- }
4010
- /** Reconciles funnel.json's channel + connectors with `~/.funnel/settings.json` on launch. */
4011
- get localConfigSync() {
4012
- if (!this.memos.localConfigSync) this.memos.localConfigSync = new FunnelLocalConfigSync({
4013
- channels: this.channels,
4014
- prompter: this.tokenPrompter
4015
- });
4016
- return this.memos.localConfigSync;
4017
- }
4018
- /** funnel MCP installer (writes/removes `.mcp.json` entries in target repos). */
4019
- get mcp() {
4020
- if (!this.memos.mcp) this.memos.mcp = new FunnelMcp({ fs: this.fs });
4021
- return this.memos.mcp;
4022
- }
4023
- /** Launch Claude Code with a channel injected via env, MCP installed, gateway ensured. */
4024
- get claude() {
4025
- if (!this.memos.claude) this.memos.claude = new FunnelClaude({
4026
- channels: this.channels,
4027
- mcp: this.mcp,
4028
- gateway: this.gateway,
4029
- profiles: this.profiles,
4030
- fs: this.fs,
4031
- process: this.process,
4032
- idGenerator: this.idGenerator,
4033
- logger: this.logger,
4034
- dir: this.paths.dir
4035
- });
4036
- return this.memos.claude;
4037
- }
4038
- /** Gateway daemon controller (PID-file, start/stop the separate `bun daemon.ts` process). */
4039
- get gateway() {
4040
- if (!this.memos.gateway) this.memos.gateway = new FunnelGateway({
4041
- fs: this.fs,
4042
- process: this.process,
4043
- clock: this.clock,
4044
- dir: this.paths.dir,
4045
- tmpDir: this.paths.tmpDir,
4046
- port: this.props.port
4047
- });
4048
- return this.memos.gateway;
4049
- }
4050
- /** Read / generate the daemon's gateway token (mode 0600 file under `dir`). */
4051
- get gatewayToken() {
4052
- if (!this.memos.gatewayToken) this.memos.gatewayToken = new FunnelGatewayToken({
4053
- fs: this.fs,
4054
- dir: this.paths.dir
4055
- });
4056
- return this.memos.gatewayToken;
4057
- }
4058
- /**
4059
- * HTTP client for `POST /channels/:channel/publish` on the running gateway
4060
- * daemon. Use it to push arbitrary content into a channel from outside any
4061
- * connector. Returns `{ state: "offline" }` if the daemon isn't up.
4062
- */
4063
- get publisher() {
4064
- if (!this.memos.publisher) {
4065
- const gateway = this.gateway;
4066
- const token = this.gatewayToken;
4067
- this.memos.publisher = new FunnelChannelPublisher({
4068
- port: gateway.getPort(),
4069
- isDaemonRunning: () => gateway.isRunning(),
4070
- getToken: () => token.read()
4071
- });
4072
- }
4073
- return this.memos.publisher;
4074
- }
4075
- /**
4076
- * HTTP client for listener operations on the running gateway daemon.
4077
- * Returns `{ state: "offline" }` when the daemon is offline so hot-reload
4078
- * paths stay write-only without parsing strings.
4079
- */
4080
- get listeners() {
4081
- if (!this.memos.listeners) {
4082
- const gateway = this.gateway;
4083
- const token = this.gatewayToken;
4084
- this.memos.listeners = new FunnelListenersClient({
4085
- port: gateway.getPort(),
4086
- isDaemonRunning: () => gateway.isRunning(),
4087
- getToken: () => token.read()
4088
- });
4089
- }
4090
- return this.memos.listeners;
4091
- }
4092
1216
  /**
4093
1217
  * In-process gateway server. Unlike `gateway.start()` (which spawns a daemon),
4094
1218
  * this returns a class that runs `Bun.serve` + listeners inside the current process —
@@ -4097,7 +1221,6 @@ var Funnel = class Funnel {
4097
1221
  gatewayServer(options = {}) {
4098
1222
  return new FunnelGatewayServer({
4099
1223
  channels: this.channels,
4100
- settings: this.store,
4101
1224
  port: options.port,
4102
1225
  hostname: options.hostname,
4103
1226
  dbPath: options.dbPath,
@@ -4112,6 +1235,20 @@ var Funnel = class Funnel {
4112
1235
  extraRoutes: options.extraRoutes
4113
1236
  });
4114
1237
  }
1238
+ /**
1239
+ * Run the gateway daemon in the foreground (tied to this terminal).
1240
+ * For background daemon management, use `funnel.gateway.start()` instead.
1241
+ */
1242
+ async runGatewayForeground(options = {}) {
1243
+ const gatewayScript = resolveDaemonScript();
1244
+ const command = options.caffeinate !== false && globalThis.process.platform === "darwin" ? [
1245
+ "caffeinate",
1246
+ "-is",
1247
+ "bun",
1248
+ gatewayScript
1249
+ ] : ["bun", gatewayScript];
1250
+ return this.process.attach(command);
1251
+ }
4115
1252
  async debug(channelName) {
4116
1253
  return buildFunnelDebugReport({
4117
1254
  gateway: this.gateway,
@@ -4125,331 +1262,6 @@ var Funnel = class Funnel {
4125
1262
  }
4126
1263
  };
4127
1264
  //#endregion
4128
- //#region lib/engine/mcp/channel-subscriber.ts
4129
- const RECONNECT_DELAY = 1e3;
4130
- const MAX_RECONNECT_DELAY = 1e4;
4131
- /**
4132
- * Subscribes to the gateway WebSocket for a single channel and forwards
4133
- * incoming events to the MCP server as `notifications/claude/channel`.
4134
- * Reconnects with exponential backoff and replays missed events via `?since=<offset>`.
4135
- */
4136
- var FunnelChannelSubscriber = class {
4137
- state = {
4138
- reconnectDelay: RECONNECT_DELAY,
4139
- lastOffset: 0
4140
- };
4141
- constructor(props) {
4142
- this.props = props;
4143
- Object.freeze(this);
4144
- }
4145
- start() {
4146
- this.connect();
4147
- }
4148
- connect() {
4149
- const sinceQuery = this.state.lastOffset > 0 ? `&since=${this.state.lastOffset}` : "";
4150
- const wsUrl = `${this.props.baseUrl}${sinceQuery}`;
4151
- const ws = new WebSocket(wsUrl, this.props.protocols);
4152
- ws.addEventListener("open", () => {
4153
- this.state.reconnectDelay = RECONNECT_DELAY;
4154
- process.stderr.write(`funnel: connected (${wsUrl})\n`);
4155
- });
4156
- ws.addEventListener("message", (event) => this.handleMessage(event));
4157
- ws.addEventListener("close", () => {
4158
- process.stderr.write(`funnel: disconnected, reconnecting in ${this.state.reconnectDelay}ms\n`);
4159
- setTimeout(() => this.connect(), this.state.reconnectDelay);
4160
- this.state.reconnectDelay = Math.min(this.state.reconnectDelay * 2, MAX_RECONNECT_DELAY);
4161
- });
4162
- ws.addEventListener("error", () => {});
4163
- }
4164
- async handleMessage(event) {
4165
- try {
4166
- const payload = JSON.parse(String(event.data));
4167
- const eventType = payload.meta?.event_type ?? "unknown";
4168
- if (typeof payload.offset === "number" && payload.offset > this.state.lastOffset) this.state.lastOffset = payload.offset;
4169
- process.stderr.write(`funnel: received event (${eventType})\n`);
4170
- await this.props.server.notification({
4171
- method: "notifications/claude/channel",
4172
- params: {
4173
- content: payload.content,
4174
- meta: payload.meta
4175
- }
4176
- });
4177
- } catch (error) {
4178
- process.stderr.write(`funnel: error: ${error instanceof Error ? error.message : String(error)}\n`);
4179
- }
4180
- }
4181
- };
4182
- //#endregion
4183
- //#region lib/engine/mcp/read-channel-connectors.ts
4184
- const TOOL_CONNECTOR_TYPES = new Set([
4185
- "slack",
4186
- "gh",
4187
- "discord"
4188
- ]);
4189
- const readChannelConnectors = (dir, channelId) => {
4190
- const settingsPath = join(dir, "settings.json");
4191
- if (!existsSync(settingsPath)) return null;
4192
- const raw = JSON.parse(readFileSync(settingsPath, "utf-8"));
4193
- const parsed = settingsSchema.safeParse(raw);
4194
- if (!parsed.success) return null;
4195
- const channel = parsed.data.channels.find((c) => c.id === channelId);
4196
- if (!channel) return null;
4197
- const connectors = channel.connectors.filter((c) => TOOL_CONNECTOR_TYPES.has(c.type)).map((c) => ({
4198
- name: c.name,
4199
- type: c.type
4200
- }));
4201
- return {
4202
- channelName: channel.name,
4203
- connectors
4204
- };
4205
- };
4206
- //#endregion
4207
- //#region lib/engine/mcp/read-gateway-token.ts
4208
- const readGatewayToken = (dir) => {
4209
- const fromEnv = process.env.FUNNEL_GATEWAY_TOKEN;
4210
- if (fromEnv && fromEnv.length > 0) return fromEnv;
4211
- const path = join(dir, "gateway.token");
4212
- if (!existsSync(path)) return null;
4213
- const value = readFileSync(path, "utf-8").trim();
4214
- return value.length > 0 ? value : null;
4215
- };
4216
- //#endregion
4217
- //#region lib/engine/mcp/usage-hint-for-type.ts
4218
- const usageHintForType = (type) => {
4219
- if (type === "slack") return [
4220
- "Slack Web API.",
4221
- "To reply in the same thread: method=POST path=chat.postMessage body={ channel: meta.channel_id, text: \"...\", thread_ts: meta.thread_ts }",
4222
- "To react: method=POST path=reactions.add body={ channel: meta.channel_id, timestamp: meta.thread_ts, name: \"thumbsup\" }",
4223
- "Use meta fields from the incoming event: channel_id (Slack channel), thread_ts (thread anchor), user_id (sender)."
4224
- ].join(" ");
4225
- if (type === "discord") return [
4226
- "Discord REST API.",
4227
- "To reply: method=POST path=/channels/<meta.channel_id>/messages body={ content: \"...\" }",
4228
- "Use meta fields: channel_id (Discord channel), user_id (sender), guild_id."
4229
- ].join(" ");
4230
- if (type === "gh") return [
4231
- "GitHub REST via gh CLI.",
4232
- "To comment: method=POST path=repos/<meta.repository>/issues/<number>/comments body={ body: \"...\" }",
4233
- "Parse <number> from meta.subject_url. meta fields: repository (owner/repo), subject_type, subject_url, reason."
4234
- ].join(" ");
4235
- return "Generic adapter call.";
4236
- };
4237
- //#endregion
4238
- //#region lib/engine/mcp/channel-server.ts
4239
- const DEFAULT_FUNNEL_DIR = join(homedir(), ".funnel");
4240
- const BUILTIN_TOOL_NAMES = ["fnl_status", "fnl_debug"];
4241
- const isBuiltinTool = (name) => BUILTIN_TOOL_NAMES.includes(name);
4242
- const readAllChannels = (dir) => {
4243
- const settingsPath = join(dir, "settings.json");
4244
- if (!existsSync(settingsPath)) return [];
4245
- try {
4246
- const raw = JSON.parse(readFileSync(settingsPath, "utf-8"));
4247
- const parsed = settingsSchema.safeParse(raw);
4248
- if (!parsed.success) return [];
4249
- return parsed.data.channels.map((c) => ({
4250
- id: c.id,
4251
- name: c.name
4252
- }));
4253
- } catch {
4254
- return [];
4255
- }
4256
- };
4257
- const startChannelServer = async (options = {}) => {
4258
- const dir = options.dir ?? DEFAULT_FUNNEL_DIR;
4259
- const gatewayBaseUrl = options.gatewayUrl ?? process.env.FUNNEL_GATEWAY_URL ?? `http://127.0.0.1:${resolveFunnelPort()}`;
4260
- const gatewayWsUrl = `${gatewayBaseUrl.replace(/^http/, "ws")}/ws`;
4261
- const channelId = options.channelId ?? process.env.FUNNEL_CHANNEL_ID;
4262
- const channel = channelId ? readChannelConnectors(dir, channelId) : null;
4263
- const token = options.token ?? readGatewayToken(dir);
4264
- const allChannels = readAllChannels(dir);
4265
- const currentChannelName = channel?.channelName ?? null;
4266
- const channelContext = allChannels.length > 0 ? [
4267
- "",
4268
- "Configured channels (use as the `channel` argument to fnl_debug):",
4269
- ...allChannels.map((ch) => ` ${ch.name}${ch.name === currentChannelName ? " ← this session" : ""}`)
4270
- ].join("\n") : "";
4271
- const server = new Server({
4272
- name: FUNNEL_MCP_NAME,
4273
- version: "1.0.0"
4274
- }, {
4275
- capabilities: {
4276
- experimental: { "claude/channel": {} },
4277
- tools: {}
4278
- },
4279
- instructions: [
4280
- `Events arrive as notifications (method: notifications/claude/channel) with two fields:`,
4281
- ` content — the event payload as a JSON string (parse it to read the message)`,
4282
- ` meta — key/value strings describing the event`,
4283
- "",
4284
- "meta fields by event_type:",
4285
- " slack: event_type=slack channel_id=C… thread_ts=1234.5678 user_id=U… mentioned=true|false",
4286
- " gh: event_type=gh repository=owner/repo subject_type=Issue|PullRequest subject_url=… reason=…",
4287
- " discord: event_type=discord channel_id=… user_id=… guild_id=… mentioned=true|false",
4288
- " schedule: event_type=schedule entry_id=…",
4289
- "",
4290
- "To reply to a Slack message in the same thread, call the connector tool with:",
4291
- ` method: POST`,
4292
- ` path: chat.postMessage`,
4293
- ` body: { channel: meta.channel_id, text: "your reply", thread_ts: meta.thread_ts }`,
4294
- "",
4295
- "To comment on a GitHub issue/PR (extract from subject_url in meta):",
4296
- ` method: POST`,
4297
- ` path: repos/<meta.repository>/issues/<number>/comments (parse number from meta.subject_url)`,
4298
- ` body: { body: "your reply" }`,
4299
- "",
4300
- "Built-in diagnostic tools — call proactively when events seem missing or delayed:",
4301
- " fnl_status — gateway running state, all listeners alive/dead, Claude WS clients",
4302
- " fnl_debug — per-channel diagnosis with last 10 events, rootCause, suggestedActions",
4303
- " omit channel arg to diagnose all channels; check summary.suggestedActions first",
4304
- channelContext
4305
- ].join("\n")
4306
- });
4307
- server.setRequestHandler(ListToolsRequestSchema, async () => {
4308
- const connectorTools = (channel?.connectors ?? []).map((c) => ({
4309
- name: c.name,
4310
- description: `Call the "${c.name}" (${c.type}) connector. ${usageHintForType(c.type)}`,
4311
- inputSchema: {
4312
- type: "object",
4313
- properties: {
4314
- method: {
4315
- type: "string",
4316
- description: "HTTP verb or API method (e.g. POST, chat.postMessage)"
4317
- },
4318
- path: {
4319
- type: "string",
4320
- description: "API path or method name (adapter-specific)"
4321
- },
4322
- body: {
4323
- type: "object",
4324
- description: "Request body / params (adapter-specific)"
4325
- }
4326
- },
4327
- required: ["method", "path"]
4328
- }
4329
- }));
4330
- const channelEnum = allChannels.length > 0 ? allChannels.map((ch) => ch.name) : void 0;
4331
- const builtinTools = [{
4332
- name: "fnl_status",
4333
- description: "Return the current funnel gateway status as JSON — gateway running state, listener alive/dead per channel, and connected Claude WS clients. Call this when you need to check whether the gateway is up or why events stopped arriving.",
4334
- inputSchema: {
4335
- type: "object",
4336
- properties: {}
4337
- }
4338
- }, {
4339
- name: "fnl_debug",
4340
- description: "Return a full channel diagnosis as JSON — gateway health, listener state, Claude WS connection, last 10 inbound events with outcome, connectionErrors (when listener is dead), and diagnosis.rootCause. Call this first when debugging missing events. Omit `channel` to diagnose all channels at once.",
4341
- inputSchema: {
4342
- type: "object",
4343
- properties: { channel: channelEnum ? {
4344
- type: "string",
4345
- description: `Channel name to inspect. One of: ${channelEnum.join(", ")}. Omit to get all channels.`,
4346
- enum: channelEnum
4347
- } : {
4348
- type: "string",
4349
- description: "Channel name to inspect. Omit to get all channels."
4350
- } }
4351
- }
4352
- }];
4353
- return { tools: [...connectorTools, ...builtinTools] };
4354
- });
4355
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
4356
- const toolName = request.params.name;
4357
- if (isBuiltinTool(toolName)) return handleBuiltinTool(toolName, request.params.arguments, gatewayBaseUrl, token, allChannels);
4358
- if (!channel) throw new Error("FUNNEL_CHANNEL_ID is not set or channel not found in settings.json");
4359
- const args = request.params.arguments ?? {};
4360
- const method = typeof args.method === "string" ? args.method : "";
4361
- const path = typeof args.path === "string" ? args.path : "";
4362
- const body = args.body ?? {};
4363
- if (!method || !path) throw new Error("`method` and `path` are required");
4364
- const url = `${gatewayBaseUrl}/channels/${encodeURIComponent(channel.channelName)}/connectors/${encodeURIComponent(toolName)}/call`;
4365
- const headers = { "content-type": "application/json" };
4366
- if (token) headers.authorization = `Bearer ${token}`;
4367
- const res = await fetch(url, {
4368
- method: "POST",
4369
- headers,
4370
- body: JSON.stringify({
4371
- method,
4372
- path,
4373
- body
4374
- })
4375
- });
4376
- const text = await res.text();
4377
- if (!res.ok) throw new Error(`gateway call failed (${res.status}): ${text}`);
4378
- return { content: [{
4379
- type: "text",
4380
- text
4381
- }] };
4382
- });
4383
- const transport = new StdioServerTransport();
4384
- await server.connect(transport);
4385
- if (!channelId) return;
4386
- new FunnelChannelSubscriber({
4387
- server,
4388
- baseUrl: `${gatewayWsUrl}?channel=${encodeURIComponent(channelId)}`,
4389
- protocols: token ? [`funnel.token.${token}`] : void 0
4390
- }).start();
4391
- };
4392
- const handleBuiltinTool = async (name, args, gatewayBaseUrl, token, allChannels) => {
4393
- const headers = {};
4394
- if (token) headers.authorization = `Bearer ${token}`;
4395
- if (name === "fnl_status") {
4396
- const res = await fetch(`${gatewayBaseUrl}/status`, { headers }).catch(() => null);
4397
- if (!res) return { content: [{
4398
- type: "text",
4399
- text: JSON.stringify({
4400
- running: false,
4401
- error: "gateway unreachable",
4402
- hint: "run: fnl gateway start",
4403
- knownChannels: allChannels.map((ch) => ch.name)
4404
- })
4405
- }] };
4406
- const body = await res.json();
4407
- return { content: [{
4408
- type: "text",
4409
- text: JSON.stringify(body)
4410
- }] };
4411
- }
4412
- const channelArg = typeof args?.channel === "string" ? args.channel : null;
4413
- const url = channelArg ? `${gatewayBaseUrl}/debug?channel=${encodeURIComponent(channelArg)}` : `${gatewayBaseUrl}/debug`;
4414
- const res = await fetch(url, { headers }).catch(() => null);
4415
- if (!res) return { content: [{
4416
- type: "text",
4417
- text: JSON.stringify({
4418
- gateway: { running: false },
4419
- channels: allChannels.map((ch) => ({
4420
- id: ch.id,
4421
- name: ch.name,
4422
- diagnosis: {
4423
- status: "error",
4424
- message: "gateway is not running",
4425
- nextAction: "fnl gateway start",
4426
- rootCause: null
4427
- }
4428
- }))
4429
- })
4430
- }] };
4431
- const body = await res.json();
4432
- return { content: [{
4433
- type: "text",
4434
- text: JSON.stringify(body)
4435
- }] };
4436
- };
4437
- //#endregion
4438
- //#region lib/engine/local-config/local-config-json-schema.ts
4439
- /**
4440
- * Generates the JSON Schema (draft 2020-12) for `funnel.json`. Useful for
4441
- * `$schema` references in committed `funnel.json` files so editors can give
4442
- * autocomplete and validation for channels[] (transport) and profiles[]
4443
- * (launch recipe) without anyone hand-maintaining a separate schema.
4444
- */
4445
- const funnelJsonSchema = () => {
4446
- return {
4447
- ...z.toJSONSchema(localConfigSchema, { target: "draft-2020-12" }),
4448
- title: "Funnel per-repo launch config",
4449
- description: "Used by `fnl claude` to declare channels (transport: connectors to materialize into ~/.funnel/settings.json on launch) and profiles (launch recipe: options / env / resume) bound to those channels."
4450
- };
4451
- };
4452
- //#endregion
4453
1265
  //#region lib/engine/logger/node-logger.ts
4454
1266
  const defaultLogFile = () => join(funnelTmpDir(), "funnel.log");
4455
1267
  var NodeFunnelLogger = class extends FunnelLogger {
@@ -4490,493 +1302,6 @@ var NoopFunnelLogger = class extends FunnelLogger {
4490
1302
  error() {}
4491
1303
  };
4492
1304
  //#endregion
4493
- //#region lib/engine/token-prompter/memory-token-prompter.ts
4494
- /**
4495
- * Pre-seeded answers keyed by prompt label. Tests configure the map up front;
4496
- * unmapped labels throw so the test surfaces unexpected prompts loudly.
4497
- */
4498
- var MemoryFunnelTokenPrompter = class extends FunnelTokenPrompter {
4499
- answers;
4500
- asked = [];
4501
- constructor(props = {}) {
4502
- super();
4503
- this.answers = new Map(Object.entries(props.answers ?? {}));
4504
- }
4505
- async promptSecret(label) {
4506
- this.asked.push(label);
4507
- const answer = this.answers.get(label);
4508
- if (answer === void 0) throw new Error(`no answer seeded for prompt "${label}"`);
4509
- return answer;
4510
- }
4511
- };
4512
- //#endregion
4513
- //#region lib/gateway/memory-funnel-event-log.ts
4514
- /**
4515
- * In-process `FunnelEventLog` backed by a plain array. Used by tests and by
4516
- * embedders that do not need durability — replay works within the process
4517
- * lifetime but is lost when the process exits. Unlike the SQLite log it does
4518
- * not truncate content or prune, so it is not meant for unbounded production
4519
- * traffic.
4520
- */
4521
- var MemoryFunnelEventLog = class extends FunnelEventLog {
4522
- events = [];
4523
- constructor() {
4524
- super();
4525
- Object.freeze(this);
4526
- }
4527
- record(record) {
4528
- this.events.push({
4529
- offset: record.offset,
4530
- content: record.content,
4531
- meta: record.meta ?? void 0,
4532
- channelId: record.channelId,
4533
- connectorId: record.connectorId
4534
- });
4535
- }
4536
- loadSince(since) {
4537
- const out = [];
4538
- for (const event of this.events) if (event.offset > since) out.push({
4539
- content: event.content,
4540
- meta: event.meta,
4541
- offset: event.offset
4542
- });
4543
- return out;
4544
- }
4545
- findMaxOffset() {
4546
- let max = 0;
4547
- for (const event of this.events) if (event.offset > max) max = event.offset;
4548
- return max;
4549
- }
4550
- clear() {
4551
- this.events.length = 0;
4552
- }
4553
- close() {}
4554
- };
4555
- //#endregion
4556
- //#region lib/gateway/connector-diagnostic-log.ts
4557
- /**
4558
- * Points in the listener's connection lifecycle. The single source of truth
4559
- * for the value set: the `status` column schema, the `ConnectorConnectionStatus`
4560
- * union, and the runtime Set used to narrow on read-back all derive from this
4561
- * array, so adding a status is a one-line change that cannot drift out of sync.
4562
- *
4563
- * started start() was called
4564
- * connected the socket opened and events can flow
4565
- * disconnected the socket was closed by a stop() call (a clean teardown)
4566
- * auth-failed the token was rejected before the socket opened
4567
- * stopped the listener was fully torn down (always follows a stop(),
4568
- * paired with the disconnected/error that preceded it)
4569
- * error start/stop threw, or Bolt surfaced an error frame — this is
4570
- * also where an unsolicited socket drop shows up when Bolt
4571
- * reports it (an `error` with no following `stopped` means the
4572
- * supervisor recycled the listener, not a clean stop)
4573
- *
4574
- * A connection row is independent of any single inbound event, so it carries
4575
- * no `eventId`. This is how "no notification arrived because the listener
4576
- * never connected (or dropped, or failed auth)" becomes visible: the
4577
- * raw/processed tables only hold events that *did* arrive.
4578
- */
4579
- const CONNECTOR_CONNECTION_STATUSES = [
4580
- "started",
4581
- "connected",
4582
- "disconnected",
4583
- "auth-failed",
4584
- "stopped",
4585
- "error"
4586
- ];
4587
- /**
4588
- * Rows stored in the diagnostic tables. Connector-agnostic on purpose: `type`
4589
- * carries the listener kind ("slack" | "discord" | "gh" | "schedule") so new
4590
- * connectors land in the same tables without a schema change. `event_id` is
4591
- * the correlation key the listener mints once per inbound event and stamps
4592
- * onto both the raw and processed rows, so the two are joinable even though
4593
- * they live in separate tables with independent `seq` counters.
4594
- *
4595
- * These schemas mirror the stored shape (snake_case columns) the way
4596
- * `FunnelEvent` does for the replay log; they exist for `z.infer` and to
4597
- * document the column set, not as a parse boundary.
4598
- */
4599
- const connectorRawEventSchema = z.object({
4600
- event_id: z.string(),
4601
- type: z.string(),
4602
- connector_id: z.string().nullable(),
4603
- channel_id: z.string().nullable(),
4604
- payload: z.string()
4605
- });
4606
- const connectorProcessedEventSchema = z.object({
4607
- event_id: z.string(),
4608
- type: z.string(),
4609
- connector_id: z.string().nullable(),
4610
- channel_id: z.string().nullable(),
4611
- outcome: z.string(),
4612
- payload: z.string()
4613
- });
4614
- const connectorConnectionEventSchema = z.object({
4615
- type: z.string(),
4616
- connector_id: z.string().nullable(),
4617
- channel_id: z.string().nullable(),
4618
- status: z.enum(CONNECTOR_CONNECTION_STATUSES),
4619
- detail: z.string()
4620
- });
4621
- /**
4622
- * Three-table diagnostic log of everything a connector listener does, so
4623
- * "why was there no notification?" is answerable whichever way it failed:
4624
- * - `raw` — every inbound event, before any filtering, with the listener's
4625
- * untouched payload (the Slack Bolt event, the GH webhook, …)
4626
- * - `processed` — the verdict for that event: `outcome` (emitted, or the
4627
- * reason it was dropped) and, when emitted, the body that was delivered.
4628
- * Shares an `eventId` with its raw row, so the two join into one story.
4629
- * - `connection` — the listener's lifecycle (started, connected, dropped,
4630
- * auth-failed, stopped, errored). This is the half the event tables can't
4631
- * show: an event that never arrived leaves no raw row, but a listener that
4632
- * never connected leaves a `connection` trail that says so.
4633
- *
4634
- * The three are physically separate (independent retention and payload-size
4635
- * policy) so a query never crosses them by accident and a huge raw payload
4636
- * never bloats the verdict or lifecycle trails. None flow to WS clients or the
4637
- * MCP channel — this is a separate store from `FunnelEventLog` (replay) and
4638
- * exists solely for debugging.
4639
- *
4640
- * Implementations:
4641
- * - `SqliteConnectorDiagnosticLog` — the default; survives daemon restarts,
4642
- * bounded by per-table row/age caps.
4643
- * - `MemoryConnectorDiagnosticLog` — an in-process double for tests.
4644
- */
4645
- var ConnectorDiagnosticLog = class {};
4646
- //#endregion
4647
- //#region lib/gateway/sqlite-connector-diagnostic-log.ts
4648
- /**
4649
- * Cap on a raw payload kept verbatim. The point of the raw table is to see
4650
- * what Slack/Discord actually sent, and a typical event is a few KB — so 256
4651
- * KiB keeps essentially everything intact while bounding the rare giant
4652
- * payload (a huge Block Kit message, a file dump) that would otherwise let a
4653
- * single row bloat the debug database without limit.
4654
- */
4655
- const RAW_PAYLOAD_CAP = 256 * 1024;
4656
- /**
4657
- * Default `ConnectorDiagnosticLog`: three independent `LeucoLoggerSqliteSink`s, one
4658
- * per table (raw / processed / connection), in separate files. Each sink
4659
- * indexes the columns its queries filter on — `event_id` / `connector_id` /
4660
- * `channel_id` for raw, plus `outcome` for processed and `status` for
4661
- * connection — so those lookups are indexed scans (`type` is a fixed column
4662
- * the sink extracts separately, not an index, so filtering by it is a scan).
4663
- *
4664
- * The raw table offloads any payload over `RAW_PAYLOAD_CAP`: rather than
4665
- * truncating mid-string (which yields unparseable JSON), it replaces the
4666
- * body with a small JSON object that keeps the diagnostic essentials and
4667
- * records the dropped size under `_funnel_oversized`. Every stored payload
4668
- * therefore stays valid JSON.
4669
- */
4670
- var SqliteConnectorDiagnosticLog = class extends ConnectorDiagnosticLog {
4671
- raw;
4672
- processed;
4673
- connection;
4674
- now;
4675
- logger;
4676
- constructor(props) {
4677
- super();
4678
- this.now = props.now ?? (() => Date.now());
4679
- this.logger = props.logger;
4680
- const ageCap = props.maxAgeMs !== void 0 ? { maxAgeMs: props.maxAgeMs } : {};
4681
- const verdictCap = {
4682
- now: this.now,
4683
- ...ageCap,
4684
- ...props.maxRows !== void 0 ? { maxRows: props.maxRows } : {}
4685
- };
4686
- const rawMax = props.rawMaxRows ?? props.maxRows;
4687
- const rawCap = {
4688
- now: this.now,
4689
- ...ageCap,
4690
- ...rawMax !== void 0 ? { maxRows: rawMax } : {}
4691
- };
4692
- this.raw = new LeucoLoggerSqliteSink({
4693
- path: props.rawPath,
4694
- indexes: [
4695
- "event_id",
4696
- "connector_id",
4697
- "channel_id"
4698
- ],
4699
- extractIndexes: (event) => ({
4700
- event_id: event.event_id,
4701
- connector_id: event.connector_id,
4702
- channel_id: event.channel_id
4703
- }),
4704
- ...rawCap
4705
- });
4706
- this.processed = new LeucoLoggerSqliteSink({
4707
- path: props.processedPath,
4708
- indexes: [
4709
- "event_id",
4710
- "connector_id",
4711
- "channel_id",
4712
- "outcome"
4713
- ],
4714
- extractIndexes: (event) => ({
4715
- event_id: event.event_id,
4716
- connector_id: event.connector_id,
4717
- channel_id: event.channel_id,
4718
- outcome: event.outcome
4719
- }),
4720
- ...verdictCap
4721
- });
4722
- this.connection = new LeucoLoggerSqliteSink({
4723
- path: props.connectionPath,
4724
- indexes: [
4725
- "connector_id",
4726
- "channel_id",
4727
- "status"
4728
- ],
4729
- extractIndexes: (event) => ({
4730
- connector_id: event.connector_id,
4731
- channel_id: event.channel_id,
4732
- status: event.status
4733
- }),
4734
- ...verdictCap
4735
- });
4736
- restrictPermissions(props.rawPath);
4737
- restrictPermissions(props.processedPath);
4738
- restrictPermissions(props.connectionPath);
4739
- Object.freeze(this);
4740
- }
4741
- recordRaw(record) {
4742
- const event = {
4743
- event_id: record.eventId,
4744
- type: record.type,
4745
- connector_id: record.connectorId,
4746
- channel_id: record.channelId,
4747
- payload: capPayload(record.payload, record.type)
4748
- };
4749
- this.report("raw", this.raw.insert({
4750
- ts: this.now(),
4751
- event
4752
- }));
4753
- }
4754
- recordProcessed(record) {
4755
- const event = {
4756
- event_id: record.eventId,
4757
- type: record.type,
4758
- connector_id: record.connectorId,
4759
- channel_id: record.channelId,
4760
- outcome: record.outcome,
4761
- payload: record.payload
4762
- };
4763
- this.report("processed", this.processed.insert({
4764
- ts: this.now(),
4765
- event
4766
- }));
4767
- }
4768
- recordConnection(record) {
4769
- const event = {
4770
- type: record.type,
4771
- connector_id: record.connectorId,
4772
- channel_id: record.channelId,
4773
- status: record.status,
4774
- detail: record.detail
4775
- };
4776
- this.report("connection", this.connection.insert({
4777
- ts: this.now(),
4778
- event
4779
- }));
4780
- }
4781
- report(table, result) {
4782
- if (result instanceof Error) this.logger?.error("diagnostic log insert failed", {
4783
- table,
4784
- error: result.message
4785
- });
4786
- }
4787
- queryRaw(query) {
4788
- return this.raw.getRecords({
4789
- ...query.type !== void 0 ? { type: query.type } : {},
4790
- ...query.limit !== void 0 ? { limit: query.limit } : {},
4791
- where: buildWhere(query),
4792
- order: "desc"
4793
- }).map((record) => ({
4794
- seq: record.seq,
4795
- ts: record.ts,
4796
- eventId: record.event.event_id,
4797
- type: record.event.type,
4798
- connectorId: record.event.connector_id,
4799
- channelId: record.event.channel_id,
4800
- payload: record.event.payload
4801
- }));
4802
- }
4803
- queryProcessed(query) {
4804
- const where = buildWhere(query);
4805
- if (query.outcome !== void 0) where.outcome = query.outcome;
4806
- return this.processed.getRecords({
4807
- ...query.type !== void 0 ? { type: query.type } : {},
4808
- ...query.limit !== void 0 ? { limit: query.limit } : {},
4809
- where,
4810
- order: "desc"
4811
- }).map((record) => ({
4812
- seq: record.seq,
4813
- ts: record.ts,
4814
- eventId: record.event.event_id,
4815
- type: record.event.type,
4816
- connectorId: record.event.connector_id,
4817
- channelId: record.event.channel_id,
4818
- outcome: record.event.outcome,
4819
- payload: record.event.payload
4820
- }));
4821
- }
4822
- queryConnection(query) {
4823
- const where = buildWhere(query);
4824
- if (query.status !== void 0) where.status = query.status;
4825
- return this.connection.getRecords({
4826
- ...query.type !== void 0 ? { type: query.type } : {},
4827
- ...query.limit !== void 0 ? { limit: query.limit } : {},
4828
- where,
4829
- order: "desc"
4830
- }).map((record) => ({
4831
- seq: record.seq,
4832
- ts: record.ts,
4833
- type: record.event.type,
4834
- connectorId: record.event.connector_id,
4835
- channelId: record.event.channel_id,
4836
- status: statusOf(record.event.status),
4837
- detail: record.event.detail
4838
- }));
4839
- }
4840
- clear() {
4841
- this.raw.clear();
4842
- this.processed.clear();
4843
- this.connection.clear();
4844
- }
4845
- close() {
4846
- this.raw.close();
4847
- this.processed.close();
4848
- this.connection.close();
4849
- }
4850
- };
4851
- const restrictPermissions = (path) => {
4852
- if (path === ":memory:") return;
4853
- for (const suffix of [
4854
- "",
4855
- "-wal",
4856
- "-shm"
4857
- ]) try {
4858
- chmodSync(`${path}${suffix}`, 384);
4859
- } catch {}
4860
- };
4861
- const buildWhere = (query) => {
4862
- const where = {};
4863
- if (query.connectorId !== void 0) where.connector_id = query.connectorId;
4864
- if (query.channelId !== void 0) where.channel_id = query.channelId;
4865
- return where;
4866
- };
4867
- const statusField = connectorConnectionEventSchema.shape.status;
4868
- const statusOf = (value) => {
4869
- const parsed = statusField.safeParse(value);
4870
- return parsed.success ? parsed.data : "error";
4871
- };
4872
- const capPayload = (payload, type) => {
4873
- const size = Buffer.byteLength(payload, "utf8");
4874
- if (size <= RAW_PAYLOAD_CAP) return payload;
4875
- return JSON.stringify({
4876
- ...headFields(payload),
4877
- _funnel_oversized: size,
4878
- _funnel_type: type
4879
- });
4880
- };
4881
- const HEAD_KEYS = [
4882
- "type",
4883
- "subtype",
4884
- "ts",
4885
- "channel",
4886
- "channel_type",
4887
- "user",
4888
- "bot_id"
4889
- ];
4890
- const headFields = (payload) => {
4891
- try {
4892
- const parsed = JSON.parse(payload);
4893
- if (typeof parsed !== "object" || parsed === null) return {};
4894
- const source = parsed;
4895
- const head = {};
4896
- for (const key of HEAD_KEYS) if (source[key] !== void 0) head[key] = source[key];
4897
- return head;
4898
- } catch {
4899
- return {};
4900
- }
4901
- };
4902
- //#endregion
4903
- //#region lib/gateway/memory-connector-diagnostic-log.ts
4904
- /**
4905
- * In-process `ConnectorDiagnosticLog` backed by one array per table. Used by tests
4906
- * and embedders that do not need durability. Like the SQLite log it keeps
4907
- * `seq` per-table (each array's 1-based position) and returns the most recent
4908
- * `limit` rows oldest-first; unlike it, it never prunes and never offloads
4909
- * oversized payloads — it keeps whatever the caller hands it, which is fine
4910
- * for the bounded volumes a test produces. Payload-validity is therefore a
4911
- * SQLite-only guarantee; do not write a test that leans on this double
4912
- * rejecting a malformed payload.
4913
- */
4914
- var MemoryConnectorDiagnosticLog = class extends ConnectorDiagnosticLog {
4915
- raws = [];
4916
- processeds = [];
4917
- connections = [];
4918
- constructor(now = () => Date.now()) {
4919
- super();
4920
- this.now = now;
4921
- Object.freeze(this);
4922
- }
4923
- recordRaw(record) {
4924
- this.raws.push({
4925
- ...record,
4926
- seq: this.raws.length + 1,
4927
- ts: this.now()
4928
- });
4929
- }
4930
- recordProcessed(record) {
4931
- this.processeds.push({
4932
- ...record,
4933
- seq: this.processeds.length + 1,
4934
- ts: this.now()
4935
- });
4936
- }
4937
- recordConnection(record) {
4938
- this.connections.push({
4939
- ...record,
4940
- seq: this.connections.length + 1,
4941
- ts: this.now()
4942
- });
4943
- }
4944
- queryRaw(query) {
4945
- return takeRecent(this.raws.filter((event) => matches(event, query)), query.limit);
4946
- }
4947
- queryProcessed(query) {
4948
- return takeRecent(this.processeds.filter((event) => {
4949
- if (!matches(event, query)) return false;
4950
- if (query.outcome !== void 0 && event.outcome !== query.outcome) return false;
4951
- return true;
4952
- }), query.limit);
4953
- }
4954
- queryConnection(query) {
4955
- return takeRecent(this.connections.filter((event) => {
4956
- if (!matches(event, query)) return false;
4957
- if (query.status !== void 0 && event.status !== query.status) return false;
4958
- return true;
4959
- }), query.limit);
4960
- }
4961
- clear() {
4962
- this.raws.length = 0;
4963
- this.processeds.length = 0;
4964
- this.connections.length = 0;
4965
- }
4966
- close() {}
4967
- };
4968
- const matches = (event, query) => {
4969
- if (query.type !== void 0 && event.type !== query.type) return false;
4970
- if (query.connectorId !== void 0 && event.connectorId !== query.connectorId) return false;
4971
- if (query.channelId !== void 0 && event.channelId !== query.channelId) return false;
4972
- return true;
4973
- };
4974
- const takeRecent = (events, limit) => {
4975
- if (limit === void 0) return events;
4976
- if (limit <= 0) return [];
4977
- return events.slice(-limit);
4978
- };
4979
- //#endregion
4980
1305
  //#region lib/cli/factory.ts
4981
1306
  const factory = createFactory();
4982
1307
  //#endregion
@@ -5465,7 +1790,6 @@ modes:
5465
1790
  fanout every connected WS client receives every event (default)
5466
1791
  exclusive each event is delivered to exactly one connected client (round-robin)
5467
1792
 
5468
- tap=all clients (TUI dashboard, debugging) always receive regardless of mode.
5469
1793
  `), (c) => {
5470
1794
  const param = c.req.valid("param");
5471
1795
  c.env.funnel.channels.setDelivery(param.channel, param.mode);
@@ -5662,19 +1986,19 @@ const claudeHandler = factory.createHandlers(zValidator$1("query", z.object({
5662
1986
  channel: z.string().optional()
5663
1987
  }).passthrough(), claudeHelp), async (c) => {
5664
1988
  const query = c.req.valid("query");
5665
- const funnel = c.env.funnel;
1989
+ const { funnel, claude, profiles, localConfig, localConfigSync } = c.env;
5666
1990
  const userArgs = queryToCliArgs(c.req.url, RESERVED_KEYS$1);
5667
1991
  if (query.channel && !query.profile) {
5668
- const exitCode = await funnel.claude.launch({
1992
+ const exitCode = await claude.launch({
5669
1993
  channel: query.channel,
5670
1994
  userArgs
5671
1995
  });
5672
1996
  process.exit(exitCode);
5673
1997
  }
5674
1998
  if (query.profile) {
5675
- const profile = funnel.profiles.get(query.profile);
1999
+ const profile = profiles.get(query.profile);
5676
2000
  if (!profile) throw new HTTPException(404, { message: `profile "${query.profile}" not found` });
5677
- const exitCode = await funnel.claude.launch({
2001
+ const exitCode = await claude.launch({
5678
2002
  channel: profile.channelId,
5679
2003
  cwd: profile.path,
5680
2004
  userArgs,
@@ -5686,24 +2010,24 @@ const claudeHandler = factory.createHandlers(zValidator$1("query", z.object({
5686
2010
  process.exit(exitCode);
5687
2011
  }
5688
2012
  const cwd = process.cwd();
5689
- const local = funnel.localConfig.read(cwd);
2013
+ const local = localConfig.read(cwd);
5690
2014
  if (local) {
5691
2015
  const picked = query.channel !== void 0 ? local.channels.find((c) => c.name === query.channel) : local.channels[0];
5692
2016
  if (!picked) throw new HTTPException(404, { message: query.channel ? `channel "${query.channel}" is not declared in funnel.json` : `funnel.json declares no channels` });
5693
- const synced = await funnel.localConfigSync.ensure(picked);
2017
+ const synced = await localConfigSync.ensure(picked);
5694
2018
  for (const outcome of synced.touched) if (outcome.changed) await funnel.listeners.restart(picked.name, outcome.name);
5695
2019
  else await funnel.listeners.start(picked.name, outcome.name);
5696
2020
  for (const name of synced.removed) await funnel.listeners.stop(picked.name, name);
5697
- const exitCode = await funnel.claude.launch({
2021
+ const exitCode = await claude.launch({
5698
2022
  channel: picked.name,
5699
2023
  cwd,
5700
2024
  userArgs
5701
2025
  });
5702
2026
  process.exit(exitCode);
5703
2027
  }
5704
- const defaultProfile = funnel.profiles.getDefault();
2028
+ const defaultProfile = profiles.getDefault();
5705
2029
  if (!defaultProfile) return c.text(claudeHelp);
5706
- const exitCode = await funnel.claude.launch({
2030
+ const exitCode = await claude.launch({
5707
2031
  channel: defaultProfile.channelId,
5708
2032
  cwd: defaultProfile.path,
5709
2033
  userArgs,
@@ -6196,7 +2520,7 @@ const buildChannelReport = async (targetChannel, gatewayStatus, gatewayBodyOrNul
6196
2520
  errors: l.errors,
6197
2521
  lastEventAt: l.lastEventAt
6198
2522
  }));
6199
- baseReport.claudeClients = gatewayBodyOrNull.clients.filter((cl) => !cl.tapAll && cl.channelName === targetChannelName).length;
2523
+ baseReport.claudeClients = gatewayBodyOrNull.clients.filter((cl) => cl.channelName === targetChannelName).length;
6200
2524
  }
6201
2525
  if (store) {
6202
2526
  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]);
@@ -6706,15 +3030,7 @@ examples:
6706
3030
  funnel gateway run
6707
3031
  funnel gateway run --no-caffeine`), async (c) => {
6708
3032
  const query = c.req.valid("query");
6709
- const funnel = c.env.funnel;
6710
- const gatewayScript = resolveDaemonScript();
6711
- const command = query["no-caffeine"] !== "true" && process.platform === "darwin" ? [
6712
- "caffeinate",
6713
- "-is",
6714
- "bun",
6715
- gatewayScript
6716
- ] : ["bun", gatewayScript];
6717
- const exitCode = await funnel.process.attach(command);
3033
+ const exitCode = await c.env.funnel.runGatewayForeground({ caffeinate: query["no-caffeine"] !== "true" });
6718
3034
  process.exit(exitCode);
6719
3035
  });
6720
3036
  //#endregion
@@ -6858,10 +3174,11 @@ const profilesAddHandler = factory.createHandlers(zValidator$1("param", z.object
6858
3174
  const param = c.req.valid("param");
6859
3175
  const query = c.req.valid("query");
6860
3176
  const funnel = c.env.funnel;
3177
+ const { profiles, claude } = c.env;
6861
3178
  const channel = funnel.channels.get(query.channel);
6862
3179
  if (!channel) throw new HTTPException(400, { message: `channel "${query.channel}" not found` });
6863
3180
  const recipe = parseProfileRecipe(query);
6864
- funnel.profiles.add({
3181
+ profiles.add({
6865
3182
  name: param.profile,
6866
3183
  path: query.path,
6867
3184
  channelId: channel.id,
@@ -6877,7 +3194,9 @@ usage: funnel profiles <name> as-default
6877
3194
 
6878
3195
  the first profile in the list is treated as the default for fnl claude.`), (c) => {
6879
3196
  const param = c.req.valid("param");
6880
- c.env.funnel.profiles.asDefault(param.profile);
3197
+ c.env.funnel;
3198
+ const { profiles, claude } = c.env;
3199
+ profiles.asDefault(param.profile);
6881
3200
  return c.text(`profile "${param.profile}" is now the default`);
6882
3201
  });
6883
3202
  //#endregion
@@ -6903,7 +3222,9 @@ const profilesRenameHandler = factory.createHandlers(zValidator$1("param", z.obj
6903
3222
  newName: z.string()
6904
3223
  })), zValidator$1("query", z.object({})), (c) => {
6905
3224
  const param = c.req.valid("param");
6906
- c.env.funnel.profiles.rename(param.profile, param.newName);
3225
+ c.env.funnel;
3226
+ const { profiles, claude } = c.env;
3227
+ profiles.rename(param.profile, param.newName);
6907
3228
  return c.text(`renamed profile "${param.profile}" to "${param.newName}"`);
6908
3229
  });
6909
3230
  //#endregion
@@ -6915,10 +3236,11 @@ usage: funnel profiles <name> run [additional claude args...]
6915
3236
  const RESERVED_KEYS = [];
6916
3237
  const profilesLaunchHandler = factory.createHandlers(zValidator$1("param", z.object({ profile: z.string() })), zValidator$1("query", z.object({}).passthrough(), launchHelp), async (c) => {
6917
3238
  const param = c.req.valid("param");
6918
- const funnel = c.env.funnel;
6919
- const profile = funnel.profiles.get(param.profile);
3239
+ c.env.funnel;
3240
+ const { profiles, claude } = c.env;
3241
+ const profile = profiles.get(param.profile);
6920
3242
  if (!profile) throw new HTTPException(404, { message: `profile "${param.profile}" not found` });
6921
- const exitCode = await funnel.claude.launch({
3243
+ const exitCode = await claude.launch({
6922
3244
  channel: profile.channelId,
6923
3245
  cwd: profile.path,
6924
3246
  userArgs: queryToCliArgs(c.req.url, RESERVED_KEYS),
@@ -6939,7 +3261,9 @@ const profilesRemoveHelpHandler = factory.createHandlers((c) => c.text(help$1));
6939
3261
  //#region lib/cli/routes/profiles.remove.$profile.ts
6940
3262
  const profilesRemoveHandler = factory.createHandlers(zValidator$1("param", z.object({ profile: z.string() })), zValidator$1("query", z.object({})), (c) => {
6941
3263
  const param = c.req.valid("param");
6942
- c.env.funnel.profiles.remove(param.profile);
3264
+ c.env.funnel;
3265
+ const { profiles, claude } = c.env;
3266
+ profiles.remove(param.profile);
6943
3267
  return c.text(`removed profile "${param.profile}"`);
6944
3268
  });
6945
3269
  //#endregion
@@ -6973,10 +3297,11 @@ const profilesSetHandler = factory.createHandlers(zValidator$1("param", z.object
6973
3297
  const param = c.req.valid("param");
6974
3298
  const query = c.req.valid("query");
6975
3299
  const funnel = c.env.funnel;
3300
+ const { profiles, claude } = c.env;
6976
3301
  const channel = query.channel !== void 0 ? funnel.channels.get(query.channel) : null;
6977
3302
  if (query.channel !== void 0 && !channel) throw new HTTPException(400, { message: `channel "${query.channel}" not found` });
6978
3303
  const recipe = parseProfileRecipe(query);
6979
- funnel.profiles.update(param.profile, {
3304
+ profiles.update(param.profile, {
6980
3305
  path: query.path,
6981
3306
  channelId: channel?.id,
6982
3307
  options: recipe.options,
@@ -7007,9 +3332,11 @@ examples:
7007
3332
  funnel profiles add cto --path /repo/myapp --channel prod-inbox --agent pm --options "--brief"
7008
3333
  funnel profiles cto as-default
7009
3334
  funnel profiles cto run`), (c) => {
7010
- const profiles = c.env.funnel.profiles.list();
7011
- if (profiles.length === 0) return c.text("no profiles");
7012
- const lines = profiles.map((profile, index) => {
3335
+ c.env.funnel;
3336
+ const { profiles } = c.env;
3337
+ const profileList = profiles.list();
3338
+ if (profileList.length === 0) return c.text("no profiles");
3339
+ const lines = profileList.map((profile, index) => {
7013
3340
  const tag = index === 0 ? " (default)" : "";
7014
3341
  const recipe = profile.options.length > 0 ? `, options=${profile.options.join(" ")}` : "";
7015
3342
  const session = profile.resume ? "" : ", resume=false";
@@ -7061,9 +3388,9 @@ const isGatewayStatus = (value) => {
7061
3388
  if (!("listeners" in value) || !Array.isArray(value.listeners)) return false;
7062
3389
  return true;
7063
3390
  };
7064
- const buildStatusLines = async (funnel) => {
3391
+ const buildStatusLines = async (funnel, profiles) => {
7065
3392
  const channels = funnel.channels.list();
7066
- const profiles = funnel.profiles.list();
3393
+ const profileList = profiles.list();
7067
3394
  const gatewayStatus = funnel.gateway.getStatus();
7068
3395
  const lines = [];
7069
3396
  lines.push("= funnel status =");
@@ -7089,7 +3416,6 @@ const buildStatusLines = async (funnel) => {
7089
3416
  const listenerAliveByChannel = /* @__PURE__ */ new Map();
7090
3417
  if (gatewayData) {
7091
3418
  for (const client of gatewayData.clients) {
7092
- if (client.tapAll) continue;
7093
3419
  const key = client.channelName ?? client.channel;
7094
3420
  clientsByChannel.set(key, (clientsByChannel.get(key) ?? 0) + 1);
7095
3421
  }
@@ -7110,8 +3436,8 @@ const buildStatusLines = async (funnel) => {
7110
3436
  lines.push(` ${indicator} ${paddedName} [${connectorLabel}]${claudeLabel}`);
7111
3437
  }
7112
3438
  lines.push("");
7113
- lines.push(`profiles: ${profiles.length}`);
7114
- for (const [index, profile] of profiles.entries()) {
3439
+ lines.push(`profiles: ${profileList.length}`);
3440
+ for (const [index, profile] of profileList.entries()) {
7115
3441
  const tag = index === 0 ? " (default)" : "";
7116
3442
  const channel = funnel.channels.getById(profile.channelId);
7117
3443
  const channelLabel = channel ? channel.name : `id:${profile.channelId}`;
@@ -7132,11 +3458,11 @@ const statusHandler = factory.createHandlers(zValidator$1("query", z.object({
7132
3458
  const isWatch = query.watch === "true" || query.watch === "";
7133
3459
  const intervalSec = Math.min(60, Math.max(1, query.interval ? Number(query.interval) : 3));
7134
3460
  if (!isWatch) {
7135
- const lines = await buildStatusLines(funnel);
3461
+ const lines = await buildStatusLines(funnel, c.env.profiles);
7136
3462
  return c.text(lines.join("\n"));
7137
3463
  }
7138
3464
  const render = async () => {
7139
- const lines = await buildStatusLines(funnel);
3465
+ const lines = await buildStatusLines(funnel, c.env.profiles);
7140
3466
  const ts = (/* @__PURE__ */ new Date()).toISOString().slice(11, 19);
7141
3467
  process.stdout.write("\x1B[2J\x1B[H");
7142
3468
  process.stdout.write(lines.join("\n"));
@@ -7178,4 +3504,4 @@ const routes = factory.createApp().onError((error, c) => {
7178
3504
  return c.text(`error: ${error instanceof Error ? error.message : String(error)}`, 400);
7179
3505
  }).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);
7180
3506
  //#endregion
7181
- export { CONNECTOR_CONNECTION_STATUSES, ConnectorDiagnosticLog, ConnectorDiagnosticSqlReader, DEFAULT_GATEWAY_PORT, DEFAULT_GATEWAY_TOKEN_PATH, FUNNEL_DIR, FUNNEL_MCP_ARGS, FUNNEL_MCP_COMMAND, FUNNEL_MCP_NAME, Funnel, FunnelBroadcaster, FunnelChannelPublisher, FunnelChannels, FunnelClaude, FunnelClock, FunnelConnectorFactory, FunnelConnectorListener, FunnelEventLog, FunnelFileSystem, FunnelGateway, FunnelGatewayServer, FunnelGatewayToken, FunnelIdGenerator, FunnelListenerSupervisor, FunnelListenersClient, FunnelLocalConfig, FunnelLocalConfigSync, FunnelLocalConfigWriter, FunnelLogger, FunnelMcp, FunnelProcessRunner, FunnelProfiles, FunnelSettingsReader, FunnelSettingsStore, FunnelSlackEventProcessor, FunnelTokenPrompter, LOCAL_CONFIG_FILENAME, MemoryConnectorDiagnosticLog, MemoryFunnelClock, MemoryFunnelEventLog, MemoryFunnelFileSystem, MemoryFunnelIdGenerator, MemoryFunnelLogger, MemoryFunnelProcessRunner, MemoryFunnelTokenPrompter, MockFunnelSettingsReader, NodeFunnelClock, NodeFunnelFileSystem, NodeFunnelIdGenerator, NodeFunnelLogger, NodeFunnelProcessRunner, NodeFunnelTokenPrompter, NoopFunnelLogger, SETTINGS_PATH, SETTINGS_VERSION, SqliteConnectorDiagnosticLog, SqliteFunnelEventLog, channelConfigSchema, channelDeliveryModeSchema, channelSpecSchema, routes as cliRoutes, connectorConfigSchema, connectorConnectionEventSchema, connectorProcessedEventSchema, connectorRawEventSchema, connectorSpecSchema, createSettings, discordConnectorSchema, factory, funnelEventSchema, funnelJsonSchema, ghConnectorSchema, localConfigSchema, profileConfigSchema, profileSpecSchema, publishRequestSchema, publishResponseSchema, queryToCliArgs, resolveFunnelDir, resolveFunnelPort, scheduleCatchupPolicySchema, scheduleConnectorSchema, scheduleEntrySchema, settingsSchema, slackConnectorSchema, startChannelServer, toRequest };
3507
+ 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, routes as cliRoutes, connectorConfigSchema, connectorConnectionEventSchema, connectorProcessedEventSchema, connectorRawEventSchema, createSettings, discordConnectorSchema, factory, funnelEventSchema, ghConnectorSchema, profileConfigSchema, publishRequestSchema, publishResponseSchema, queryToCliArgs, resolveFunnelDir, resolveFunnelPort, scheduleCatchupPolicySchema, scheduleConnectorSchema, scheduleEntrySchema, settingsSchema, slackConnectorSchema, toRequest };