@interactive-inc/claude-funnel 0.49.0 → 0.51.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 (53) hide show
  1. package/dist/bin.js +1 -1
  2. package/dist/claude-CB1WkV77.d.ts +115 -0
  3. package/dist/claude.d.ts +59 -0
  4. package/dist/claude.js +322 -0
  5. package/dist/{connector-diagnostic-log-OPpPi9V9.d.ts → connector-diagnostic-log-yTOojKUR.d.ts} +14 -14
  6. package/dist/{logger-Czli2OKh.js → connector-listener-DU54DN-f.js} +1 -9
  7. package/dist/connectors/discord.d.ts +3 -3
  8. package/dist/connectors/discord.js +2 -1
  9. package/dist/connectors/gh.d.ts +4 -3
  10. package/dist/connectors/gh.js +2 -1
  11. package/dist/connectors/schedule.d.ts +1 -1
  12. package/dist/connectors/schedule.js +2 -1
  13. package/dist/connectors/slack.d.ts +2 -2
  14. package/dist/connectors/slack.js +2 -1
  15. package/dist/discord-connector-schema-CBDyGdOI.js +21 -0
  16. package/dist/{discord-connector-schema-BeThExJp.js → discord-listener-_jSE3HsQ.js} +2 -22
  17. package/dist/file-system-BeOKXjlV.d.ts +26 -0
  18. package/dist/file-system-PWKKU7lA.js +9 -0
  19. package/dist/gateway.d.ts +3 -0
  20. package/dist/gateway.js +2 -0
  21. package/dist/gh-connector-schema-eoTtHbY6.d.ts +14 -0
  22. package/dist/{gh-connector-schema-eYE4g77K.js → gh-connector-schema-o3Q1-ojL.js} +1 -176
  23. package/dist/gh-listener-DH-fClQm.js +178 -0
  24. package/dist/index-BM0-f6KL.d.ts +3404 -0
  25. package/dist/index.d.ts +11 -4083
  26. package/dist/index.js +247 -3459
  27. package/dist/local-config-json-schema-8IHjS4Q7.js +439 -0
  28. package/dist/local-config-sync-BdsrDZOu.d.ts +381 -0
  29. package/dist/local-config.d.ts +3 -0
  30. package/dist/local-config.js +3 -0
  31. package/dist/logger-BP6SisKt.js +9 -0
  32. package/dist/mcp-Dr-nIBwN.js +253 -0
  33. package/dist/memory-connector-diagnostic-log-CrW1ltLM.js +2245 -0
  34. package/dist/memory-token-prompter-B5FFCsGP.d.ts +57 -0
  35. package/dist/memory-token-prompter-CLerGsgM.js +61 -0
  36. package/dist/node-file-system-BcrmWN9I.js +48 -0
  37. package/dist/{gh-connector-schema-CQmEWzdV.d.ts → process-runner-DfniuWVU.d.ts} +1 -14
  38. package/dist/profiles-f0mNmEyP.d.ts +64 -0
  39. package/dist/profiles-wMRnjSid.js +129 -0
  40. package/dist/profiles.d.ts +2 -0
  41. package/dist/profiles.js +2 -0
  42. package/dist/schedule-connector-schema-iCI61gzU.js +31 -0
  43. package/dist/{schedule-listener-3M6WkH1Y.d.ts → schedule-listener-CUyUFFR1.d.ts} +22 -46
  44. package/dist/{schedule-connector-schema-CM-sRkac.js → schedule-listener-ePAjians.js} +3 -86
  45. package/dist/settings-reader-BSU6JyvM.d.ts +167 -0
  46. package/dist/settings-reader-DPqrpV7s.js +11 -0
  47. package/dist/settings-store-D2XSXTyt.js +186 -0
  48. package/dist/slack-connector-schema-BCNWluHM.js +32 -0
  49. package/dist/{slack-listener-9UdAn_ui.d.ts → slack-listener-Bv5xI9gC.d.ts} +31 -31
  50. package/dist/{slack-connector-schema-DDbSGPZn.js → slack-listener-ClQuHhEF.js} +2 -32
  51. package/package.json +6 -1
  52. /package/dist/{connector-adapter-VA6undzc.d.ts → connector-adapter-DKgsVuMH.d.ts} +0 -0
  53. /package/dist/{discord-connector-schema-DF4pL3Sc.d.ts → discord-connector-schema-R0Uu-3ns.d.ts} +0 -0
package/dist/index.js CHANGED
@@ -1,209 +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 } 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
- //#region lib/engine/id/id-generator.ts
19
- /**
20
- * ID generator boundary. Default NodeFunnelIdGenerator wraps `crypto.randomUUID()`;
21
- * MemoryFunnelIdGenerator emits `<prefix>-1, <prefix>-2, ...` for deterministic tests.
22
- */
23
- var FunnelIdGenerator = class {};
24
- //#endregion
25
- //#region lib/engine/id/node-id-generator.ts
26
- var NodeFunnelIdGenerator = class extends FunnelIdGenerator {
27
- generate() {
28
- return crypto.randomUUID();
29
- }
30
- };
31
- //#endregion
32
- //#region lib/engine/settings/settings-reader.ts
33
- var FunnelSettingsReader = class {};
34
- //#endregion
35
- //#region lib/connectors/connector-config-schema.ts
36
- const connectorConfigSchema = z.discriminatedUnion("type", [
37
- slackConnectorSchema,
38
- ghConnectorSchema,
39
- discordConnectorSchema,
40
- scheduleConnectorSchema
41
- ]);
42
- //#endregion
43
- //#region lib/engine/settings/settings-schema.ts
44
- /**
45
- * Routing mode when multiple WS clients are subscribed to the same channel.
46
- *
47
- * - `fanout` (default): every connected client receives every event. Right when each
48
- * subscriber has its own job (e.g., TUI mirrors, distinct Claude profiles each running
49
- * their own pipeline against the same source).
50
- * - `exclusive`: each event is delivered to exactly one connected client, picked
51
- * round-robin per channel. Right when subscribers are interchangeable workers and you
52
- * want each event handled once. Tap=all clients (TUI dashboard) always receive,
53
- * regardless of mode, so they can passively observe.
54
- */
55
- const channelDeliveryModeSchema = z.enum(["fanout", "exclusive"]);
56
- const channelConfigSchema = z.object({
57
- id: z.string(),
58
- name: z.string(),
59
- delivery: channelDeliveryModeSchema.default("fanout"),
60
- connectors: z.array(connectorConfigSchema).default([])
61
- });
62
- const profileConfigSchema = z.object({
63
- /** Stable identity (uuid). The primary key everything internal resolves to;
64
- * survives renames. CLI surfaces still address profiles by `name`. */
65
- id: z.string(),
66
- /** Human-facing label used only at the CLI/TUI surface (`--profile <name>`).
67
- * Renameable; never used as a storage key. */
68
- name: z.string(),
69
- path: z.string(),
70
- channelId: z.string(),
71
- /** Args prepended to the claude argv on every launch through this profile. */
72
- options: z.array(z.string()).default([]),
73
- /** Env vars layered under the launched claude process. process.env wins on collision. */
74
- env: z.record(z.string(), z.string()).default({}),
75
- /**
76
- * When true (the default), funnel resumes this profile's previous claude
77
- * session via `--session-id`/`--resume`. The id lives in `sessionId` below,
78
- * scoped to this profile so an unrelated session in the same repo can't bleed
79
- * in. Set to false for profiles that should always start fresh.
80
- */
81
- resume: z.boolean().default(true),
82
- /**
83
- * Execution state, not config: the claude session id this profile last
84
- * launched. Written by the launcher, read on the next resume. Absent until
85
- * the first launch; kept inside the profile (rather than a separate file) so
86
- * the session belongs to the profile by identity and the transport layer
87
- * (channels) never has to know profiles exist.
88
- */
89
- sessionId: z.string().optional()
90
- });
91
- const SETTINGS_VERSION = 1;
92
- const settingsSchema = z.object({
93
- /** Schema version. New files always write the current version; older files without one are read as v1. */
94
- version: z.literal(1).default(1),
95
- channels: z.array(channelConfigSchema).default([]),
96
- profiles: z.array(profileConfigSchema).default([])
97
- });
98
- //#endregion
99
- //#region lib/engine/settings/settings-store.ts
100
- /**
101
- * Resolves the funnel home dir. Defaults to `~/.funnel`, overridable via
102
- * `FUNNEL_DIR` so a funnel.json-scoped launch can point everything (settings,
103
- * gateway pid/token, claude pids) at a repo-local `<repo>/.funnel` and never
104
- * touch the global home. Read at call time, not module load, so a daemon
105
- * spawned with the env set resolves the override.
106
- */
107
- function resolveFunnelDir() {
108
- const override = process.env.FUNNEL_DIR;
109
- if (override && override.length > 0) return override;
110
- return join(homedir(), ".funnel");
111
- }
112
- const DEFAULT_GATEWAY_PORT = 9742;
113
- /**
114
- * Resolves the gateway port. Defaults to 9742 — the port a programmatically
115
- * hosted gateway (`new Funnel().gatewayServer()`) uses. The `funnel` CLI entry
116
- * sets `FUNNEL_PORT` to a distinct default so a CLI launch never collides with
117
- * an embedding app's gateway on 9742. Read at call time so a daemon spawned
118
- * with the env set resolves the override.
119
- */
120
- function resolveFunnelPort() {
121
- return Number(process.env.FUNNEL_PORT) || 9742;
122
- }
123
- const FUNNEL_DIR = join(homedir(), ".funnel");
124
- const SETTINGS_PATH = join(FUNNEL_DIR, "settings.json");
125
- const defaultFs$6 = new NodeFunnelFileSystem();
126
- const defaultIdGenerator$2 = new NodeFunnelIdGenerator();
127
- var FunnelSettingsStore = class extends FunnelSettingsReader {
128
- path;
129
- fs;
130
- idGenerator;
131
- constructor(deps = {}) {
132
- super();
133
- this.path = deps.path ?? SETTINGS_PATH;
134
- this.fs = deps.fs ?? defaultFs$6;
135
- this.idGenerator = deps.idGenerator ?? defaultIdGenerator$2;
136
- Object.freeze(this);
137
- }
138
- read() {
139
- if (!this.fs.existsSync(this.path)) return {
140
- version: 1,
141
- channels: [],
142
- profiles: []
143
- };
144
- const content = this.fs.readFileSync(this.path);
145
- const parsed = JSON.parse(content);
146
- 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`);
147
- 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)}`);
148
- const minted = this.backfillProfileIds(parsed);
149
- const result = settingsSchema.safeParse(parsed);
150
- if (!result.success) throw new Error(`invalid settings.json (${this.path}): ${result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(", ")}`);
151
- if (minted) this.write(result.data);
152
- return result.data;
153
- }
154
- looksLikeLegacy(parsed) {
155
- if (!parsed || typeof parsed !== "object") return false;
156
- const obj = parsed;
157
- if (Array.isArray(obj.channels)) for (const channel of obj.channels) {
158
- if (!channel || typeof channel !== "object") continue;
159
- const ch = channel;
160
- if (Array.isArray(ch.connectors) && ch.connectors.some((x) => typeof x === "string")) return true;
161
- if (!("id" in ch) && "name" in ch) return true;
162
- }
163
- if (Array.isArray(obj.connectors)) return true;
164
- if (Array.isArray(obj.repositories)) return true;
165
- if (Array.isArray(obj.profiles)) for (const profile of obj.profiles) {
166
- if (!profile || typeof profile !== "object") continue;
167
- const p = profile;
168
- if ("repository" in p || "envFiles" in p || "channel" in p && !("channelId" in p)) return true;
169
- }
170
- return false;
171
- }
172
- /**
173
- * Non-destructive migration for profiles written before `id` existed. Mints a
174
- * uuid for each profile lacking one and returns whether anything was minted, so
175
- * `read` can persist it immediately — a profile id must be STABLE across reads,
176
- * otherwise `setSessionId` (a second read) sees a different id and can't match
177
- * the one the launch used. Mutates `parsed` in place (freshly JSON-parsed).
178
- */
179
- backfillProfileIds(parsed) {
180
- if (!parsed || typeof parsed !== "object") return false;
181
- const obj = parsed;
182
- if (!Array.isArray(obj.profiles)) return false;
183
- let minted = false;
184
- for (const profile of obj.profiles) {
185
- if (!profile || typeof profile !== "object") continue;
186
- const p = profile;
187
- if (typeof p.id !== "string") {
188
- p.id = this.idGenerator.generate();
189
- minted = true;
190
- }
191
- }
192
- return minted;
193
- }
194
- write(settings) {
195
- this.fs.mkdirSync(dirname(this.path), { recursive: true });
196
- const versioned = {
197
- ...settings,
198
- version: 1
199
- };
200
- this.fs.writeSecretFileSync(this.path, `${JSON.stringify(versioned, null, 2)}\n`);
201
- }
202
- };
203
- //#endregion
204
27
  //#region lib/connectors/connector-factory.ts
205
- const defaultFs$5 = new NodeFunnelFileSystem();
206
- const defaultProcess$4 = new NodeFunnelProcessRunner();
28
+ const defaultFs$1 = new NodeFunnelFileSystem();
29
+ const defaultProcess$1 = new NodeFunnelProcessRunner();
207
30
  /**
208
31
  * Pure factory for per-type listeners and adapters. The factory has no CRUD
209
32
  * responsibility — connector configs live inside settings.json under their
@@ -225,8 +48,8 @@ var FunnelConnectorFactory = class {
225
48
  slackListenerOptions;
226
49
  scheduleListenerOptions;
227
50
  constructor(deps = {}) {
228
- this.fs = deps.fs ?? defaultFs$5;
229
- this.process = deps.process ?? defaultProcess$4;
51
+ this.fs = deps.fs ?? defaultFs$1;
52
+ this.process = deps.process ?? defaultProcess$1;
230
53
  this.logger = deps.logger;
231
54
  this.diagnosticLog = deps.diagnosticLog;
232
55
  this.dir = deps.dir ?? FUNNEL_DIR;
@@ -361,7 +184,7 @@ const slotFields = (literalKey, envKey, fields, current) => {
361
184
  return result;
362
185
  };
363
186
  const defaultClock$1 = new NodeFunnelClock();
364
- const defaultIdGenerator$1 = new NodeFunnelIdGenerator();
187
+ const defaultIdGenerator = new NodeFunnelIdGenerator();
365
188
  /**
366
189
  * Channels own their connectors. Each channel has a stable id (UUID); the
367
190
  * `name` is the human-facing label used by the CLI. Connectors live nested
@@ -381,7 +204,7 @@ var FunnelChannels = class {
381
204
  this.factory = deps.factory;
382
205
  this.profileChecker = deps.profileChecker ?? null;
383
206
  this.clock = deps.clock ?? defaultClock$1;
384
- this.idGenerator = deps.idGenerator ?? defaultIdGenerator$1;
207
+ this.idGenerator = deps.idGenerator ?? defaultIdGenerator;
385
208
  Object.freeze(this);
386
209
  }
387
210
  list() {
@@ -635,898 +458,110 @@ var FunnelChannels = class {
635
458
  }
636
459
  };
637
460
  //#endregion
638
- //#region lib/engine/claude/claude.ts
639
- const defaultProcess$3 = new NodeFunnelProcessRunner();
640
- const defaultIdGenerator = new NodeFunnelIdGenerator();
641
- /**
642
- * Launches Claude Code with funnel pre-wired: ensures the gateway is running,
643
- * installs the funnel MCP into the target repo's `.mcp.json` if missing,
644
- * injects `FUNNEL_CHANNEL_ID` into the child env, and delegates singleton
645
- * enforcement to a ProcessGuard.
646
- */
647
- var FunnelClaude = class {
648
- channels;
649
- mcp;
650
- gateway;
651
- sessions;
652
- guard;
653
- process;
654
- idGenerator;
655
- logger;
656
- constructor(deps) {
657
- this.channels = deps.channels;
658
- this.mcp = deps.mcp;
659
- this.gateway = deps.gateway;
660
- this.sessions = deps.sessions;
661
- this.guard = deps.guard;
662
- this.process = deps.process ?? defaultProcess$3;
663
- this.idGenerator = deps.idGenerator ?? defaultIdGenerator;
664
- this.logger = deps.logger;
665
- Object.freeze(this);
461
+ //#region lib/engine/fs/memory-file-system.ts
462
+ const SECRET_MODE = 384;
463
+ var MemoryFunnelFileSystem = class extends FunnelFileSystem {
464
+ dirs;
465
+ files;
466
+ mtimes;
467
+ modes;
468
+ now;
469
+ constructor(props = {}) {
470
+ super();
471
+ this.dirs = new Set(props.dirs ?? []);
472
+ this.files = new Map(Object.entries(props.files ?? {}));
473
+ this.mtimes = new Map(Object.entries(props.mtimes ?? {}));
474
+ this.modes = new Map(Object.entries(props.modes ?? {}));
475
+ this.now = props.now ?? (() => Date.now());
666
476
  }
667
- async launch(options) {
668
- const channel = this.channels.get(options.channel) ?? this.channels.getById(options.channel);
669
- if (!channel) throw new Error(`channel "${options.channel}" not found`);
670
- if (options.profileId && this.guard.isRunning(options.profileId)) throw new Error(`profile "${options.profileId}" is already running`);
671
- const cwd = options.cwd ?? globalThis.process.cwd();
672
- if ((options.installMcp ?? true) && !this.mcp.findInstalledName(cwd)) {
673
- this.mcp.install(cwd);
674
- this.logger?.info(`added funnel MCP to .mcp.json`, { cwd });
675
- }
676
- if (!this.gateway.isRunning()) {
677
- this.logger?.info(`starting gateway automatically`);
678
- await this.gateway.start();
679
- }
680
- if (options.profileId) this.guard.acquire(options.profileId);
681
- const session = (options.resume ?? false) && options.profileId ? this.resolveSession(options.profileId, cwd, options.userArgs ?? [], options.env ?? {}) : null;
682
- const claudeArgs = this.buildArgs(options.options ?? [], options.userArgs ?? [], cwd, session);
683
- const env = this.buildEnv(channel.id, options.env ?? {});
684
- this.logger?.info(`claude launch`, {
685
- channel: options.channel,
686
- channelId: channel.id,
687
- cwd
688
- });
689
- try {
690
- return await this.process.attach(["claude", ...claudeArgs], {
691
- cwd,
692
- env,
693
- onSpawned: options.onSpawned
694
- });
695
- } finally {
696
- if (options.profileId) this.guard.release(options.profileId);
697
- }
477
+ existsSync(path) {
478
+ return this.dirs.has(path) || this.files.has(path);
479
+ }
480
+ readFileSync(path) {
481
+ return this.files.get(path) ?? "";
698
482
  }
699
- buildArgs(recipeOptions, userArgs, cwd, session) {
700
- const result = [...recipeOptions, ...userArgs];
701
- if (session !== null) if (session.mode === "resume") result.push("--resume", session.id);
702
- else result.push("--session-id", session.id);
703
- const mcpName = this.mcp.findInstalledName(cwd);
704
- if (mcpName && !result.includes("--dangerously-load-development-channels") && !result.includes("--channels")) result.push("--dangerously-load-development-channels", `server:${mcpName}`);
705
- return result;
483
+ writeFileSync(path, data) {
484
+ this.files.set(path, data);
485
+ this.touch(path);
706
486
  }
707
- /**
708
- * Decides whether funnel should resume an existing claude session or start
709
- * a freshly minted one. Backs off when the user already passed a
710
- * session-shaping flag, since combining them would either confuse claude
711
- * or override the explicit user intent.
712
- *
713
- * The session is owned by the profile (by id), not by cwd: two profiles
714
- * pointing at the same repo each keep their own conversation, and a launch
715
- * with no profile never resumes — so an unrelated session in the same repo
716
- * can't bleed in. The channel never enters into it; sessions belong to the
717
- * launch layer (profiles), keeping the transport layer ignorant of them.
718
- *
719
- * A persisted id is only resumed when its session jsonl still exists on
720
- * disk. claude errors out on `--resume <id>` for a missing conversation, and
721
- * a persisted id can outlive its jsonl (claude pruned it, or the very first
722
- * launch was aborted after the id was written but before the jsonl
723
- * appeared). When the file is gone we mint a fresh session instead, which
724
- * overwrites the dangling entry — so the store self-heals.
725
- */
726
- resolveSession(profileId, cwd, userArgs, recipeEnv) {
727
- for (const arg of userArgs) {
728
- if (arg === "-c" || arg === "--continue") return null;
729
- if (arg === "--resume" || arg.startsWith("--resume=")) return null;
730
- if (arg === "--session-id" || arg.startsWith("--session-id=")) return null;
731
- }
732
- const existing = this.sessions.getSessionId(profileId);
733
- if (existing !== null && this.sessions.sessionFileExists(cwd, existing, recipeEnv)) return {
734
- id: existing,
735
- mode: "resume"
736
- };
737
- const fresh = this.idGenerator.generate();
738
- this.sessions.setSessionId(profileId, fresh);
739
- return {
740
- id: fresh,
741
- mode: "new"
742
- };
487
+ writeSecretFileSync(path, data) {
488
+ this.files.set(path, data);
489
+ this.modes.set(path, SECRET_MODE);
490
+ this.touch(path);
743
491
  }
744
- buildEnv(channelId, recipeEnv) {
745
- const env = {};
746
- for (const [key, value] of Object.entries(recipeEnv)) env[key] = value;
747
- for (const [key, value] of Object.entries(globalThis.process.env)) if (typeof value === "string") env[key] = value;
748
- env.FUNNEL_CHANNEL_ID = channelId;
749
- env.FUNNEL_PORT = String(resolveFunnelPort());
750
- return env;
492
+ appendFileSync(path, data) {
493
+ const prev = this.files.get(path) ?? "";
494
+ this.files.set(path, prev + data);
495
+ this.touch(path);
751
496
  }
752
- };
753
- //#endregion
754
- //#region lib/engine/claude/file-process-guard.ts
755
- const defaultFs$4 = new NodeFunnelFileSystem();
756
- const defaultProcess$2 = new NodeFunnelProcessRunner();
757
- var FileProcessGuard = class {
758
- fs;
759
- process;
760
- pidDir;
761
- constructor(deps = {}) {
762
- this.fs = deps.fs ?? defaultFs$4;
763
- this.process = deps.process ?? defaultProcess$2;
764
- this.pidDir = join(deps.dir ?? FUNNEL_DIR, "claude");
765
- Object.freeze(this);
497
+ unlink(path) {
498
+ this.files.delete(path);
499
+ this.mtimes.delete(path);
500
+ this.modes.delete(path);
766
501
  }
767
- isRunning(profileId) {
768
- const pid = this.readPid(profileId);
769
- if (!pid) return false;
770
- return this.process.isAlive(pid);
502
+ mkdirSync(path, options) {
503
+ this.dirs.add(path);
504
+ }
505
+ readdirSync(path) {
506
+ const prefix = path.endsWith("/") ? path : `${path}/`;
507
+ const names = [];
508
+ for (const file of this.files.keys()) {
509
+ if (!file.startsWith(prefix)) continue;
510
+ const rest = file.slice(prefix.length);
511
+ if (!rest.includes("/")) names.push(rest);
512
+ }
513
+ return names;
771
514
  }
772
- acquire(profileId) {
773
- this.fs.mkdirSync(this.pidDir, { recursive: true });
774
- this.fs.writeFileSync(this.pidPath(profileId), String(globalThis.process.pid));
775
- globalThis.process.once("exit", () => this.release(profileId));
515
+ statSync(path) {
516
+ const mtimeMs = this.mtimes.get(path);
517
+ if (mtimeMs === void 0) throw new Error(`not found: ${path}`);
518
+ return {
519
+ mtimeMs,
520
+ mode: this.modes.get(path) ?? null
521
+ };
776
522
  }
777
- release(profileId) {
778
- const path = this.pidPath(profileId);
779
- if (this.fs.existsSync(path)) this.fs.unlink(path);
523
+ setMtime(path, mtimeMs) {
524
+ this.mtimes.set(path, mtimeMs);
780
525
  }
781
- pidPath(profileId) {
782
- return join(this.pidDir, `${profileId}.pid`);
526
+ setMode(path, mode) {
527
+ this.modes.set(path, mode);
783
528
  }
784
- readPid(profileId) {
785
- const path = this.pidPath(profileId);
786
- if (!this.fs.existsSync(path)) return null;
787
- try {
788
- const content = this.fs.readFileSync(path).trim();
789
- const pid = Number(content);
790
- if (!pid || pid <= 0) return null;
791
- return pid;
792
- } catch {
793
- return null;
794
- }
529
+ touch(path) {
530
+ if (!this.mtimes.has(path)) this.mtimes.set(path, this.now());
531
+ else this.mtimes.set(path, this.now());
795
532
  }
796
533
  };
797
534
  //#endregion
798
- //#region lib/engine/local-config/local-config-schema.ts
799
- /**
800
- * Per-repo launch config (`funnel.json`).
801
- *
802
- * `fnl claude` reads this when no global --profile preset is used. It picks one
803
- * of the declared channels (`--channel <name>` selects by name; otherwise the
804
- * first entry wins) and materializes its transport (connectors / delivery) into
805
- * the repo's scoped settings (`~/.funnel/projects/<id>/settings.json`) on launch.
806
- * Connectors carry no tokens here — a token absent from settings is prompted for
807
- * at launch (TTY) and saved there, never in the repo.
808
- *
809
- * The launch recipe (`options` / `env` / `resume`) lives on `profiles[]`, not on
810
- * the channel: a channel only describes where events come from. `fnl claude`
811
- * applies the first profile bound to the chosen channel; the recipe is passed
812
- * straight to the launcher and is not persisted into the global profile list.
813
- * These profiles are selected by their `channel` binding, not by name.
814
- */
815
- const slackConnectorSpecSchema = z.object({
816
- type: z.literal("slack"),
817
- name: z.string(),
818
- /** Shrink raw Slack events before fanout. Defaults to true. */
819
- minify: z.boolean().optional()
820
- });
821
- const discordConnectorSpecSchema = z.object({
822
- type: z.literal("discord"),
823
- name: z.string()
824
- });
825
- const ghConnectorSpecSchema = z.object({
826
- type: z.literal("gh"),
827
- name: z.string(),
828
- pollInterval: z.number().int().positive().optional()
829
- });
830
- const scheduleConnectorSpecSchema = z.object({
831
- type: z.literal("schedule"),
832
- name: z.string()
833
- });
834
- const connectorSpecSchema = z.discriminatedUnion("type", [
835
- slackConnectorSpecSchema,
836
- discordConnectorSpecSchema,
837
- ghConnectorSpecSchema,
838
- scheduleConnectorSpecSchema
839
- ]);
840
- const channelSpecSchema = z.object({
841
- name: z.string(),
842
- connectors: z.array(connectorSpecSchema).optional()
843
- });
844
- const profileSpecSchema = z.object({
845
- /** Handle for `fnl claude --profile <name>`. A profile is only launchable by this name. */
846
- name: z.string(),
847
- /** Name of the channel (declared in `channels[]`) this profile binds. The profile depends on the channel, never the reverse. */
848
- channel: z.string(),
849
- /** Args prepended to the claude argv on every launch through this profile. */
850
- options: z.array(z.string()).optional(),
851
- /** Env vars layered under the launched claude process. process.env wins on collision. */
852
- env: z.record(z.string(), z.string()).optional(),
853
- /**
854
- * When true (the default), funnel injects `--session-id <uuid>` so that
855
- * relaunching from the same cwd resumes the previous claude session
856
- * without bleeding into other channels or workspaces. Set to false for
857
- * profiles that should always start a fresh session.
858
- */
859
- resume: z.boolean().optional()
860
- });
861
- const localConfigSchema = z.object({
862
- $schema: z.string().optional(),
863
- /**
864
- * Stable per-repo identifier. funnel writes this on first launch when absent;
865
- * all funnel state for this repo lives under `~/.funnel/projects/<id>/`, so the
866
- * repo itself never holds settings or tokens. Committed alongside funnel.json.
867
- */
868
- id: z.string().optional(),
869
- /** Declared channels (transport only). First entry is the default; --channel <name> selects by name. */
870
- channels: z.array(channelSpecSchema).min(1),
871
- /** Launch presets bound to a channel. First entry bound to the chosen channel is the default. */
872
- profiles: z.array(profileSpecSchema).optional()
873
- });
874
- const LOCAL_CONFIG_FILENAME = "funnel.json";
875
- //#endregion
876
- //#region lib/engine/local-config/local-config.ts
877
- /**
878
- * Reads `funnel.json` from a directory. Returns `null` when the file is
879
- * absent so callers can fall through to other resolution paths (default
880
- * profile, help). Throws on present-but-invalid files so misconfiguration
881
- * surfaces loudly instead of silently launching the wrong channel.
882
- */
883
- var FunnelLocalConfig = class {
884
- fs;
885
- constructor(deps) {
886
- this.fs = deps.fs;
887
- Object.freeze(this);
535
+ //#region lib/engine/id/memory-id-generator.ts
536
+ var MemoryFunnelIdGenerator = class extends FunnelIdGenerator {
537
+ counter = 0;
538
+ prefix;
539
+ constructor(props = {}) {
540
+ super();
541
+ this.prefix = props.prefix ?? "id";
888
542
  }
889
- read(cwd) {
890
- const path = join(cwd, LOCAL_CONFIG_FILENAME);
891
- if (!this.fs.existsSync(path)) return null;
892
- const raw = this.fs.readFileSync(path);
893
- const parsed = (() => {
894
- try {
895
- return JSON.parse(raw);
896
- } catch (error) {
897
- const message = error instanceof Error ? error.message : String(error);
898
- throw new Error(`${LOCAL_CONFIG_FILENAME} is not valid JSON: ${message}`);
899
- }
900
- })();
901
- const result = localConfigSchema.safeParse(parsed);
902
- if (!result.success) throw new Error(`${LOCAL_CONFIG_FILENAME} is invalid: ${result.error.message}`);
903
- this.assertProfilesValid(result.data);
904
- return result.data;
905
- }
906
- assertProfilesValid(config) {
907
- const profiles = config.profiles ?? [];
908
- if (profiles.length === 0) return;
909
- const channelNames = new Set(config.channels.map((channel) => channel.name));
910
- const seenNames = /* @__PURE__ */ new Set();
911
- for (const profile of profiles) {
912
- 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[]`);
913
- 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`);
914
- seenNames.add(profile.name);
915
- }
543
+ generate() {
544
+ this.counter++;
545
+ return `${this.prefix}-${this.counter}`;
916
546
  }
917
547
  };
918
548
  //#endregion
919
- //#region lib/engine/token-prompter/token-prompter.ts
920
- /**
921
- * Asks the user for a secret value on stdin. Used as a last resort when a
922
- * funnel.json token field is absent and not present in `~/.funnel`. The Node
923
- * implementation refuses to prompt when stdin is not a TTY so non-interactive
924
- * launches (CI, agent spawning agent, daemons) fail fast instead of hanging.
925
- */
926
- var FunnelTokenPrompter = class {};
927
- //#endregion
928
- //#region lib/engine/local-config/local-config-sync.ts
929
- /**
930
- * Reconciles a single funnel.json channel spec with `~/.funnel/settings.json`.
931
- * The spec is the source of truth for the channel it declares:
932
- *
933
- * - missing channel → created
934
- * - declared connector matched by name → tokens reconciled
935
- * - declared connector matched by token in the same channel under a
936
- * different name → renamed in place (then tokens reconciled)
937
- * - declared connector with no match → added
938
- * - any connector left in the channel that the spec did not touch → removed
939
- *
940
- * Removal only fires when the channel spec has a `connectors` field. An
941
- * absent field means "do not manage connectors from here" and leaves
942
- * everything in `~/.funnel` alone. Other channels in funnel.json (not
943
- * passed to this call) are untouched.
944
- *
945
- * Returns the per-connector change set so callers (e.g. the claude launcher)
946
- * can drive listener hot-reload on the gateway after settings are written.
947
- */
948
- var FunnelLocalConfigSync = class {
949
- channels;
950
- prompter;
951
- constructor(deps) {
952
- this.channels = deps.channels;
953
- this.prompter = deps.prompter;
954
- Object.freeze(this);
955
- }
956
- async ensure(channel) {
957
- if (!this.channels.get(channel.name)) this.channels.add({ name: channel.name });
958
- if (channel.connectors === void 0) return {
959
- touched: [],
960
- removed: []
961
- };
962
- const touched = [];
963
- const touchedIds = /* @__PURE__ */ new Set();
964
- for (const spec of channel.connectors) {
965
- const outcome = await this.ensureConnector(channel.name, spec);
966
- touched.push({
967
- name: outcome.name,
968
- changed: outcome.changed
969
- });
970
- touchedIds.add(outcome.id);
971
- }
972
- return {
973
- touched,
974
- removed: this.removeExtras(channel.name, touchedIds)
975
- };
976
- }
977
- async ensureConnector(channelName, spec) {
978
- if (spec.type === "slack") return await this.ensureSlack(channelName, spec);
979
- if (spec.type === "discord") return await this.ensureDiscord(channelName, spec);
980
- if (spec.type === "gh") return this.ensureGh(channelName, spec);
981
- return this.ensureSchedule(channelName, spec);
982
- }
983
- async ensureSlack(channelName, spec) {
984
- const byName = this.findExistingSlack(channelName, spec.name);
985
- const bot = await this.resolveSlot({
986
- label: `${spec.name}.botToken`,
987
- existingLiteral: byName?.botToken,
988
- existingEnv: byName?.botTokenEnv
989
- });
990
- const app = await this.resolveSlot({
991
- label: `${spec.name}.appToken`,
992
- existingLiteral: byName?.appToken,
993
- existingEnv: byName?.appTokenEnv
549
+ //#region lib/engine/logger/memory-logger.ts
550
+ var MemoryFunnelLogger = class extends FunnelLogger {
551
+ file = null;
552
+ entries = [];
553
+ info(message, meta) {
554
+ this.entries.push({
555
+ level: "info",
556
+ message,
557
+ meta
994
558
  });
995
- const update = {
996
- botToken: bot.token,
997
- botTokenEnv: bot.tokenEnv,
998
- appToken: app.token,
999
- appTokenEnv: app.tokenEnv
1000
- };
1001
- if (byName) {
1002
- if (!(byName.botToken === bot.token && byName.botTokenEnv === bot.tokenEnv && byName.appToken === app.token && byName.appTokenEnv === app.tokenEnv)) {
1003
- this.channels.updateSlackConnector(channelName, spec.name, update);
1004
- return {
1005
- id: byName.id,
1006
- name: spec.name,
1007
- changed: true
1008
- };
1009
- }
1010
- return {
1011
- id: byName.id,
1012
- name: spec.name,
1013
- changed: false
1014
- };
1015
- }
1016
- return {
1017
- id: this.channels.addConnector(channelName, {
1018
- type: "slack",
1019
- name: spec.name,
1020
- ...update,
1021
- ...spec.minify !== void 0 ? { minify: spec.minify } : {}
1022
- }).id,
1023
- name: spec.name,
1024
- changed: true
1025
- };
1026
559
  }
1027
- async ensureDiscord(channelName, spec) {
1028
- const byName = this.findExistingDiscord(channelName, spec.name);
1029
- const bot = await this.resolveSlot({
1030
- label: `${spec.name}.botToken`,
1031
- existingLiteral: byName?.botToken,
1032
- existingEnv: byName?.botTokenEnv
1033
- });
1034
- const update = {
1035
- botToken: bot.token,
1036
- botTokenEnv: bot.tokenEnv
1037
- };
1038
- if (byName) {
1039
- if (byName.botToken !== bot.token || byName.botTokenEnv !== bot.tokenEnv) {
1040
- this.channels.updateDiscordConnector(channelName, spec.name, update);
1041
- return {
1042
- id: byName.id,
1043
- name: spec.name,
1044
- changed: true
1045
- };
1046
- }
1047
- return {
1048
- id: byName.id,
1049
- name: spec.name,
1050
- changed: false
1051
- };
1052
- }
1053
- return {
1054
- id: this.channels.addConnector(channelName, {
1055
- type: "discord",
1056
- name: spec.name,
1057
- ...update
1058
- }).id,
1059
- name: spec.name,
1060
- changed: true
1061
- };
1062
- }
1063
- ensureGh(channelName, spec) {
1064
- const existing = this.channels.getConnector(channelName, spec.name);
1065
- if (existing && existing.type !== "gh") throw new Error(`connector "${spec.name}" exists in channel "${channelName}" with type "${existing.type}", funnel.json declares "gh"`);
1066
- if (existing && existing.type === "gh") {
1067
- if (spec.pollInterval !== void 0 && existing.pollInterval !== spec.pollInterval) {
1068
- this.channels.updateGhConnector(channelName, spec.name, { pollInterval: spec.pollInterval });
1069
- return {
1070
- id: existing.id,
1071
- name: spec.name,
1072
- changed: true
1073
- };
1074
- }
1075
- return {
1076
- id: existing.id,
1077
- name: spec.name,
1078
- changed: false
1079
- };
1080
- }
1081
- return {
1082
- id: this.channels.addConnector(channelName, {
1083
- type: "gh",
1084
- name: spec.name,
1085
- ...spec.pollInterval !== void 0 ? { pollInterval: spec.pollInterval } : {}
1086
- }).id,
1087
- name: spec.name,
1088
- changed: true
1089
- };
1090
- }
1091
- ensureSchedule(channelName, spec) {
1092
- const existing = this.channels.getConnector(channelName, spec.name);
1093
- if (existing && existing.type !== "schedule") throw new Error(`connector "${spec.name}" exists in channel "${channelName}" with type "${existing.type}", funnel.json declares "schedule"`);
1094
- if (existing && existing.type === "schedule") return {
1095
- id: existing.id,
1096
- name: spec.name,
1097
- changed: false
1098
- };
1099
- return {
1100
- id: this.channels.addConnector(channelName, {
1101
- type: "schedule",
1102
- name: spec.name
1103
- }).id,
1104
- name: spec.name,
1105
- changed: true
1106
- };
1107
- }
1108
- findExistingSlack(channelName, connectorName) {
1109
- const existing = this.channels.getConnector(channelName, connectorName);
1110
- if (!existing) return null;
1111
- if (existing.type !== "slack") throw new Error(`connector "${connectorName}" exists in channel "${channelName}" with type "${existing.type}", funnel.json declares "slack"`);
1112
- return existing;
1113
- }
1114
- findExistingDiscord(channelName, connectorName) {
1115
- const existing = this.channels.getConnector(channelName, connectorName);
1116
- if (!existing) return null;
1117
- if (existing.type !== "discord") throw new Error(`connector "${connectorName}" exists in channel "${channelName}" with type "${existing.type}", funnel.json declares "discord"`);
1118
- return existing;
1119
- }
1120
- removeExtras(channelName, touched) {
1121
- const channel = this.channels.get(channelName);
1122
- if (!channel) return [];
1123
- const stale = channel.connectors.filter((c) => !touched.has(c.id));
1124
- for (const connector of stale) this.channels.removeConnector(channelName, connector.name);
1125
- return stale.map((c) => c.name);
1126
- }
1127
- /**
1128
- * Decides how a single token slot is stored in settings.json. funnel.json
1129
- * never carries tokens, so the only sources are a value already in
1130
- * settings.json (carried over verbatim, whichever form it was — literal or an
1131
- * `env`-var reference set via the CLI) or, on first sync, a TTY prompt for a
1132
- * literal (throws when stdin is not a TTY). Either way the secret lands in the
1133
- * repo-scoped settings, never in the repo itself.
1134
- */
1135
- async resolveSlot(input) {
1136
- if (input.existingEnv !== void 0) return {
1137
- token: void 0,
1138
- tokenEnv: input.existingEnv
1139
- };
1140
- if (input.existingLiteral !== void 0) return {
1141
- token: input.existingLiteral,
1142
- tokenEnv: void 0
1143
- };
1144
- return {
1145
- token: await this.prompter.promptSecret(input.label),
1146
- tokenEnv: void 0
1147
- };
1148
- }
1149
- };
1150
- const FUNNEL_MCP_ARGS = ["funnel", "mcp"];
1151
- const mcpEntrySchema = z.object({
1152
- command: z.string().optional(),
1153
- args: z.array(z.string()).optional()
1154
- });
1155
- const mcpConfigSchema = z.object({ mcpServers: z.record(z.string(), mcpEntrySchema).optional() });
1156
- const defaultFs$3 = new NodeFunnelFileSystem();
1157
- /**
1158
- * Installs/uninstalls the funnel MCP entry into a target repository's
1159
- * `.mcp.json`. Detects an existing entry by command match so renaming is
1160
- * preserved across re-installs.
1161
- */
1162
- var FunnelMcp = class {
1163
- fs;
1164
- constructor(deps = {}) {
1165
- this.fs = deps.fs ?? defaultFs$3;
1166
- Object.freeze(this);
1167
- }
1168
- install(repoPath) {
1169
- if (!this.fs.existsSync(repoPath)) throw new Error(`repository does not exist: ${repoPath}`);
1170
- const config = this.readConfig(repoPath);
1171
- const servers = config.mcpServers ?? {};
1172
- const targetName = this.findServerName(servers) ?? "funnel";
1173
- servers[targetName] = {
1174
- command: "bun",
1175
- args: FUNNEL_MCP_ARGS
1176
- };
1177
- this.writeConfig(repoPath, {
1178
- ...config,
1179
- mcpServers: servers
1180
- });
1181
- }
1182
- uninstall(repoPath) {
1183
- if (!this.fs.existsSync(repoPath)) return;
1184
- const config = this.readConfig(repoPath);
1185
- const servers = config.mcpServers ?? {};
1186
- const name = this.findServerName(servers);
1187
- if (!name) return;
1188
- const next = { ...servers };
1189
- delete next[name];
1190
- this.writeConfig(repoPath, {
1191
- ...config,
1192
- mcpServers: next
1193
- });
1194
- }
1195
- findInstalledName(cwd) {
1196
- const config = this.readConfig(cwd);
1197
- return this.findServerName(config.mcpServers ?? {});
1198
- }
1199
- findServerName(servers) {
1200
- for (const entry of Object.entries(servers)) {
1201
- const name = entry[0];
1202
- const value = entry[1];
1203
- if (this.isFunnelEntry(value)) return name;
1204
- }
1205
- return null;
1206
- }
1207
- isFunnelEntry(value) {
1208
- if (!value) return false;
1209
- if (value.command === "bun" && value.args?.[0] === "funnel") return true;
1210
- if (value.command === "funnel") return true;
1211
- return false;
1212
- }
1213
- readConfig(repoPath) {
1214
- const mcpPath = join(repoPath, ".mcp.json");
1215
- if (!this.fs.existsSync(mcpPath)) return {};
1216
- const content = this.fs.readFileSync(mcpPath).trim();
1217
- if (!content) return {};
1218
- let parsed;
1219
- try {
1220
- parsed = JSON.parse(content);
1221
- } catch (error) {
1222
- throw new Error(`invalid .mcp.json (${mcpPath}): ${error instanceof Error ? error.message : String(error)}`);
1223
- }
1224
- const result = mcpConfigSchema.safeParse(parsed);
1225
- if (!result.success) throw new Error(`invalid .mcp.json (${mcpPath}): ${result.error.message}`);
1226
- return result.data;
1227
- }
1228
- writeConfig(repoPath, config) {
1229
- const mcpPath = join(repoPath, ".mcp.json");
1230
- this.fs.writeFileSync(mcpPath, `${JSON.stringify(config, null, 2)}\n`);
1231
- }
1232
- };
1233
- //#endregion
1234
- //#region lib/engine/profiles/profiles.ts
1235
- const defaultFs$2 = new NodeFunnelFileSystem();
1236
- /**
1237
- * Named launch presets for `fnl claude`. Each profile bundles a working
1238
- * directory, the channel id its Claude instance subscribes to, and the launch
1239
- * recipe (`options` prepended to the claude argv, `env` layered under the
1240
- * process, `resume` toggling session reuse). Implements ProfileChannelChecker
1241
- * so FunnelChannels can refuse to remove a channel that is still referenced.
1242
- *
1243
- * Each profile has a stable `id` (uuid) minted at `add`. That id is the unit
1244
- * everything internal keys on — the PID file, the resumable session id — so a
1245
- * rename never strands either. `name` is purely the CLI/TUI handle; the CRUD
1246
- * methods here take it because that is what the user types, but resolve to the
1247
- * id before touching id-keyed state. The first array entry is the default
1248
- * profile; `asDefault` reorders to put one first.
1249
- *
1250
- * `channelId` always stores the channel's stable id (uuid). CLI surfaces
1251
- * resolve channel name → id before calling `add`/`update` here.
1252
- */
1253
- var FunnelProfiles = class {
1254
- store;
1255
- idGenerator;
1256
- fs;
1257
- constructor(deps) {
1258
- this.store = deps.store;
1259
- this.idGenerator = deps.idGenerator;
1260
- this.fs = deps.fs ?? defaultFs$2;
1261
- Object.freeze(this);
1262
- }
1263
- list() {
1264
- return this.store.read().profiles;
1265
- }
1266
- get(name) {
1267
- return this.list().find((p) => p.name === name) ?? null;
1268
- }
1269
- getById(id) {
1270
- return this.list().find((p) => p.id === id) ?? null;
1271
- }
1272
- getDefault() {
1273
- return this.list()[0] ?? null;
1274
- }
1275
- add(input) {
1276
- const settings = this.store.read();
1277
- if (settings.profiles.some((p) => p.name === input.name)) throw new Error(`profile "${input.name}" already exists`);
1278
- if (!settings.channels.some((c) => c.id === input.channelId)) throw new Error(`channel id "${input.channelId}" not found`);
1279
- settings.profiles.push({
1280
- id: this.idGenerator.generate(),
1281
- name: input.name,
1282
- path: input.path,
1283
- channelId: input.channelId,
1284
- options: input.options ?? [],
1285
- env: input.env ?? {},
1286
- resume: input.resume ?? true
1287
- });
1288
- this.store.write(settings);
1289
- }
1290
- remove(name) {
1291
- const settings = this.store.read();
1292
- const index = settings.profiles.findIndex((p) => p.name === name);
1293
- if (index < 0) throw new Error(`profile "${name}" not found`);
1294
- settings.profiles.splice(index, 1);
1295
- this.store.write(settings);
1296
- }
1297
- rename(oldName, newName) {
1298
- const settings = this.store.read();
1299
- const profile = settings.profiles.find((p) => p.name === oldName);
1300
- if (!profile) throw new Error(`profile "${oldName}" not found`);
1301
- if (settings.profiles.some((p) => p.name === newName)) throw new Error(`profile "${newName}" already exists`);
1302
- profile.name = newName;
1303
- this.store.write(settings);
1304
- }
1305
- asDefault(name) {
1306
- const settings = this.store.read();
1307
- const index = settings.profiles.findIndex((p) => p.name === name);
1308
- if (index < 0) throw new Error(`profile "${name}" not found`);
1309
- if (index === 0) return;
1310
- const [profile] = settings.profiles.splice(index, 1);
1311
- if (!profile) return;
1312
- settings.profiles.unshift(profile);
1313
- this.store.write(settings);
1314
- }
1315
- hasChannelRef(channelId) {
1316
- return this.store.read().profiles.some((p) => p.channelId === channelId);
1317
- }
1318
- /** Resumable claude session id last launched by this profile (by id), or null. */
1319
- getSessionId(id) {
1320
- return this.getById(id)?.sessionId ?? null;
1321
- }
1322
- /** Records the claude session id this profile launched, overwriting any prior one. */
1323
- setSessionId(id, sessionId) {
1324
- const settings = this.store.read();
1325
- const profile = settings.profiles.find((p) => p.id === id);
1326
- if (!profile) throw new Error(`profile id "${id}" not found`);
1327
- profile.sessionId = sessionId;
1328
- this.store.write(settings);
1329
- }
1330
- /**
1331
- * Mirrors claude's session storage path
1332
- * (`<config-dir>/projects/<cwd-with-slashes-as-dashes>/<id>.jsonl`) to check
1333
- * whether a recorded session still exists AND is non-empty. Reads the same
1334
- * `CLAUDE_CONFIG_DIR` the child will run under so the check matches reality; a
1335
- * wrong guess can only ever produce a false negative (start fresh), never a
1336
- * bad resume.
1337
- */
1338
- sessionFileExists(cwd, sessionId, env) {
1339
- const path = join(env.CLAUDE_CONFIG_DIR ?? globalThis.process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude"), "projects", cwd.replace(/\//g, "-"), `${sessionId}.jsonl`);
1340
- if (!this.fs.existsSync(path)) return false;
1341
- return this.fs.readFileSync(path).trim().length > 0;
1342
- }
1343
- update(name, fields) {
1344
- const settings = this.store.read();
1345
- const profile = settings.profiles.find((p) => p.name === name);
1346
- if (!profile) throw new Error(`profile "${name}" not found`);
1347
- if (fields.channelId !== void 0) {
1348
- if (!settings.channels.some((c) => c.id === fields.channelId)) throw new Error(`channel id "${fields.channelId}" not found`);
1349
- profile.channelId = fields.channelId;
1350
- }
1351
- if (fields.path !== void 0) profile.path = fields.path;
1352
- if (fields.options !== void 0) profile.options = fields.options;
1353
- if (fields.env !== void 0) profile.env = fields.env;
1354
- if (fields.resume !== void 0) profile.resume = fields.resume;
1355
- this.store.write(settings);
1356
- }
1357
- };
1358
- //#endregion
1359
- //#region lib/engine/token-prompter/node-token-prompter.ts
1360
- const STAR = "*";
1361
- const CR = "\r";
1362
- const LF = "\n";
1363
- const BACKSPACE = String.fromCharCode(8);
1364
- const DEL = String.fromCharCode(127);
1365
- const CTRL_C = String.fromCharCode(3);
1366
- const CTRL_D = String.fromCharCode(4);
1367
- /**
1368
- * Reads a secret from stdin in raw mode. Echoes a `*` per byte so the user
1369
- * can see progress without exposing the token. Refuses to prompt when stdin
1370
- * is not a TTY — callers should surface the resulting error with a hint
1371
- * pointing at the corresponding env var or CLI command.
1372
- */
1373
- var NodeFunnelTokenPrompter = class extends FunnelTokenPrompter {
1374
- async promptSecret(label) {
1375
- 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.`);
1376
- stderr.write(`${label}: `);
1377
- const wasRaw = stdin.isRaw;
1378
- stdin.setRawMode(true);
1379
- stdin.resume();
1380
- try {
1381
- return await this.readSecret();
1382
- } finally {
1383
- stdin.setRawMode(wasRaw);
1384
- stdin.pause();
1385
- stderr.write(LF);
1386
- }
1387
- }
1388
- readSecret() {
1389
- return new Promise((resolve, reject) => {
1390
- let buffer = "";
1391
- const onData = (chunk) => {
1392
- for (const byte of chunk) {
1393
- const char = String.fromCharCode(byte);
1394
- if (char === LF || char === CR) {
1395
- stdin.off("data", onData);
1396
- resolve(buffer);
1397
- return;
1398
- }
1399
- if (char === CTRL_C) {
1400
- stdin.off("data", onData);
1401
- reject(/* @__PURE__ */ new Error("prompt cancelled"));
1402
- return;
1403
- }
1404
- if (char === CTRL_D) {
1405
- stdin.off("data", onData);
1406
- if (buffer.length === 0) reject(/* @__PURE__ */ new Error("prompt cancelled"));
1407
- else resolve(buffer);
1408
- return;
1409
- }
1410
- if (char === BACKSPACE || char === DEL) {
1411
- if (buffer.length > 0) {
1412
- buffer = buffer.slice(0, -1);
1413
- stderr.write("\b \b");
1414
- }
1415
- continue;
1416
- }
1417
- buffer += char;
1418
- stderr.write(STAR);
1419
- }
1420
- };
1421
- stdin.on("data", onData);
1422
- });
1423
- }
1424
- };
1425
- //#endregion
1426
- //#region lib/engine/fs/memory-file-system.ts
1427
- const SECRET_MODE = 384;
1428
- var MemoryFunnelFileSystem = class extends FunnelFileSystem {
1429
- dirs;
1430
- files;
1431
- mtimes;
1432
- modes;
1433
- now;
1434
- constructor(props = {}) {
1435
- super();
1436
- this.dirs = new Set(props.dirs ?? []);
1437
- this.files = new Map(Object.entries(props.files ?? {}));
1438
- this.mtimes = new Map(Object.entries(props.mtimes ?? {}));
1439
- this.modes = new Map(Object.entries(props.modes ?? {}));
1440
- this.now = props.now ?? (() => Date.now());
1441
- }
1442
- existsSync(path) {
1443
- return this.dirs.has(path) || this.files.has(path);
1444
- }
1445
- readFileSync(path) {
1446
- return this.files.get(path) ?? "";
1447
- }
1448
- writeFileSync(path, data) {
1449
- this.files.set(path, data);
1450
- this.touch(path);
1451
- }
1452
- writeSecretFileSync(path, data) {
1453
- this.files.set(path, data);
1454
- this.modes.set(path, SECRET_MODE);
1455
- this.touch(path);
1456
- }
1457
- appendFileSync(path, data) {
1458
- const prev = this.files.get(path) ?? "";
1459
- this.files.set(path, prev + data);
1460
- this.touch(path);
1461
- }
1462
- unlink(path) {
1463
- this.files.delete(path);
1464
- this.mtimes.delete(path);
1465
- this.modes.delete(path);
1466
- }
1467
- mkdirSync(path, options) {
1468
- this.dirs.add(path);
1469
- }
1470
- readdirSync(path) {
1471
- const prefix = path.endsWith("/") ? path : `${path}/`;
1472
- const names = [];
1473
- for (const file of this.files.keys()) {
1474
- if (!file.startsWith(prefix)) continue;
1475
- const rest = file.slice(prefix.length);
1476
- if (!rest.includes("/")) names.push(rest);
1477
- }
1478
- return names;
1479
- }
1480
- statSync(path) {
1481
- const mtimeMs = this.mtimes.get(path);
1482
- if (mtimeMs === void 0) throw new Error(`not found: ${path}`);
1483
- return {
1484
- mtimeMs,
1485
- mode: this.modes.get(path) ?? null
1486
- };
1487
- }
1488
- setMtime(path, mtimeMs) {
1489
- this.mtimes.set(path, mtimeMs);
1490
- }
1491
- setMode(path, mode) {
1492
- this.modes.set(path, mode);
1493
- }
1494
- touch(path) {
1495
- if (!this.mtimes.has(path)) this.mtimes.set(path, this.now());
1496
- else this.mtimes.set(path, this.now());
1497
- }
1498
- };
1499
- //#endregion
1500
- //#region lib/engine/id/memory-id-generator.ts
1501
- var MemoryFunnelIdGenerator = class extends FunnelIdGenerator {
1502
- counter = 0;
1503
- prefix;
1504
- constructor(props = {}) {
1505
- super();
1506
- this.prefix = props.prefix ?? "id";
1507
- }
1508
- generate() {
1509
- this.counter++;
1510
- return `${this.prefix}-${this.counter}`;
1511
- }
1512
- };
1513
- //#endregion
1514
- //#region lib/engine/logger/memory-logger.ts
1515
- var MemoryFunnelLogger = class extends FunnelLogger {
1516
- file = null;
1517
- entries = [];
1518
- info(message, meta) {
1519
- this.entries.push({
1520
- level: "info",
1521
- message,
1522
- meta
1523
- });
1524
- }
1525
- warn(message, meta) {
1526
- this.entries.push({
1527
- level: "warn",
1528
- message,
1529
- meta
560
+ warn(message, meta) {
561
+ this.entries.push({
562
+ level: "warn",
563
+ message,
564
+ meta
1530
565
  });
1531
566
  }
1532
567
  error(message, meta) {
@@ -1662,18 +697,6 @@ var MockFunnelSettingsReader = class extends FunnelSettingsReader {
1662
697
  }
1663
698
  };
1664
699
  //#endregion
1665
- //#region lib/engine/settings/tmp-dir.ts
1666
- /**
1667
- * Resolves the funnel temp/log root for the current OS. Defaults to
1668
- * `<os.tmpdir()>/funnel` so Windows lands under `%TEMP%\funnel` and POSIX
1669
- * lands under `/tmp/funnel`. Callers may override via `FUNNEL_TMP_DIR`.
1670
- */
1671
- function funnelTmpDir() {
1672
- const override = process.env.FUNNEL_TMP_DIR;
1673
- if (override && override.length > 0) return override;
1674
- return join(tmpdir(), "funnel");
1675
- }
1676
- //#endregion
1677
700
  //#region lib/engine/time/memory-clock.ts
1678
701
  var MemoryFunnelClock = class extends FunnelClock {
1679
702
  current;
@@ -1692,121 +715,43 @@ var MemoryFunnelClock = class extends FunnelClock {
1692
715
  }
1693
716
  };
1694
717
  //#endregion
1695
- //#region lib/gateway/publish-schema.ts
718
+ //#region lib/gateway/resolve-daemon-script.ts
1696
719
  /**
1697
- * Shared schema for `POST /channels/:channel/publish` used by both the
1698
- * gateway route handler (input validation) and the CLI / programmable client
1699
- * (request shape). The route resolves `channel` from the path; this body
1700
- * covers everything else.
720
+ * Locate the daemon entry script. Works in both dev (running from source)
721
+ * and built mode (bundled into dist/bin.js with daemon at dist/gateway/daemon.js).
722
+ *
723
+ * The candidates cover:
724
+ * 1. dev: this helper lives at lib/gateway/, so daemon.ts is its sibling
725
+ * 2. built sibling: dist/gateway/daemon.js if the helper itself ends up at dist/gateway/
726
+ * 3. bundled: when this helper is inlined into dist/bin.js, the helper's dir is dist/,
727
+ * and daemon.js lives at dist/gateway/daemon.js
728
+ *
729
+ * Uses `fileURLToPath(import.meta.url)` rather than `import.meta.dir` so the
730
+ * same helper resolves correctly whether run from source, the built sibling,
731
+ * or inlined into the bundle.
1701
732
  */
1702
- const publishRequestSchema = z.object({
1703
- content: z.string().min(1),
1704
- meta: z.record(z.string(), z.string()).optional(),
1705
- connector: z.string().min(1).optional(),
1706
- /**
1707
- * Address the event to a single subscriber. When set, only the WS client that
1708
- * declared this id at upgrade time (`?id=<subscriberId>`) receives it. Omit for
1709
- * the default fanout. The route surfaces it to subscribers as `meta.target`.
1710
- */
1711
- target: z.string().min(1).optional()
1712
- });
1713
- const publishResponseSchema = z.object({
1714
- ok: z.literal(true),
1715
- offset: z.number().int().nonnegative()
1716
- });
733
+ const resolveDaemonScript = () => {
734
+ const here = dirname(fileURLToPath(import.meta.url));
735
+ const candidates = [
736
+ resolve(here, "./daemon.ts"),
737
+ resolve(here, "./daemon.js"),
738
+ resolve(here, "./gateway/daemon.js")
739
+ ];
740
+ for (const candidate of candidates) if (existsSync(candidate)) return candidate;
741
+ throw new Error(`daemon script not found (looked in ${candidates.join(", ")})`);
742
+ };
1717
743
  //#endregion
1718
- //#region lib/gateway/channel-publisher.ts
1719
- const OFFLINE$1 = { state: "offline" };
1720
- /**
1721
- * HTTP client for `POST /channels/:channel/publish` on a running gateway
1722
- * daemon. Returns `{ state: "offline" }` when the daemon isn't up so callers
1723
- * can branch without exceptions, mirroring `FunnelListenersClient`.
1724
- */
1725
- var FunnelChannelPublisher = class {
1726
- port;
1727
- isDaemonRunning;
1728
- getToken;
1729
- constructor(deps) {
1730
- this.port = deps.port;
1731
- this.isDaemonRunning = deps.isDaemonRunning;
1732
- this.getToken = deps.getToken ?? (() => null);
1733
- Object.freeze(this);
1734
- }
1735
- async publish(channelName, request) {
1736
- if (!this.isDaemonRunning()) return OFFLINE$1;
1737
- try {
1738
- const url = `http://127.0.0.1:${this.port}/channels/${encodeURIComponent(channelName)}/publish`;
1739
- const res = await fetch(url, {
1740
- method: "POST",
1741
- headers: {
1742
- ...this.authHeaders(),
1743
- "content-type": "application/json"
1744
- },
1745
- body: JSON.stringify(request)
1746
- });
1747
- if (!res.ok) return {
1748
- state: "error",
1749
- reason: await res.text() || `HTTP ${res.status}`
1750
- };
1751
- const parsed = publishResponseSchema.safeParse(await res.json());
1752
- if (!parsed.success) return {
1753
- state: "error",
1754
- reason: "malformed daemon response"
1755
- };
1756
- return {
1757
- state: "ok",
1758
- offset: parsed.data.offset
1759
- };
1760
- } catch (error) {
1761
- return {
1762
- state: "error",
1763
- reason: error instanceof Error ? error.message : String(error)
1764
- };
1765
- }
1766
- }
1767
- authHeaders() {
1768
- const token = this.getToken();
1769
- return token ? { authorization: `Bearer ${token}` } : {};
1770
- }
1771
- };
1772
- //#endregion
1773
- //#region lib/gateway/resolve-daemon-script.ts
1774
- /**
1775
- * Locate the daemon entry script. Works in both dev (running from source)
1776
- * and built mode (bundled into dist/bin.js with daemon at dist/gateway/daemon.js).
1777
- *
1778
- * The candidates cover:
1779
- * 1. dev: this helper lives at lib/gateway/, so daemon.ts is its sibling
1780
- * 2. built sibling: dist/gateway/daemon.js if the helper itself ends up at dist/gateway/
1781
- * 3. bundled: when this helper is inlined into dist/bin.js, the helper's dir is dist/,
1782
- * and daemon.js lives at dist/gateway/daemon.js
1783
- *
1784
- * Uses `fileURLToPath(import.meta.url)` rather than `import.meta.dir` so the
1785
- * same helper resolves correctly whether run from source, the built sibling,
1786
- * or inlined into the bundle.
1787
- */
1788
- const resolveDaemonScript = () => {
1789
- const here = dirname(fileURLToPath(import.meta.url));
1790
- const candidates = [
1791
- resolve(here, "./daemon.ts"),
1792
- resolve(here, "./daemon.js"),
1793
- resolve(here, "./gateway/daemon.js")
1794
- ];
1795
- for (const candidate of candidates) if (existsSync(candidate)) return candidate;
1796
- throw new Error(`daemon script not found (looked in ${candidates.join(", ")})`);
1797
- };
1798
- //#endregion
1799
- //#region lib/gateway/gateway.ts
1800
- const STARTUP_TIMEOUT_MS = 5e3;
1801
- const SIGTERM_TIMEOUT_MS = 2e3;
1802
- const POLL_INTERVAL_MS = 100;
1803
- const SIGKILL_GRACE_MS = 200;
1804
- const defaultProcess$1 = new NodeFunnelProcessRunner();
1805
- const defaultFs$1 = new NodeFunnelFileSystem();
1806
- const defaultClock = new NodeFunnelClock();
1807
- const defaultSleep$1 = (ms) => new Promise((r) => {
1808
- setTimeout(r, ms);
1809
- });
744
+ //#region lib/gateway/gateway.ts
745
+ const STARTUP_TIMEOUT_MS = 5e3;
746
+ const SIGTERM_TIMEOUT_MS = 2e3;
747
+ const POLL_INTERVAL_MS = 100;
748
+ const SIGKILL_GRACE_MS = 200;
749
+ const defaultProcess = new NodeFunnelProcessRunner();
750
+ const defaultFs = new NodeFunnelFileSystem();
751
+ const defaultClock = new NodeFunnelClock();
752
+ const defaultSleep = (ms) => new Promise((r) => {
753
+ setTimeout(r, ms);
754
+ });
1810
755
  /**
1811
756
  * Manages the gateway daemon as a separate process via PID file.
1812
757
  * Use `start()` to spawn `bun daemon.ts` in the background and `stop()` to
@@ -1823,1812 +768,137 @@ var FunnelGateway = class {
1823
768
  port;
1824
769
  sleep;
1825
770
  constructor(deps = {}) {
1826
- this.process = deps.process ?? defaultProcess$1;
1827
- this.fs = deps.fs ?? defaultFs$1;
771
+ this.process = deps.process ?? defaultProcess;
772
+ this.fs = deps.fs ?? defaultFs;
1828
773
  this.clock = deps.clock ?? defaultClock;
1829
774
  this.dir = deps.dir ?? FUNNEL_DIR;
1830
775
  this.tmpDir = deps.tmpDir ?? funnelTmpDir();
1831
776
  this.pidFile = join(this.dir, "gateway.pid");
1832
777
  this.gatewayLog = join(this.tmpDir, "gateway.log");
1833
778
  this.port = deps.port ?? resolveFunnelPort();
1834
- this.sleep = deps.sleep ?? defaultSleep$1;
779
+ this.sleep = deps.sleep ?? defaultSleep;
1835
780
  Object.freeze(this);
1836
781
  }
1837
- isRunning() {
1838
- const pid = this.readPid();
1839
- if (!pid) return false;
1840
- return this.isProcessAlive(pid);
1841
- }
1842
- getStatus() {
1843
- const pid = this.readPid();
1844
- const running = pid !== null && this.isProcessAlive(pid);
1845
- return {
1846
- running,
1847
- pid: running ? pid : null,
1848
- port: this.port
1849
- };
1850
- }
1851
- async start(options = {}) {
1852
- if (this.isRunning()) return true;
1853
- this.fs.mkdirSync(this.tmpDir, { recursive: true });
1854
- const gatewayScript = resolveDaemonScript();
1855
- const command = this.buildStartCommand(gatewayScript, options);
1856
- this.process.detach(command, {
1857
- env: { FUNNEL_DIR: this.dir },
1858
- stdoutFile: this.gatewayLog,
1859
- stderrFile: this.gatewayLog
1860
- });
1861
- const deadline = this.clock.millis() + STARTUP_TIMEOUT_MS;
1862
- while (this.clock.millis() < deadline) {
1863
- if (this.isRunning()) return true;
1864
- await this.sleep(POLL_INTERVAL_MS);
1865
- }
1866
- return this.isRunning();
1867
- }
1868
- buildStartCommand(gatewayScript, options = {}) {
1869
- const tag = `funnel-gateway[${this.dir}]`;
1870
- if (options.caffeinate !== false && globalThis.process.platform === "darwin") return [
1871
- "caffeinate",
1872
- "-is",
1873
- "bun",
1874
- gatewayScript,
1875
- tag
1876
- ];
1877
- return [
1878
- "bun",
1879
- gatewayScript,
1880
- tag
1881
- ];
1882
- }
1883
- async stop() {
1884
- const pid = this.readPid();
1885
- if (!pid) return true;
1886
- if (!this.isProcessAlive(pid)) {
1887
- this.removePid();
1888
- return true;
1889
- }
1890
- try {
1891
- this.process.kill(pid, "SIGTERM");
1892
- } catch {
1893
- return false;
1894
- }
1895
- const deadline = this.clock.millis() + SIGTERM_TIMEOUT_MS;
1896
- while (this.clock.millis() < deadline) {
1897
- if (!this.isProcessAlive(pid)) {
1898
- this.removePid();
1899
- return true;
1900
- }
1901
- await this.sleep(POLL_INTERVAL_MS);
1902
- }
1903
- try {
1904
- this.process.kill(pid, "SIGKILL");
1905
- } catch {}
1906
- await this.sleep(SIGKILL_GRACE_MS);
1907
- this.removePid();
1908
- return !this.isProcessAlive(pid);
1909
- }
1910
- async restart(options = {}) {
1911
- const wasRunning = this.isRunning();
1912
- if (options.onlyIfRunning && !wasRunning) return {
1913
- ok: true,
1914
- wasRunning: false,
1915
- stopped: false,
1916
- started: false
1917
- };
1918
- const stopped = wasRunning ? await this.stop() : true;
1919
- if (!stopped) return {
1920
- ok: false,
1921
- wasRunning,
1922
- stopped: false,
1923
- started: false
1924
- };
1925
- const started = await this.start({ caffeinate: options.caffeinate });
1926
- return {
1927
- ok: started,
1928
- wasRunning,
1929
- stopped,
1930
- started
1931
- };
1932
- }
1933
- getGatewayLog() {
1934
- return this.gatewayLog;
1935
- }
1936
- getPort() {
1937
- return this.port;
1938
- }
1939
- readPid() {
1940
- if (!this.fs.existsSync(this.pidFile)) return null;
1941
- try {
1942
- const content = this.fs.readFileSync(this.pidFile).trim();
1943
- const pid = Number(content);
1944
- if (!pid || pid <= 0) return null;
1945
- return pid;
1946
- } catch {
1947
- return null;
1948
- }
1949
- }
1950
- removePid() {
1951
- this.fs.unlink(this.pidFile);
1952
- }
1953
- isProcessAlive(pid) {
1954
- return this.process.isAlive(pid);
1955
- }
1956
- };
1957
- //#endregion
1958
- //#region lib/gateway/auth-middleware.ts
1959
- /**
1960
- * Verifies `Authorization: Bearer <token>` against the daemon's gateway token.
1961
- * Mounted on the routes that mutate listener state or expose detailed status.
1962
- * `/health` is intentionally left unauthenticated so the daemon manager can
1963
- * probe liveness without needing the token.
1964
- */
1965
- const requireBearerToken = (deps) => {
1966
- return async (c, next) => {
1967
- if (!constantTimeEqual((c.req.header("authorization") ?? "").match(/^Bearer\s+(.+)$/i)?.[1] ?? "", deps.expected)) return c.text("unauthorized", 401);
1968
- return await next();
1969
- };
1970
- };
1971
- const constantTimeEqual = (a, b) => {
1972
- const bufA = Buffer.from(a, "utf-8");
1973
- const bufB = Buffer.from(b, "utf-8");
1974
- const maxLen = Math.max(bufA.length, bufB.length, 1);
1975
- const padA = Buffer.alloc(maxLen);
1976
- const padB = Buffer.alloc(maxLen);
1977
- bufA.copy(padA);
1978
- bufB.copy(padB);
1979
- return timingSafeEqual(padA, padB) && bufA.length === bufB.length;
1980
- };
1981
- //#endregion
1982
- //#region lib/gateway/factory.ts
1983
- const factory$1 = createFactory();
1984
- //#endregion
1985
- //#region lib/gateway/broadcaster.ts
1986
- const byteLengthOf = (event) => {
1987
- let bytes = Buffer.byteLength(event.content, "utf-8");
1988
- if (event.meta) for (const [k, v] of Object.entries(event.meta)) bytes += Buffer.byteLength(k, "utf-8") + Buffer.byteLength(v, "utf-8");
1989
- return bytes;
1990
- };
1991
- const DEFAULT_MAX_BUFFERED_BYTES = 1024 * 1024;
1992
- const DEFAULT_REPLAY_BUFFER_SIZE = 200;
1993
- const DEFAULT_REPLAY_BUFFER_MAX_BYTES = 4 * 1024 * 1024;
1994
- const defaultOnError$2 = () => {};
1995
- /**
1996
- * In-process pub/sub for connector events.
1997
- *
1998
- * Two outbound paths:
1999
- * - WS clients connected via the gateway's `/ws` endpoint, scoped per channel
2000
- * - In-process subscribers registered via `subscribe()` (programmable API)
2001
- *
2002
- * Backpressure: if a WS client's `bufferedAmount` exceeds `maxBufferedBytes`
2003
- * (default 1 MiB), the client is closed with code 1009 and dropped from the
2004
- * registry to keep one slow consumer from blocking the daemon.
2005
- *
2006
- * Replay: every emitted event gets a strictly increasing `offset`. The latest
2007
- * `replayBufferSize` events are kept in memory; reconnecting WS clients can
2008
- * pass `?since=<offset>` and the broadcaster resends matching events before
2009
- * resuming the live stream. The in-memory ring covers short reconnects;
2010
- * older history is served from the event log wired in as `persistentReplay`.
2011
- */
2012
- var FunnelBroadcaster = class {
2013
- clients = /* @__PURE__ */ new Map();
2014
- subscribers = /* @__PURE__ */ new Set();
2015
- logger;
2016
- onError;
2017
- maxBufferedBytes;
2018
- now;
2019
- replayBufferSize;
2020
- replayBufferMaxBytes;
2021
- replayBuffer = [];
2022
- persistentReplay;
2023
- exclusiveCursor = /* @__PURE__ */ new Map();
2024
- replayBufferBytes = 0;
2025
- eventsBroadcast = 0;
2026
- droppedSlowClients = 0;
2027
- lastBroadcastAt = null;
2028
- latestOffset = 0;
2029
- constructor(deps = {}) {
2030
- this.logger = deps.logger;
2031
- this.onError = deps.onError ?? defaultOnError$2;
2032
- this.maxBufferedBytes = deps.maxBufferedBytes ?? DEFAULT_MAX_BUFFERED_BYTES;
2033
- this.now = deps.now ?? (() => Date.now());
2034
- this.replayBufferSize = Math.max(0, deps.replayBufferSize ?? DEFAULT_REPLAY_BUFFER_SIZE);
2035
- this.replayBufferMaxBytes = Math.max(0, deps.replayBufferMaxBytes ?? DEFAULT_REPLAY_BUFFER_MAX_BYTES);
2036
- this.persistentReplay = deps.persistentReplay ?? null;
2037
- }
2038
- getMetrics() {
2039
- return {
2040
- clients: this.clients.size,
2041
- subscribers: this.subscribers.size,
2042
- eventsBroadcast: this.eventsBroadcast,
2043
- droppedSlowClients: this.droppedSlowClients,
2044
- lastBroadcastAt: this.lastBroadcastAt ? new Date(this.lastBroadcastAt).toISOString() : null,
2045
- latestOffset: this.latestOffset,
2046
- oldestReplayableOffset: this.replayBuffer[0]?.offset ?? null
2047
- };
2048
- }
2049
- /**
2050
- * Returns events with offset > since, filtered by the connector subscription
2051
- * rules of `data`. Used at WS upgrade time when the client passes `?since=<offset>`.
2052
- *
2053
- * Two-tier lookup:
2054
- * 1. The in-memory ring buffer (covers short reconnects, last `replayBufferSize` events).
2055
- * 2. If `since` predates the oldest in-memory entry and a persistent replay source
2056
- * is wired in (SQLite by default), the gap is filled from it. This covers reconnects
2057
- * across daemon restarts where the in-memory buffer was lost.
2058
- *
2059
- * Result is sorted ascending by offset and de-duplicated against the in-memory buffer.
2060
- */
2061
- replaySince(since, data) {
2062
- const oldestInMemory = this.replayBuffer[0]?.offset;
2063
- const needFallback = this.persistentReplay && (oldestInMemory === void 0 || since < oldestInMemory - 1);
2064
- const fromMemory = this.replayBuffer.filter((event) => event.offset > since && this.matchesClient(event, data));
2065
- if (!needFallback) return fromMemory;
2066
- const persisted = this.persistentReplay ? this.persistentReplay.loadSince(since).filter((event) => this.matchesClient(event, data)) : [];
2067
- const cutoff = oldestInMemory ?? Number.POSITIVE_INFINITY;
2068
- return [...persisted.filter((event) => event.offset < cutoff), ...fromMemory];
2069
- }
2070
- matchesClient(event, data) {
2071
- const target = event.meta?.target;
2072
- if (target && target !== data.subscriberId) return false;
2073
- const channelId = event.meta?.channelId;
2074
- if (channelId && channelId !== data.channel) return false;
2075
- const connector = event.meta?.connector;
2076
- if (!connector) return true;
2077
- return data.connectors.includes(connector);
2078
- }
2079
- /**
2080
- * Returns the list of WS clients that should receive `event`. For each per-channel group:
2081
- * - fanout → every matching client receives
2082
- * - exclusive → exactly one client receives, picked round-robin per channel
2083
- *
2084
- * `meta.target` narrows the recipient set via `matchesClient`: only the subscriber
2085
- * whose `subscriberId` equals `target` receives a targeted event.
2086
- */
2087
- pickRecipients(event) {
2088
- const exclusiveByChannel = /* @__PURE__ */ new Map();
2089
- const recipients = [];
2090
- for (const [ws, data] of this.clients) {
2091
- if (!this.matchesClient(event, data)) continue;
2092
- if (data.delivery === "exclusive") {
2093
- const list = exclusiveByChannel.get(data.channel) ?? [];
2094
- list.push(ws);
2095
- exclusiveByChannel.set(data.channel, list);
2096
- continue;
2097
- }
2098
- recipients.push(ws);
2099
- }
2100
- for (const [channel, candidates] of exclusiveByChannel) {
2101
- if (candidates.length === 0) continue;
2102
- const cursor = this.exclusiveCursor.get(channel) ?? 0;
2103
- const picked = candidates[cursor % candidates.length];
2104
- if (picked) recipients.push(picked);
2105
- this.exclusiveCursor.set(channel, cursor + 1);
2106
- }
2107
- return recipients;
2108
- }
2109
- addClient(ws, data) {
2110
- this.clients.set(ws, data);
2111
- }
2112
- removeClient(ws) {
2113
- this.clients.delete(ws);
2114
- }
2115
- getClientCount() {
2116
- return this.clients.size;
2117
- }
2118
- listChannels() {
2119
- return [...this.clients.values()].map((d) => ({ ...d }));
2120
- }
2121
- subscribe(handler) {
2122
- this.subscribers.add(handler);
2123
- return () => {
2124
- this.subscribers.delete(handler);
2125
- };
2126
- }
2127
- broadcast(content, meta) {
2128
- this.latestOffset += 1;
2129
- const event = {
2130
- content,
2131
- meta,
2132
- offset: this.latestOffset
2133
- };
2134
- const payload = JSON.stringify(event);
2135
- meta?.connector;
2136
- this.eventsBroadcast += 1;
2137
- this.lastBroadcastAt = this.now();
2138
- if (this.replayBufferSize > 0) {
2139
- const eventBytes = byteLengthOf(event);
2140
- this.replayBuffer.push(event);
2141
- this.replayBufferBytes += eventBytes;
2142
- while ((this.replayBuffer.length > this.replayBufferSize || this.replayBufferBytes > this.replayBufferMaxBytes) && this.replayBuffer.length > 0) {
2143
- const dropped = this.replayBuffer.shift();
2144
- if (dropped) this.replayBufferBytes -= byteLengthOf(dropped);
2145
- }
2146
- }
2147
- const recipients = this.pickRecipients(event);
2148
- for (const ws of recipients) {
2149
- const buffered = ws.getBufferedAmount();
2150
- if (buffered > this.maxBufferedBytes) {
2151
- const data = this.clients.get(ws);
2152
- this.logger?.warn("dropping slow WS client (backpressure)", {
2153
- channel: data?.channel,
2154
- buffered,
2155
- max: this.maxBufferedBytes
2156
- });
2157
- try {
2158
- ws.close(1009, "backpressure");
2159
- } catch {}
2160
- this.clients.delete(ws);
2161
- this.droppedSlowClients += 1;
2162
- continue;
2163
- }
2164
- ws.send(payload);
2165
- }
2166
- for (const handler of this.subscribers) try {
2167
- handler(event);
2168
- } catch (error) {
2169
- const err = error instanceof Error ? error : new Error(String(error));
2170
- this.logger?.error("broadcast subscriber threw", { error: err.message });
2171
- this.onError(err, {
2172
- component: "broadcaster.subscriber",
2173
- offset: event.offset,
2174
- connector: event.meta?.connector ?? null,
2175
- channel: event.meta?.channel ?? null
2176
- });
2177
- }
2178
- return event;
2179
- }
2180
- /** Forward-seed the offset counter (used at startup from the persisted event store). */
2181
- seedLatestOffset(offset) {
2182
- if (offset > this.latestOffset) this.latestOffset = offset;
2183
- }
2184
- };
2185
- //#endregion
2186
- //#region lib/gateway/funnel-event-log.ts
2187
- /**
2188
- * Replayable event payload persisted by the gateway. Domain events the
2189
- * broadcaster emits to WS clients land here so reconnects across daemon
2190
- * restarts can be served from disk. System events (gateway start, channel
2191
- * connected, etc.) are routed to `FunnelLogger` instead — they never go
2192
- * through this log, which keeps the offset space clean for replay.
2193
- */
2194
- const funnelEventSchema = z.object({
2195
- type: z.string(),
2196
- content: z.string(),
2197
- channel_id: z.string().nullable(),
2198
- connector_id: z.string().nullable(),
2199
- meta: z.record(z.string(), z.string()).nullable()
2200
- });
2201
- /**
2202
- * Durable, append-only log of broadcaster events keyed by the offset the
2203
- * broadcaster assigns. The gateway persists every domain event here, and
2204
- * across restarts it both seeds the broadcaster's offset counter
2205
- * (`findMaxOffset`) and serves reconnect replay (`loadSince`) from it.
2206
- *
2207
- * `loadSince` is the only method the broadcaster itself needs, which makes
2208
- * any implementation assignable to the broadcaster's narrow `ReplaySource`.
2209
- *
2210
- * Implementations:
2211
- * - `SqliteFunnelEventLog` — the default; durable across daemon restarts.
2212
- * - `MemoryFunnelEventLog` — an in-process double for tests and embedders
2213
- * that do not need durability (replay is lost when the process exits).
2214
- */
2215
- var FunnelEventLog = class {};
2216
- //#endregion
2217
- //#region lib/logger/leuco-logger-sqlite-sink.ts
2218
- /** Conservative whitelist for column names interpolated into SQL. */
2219
- const COLUMN_NAME_RE = /^[a-z_][a-z0-9_]*$/;
2220
- /** How many inserts between on-disk size checks (see insertsSinceByteCheck). */
2221
- const BYTE_CHECK_INTERVAL = 500;
2222
- const RESERVED_COLUMNS = new Set([
2223
- "seq",
2224
- "ts",
2225
- "type",
2226
- "event"
2227
- ]);
2228
- /**
2229
- * Schema versions. Each entry is the list of DDL statements that take the
2230
- * database from version i to version i + 1. Migrations run in a transaction
2231
- * so a partial failure rolls back. Adding a new version is append-only —
2232
- * never edit a published one. Caller-defined index columns are added
2233
- * dynamically on construct (independent of versioned migrations) because
2234
- * they are configuration, not schema evolution.
2235
- */
2236
- const MIGRATIONS = [[
2237
- "CREATE TABLE IF NOT EXISTS leuco_log (seq INTEGER PRIMARY KEY, ts INTEGER NOT NULL, type TEXT, event TEXT NOT NULL)",
2238
- "CREATE INDEX IF NOT EXISTS idx_leuco_log_ts ON leuco_log (ts)",
2239
- "CREATE INDEX IF NOT EXISTS idx_leuco_log_type ON leuco_log (type)"
2240
- ]];
2241
- /**
2242
- * SQLite-backed sink built on `bun:sqlite`. Implements both primary and
2243
- * relay roles so the same instance can own seq generation for one bus and
2244
- * mirror records from another (e.g. cross-process replication, restore
2245
- * from a backup stream).
2246
- *
2247
- * Concurrency model: seq is `INTEGER PRIMARY KEY`, so SQLite assigns it
2248
- * atomically via `lastInsertRowid`. Two `LeucoLogger` instances pointed
2249
- * at the same database file therefore see one monotonically increasing
2250
- * seq stream without any bus-level coordination — the database itself is
2251
- * the synchronization point.
2252
- *
2253
- * Schema is version-managed via `PRAGMA user_version`. Migrations are
2254
- * append-only and run in a transaction on every construct so a partial
2255
- * upgrade rolls back cleanly. Caller-defined `indexes` are layered on top
2256
- * via `ALTER TABLE ADD COLUMN` + `CREATE INDEX IF NOT EXISTS`, so adding
2257
- * a new index to an existing database is a no-downtime operation.
2258
- *
2259
- * Type safety: the second generic parameter `I` is the literal tuple of
2260
- * index column names. `extractIndexes` and `getRecords({ where })` are
2261
- * both type-checked against this tuple, so a typo at the call site is a
2262
- * compile-time error rather than a silent miss at runtime.
2263
- *
2264
- * Retention is bounded by `maxRows` and/or `maxAgeMs`. Both run on every
2265
- * insert as a single indexed DELETE that no-ops below the cap.
2266
- *
2267
- * Bulk inserts use `insertMany`, which wraps the batch in one transaction
2268
- * for ~10–100x throughput at the cost of one fsync per batch instead of
2269
- * one per row.
2270
- */
2271
- var LeucoLoggerSqliteSink = class {
2272
- db;
2273
- maxRows;
2274
- maxAgeMs;
2275
- maxBytes;
2276
- targetBytes;
2277
- now;
2278
- indexes;
2279
- extractIndexes;
2280
- insertStmt;
2281
- insertWithSeqStmt;
2282
- maxSeqStmt;
2283
- countStmt;
2284
- trimRowsStmt;
2285
- trimAgeStmt;
2286
- trimOldestStmt;
2287
- insertsSinceByteCheck = 0;
2288
- constructor(props) {
2289
- this.db = new Database(props.path);
2290
- this.db.run("PRAGMA journal_mode = WAL");
2291
- this.migrate();
2292
- this.maxRows = props.maxRows ?? null;
2293
- this.maxAgeMs = props.maxAgeMs ?? null;
2294
- this.maxBytes = props.maxBytes ?? null;
2295
- this.targetBytes = props.targetBytes ?? (props.maxBytes !== void 0 ? Math.floor(props.maxBytes / 4) : null);
2296
- this.now = props.now ?? (() => Date.now());
2297
- this.indexes = props.indexes ?? [];
2298
- if (this.indexes.length > 0) {
2299
- validateIndexNames(this.indexes);
2300
- this.extractIndexes = props.extractIndexes ?? null;
2301
- this.syncIndexColumns();
2302
- } else this.extractIndexes = null;
2303
- const cols = [
2304
- "ts",
2305
- "type",
2306
- "event",
2307
- ...this.indexes
2308
- ];
2309
- const placeholders = cols.map(() => "?").join(", ");
2310
- this.insertStmt = this.db.prepare(`INSERT INTO leuco_log (${cols.join(", ")}) VALUES (${placeholders})`);
2311
- const colsWithSeq = ["seq", ...cols];
2312
- const placeholdersWithSeq = colsWithSeq.map(() => "?").join(", ");
2313
- this.insertWithSeqStmt = this.db.prepare(`INSERT INTO leuco_log (${colsWithSeq.join(", ")}) VALUES (${placeholdersWithSeq})`);
2314
- this.maxSeqStmt = this.db.prepare("SELECT COALESCE(MAX(seq), 0) AS max FROM leuco_log");
2315
- this.countStmt = this.db.prepare("SELECT COUNT(*) AS n FROM leuco_log");
2316
- this.trimRowsStmt = this.db.prepare("DELETE FROM leuco_log WHERE seq <= (SELECT seq FROM leuco_log ORDER BY seq DESC LIMIT 1 OFFSET ?)");
2317
- this.trimAgeStmt = this.db.prepare("DELETE FROM leuco_log WHERE ts < ?");
2318
- this.trimOldestStmt = this.db.prepare("DELETE FROM leuco_log WHERE seq IN (SELECT seq FROM leuco_log ORDER BY seq ASC LIMIT ?)");
2319
- }
2320
- insert(input) {
2321
- try {
2322
- const params = this.buildInsertParams(input.ts, input.event);
2323
- const result = this.insertStmt.run(...params);
2324
- const seq = Number(result.lastInsertRowid);
2325
- this.trim();
2326
- return {
2327
- seq,
2328
- ts: input.ts,
2329
- event: input.event
2330
- };
2331
- } catch (e) {
2332
- return e instanceof Error ? e : new Error(String(e));
2333
- }
2334
- }
2335
- insertMany(inputs) {
2336
- if (inputs.length === 0) return [];
2337
- try {
2338
- const records = [];
2339
- this.db.transaction((batch) => {
2340
- for (const input of batch) {
2341
- const params = this.buildInsertParams(input.ts, input.event);
2342
- const result = this.insertStmt.run(...params);
2343
- records.push({
2344
- seq: Number(result.lastInsertRowid),
2345
- ts: input.ts,
2346
- event: input.event
2347
- });
2348
- }
2349
- })(inputs);
2350
- this.trim();
2351
- return records;
2352
- } catch (e) {
2353
- return e instanceof Error ? e : new Error(String(e));
2354
- }
2355
- }
2356
- write(record) {
2357
- try {
2358
- const params = [record.seq, ...this.buildInsertParams(record.ts, record.event)];
2359
- this.insertWithSeqStmt.run(...params);
2360
- this.trim();
2361
- } catch (e) {
2362
- return e instanceof Error ? e : new Error(String(e));
2363
- }
2364
- }
2365
- getMaxSeq() {
2366
- const row = this.maxSeqStmt.get();
2367
- return row ? row.max : 0;
2368
- }
2369
- getRecords(props = {}) {
2370
- const conditions = ["seq > ?"];
2371
- const params = [props.sinceSeq ?? 0];
2372
- if (typeof props.type === "string") {
2373
- conditions.push("type = ?");
2374
- params.push(props.type);
2375
- }
2376
- if (props.where) this.appendWhereConditions(props.where, conditions, params);
2377
- const limit = props.limit ?? 1e3;
2378
- params.push(limit);
2379
- const dir = props.order === "desc" ? "DESC" : "ASC";
2380
- const sql = `SELECT seq, ts, type, event FROM leuco_log WHERE ${conditions.join(" AND ")} ORDER BY seq ${dir} LIMIT ?`;
2381
- const rows = this.db.prepare(sql).all(...params);
2382
- if (dir === "DESC") rows.reverse();
2383
- return rows.map(toRecord);
2384
- }
2385
- /**
2386
- * Current schema version. Useful for diagnostics and for tests that want
2387
- * to verify migrations ran. Reads `PRAGMA user_version` once per call.
2388
- */
2389
- getSchemaVersion() {
2390
- return this.db.prepare("PRAGMA user_version").get()?.user_version ?? 0;
2391
- }
2392
- close() {
2393
- this.db.close();
2394
- }
2395
- buildInsertParams(ts, event) {
2396
- const type = extractType(event);
2397
- const json = JSON.stringify(event);
2398
- if (this.indexes.length === 0) return [
2399
- ts,
2400
- type,
2401
- json
2402
- ];
2403
- const values = this.extractIndexes ? this.extractIndexes(event) : null;
2404
- return [
2405
- ts,
2406
- type,
2407
- json,
2408
- ...this.indexes.map((col) => values?.[col] ?? null)
2409
- ];
2410
- }
2411
- appendWhereConditions(where, conditions, params) {
2412
- const widened = where;
2413
- for (const col of this.indexes) {
2414
- const value = widened[col];
2415
- if (value === void 0) continue;
2416
- if (value === null) conditions.push(`${col} IS NULL`);
2417
- else {
2418
- conditions.push(`${col} = ?`);
2419
- params.push(value);
2420
- }
2421
- }
2422
- }
2423
- trim() {
2424
- if (this.maxRows !== null) {
2425
- const row = this.countStmt.get();
2426
- if (row && row.n > this.maxRows) this.trimRowsStmt.run(this.maxRows);
2427
- }
2428
- if (this.maxAgeMs !== null) this.trimAgeStmt.run(this.now() - this.maxAgeMs);
2429
- this.maybeTrimBytes();
2430
- }
2431
- /**
2432
- * Throttled byte-size enforcement. Only every BYTE_CHECK_INTERVAL inserts do
2433
- * we measure the file; on overflow we estimate how many of the oldest rows to
2434
- * drop to land near targetBytes (by the byte/row ratio), delete them in one
2435
- * statement, then VACUUM once to return the freed pages to the filesystem (a
2436
- * plain DELETE only frees pages inside the file). One DELETE + one VACUUM per
2437
- * overflow keeps the expensive rewrite rare — the file must refill the whole
2438
- * maxBytes→targetBytes delta before the next overflow can trigger.
2439
- */
2440
- maybeTrimBytes() {
2441
- if (this.maxBytes === null || this.targetBytes === null) return;
2442
- this.insertsSinceByteCheck += 1;
2443
- if (this.insertsSinceByteCheck < BYTE_CHECK_INTERVAL) return;
2444
- this.insertsSinceByteCheck = 0;
2445
- const bytes = this.byteSize();
2446
- if (bytes <= this.maxBytes) return;
2447
- const rows = this.countStmt.get()?.n ?? 0;
2448
- if (rows === 0) return;
2449
- const bytesToFree = bytes - this.targetBytes;
2450
- const bytesPerRow = bytes / rows;
2451
- const rowsToDrop = Math.min(rows, Math.ceil(bytesToFree / bytesPerRow));
2452
- this.trimOldestStmt.run(rowsToDrop);
2453
- this.db.run("VACUUM");
2454
- }
2455
- byteSize() {
2456
- return (this.db.prepare("PRAGMA page_count").get()?.n ?? 0) * (this.db.prepare("PRAGMA page_size").get()?.n ?? 0);
2457
- }
2458
- /** Drop every row and reclaim the file space. Used by `<log>.clear()`. */
2459
- clear() {
2460
- this.db.run("DELETE FROM leuco_log");
2461
- this.db.run("VACUUM");
2462
- this.insertsSinceByteCheck = 0;
2463
- }
2464
- syncIndexColumns() {
2465
- const existing = new Set(this.db.prepare("PRAGMA table_info(leuco_log)").all().map((r) => r.name));
2466
- for (const col of this.indexes) {
2467
- if (!existing.has(col)) this.db.run(`ALTER TABLE leuco_log ADD COLUMN ${col} TEXT`);
2468
- this.db.run(`CREATE INDEX IF NOT EXISTS idx_leuco_log_${col} ON leuco_log (${col})`);
2469
- }
2470
- }
2471
- migrate() {
2472
- const current = this.db.prepare("PRAGMA user_version").get()?.user_version ?? 0;
2473
- if (current >= MIGRATIONS.length) return;
2474
- const pending = MIGRATIONS.slice(current);
2475
- let version = current;
2476
- for (const stmts of pending) {
2477
- version += 1;
2478
- this.db.transaction(() => {
2479
- for (const stmt of stmts) this.db.run(stmt);
2480
- this.db.run(`PRAGMA user_version = ${version}`);
2481
- })();
2482
- }
2483
- }
2484
- };
2485
- function validateIndexNames(names) {
2486
- for (const name of names) {
2487
- if (!COLUMN_NAME_RE.test(name)) throw new Error(`invalid index column name: ${name}`);
2488
- if (RESERVED_COLUMNS.has(name)) throw new Error(`reserved index column name: ${name}`);
2489
- }
2490
- }
2491
- function extractType(event) {
2492
- if (typeof event !== "object" || event === null) return null;
2493
- if (!("type" in event)) return null;
2494
- const t = event.type;
2495
- return typeof t === "string" ? t : null;
2496
- }
2497
- function toRecord(row) {
2498
- return {
2499
- seq: row.seq,
2500
- ts: row.ts,
2501
- event: JSON.parse(row.event)
2502
- };
2503
- }
2504
- //#endregion
2505
- //#region lib/gateway/sqlite-funnel-event-log.ts
2506
- const MAX_CONTENT_CHARS = 2e3;
2507
- /**
2508
- * SQLite-backed `FunnelEventLog`. One indexed table holds every broadcaster
2509
- * event with `channel_id` and `connector_id` as dedicated columns, so
2510
- * per-channel and per-connector replay is an indexed range scan.
2511
- *
2512
- * Concurrency: `seq` is `INTEGER PRIMARY KEY`, so SQLite assigns it
2513
- * atomically. The broadcaster owns its own offset counter at runtime
2514
- * (seeded from `findMaxOffset()` at startup); each broadcaster event
2515
- * flows in here via `record()` with that pre-assigned offset, which the
2516
- * sink stores via `write()` — PK uniqueness catches double-emit bugs.
2517
- *
2518
- * System events (gateway lifecycle, channel connect/disconnect, etc.) do
2519
- * NOT go through this store. They are diagnostic only and live in
2520
- * `FunnelLogger`'s file so the seq space here stays exclusive to
2521
- * broadcaster traffic. This is what makes the broadcaster's seq seeding
2522
- * (`getMaxSeq()` at startup) correct without per-event coordination.
2523
- */
2524
- var SqliteFunnelEventLog = class extends FunnelEventLog {
2525
- sink;
2526
- now;
2527
- constructor(props) {
2528
- super();
2529
- this.now = props.now ?? (() => Date.now());
2530
- this.sink = new LeucoLoggerSqliteSink({
2531
- path: props.path,
2532
- indexes: ["channel_id", "connector_id"],
2533
- extractIndexes: (event) => ({
2534
- channel_id: event.channel_id,
2535
- connector_id: event.connector_id
2536
- }),
2537
- now: this.now,
2538
- ...props.maxRows !== void 0 ? { maxRows: props.maxRows } : {},
2539
- ...props.maxAgeMs !== void 0 ? { maxAgeMs: props.maxAgeMs } : {},
2540
- ...props.maxBytes !== void 0 ? { maxBytes: props.maxBytes } : {},
2541
- ...props.targetBytes !== void 0 ? { targetBytes: props.targetBytes } : {}
2542
- });
2543
- }
2544
- /**
2545
- * Persist a broadcaster-driven event with its assigned offset. Caller
2546
- * (the gateway-server) supplies the offset from `broadcaster.broadcast()`
2547
- * so this store and the broadcaster's in-memory ring stay aligned.
2548
- */
2549
- record(record) {
2550
- const event = {
2551
- type: record.meta?.event_type ?? "unknown",
2552
- content: truncate$1(record.content),
2553
- channel_id: record.channelId,
2554
- connector_id: record.connectorId,
2555
- meta: record.meta
2556
- };
2557
- this.sink.write({
2558
- seq: record.offset,
2559
- ts: this.now(),
2560
- event
2561
- });
2562
- }
2563
- /**
2564
- * Returns events with offset > since. Filtering by channel/connector is
2565
- * the broadcaster's responsibility (it knows the client's subscription),
2566
- * so this returns the full slice and lets the caller filter.
2567
- */
2568
- loadSince(since) {
2569
- const records = this.sink.getRecords({ sinceSeq: since });
2570
- const out = [];
2571
- for (const record of records) out.push({
2572
- content: record.event.content,
2573
- meta: record.event.meta ?? void 0,
2574
- offset: record.seq
2575
- });
2576
- return out;
2577
- }
2578
- /**
2579
- * Returns events for one channel (and optionally one connector). Used
2580
- * by the gateway logs CLI for scoped queries. Channel/connector filters
2581
- * are indexed columns, so this is an indexed range scan.
2582
- */
2583
- loadForChannel(props) {
2584
- const where = { channel_id: props.channelId };
2585
- if (props.connectorId !== void 0) where.connector_id = props.connectorId;
2586
- const records = this.sink.getRecords({
2587
- where,
2588
- ...props.sinceSeq !== void 0 ? { sinceSeq: props.sinceSeq } : {},
2589
- ...props.limit !== void 0 ? { limit: props.limit } : {}
2590
- });
2591
- const out = [];
2592
- for (const record of records) out.push({
2593
- content: record.event.content,
2594
- meta: record.event.meta ?? void 0,
2595
- offset: record.seq
2596
- });
2597
- return out;
2598
- }
2599
- findMaxOffset() {
2600
- return this.sink.getMaxSeq();
2601
- }
2602
- clear() {
2603
- this.sink.clear();
2604
- }
2605
- close() {
2606
- this.sink.close();
2607
- }
2608
- };
2609
- function truncate$1(content) {
2610
- if (content.length <= MAX_CONTENT_CHARS) return content;
2611
- return `${content.slice(0, MAX_CONTENT_CHARS)}...`;
2612
- }
2613
- //#endregion
2614
- //#region lib/gateway/listener-supervisor.ts
2615
- const defaultOnError$1 = () => {};
2616
- const DEFAULT_HEALTH_INTERVAL_MS = 3e4;
2617
- const DEFAULT_MAX_BACKOFF_MS = 6e4;
2618
- const defaultSleep = (ms) => new Promise((r) => {
2619
- setTimeout(r, ms);
2620
- });
2621
- /**
2622
- * Owns the running listener instances and their lifecycle.
2623
- *
2624
- * Lives in the gateway process and is the only place that calls
2625
- * `listener.start()` / `listener.stop()`. Each entry is keyed by
2626
- * `${channelName}/${connectorName}` so the same connector name can exist in
2627
- * multiple channels without colliding.
2628
- *
2629
- * Periodically polls each running listener's `isAlive()` and auto-restarts
2630
- * dead listeners with exponential backoff (1s, 2s, 4s, ... capped). Resets
2631
- * the backoff counter on successful restart.
2632
- */
2633
- var FunnelListenerSupervisor = class FunnelListenerSupervisor {
2634
- channels;
2635
- notify;
2636
- logger;
2637
- onError;
2638
- running = /* @__PURE__ */ new Map();
2639
- failureCounts = /* @__PURE__ */ new Map();
2640
- stats = /* @__PURE__ */ new Map();
2641
- healthCheckIntervalMs;
2642
- maxBackoffMs;
2643
- sleep;
2644
- now;
2645
- healthCheckTimer = null;
2646
- healthCheckInFlight = false;
2647
- constructor(deps) {
2648
- this.channels = deps.channels;
2649
- this.notify = deps.notify;
2650
- this.logger = deps.logger;
2651
- this.onError = deps.onError ?? defaultOnError$1;
2652
- this.healthCheckIntervalMs = deps.healthCheckIntervalMs ?? DEFAULT_HEALTH_INTERVAL_MS;
2653
- this.maxBackoffMs = deps.maxBackoffMs ?? DEFAULT_MAX_BACKOFF_MS;
2654
- this.sleep = deps.sleep ?? defaultSleep;
2655
- this.now = deps.now ?? (() => Date.now());
2656
- }
2657
- static keyOf(channelName, connectorName) {
2658
- return `${channelName}/${connectorName}`;
2659
- }
2660
- isRunning(channelName, connectorName) {
2661
- return this.running.has(FunnelListenerSupervisor.keyOf(channelName, connectorName));
2662
- }
2663
- list() {
2664
- return [...this.running.entries()].map(([key, entry]) => {
2665
- const stats = this.stats.get(key);
2666
- return {
2667
- channelName: entry.channelName,
2668
- channelId: entry.channelId,
2669
- name: entry.config.name,
2670
- type: entry.config.type,
2671
- alive: entry.listener.isAlive(),
2672
- events: stats?.events ?? 0,
2673
- errors: stats?.errors ?? 0,
2674
- failureCount: this.failureCounts.get(key) ?? 0,
2675
- lastEventAt: stats?.lastEventAt ?? null
2676
- };
2677
- });
2678
- }
2679
- async start(channelName, connectorName) {
2680
- const key = FunnelListenerSupervisor.keyOf(channelName, connectorName);
2681
- if (this.running.has(key)) return {
2682
- ok: true,
2683
- reason: "already running"
2684
- };
2685
- const created = this.channels.createListener(channelName, connectorName);
2686
- if (!created) return {
2687
- ok: false,
2688
- reason: `connector "${connectorName}" not found in channel "${channelName}"`
2689
- };
2690
- const bind = async (content, meta) => {
2691
- try {
2692
- await this.notify(channelName, connectorName, content, meta);
2693
- this.recordEvent(key);
2694
- } catch (error) {
2695
- this.recordError(key);
2696
- throw error;
2697
- }
2698
- };
2699
- try {
2700
- await created.listener.start(bind);
2701
- this.running.set(key, {
2702
- config: created.config,
2703
- channelName,
2704
- channelId: created.channelId,
2705
- listener: created.listener
2706
- });
2707
- this.ensureStats(key);
2708
- this.logger?.info(`${created.config.type} listener started`, {
2709
- channel: channelName,
2710
- connector: connectorName
2711
- });
2712
- return { ok: true };
2713
- } catch (error) {
2714
- const err = error instanceof Error ? error : new Error(String(error));
2715
- this.logger?.error(`${created.config.type} listener failed to start`, {
2716
- channel: channelName,
2717
- connector: connectorName,
2718
- error: err.message
2719
- });
2720
- this.onError(err, {
2721
- component: "listener-supervisor.start",
2722
- channel: channelName,
2723
- connector: connectorName,
2724
- type: created.config.type
2725
- });
2726
- return {
2727
- ok: false,
2728
- reason: err.message
2729
- };
2730
- }
2731
- }
2732
- async stop(channelName, connectorName) {
2733
- const key = FunnelListenerSupervisor.keyOf(channelName, connectorName);
2734
- const entry = this.running.get(key);
2735
- if (!entry) return {
2736
- ok: true,
2737
- reason: "not running"
2738
- };
2739
- try {
2740
- await entry.listener.stop();
2741
- this.running.delete(key);
2742
- this.failureCounts.delete(key);
2743
- this.logger?.info(`${entry.config.type} listener stopped`, {
2744
- channel: channelName,
2745
- connector: connectorName
2746
- });
2747
- return { ok: true };
2748
- } catch (error) {
2749
- const err = error instanceof Error ? error : new Error(String(error));
2750
- this.logger?.error(`${entry.config.type} listener failed to stop`, {
2751
- channel: channelName,
2752
- connector: connectorName,
2753
- error: err.message
2754
- });
2755
- this.onError(err, {
2756
- component: "listener-supervisor.stop",
2757
- channel: channelName,
2758
- connector: connectorName,
2759
- type: entry.config.type
2760
- });
2761
- return {
2762
- ok: false,
2763
- reason: err.message
2764
- };
2765
- }
2766
- }
2767
- async restart(channelName, connectorName) {
2768
- const stopped = await this.stop(channelName, connectorName);
2769
- if (!stopped.ok) return stopped;
2770
- return await this.start(channelName, connectorName);
2771
- }
2772
- async startAll() {
2773
- const all = this.channels.listAllConnectors();
2774
- for (const view of all) await this.start(view.channelName, view.name);
2775
- this.startHealthCheck();
2776
- }
2777
- async stopAll() {
2778
- this.stopHealthCheck();
2779
- for (const [, entry] of [...this.running.entries()]) await this.stop(entry.channelName, entry.config.name);
2780
- }
2781
- ensureStats(key) {
2782
- const existing = this.stats.get(key);
2783
- if (existing) return existing;
2784
- const fresh = {
2785
- events: 0,
2786
- errors: 0,
2787
- failureCount: 0,
2788
- lastEventAt: null
2789
- };
2790
- this.stats.set(key, fresh);
2791
- return fresh;
2792
- }
2793
- recordEvent(key) {
2794
- const stats = this.ensureStats(key);
2795
- stats.events += 1;
2796
- stats.lastEventAt = new Date(this.now()).toISOString();
2797
- }
2798
- recordError(key) {
2799
- this.ensureStats(key).errors += 1;
2800
- }
2801
- startHealthCheck() {
2802
- if (this.healthCheckTimer) return;
2803
- this.healthCheckTimer = setInterval(() => {
2804
- this.runHealthCheck();
2805
- }, this.healthCheckIntervalMs);
2806
- this.healthCheckTimer.unref();
2807
- }
2808
- stopHealthCheck() {
2809
- if (!this.healthCheckTimer) return;
2810
- clearInterval(this.healthCheckTimer);
2811
- this.healthCheckTimer = null;
2812
- }
2813
- async runHealthCheck() {
2814
- if (this.healthCheckInFlight) return;
2815
- this.healthCheckInFlight = true;
2816
- try {
2817
- for (const [key, entry] of [...this.running.entries()]) {
2818
- if (entry.listener.isAlive()) {
2819
- this.failureCounts.delete(key);
2820
- continue;
2821
- }
2822
- await this.recoverDead(entry.channelName, entry.config.name, entry.config.type);
2823
- }
2824
- } finally {
2825
- this.healthCheckInFlight = false;
2826
- }
2827
- }
2828
- async recoverDead(channelName, connectorName, type) {
2829
- const key = FunnelListenerSupervisor.keyOf(channelName, connectorName);
2830
- const failureCount = this.failureCounts.get(key) ?? 0;
2831
- const backoffMs = Math.min(1e3 * 2 ** failureCount, this.maxBackoffMs);
2832
- this.logger?.warn(`${type} listener unhealthy, restarting`, {
2833
- channel: channelName,
2834
- connector: connectorName,
2835
- attempt: failureCount + 1,
2836
- backoffMs
2837
- });
2838
- await this.stop(channelName, connectorName);
2839
- await this.sleep(backoffMs);
2840
- if ((await this.start(channelName, connectorName)).ok) {
2841
- this.failureCounts.delete(key);
2842
- this.logger?.info(`${type} listener recovered`, {
2843
- channel: channelName,
2844
- connector: connectorName
2845
- });
2846
- } else this.failureCounts.set(key, failureCount + 1);
2847
- }
2848
- };
2849
- //#endregion
2850
- //#region lib/gateway/kill-competing-slack-gateways.ts
2851
- const defaultProcess = new NodeFunnelProcessRunner();
2852
- const titleFor = (dir) => `funnel-gateway[${dir}]`;
2853
- /**
2854
- * Kills other funnel daemon processes that share the SAME funnel home dir,
2855
- * which is the only situation that causes a real conflict (duplicate Slack
2856
- * Socket Mode connections with the same tokens). Daemons rooted at a
2857
- * different `~/.funnel/` are left alone — they hold different tokens and
2858
- * speak to different Slack apps. The daemon advertises its dir via the
2859
- * `funnel-gateway[<dir>]` marker appended to argv (also assigned to
2860
- * `process.title` on POSIX). `FunnelProcessRunner.listProcessesContaining`
2861
- * absorbs the POSIX/Windows enumeration difference behind the marker match.
2862
- */
2863
- const killCompetingSlackGateways = async (props) => {
2864
- const runner = props.process ?? defaultProcess;
2865
- const logger = props.logger;
2866
- const expectedTitle = titleFor(props.dir);
2867
- const snapshots = runner.listProcessesContaining(expectedTitle);
2868
- const killed = [];
2869
- for (const snapshot of snapshots) {
2870
- if (snapshot.pid === props.selfPid) continue;
2871
- runner.kill(snapshot.pid, "SIGTERM");
2872
- killed.push(snapshot.pid);
2873
- logger?.info("killed competing Slack gateway process", {
2874
- pid: snapshot.pid,
2875
- args: snapshot.command.slice(0, 160)
2876
- });
2877
- }
2878
- return killed;
2879
- };
2880
- //#endregion
2881
- //#region lib/gateway/routes/validator.ts
2882
- /**
2883
- * Path-param validator for gateway routes. On failure it answers with the same
2884
- * `{ ok: false, reason }` shape the listener routes already use, so
2885
- * `FunnelListenersClient` can surface the message without special-casing.
2886
- */
2887
- const zParam = (schema) => zValidator("param", schema, (result, c) => {
2888
- if (result.success) return;
2889
- const issue = result.error.issues[0];
2890
- const reason = issue ? `${issue.path.join(".")}: ${issue.message}` : "invalid request";
2891
- return c.json({
2892
- ok: false,
2893
- reason
2894
- }, 400);
2895
- });
2896
- //#endregion
2897
- //#region lib/gateway/routes/channels.connectors.call.ts
2898
- const bodySchema = z.object({
2899
- method: z.string().min(1),
2900
- path: z.string().min(1),
2901
- body: z.unknown().optional()
2902
- });
2903
- /**
2904
- * POST /channels/:channel/connectors/:connector/call
2905
- *
2906
- * Generic adapter call. Used by the funnel MCP server (running in the Claude
2907
- * Code process) to send replies/reactions/etc. without spawning a CLI
2908
- * subprocess. Mirrors the CLI's `funnel channels <c> connectors <conn> request
2909
- * --method=...` but with a structured JSON body and no shell.
2910
- */
2911
- const channelsConnectorsCallHandler = factory$1.createHandlers(zParam(z.object({
2912
- channel: z.string().min(1),
2913
- connector: z.string().min(1)
2914
- })), async (c) => {
2915
- const param = c.req.valid("param");
2916
- const raw = await c.req.json().catch(() => null);
2917
- const parsed = bodySchema.safeParse(raw);
2918
- if (!parsed.success) throw new HTTPException(400, { message: parsed.error.issues[0]?.message ?? "invalid body" });
2919
- const result = await c.var.deps.channels.call(param.channel, param.connector, {
2920
- method: parsed.data.method,
2921
- path: parsed.data.path,
2922
- body: parsed.data.body ?? {}
2923
- });
2924
- return c.json({
2925
- ok: true,
2926
- result
2927
- });
2928
- });
2929
- //#endregion
2930
- //#region lib/gateway/routes/channels.publish.ts
2931
- /**
2932
- * POST /channels/:channel/publish
2933
- *
2934
- * Inject arbitrary content into a channel. Mirrors the connector-driven `notify`
2935
- * path: events go through `broadcaster.broadcast` + `eventLog.record`, so
2936
- * subscribers see them exactly as if a listener had produced them.
2937
- *
2938
- * Body validation is Zod-shared with the client (`publishRequestSchema`); the
2939
- * response (`publishResponseSchema`) carries the assigned offset so callers can
2940
- * correlate with the persistent event store.
2941
- */
2942
- const channelsPublishHandler$1 = factory$1.createHandlers(zParam(z.object({ channel: z.string().min(1) })), zValidator("json", publishRequestSchema, (result, c) => {
2943
- if (result.success) return;
2944
- const issue = result.error.issues[0];
2945
- const reason = issue ? `${issue.path.join(".")}: ${issue.message}` : "invalid body";
2946
- return c.json({
2947
- ok: false,
2948
- reason
2949
- }, 400);
2950
- }), (c) => {
2951
- const param = c.req.valid("param");
2952
- const body = c.req.valid("json");
2953
- const meta = body.target ? {
2954
- ...body.meta,
2955
- target: body.target
2956
- } : body.meta;
2957
- const response = {
2958
- ok: true,
2959
- offset: c.var.deps.emit({
2960
- channel: param.channel,
2961
- connector: body.connector,
2962
- content: body.content,
2963
- meta
2964
- }).offset
2965
- };
2966
- return c.json(response);
2967
- });
2968
- //#endregion
2969
- //#region lib/gateway/connector-diagnostic-sql-reader.ts
2970
- /**
2971
- * Read-only SQL surface over the three diagnostic tables, for Claude to query
2972
- * the log with arbitrary `SELECT`s. It opens all files read-only and exposes
2973
- * three views — `raw`, `processed`, `connection` — that hide the storage
2974
- * details (the physical table is `leuco_log` and each row's columns live
2975
- * inside a JSON `event` blob): the views surface the columns as plain fields,
2976
- * with `payload` already pulled out of the nested JSON.
2977
- *
2978
- * The tables are separate files. `raw` and `processed` share an `event_id`,
2979
- * so a `JOIN` answers "the event arrived, but what verdict did it get?";
2980
- * `connection` answers the other half — "did the listener ever connect at
2981
- * all?". Writes are impossible: the connection is read-only and `query`
2982
- * rejects anything but a single `SELECT`.
2983
- */
2984
- var ConnectorDiagnosticSqlReader = class {
2985
- db;
2986
- constructor(props) {
2987
- const db = new Database(props.rawPath, { readonly: true });
2988
- try {
2989
- db.run("PRAGMA busy_timeout = 500");
2990
- db.prepare("ATTACH DATABASE ? AS processeddb").run(props.processedPath);
2991
- db.prepare("ATTACH DATABASE ? AS connectiondb").run(props.connectionPath);
2992
- db.run(rawViewSql);
2993
- db.run(processedViewSql);
2994
- db.run(connectionViewSql);
2995
- } catch (error) {
2996
- db.close();
2997
- throw error;
2998
- }
2999
- this.db = db;
3000
- Object.freeze(this);
3001
- }
3002
- /**
3003
- * Run one read-only `SELECT` and return the rows. Returns an `Error` (rather
3004
- * than throwing) for a non-SELECT statement or a SQL error, so the caller
3005
- * can surface the message without a stack trace.
3006
- */
3007
- query(sql, params = []) {
3008
- const trimmed = sql.trim().replace(/;$/, "").trim();
3009
- if (!/^select\b/i.test(trimmed)) return /* @__PURE__ */ new Error("only a single SELECT statement is allowed");
3010
- if (trimmed.includes(";")) return /* @__PURE__ */ new Error("only a single statement is allowed (remove the ';')");
3011
- try {
3012
- return this.db.prepare(trimmed).all(...params);
3013
- } catch (error) {
3014
- return error instanceof Error ? error : new Error(String(error));
3015
- }
3016
- }
3017
- close() {
3018
- this.db.close();
3019
- }
3020
- };
3021
- const rawViewSql = `CREATE TEMP VIEW raw AS SELECT
3022
- seq,
3023
- ts,
3024
- json_extract(event, '$.event_id') AS event_id,
3025
- json_extract(event, '$.type') AS type,
3026
- json_extract(event, '$.connector_id') AS connector_id,
3027
- json_extract(event, '$.channel_id') AS channel_id,
3028
- json_extract(event, '$.payload') AS payload
3029
- FROM main.leuco_log`;
3030
- const processedViewSql = `CREATE TEMP VIEW processed AS SELECT
3031
- seq,
3032
- ts,
3033
- json_extract(event, '$.event_id') AS event_id,
3034
- json_extract(event, '$.type') AS type,
3035
- json_extract(event, '$.connector_id') AS connector_id,
3036
- json_extract(event, '$.channel_id') AS channel_id,
3037
- json_extract(event, '$.outcome') AS outcome,
3038
- json_extract(event, '$.payload') AS payload
3039
- FROM processeddb.leuco_log`;
3040
- const connectionViewSql = `CREATE TEMP VIEW connection AS SELECT
3041
- seq,
3042
- ts,
3043
- json_extract(event, '$.type') AS type,
3044
- json_extract(event, '$.connector_id') AS connector_id,
3045
- json_extract(event, '$.channel_id') AS channel_id,
3046
- json_extract(event, '$.status') AS status,
3047
- json_extract(event, '$.detail') AS detail
3048
- FROM connectiondb.leuco_log`;
3049
- //#endregion
3050
- //#region lib/gateway/routes/debug.ts
3051
- const extractPreview = (payload) => {
3052
- if (typeof payload !== "string" || payload.length === 0) return null;
3053
- try {
3054
- const parsed = JSON.parse(payload);
3055
- if (parsed !== null && typeof parsed === "object" && "text" in parsed) {
3056
- const text = String(parsed.text);
3057
- return text.length > 80 ? `${text.slice(0, 80)}…` : text;
3058
- }
3059
- } catch {
3060
- return payload.length > 80 ? `${payload.slice(0, 80)}…` : payload;
3061
- }
3062
- return payload.length > 80 ? `${payload.slice(0, 80)}…` : payload;
3063
- };
3064
- const buildChannelDiagnosis = (channel) => {
3065
- const rootCause = (channel.connectionErrors[channel.connectionErrors.length - 1] ?? null)?.detail ?? null;
3066
- if (channel.connectors.length === 0) return {
3067
- status: "warn",
3068
- message: "no connectors configured on this channel",
3069
- nextActions: [`fnl channels ${channel.name} connectors add <name> --type=slack ...`],
3070
- rootCause: null
3071
- };
3072
- if (!channel.listener) return {
3073
- status: "error",
3074
- message: "no listener running for this channel",
3075
- nextActions: ["fnl gateway restart"],
3076
- rootCause
3077
- };
3078
- if (!channel.listener.alive) return {
3079
- status: "error",
3080
- message: "listener is dead",
3081
- nextActions: ["fnl gateway logs", "fnl gateway restart"],
3082
- rootCause
3083
- };
3084
- if (channel.claudeClients === 0) return {
3085
- status: "warn",
3086
- message: "no Claude connected to this channel",
3087
- nextActions: [`fnl claude --channel ${channel.name}`],
3088
- rootCause: null
3089
- };
3090
- if (channel.listener.errors > 0) return {
3091
- status: "warn",
3092
- message: "listener has errors",
3093
- nextActions: ["fnl gateway logs"],
3094
- rootCause
3095
- };
3096
- return {
3097
- status: "ok",
3098
- message: "healthy",
3099
- nextActions: [],
3100
- rootCause: null
3101
- };
3102
- };
3103
- /** GET /debug[?channel=<name>] — per-channel diagnosis with recent events. Used by MCP fnl_debug tool. */
3104
- const debugHandler$1 = factory$1.createHandlers(async (c) => {
3105
- const deps = c.var.deps;
3106
- const channelFilter = c.req.query("channel") ?? null;
3107
- const allChannels = deps.channels.list();
3108
- const targetChannels = channelFilter ? allChannels.filter((ch) => ch.name === channelFilter || ch.id === channelFilter) : allChannels;
3109
- const gatewayListeners = deps.supervisor.list();
3110
- const gatewayClients = deps.broadcaster.listChannels();
3111
- const metrics = deps.broadcaster.getMetrics();
3112
- const tmpDir = funnelTmpDir();
3113
- const rawPath = join(tmpDir, "connector-raw.db");
3114
- const processedPath = join(tmpDir, "connector-processed.db");
3115
- const connectionPath = join(tmpDir, "connector-connection.db");
3116
- const hasStore = existsSync(rawPath) && existsSync(processedPath) && existsSync(connectionPath);
3117
- const channels = targetChannels.map((ch) => {
3118
- const listenerEntry = gatewayListeners.find((l) => l.channelName === ch.name) ?? null;
3119
- const listener = listenerEntry ? {
3120
- alive: listenerEntry.alive,
3121
- events: listenerEntry.events,
3122
- errors: listenerEntry.errors,
3123
- lastEventAt: listenerEntry.lastEventAt
3124
- } : null;
3125
- const claudeClients = gatewayClients.filter((cl) => cl.channel === ch.id || cl.channel === ch.name).length;
3126
- const recentEvents = [];
3127
- const connectionErrors = [];
3128
- if (hasStore) {
3129
- const reader = new ConnectorDiagnosticSqlReader({
3130
- rawPath,
3131
- processedPath,
3132
- connectionPath
3133
- });
3134
- const rows = (() => {
3135
- try {
3136
- return reader.query("SELECT seq, ts, type, outcome, payload FROM processed WHERE channel_id = ? ORDER BY seq DESC LIMIT 10", [ch.id]);
3137
- } finally {
3138
- reader.close();
3139
- }
3140
- })();
3141
- if (!(rows instanceof Error)) for (const row of [...rows].reverse()) {
3142
- const rawPayload = typeof row.payload === "string" ? row.payload : null;
3143
- let payloadParsed = null;
3144
- if (rawPayload) try {
3145
- const parsed = JSON.parse(rawPayload);
3146
- if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) payloadParsed = parsed;
3147
- } catch {
3148
- payloadParsed = null;
3149
- }
3150
- recentEvents.push({
3151
- seq: typeof row.seq === "number" ? row.seq : null,
3152
- ts: typeof row.ts === "number" ? row.ts : null,
3153
- type: typeof row.type === "string" ? row.type : "?",
3154
- outcome: typeof row.outcome === "string" ? row.outcome : "?",
3155
- payload: rawPayload,
3156
- payloadParsed,
3157
- preview: extractPreview(row.payload)
3158
- });
3159
- }
3160
- if (listener && (!listener.alive || listener.errors > 0) || !listener) {
3161
- const errReader = new ConnectorDiagnosticSqlReader({
3162
- rawPath,
3163
- processedPath,
3164
- connectionPath
3165
- });
3166
- const errRows = (() => {
3167
- try {
3168
- 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]);
3169
- } finally {
3170
- errReader.close();
3171
- }
3172
- })();
3173
- if (!(errRows instanceof Error)) for (const row of [...errRows].reverse()) connectionErrors.push({
3174
- ts: typeof row.ts === "number" ? row.ts : null,
3175
- type: typeof row.type === "string" ? row.type : "?",
3176
- status: typeof row.status === "string" ? row.status : "?",
3177
- detail: typeof row.detail === "string" && row.detail.length > 0 ? row.detail : null
3178
- });
3179
- }
3180
- }
3181
- const base = {
3182
- id: ch.id,
3183
- name: ch.name,
3184
- connectors: ch.connectors.map((conn) => conn.name),
3185
- listener,
3186
- claudeClients,
3187
- recentEvents,
3188
- connectionErrors
3189
- };
3190
- return {
3191
- ...base,
3192
- diagnosis: buildChannelDiagnosis(base)
3193
- };
3194
- });
3195
- return c.json({
3196
- pid: deps.selfPid,
3197
- uptimeMs: deps.uptimeMs(),
3198
- eventsBroadcast: metrics.eventsBroadcast,
3199
- channels
3200
- });
3201
- });
3202
- //#endregion
3203
- //#region lib/gateway/routes/health.ts
3204
- /** GET /health — liveness + listener registry snapshot. */
3205
- const healthHandler = factory$1.createHandlers((c) => {
3206
- const deps = c.var.deps;
3207
- return c.json({
3208
- ok: true,
3209
- pid: deps.selfPid,
3210
- clients: deps.broadcaster.getClientCount(),
3211
- listeners: deps.supervisor.list()
3212
- });
3213
- });
3214
- //#endregion
3215
- //#region lib/gateway/routes/listeners.list.ts
3216
- /** GET /listeners — running connector listeners with alive/dead status. */
3217
- const listenersListHandler = factory$1.createHandlers((c) => {
3218
- return c.json({ listeners: c.var.deps.supervisor.list() });
3219
- });
3220
- //#endregion
3221
- //#region lib/gateway/routes/listeners.restart.ts
3222
- /** POST /listeners/:channel/:connector/restart — stop + start a connector listener. */
3223
- const listenersRestartHandler = factory$1.createHandlers(zParam(z.object({
3224
- channel: z.string().min(1),
3225
- connector: z.string().min(1)
3226
- })), async (c) => {
3227
- const param = c.req.valid("param");
3228
- const result = await c.var.deps.supervisor.restart(param.channel, param.connector);
3229
- return c.json(result, result.ok ? 200 : 400);
3230
- });
3231
- //#endregion
3232
- //#region lib/gateway/routes/listeners.start.ts
3233
- /** POST /listeners/:channel/:connector/start — start a connector listener. */
3234
- const listenersStartHandler = factory$1.createHandlers(zParam(z.object({
3235
- channel: z.string().min(1),
3236
- connector: z.string().min(1)
3237
- })), async (c) => {
3238
- const param = c.req.valid("param");
3239
- const result = await c.var.deps.supervisor.start(param.channel, param.connector);
3240
- return c.json(result, result.ok ? 200 : 400);
3241
- });
3242
- //#endregion
3243
- //#region lib/gateway/routes/listeners.stop.ts
3244
- /** DELETE /listeners/:channel/:connector — stop a connector listener. */
3245
- const listenersStopHandler = factory$1.createHandlers(zParam(z.object({
3246
- channel: z.string().min(1),
3247
- connector: z.string().min(1)
3248
- })), async (c) => {
3249
- const param = c.req.valid("param");
3250
- const result = await c.var.deps.supervisor.stop(param.channel, param.connector);
3251
- return c.json(result, result.ok ? 200 : 400);
3252
- });
3253
- //#endregion
3254
- //#region lib/gateway/routes/status.ts
3255
- /** GET /status — listener registry, connected channels, and broadcaster metrics. */
3256
- const statusHandler$1 = factory$1.createHandlers((c) => {
3257
- const deps = c.var.deps;
3258
- return c.json({
3259
- ok: true,
3260
- pid: deps.selfPid,
3261
- uptimeMs: deps.uptimeMs(),
3262
- clients: deps.broadcaster.listChannels(),
3263
- listeners: deps.supervisor.list(),
3264
- broadcaster: deps.broadcaster.getMetrics()
3265
- });
3266
- });
3267
- //#endregion
3268
- //#region lib/gateway/routes/index.ts
3269
- function buildGatewayRoutes() {
3270
- 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);
3271
- }
3272
- const gatewayRoutes = buildGatewayRoutes();
3273
- //#endregion
3274
- //#region lib/gateway/gateway-server.ts
3275
- const DEFAULT_HOST = "127.0.0.1";
3276
- const LOOPBACK_HOSTS = new Set([
3277
- "127.0.0.1",
3278
- "localhost",
3279
- "::1",
3280
- "::ffff:127.0.0.1"
3281
- ]);
3282
- const defaultDbPath = () => join(funnelTmpDir(), "events.db");
3283
- const defaultOnError = () => {};
3284
- /**
3285
- * In-process gateway: runs `Bun.serve` (HTTP + WebSocket /ws), boots connector
3286
- * listeners through `FunnelListenerSupervisor`, fans events out via
3287
- * `FunnelBroadcaster`, and persists them via a `FunnelEventLog` (SQLite by default).
3288
- * System events (gateway lifecycle, connect/disconnect) flow to `FunnelLogger`
3289
- * instead — keeping the SQLite seq space exclusive to broadcaster traffic so
3290
- * the broadcaster's offset counter and `getMaxSeq()` stay aligned without
3291
- * per-event coordination. Exposes `/listeners` HTTP for runtime
3292
- * start/stop/restart of individual connectors.
3293
- */
3294
- var FunnelGatewayServer = class {
3295
- channels;
3296
- port;
3297
- hostname;
3298
- dbPath;
3299
- process;
3300
- logger;
3301
- onError;
3302
- selfPid;
3303
- dir;
3304
- killCompetingSlack;
3305
- token;
3306
- broadcaster;
3307
- eventLog;
3308
- supervisor;
3309
- nowMs;
3310
- extraRoutes;
3311
- startedAt = null;
3312
- server = null;
3313
- constructor(deps) {
3314
- this.channels = deps.channels;
3315
- this.port = deps.port ?? resolveFunnelPort();
3316
- this.hostname = deps.hostname ?? DEFAULT_HOST;
3317
- this.dbPath = deps.dbPath ?? defaultDbPath();
3318
- this.process = deps.process;
3319
- this.logger = deps.logger;
3320
- this.onError = deps.onError ?? defaultOnError;
3321
- this.selfPid = deps.selfPid ?? globalThis.process.pid;
3322
- this.dir = deps.dir ?? FUNNEL_DIR;
3323
- this.killCompetingSlack = deps.killCompetingSlack ?? true;
3324
- this.token = deps.token ?? "";
3325
- this.extraRoutes = deps.extraRoutes ?? null;
3326
- const clock = deps.clock;
3327
- this.nowMs = clock ? () => clock.millis() : () => Date.now();
3328
- if (deps.eventLog) this.eventLog = deps.eventLog;
3329
- else {
3330
- const dbDir = dirname(this.dbPath);
3331
- if (!existsSync(dbDir)) mkdirSync(dbDir, { recursive: true });
3332
- this.eventLog = new SqliteFunnelEventLog({
3333
- path: this.dbPath,
3334
- now: this.nowMs
3335
- });
3336
- }
3337
- this.broadcaster = new FunnelBroadcaster({
3338
- logger: this.logger,
3339
- onError: this.onError,
3340
- now: this.nowMs,
3341
- persistentReplay: this.eventLog
3342
- });
3343
- this.broadcaster.seedLatestOffset(this.eventLog.findMaxOffset());
3344
- this.supervisor = new FunnelListenerSupervisor({
3345
- channels: this.channels,
3346
- logger: this.logger,
3347
- onError: this.onError,
3348
- notify: async (channelName, connectorName, content, meta) => {
3349
- this.emit({
3350
- channel: channelName,
3351
- connector: connectorName,
3352
- content,
3353
- meta
3354
- });
3355
- },
3356
- now: this.nowMs
3357
- });
3358
- }
3359
- async start() {
3360
- if (this.server) return this.server;
3361
- 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 });
3362
- const app = this.buildApp();
3363
- this.startedAt = this.nowMs();
3364
- this.server = Bun.serve({
3365
- port: this.port,
3366
- hostname: this.hostname,
3367
- development: false,
3368
- fetch: (request, server) => this.handleFetch(request, server, app),
3369
- websocket: {
3370
- open: (ws) => this.handleWsOpen(ws),
3371
- close: (ws) => this.handleWsClose(ws),
3372
- message() {}
3373
- }
3374
- });
3375
- this.logServerStarted();
3376
- await this.bootListeners();
3377
- return this.server;
3378
- }
3379
- async stop() {
3380
- await this.supervisor.stopAll();
3381
- if (this.server) {
3382
- this.server.stop();
3383
- this.server = null;
3384
- }
782
+ isRunning() {
783
+ const pid = this.readPid();
784
+ if (!pid) return false;
785
+ return this.isProcessAlive(pid);
3385
786
  }
3386
787
  getStatus() {
788
+ const pid = this.readPid();
789
+ const running = pid !== null && this.isProcessAlive(pid);
3387
790
  return {
3388
- clients: this.broadcaster.getClientCount(),
3389
- channels: this.broadcaster.listChannels()
791
+ running,
792
+ pid: running ? pid : null,
793
+ port: this.port
3390
794
  };
3391
795
  }
3392
- getBroadcaster() {
3393
- return this.broadcaster;
3394
- }
3395
- getSupervisor() {
3396
- return this.supervisor;
796
+ async start(options = {}) {
797
+ if (this.isRunning()) return true;
798
+ this.fs.mkdirSync(this.tmpDir, { recursive: true });
799
+ const gatewayScript = resolveDaemonScript();
800
+ const command = this.buildStartCommand(gatewayScript, options);
801
+ this.process.detach(command, {
802
+ env: { FUNNEL_DIR: this.dir },
803
+ stdoutFile: this.gatewayLog,
804
+ stderrFile: this.gatewayLog
805
+ });
806
+ const deadline = this.clock.millis() + STARTUP_TIMEOUT_MS;
807
+ while (this.clock.millis() < deadline) {
808
+ if (this.isRunning()) return true;
809
+ await this.sleep(POLL_INTERVAL_MS);
810
+ }
811
+ return this.isRunning();
3397
812
  }
3398
- getEventLog() {
3399
- return this.eventLog;
813
+ buildStartCommand(gatewayScript, options = {}) {
814
+ const tag = `funnel-gateway[${this.dir}]`;
815
+ if (options.caffeinate !== false && globalThis.process.platform === "darwin") return [
816
+ "caffeinate",
817
+ "-is",
818
+ "bun",
819
+ gatewayScript,
820
+ tag
821
+ ];
822
+ return [
823
+ "bun",
824
+ gatewayScript,
825
+ tag
826
+ ];
3400
827
  }
3401
- /**
3402
- * Register an in-process observer for every broadcast event. Fires after
3403
- * the event is fanned out to WS clients and recorded in the event log.
3404
- * Returns an unsubscribe function. Only meaningful in-process (embedded
3405
- * hosts / `new Funnel(...)` running their own gateway-server); a separate
3406
- * daemon process cannot be observed this way — use a WS client for that.
3407
- */
3408
- onEvent(handler) {
3409
- return this.broadcaster.subscribe(handler);
3410
- }
3411
- handleFetch(request, server, app) {
3412
- const url = new URL(request.url);
3413
- if (url.pathname === "/ws" && request.headers.get("upgrade") === "websocket") {
3414
- if (this.token && !this.tokenMatchesUpgrade(request)) return new Response("unauthorized", { status: 401 });
3415
- const requestedChannel = url.searchParams.get("channel") ?? "";
3416
- const channel = requestedChannel ? this.resolveChannel(requestedChannel) : null;
3417
- const channelId = channel?.id ?? requestedChannel;
3418
- const channelName = channel?.name ?? null;
3419
- const connectors = channel?.connectors ?? [];
3420
- const delivery = channel?.delivery ?? "fanout";
3421
- const sinceRaw = url.searchParams.get("since");
3422
- const sinceParsed = sinceRaw === null ? NaN : Number.parseInt(sinceRaw, 10);
3423
- const since = Number.isFinite(sinceParsed) && sinceParsed >= 0 ? sinceParsed : void 0;
3424
- const subscriberId = url.searchParams.get("id") ?? void 0;
3425
- if (server.upgrade(request, { data: {
3426
- channel: channelId,
3427
- channelName,
3428
- connectors,
3429
- delivery,
3430
- subscriberId,
3431
- since
3432
- } })) return void 0;
3433
- return new Response("WebSocket upgrade failed", { status: 400 });
828
+ async stop() {
829
+ const pid = this.readPid();
830
+ if (!pid) return true;
831
+ if (!this.isProcessAlive(pid)) {
832
+ this.removePid();
833
+ return true;
3434
834
  }
3435
- return app.fetch(request);
3436
- }
3437
- handleWsOpen(ws) {
3438
- if (typeof ws.data.since === "number") {
3439
- const replay = this.broadcaster.replaySince(ws.data.since, ws.data);
3440
- for (const event of replay) ws.send(JSON.stringify(event));
835
+ try {
836
+ this.process.kill(pid, "SIGTERM");
837
+ } catch {
838
+ return false;
3441
839
  }
3442
- this.broadcaster.addClient(ws, ws.data);
3443
- this.logger?.info("channel connected", {
3444
- event_type: "system",
3445
- action: "channel_connect",
3446
- channel: ws.data.channelName ?? "",
3447
- channelId: ws.data.channel,
3448
- connectors: ws.data.connectors.join(","),
3449
- total: String(this.broadcaster.getClientCount())
3450
- });
3451
- }
3452
- handleWsClose(ws) {
3453
- this.broadcaster.removeClient(ws);
3454
- this.logger?.info("channel disconnected", {
3455
- event_type: "system",
3456
- action: "channel_disconnect",
3457
- channel: ws.data.channelName ?? "",
3458
- channelId: ws.data.channel,
3459
- total: String(this.broadcaster.getClientCount())
3460
- });
3461
- }
3462
- logServerStarted() {
3463
- this.logger?.info("gateway started", {
3464
- event_type: "system",
3465
- action: "gateway_start",
3466
- port: String(this.port),
3467
- pid: String(this.selfPid)
3468
- });
3469
- this.logger?.info("funnel gateway listening", {
3470
- url: `http://localhost:${this.port}`,
3471
- websocket: `ws://localhost:${this.port}/ws`,
3472
- health: `http://localhost:${this.port}/health`
3473
- });
3474
- }
3475
- buildApp() {
3476
- const base = factory$1.createApp();
3477
- base.use((c, next) => {
3478
- c.set("deps", {
3479
- selfPid: this.selfPid,
3480
- broadcaster: this.broadcaster,
3481
- supervisor: this.supervisor,
3482
- channels: this.channels,
3483
- uptimeMs: () => this.startedAt ? this.nowMs() - this.startedAt : 0,
3484
- emit: (input) => this.emit(input)
3485
- });
3486
- return next();
3487
- });
3488
- if (this.token) {
3489
- base.use("/listeners/*", requireBearerToken({ expected: this.token }));
3490
- base.use("/status", requireBearerToken({ expected: this.token }));
3491
- base.use("/debug", requireBearerToken({ expected: this.token }));
3492
- base.use("/channels/*", requireBearerToken({ expected: this.token }));
840
+ const deadline = this.clock.millis() + SIGTERM_TIMEOUT_MS;
841
+ while (this.clock.millis() < deadline) {
842
+ if (!this.isProcessAlive(pid)) {
843
+ this.removePid();
844
+ return true;
845
+ }
846
+ await this.sleep(POLL_INTERVAL_MS);
3493
847
  }
3494
- return (this.extraRoutes ? base.route("/", this.extraRoutes) : base).route("/", gatewayRoutes);
848
+ try {
849
+ this.process.kill(pid, "SIGKILL");
850
+ } catch {}
851
+ await this.sleep(SIGKILL_GRACE_MS);
852
+ this.removePid();
853
+ return !this.isProcessAlive(pid);
3495
854
  }
3496
- /**
3497
- * Reads the bearer token from the WebSocket upgrade request. Accepts:
3498
- * - `Sec-WebSocket-Protocol: funnel.token.<value>` (preferred header, never logged in URLs)
3499
- * - `Authorization: Bearer <value>` (also header-based)
3500
- * Returns true on a constant-time match against the daemon token.
3501
- */
3502
- tokenMatchesUpgrade(request) {
3503
- const protocols = (request.headers.get("sec-websocket-protocol") ?? "").split(",").map((p) => p.trim()).filter((p) => p.length > 0);
3504
- for (const proto of protocols) if (proto.startsWith("funnel.token.") && constantTimeEqual(proto.slice(13), this.token)) return true;
3505
- const match = (request.headers.get("authorization") ?? "").match(/^Bearer\s+(.+)$/i);
3506
- if (match && constantTimeEqual(match[1] ?? "", this.token)) return true;
3507
- return false;
3508
- }
3509
- resolveChannel(requested) {
3510
- const channel = this.channels.get(requested) ?? this.channels.getById(requested);
3511
- if (!channel) return null;
3512
- return {
3513
- id: channel.id,
3514
- name: channel.name,
3515
- connectors: channel.connectors.map((c) => c.name),
3516
- delivery: channel.delivery
855
+ async restart(options = {}) {
856
+ const wasRunning = this.isRunning();
857
+ if (options.onlyIfRunning && !wasRunning) return {
858
+ ok: true,
859
+ wasRunning: false,
860
+ stopped: false,
861
+ started: false
3517
862
  };
3518
- }
3519
- async bootListeners() {
3520
- const allConnectors = this.channels.listAllConnectors();
3521
- if (this.killCompetingSlack && allConnectors.some((c) => c.type === "slack")) {
3522
- const killed = await killCompetingSlackGateways({
3523
- selfPid: this.selfPid,
3524
- dir: this.dir,
3525
- process: this.process,
3526
- logger: this.logger
3527
- });
3528
- if (killed.length > 0) this.logger?.info("killed competing Slack gateway processes", {
3529
- event_type: "system",
3530
- action: "kill_competing",
3531
- pids: killed.join(",")
3532
- });
3533
- }
3534
- await this.supervisor.startAll();
3535
- for (const entry of this.supervisor.list()) this.logger?.info(`${entry.type} listener started: ${entry.name}`, {
3536
- event_type: "system",
3537
- action: `${entry.type}_connect`,
3538
- channel: entry.channelName,
3539
- connector: entry.name
3540
- });
3541
- this.logger?.info(`event store: ${this.dbPath}`);
3542
- this.logger?.info("funnel gateway running");
3543
- }
3544
- /**
3545
- * Broadcast `content` to subscribers of `channel`, persisting the event in
3546
- * the SQLite store and stamping `meta.channel{,Id}` / `meta.connector{,Id}`
3547
- * when they resolve. Used by both the connector-listener path (via the
3548
- * supervisor's `notify` callback) and the public `/channels/:channel/publish`
3549
- * route. Returns the assigned event offset.
3550
- */
3551
- emit(input) {
3552
- const channelId = this.lookupChannelId(input.channel);
3553
- const connectorId = channelId && input.connector ? this.lookupConnectorId(channelId, input.connector) : null;
3554
- const enriched = {
3555
- ...input.meta,
3556
- channel: input.channel
863
+ const stopped = wasRunning ? await this.stop() : true;
864
+ if (!stopped) return {
865
+ ok: false,
866
+ wasRunning,
867
+ stopped: false,
868
+ started: false
869
+ };
870
+ const started = await this.start({ caffeinate: options.caffeinate });
871
+ return {
872
+ ok: started,
873
+ wasRunning,
874
+ stopped,
875
+ started
3557
876
  };
3558
- if (input.connector) enriched.connector = input.connector;
3559
- if (channelId) enriched.channelId = channelId;
3560
- if (connectorId) enriched.connectorId = connectorId;
3561
- const event = this.broadcaster.broadcast(input.content, enriched);
3562
- this.eventLog.record({
3563
- content: input.content,
3564
- channelId: channelId ?? null,
3565
- connectorId: connectorId ?? null,
3566
- meta: enriched,
3567
- offset: event.offset
3568
- });
3569
- return { offset: event.offset };
3570
- }
3571
- lookupChannelId(channelName) {
3572
- return this.channels.get(channelName)?.id ?? null;
3573
877
  }
3574
- lookupConnectorId(channelId, connectorName) {
3575
- return this.channels.getById(channelId)?.connectors.find((c) => c.name === connectorName)?.id ?? null;
878
+ getGatewayLog() {
879
+ return this.gatewayLog;
3576
880
  }
3577
- };
3578
- //#endregion
3579
- //#region lib/gateway/gateway-token.ts
3580
- const TOKEN_FILE_NAME = "gateway.token";
3581
- const TOKEN_BYTES = 32;
3582
- const defaultFs = new NodeFunnelFileSystem();
3583
- const defaultGenerate = () => {
3584
- const buf = new Uint8Array(TOKEN_BYTES);
3585
- crypto.getRandomValues(buf);
3586
- return [...buf].map((b) => b.toString(16).padStart(2, "0")).join("");
3587
- };
3588
- /**
3589
- * Reads / generates the gateway daemon token used to authenticate
3590
- * `/listeners*`, `/status`, and `/ws` connections.
3591
- *
3592
- * Token file: `<dir>/gateway.token` (default `~/.funnel/gateway.token`),
3593
- * written with mode 0600. Clients on the same machine as the daemon read
3594
- * the file directly; the token never leaves the user's home directory.
3595
- */
3596
- var FunnelGatewayToken = class {
3597
- fs;
3598
- path;
3599
- generate;
3600
- constructor(deps = {}) {
3601
- this.fs = deps.fs ?? defaultFs;
3602
- this.path = join(deps.dir ?? FUNNEL_DIR, TOKEN_FILE_NAME);
3603
- this.generate = deps.generate ?? defaultGenerate;
3604
- Object.freeze(this);
881
+ getPort() {
882
+ return this.port;
3605
883
  }
3606
- read() {
3607
- if (!this.fs.existsSync(this.path)) return null;
3608
- const value = this.fs.readFileSync(this.path).trim();
3609
- return value.length > 0 ? value : null;
884
+ readPid() {
885
+ if (!this.fs.existsSync(this.pidFile)) return null;
886
+ try {
887
+ const content = this.fs.readFileSync(this.pidFile).trim();
888
+ const pid = Number(content);
889
+ if (!pid || pid <= 0) return null;
890
+ return pid;
891
+ } catch {
892
+ return null;
893
+ }
3610
894
  }
3611
- /**
3612
- * Returns the existing token or, if missing, generates one and writes it with mode 0600.
3613
- *
3614
- * NOTE: not atomic — two concurrent `ensure()` calls (e.g., `fnl gateway start` racing
3615
- * itself before the PID lock is acquired) could each generate independent tokens. The
3616
- * gateway PID file makes this practically a non-issue; if you need stronger guarantees,
3617
- * take a file lock around this call externally.
3618
- */
3619
- ensure() {
3620
- const existing = this.read();
3621
- if (existing) return existing;
3622
- const token = this.generate();
3623
- this.fs.mkdirSync(dirname(this.path), { recursive: true });
3624
- this.fs.writeSecretFileSync(this.path, `${token}\n`);
3625
- return token;
895
+ removePid() {
896
+ this.fs.unlink(this.pidFile);
3626
897
  }
3627
- getPath() {
3628
- return this.path;
898
+ isProcessAlive(pid) {
899
+ return this.process.isAlive(pid);
3629
900
  }
3630
901
  };
3631
- const DEFAULT_GATEWAY_TOKEN_PATH = join(homedir(), ".funnel", TOKEN_FILE_NAME);
3632
902
  //#endregion
3633
903
  //#region lib/gateway/listeners-client.ts
3634
904
  const listenerEntrySchema = z.object({
@@ -4032,473 +1302,6 @@ var NoopFunnelLogger = class extends FunnelLogger {
4032
1302
  error() {}
4033
1303
  };
4034
1304
  //#endregion
4035
- //#region lib/gateway/memory-funnel-event-log.ts
4036
- /**
4037
- * In-process `FunnelEventLog` backed by a plain array. Used by tests and by
4038
- * embedders that do not need durability — replay works within the process
4039
- * lifetime but is lost when the process exits. Unlike the SQLite log it does
4040
- * not truncate content or prune, so it is not meant for unbounded production
4041
- * traffic.
4042
- */
4043
- var MemoryFunnelEventLog = class extends FunnelEventLog {
4044
- events = [];
4045
- constructor() {
4046
- super();
4047
- Object.freeze(this);
4048
- }
4049
- record(record) {
4050
- this.events.push({
4051
- offset: record.offset,
4052
- content: record.content,
4053
- meta: record.meta ?? void 0,
4054
- channelId: record.channelId,
4055
- connectorId: record.connectorId
4056
- });
4057
- }
4058
- loadSince(since) {
4059
- const out = [];
4060
- for (const event of this.events) if (event.offset > since) out.push({
4061
- content: event.content,
4062
- meta: event.meta,
4063
- offset: event.offset
4064
- });
4065
- return out;
4066
- }
4067
- findMaxOffset() {
4068
- let max = 0;
4069
- for (const event of this.events) if (event.offset > max) max = event.offset;
4070
- return max;
4071
- }
4072
- clear() {
4073
- this.events.length = 0;
4074
- }
4075
- close() {}
4076
- };
4077
- //#endregion
4078
- //#region lib/gateway/connector-diagnostic-log.ts
4079
- /**
4080
- * Points in the listener's connection lifecycle. The single source of truth
4081
- * for the value set: the `status` column schema, the `ConnectorConnectionStatus`
4082
- * union, and the runtime Set used to narrow on read-back all derive from this
4083
- * array, so adding a status is a one-line change that cannot drift out of sync.
4084
- *
4085
- * started start() was called
4086
- * connected the socket opened and events can flow
4087
- * disconnected the socket was closed by a stop() call (a clean teardown)
4088
- * auth-failed the token was rejected before the socket opened
4089
- * stopped the listener was fully torn down (always follows a stop(),
4090
- * paired with the disconnected/error that preceded it)
4091
- * error start/stop threw, or Bolt surfaced an error frame — this is
4092
- * also where an unsolicited socket drop shows up when Bolt
4093
- * reports it (an `error` with no following `stopped` means the
4094
- * supervisor recycled the listener, not a clean stop)
4095
- *
4096
- * A connection row is independent of any single inbound event, so it carries
4097
- * no `eventId`. This is how "no notification arrived because the listener
4098
- * never connected (or dropped, or failed auth)" becomes visible: the
4099
- * raw/processed tables only hold events that *did* arrive.
4100
- */
4101
- const CONNECTOR_CONNECTION_STATUSES = [
4102
- "started",
4103
- "connected",
4104
- "disconnected",
4105
- "auth-failed",
4106
- "stopped",
4107
- "error"
4108
- ];
4109
- /**
4110
- * Rows stored in the diagnostic tables. Connector-agnostic on purpose: `type`
4111
- * carries the listener kind ("slack" | "discord" | "gh" | "schedule") so new
4112
- * connectors land in the same tables without a schema change. `event_id` is
4113
- * the correlation key the listener mints once per inbound event and stamps
4114
- * onto both the raw and processed rows, so the two are joinable even though
4115
- * they live in separate tables with independent `seq` counters.
4116
- *
4117
- * These schemas mirror the stored shape (snake_case columns) the way
4118
- * `FunnelEvent` does for the replay log; they exist for `z.infer` and to
4119
- * document the column set, not as a parse boundary.
4120
- */
4121
- const connectorRawEventSchema = z.object({
4122
- event_id: z.string(),
4123
- type: z.string(),
4124
- connector_id: z.string().nullable(),
4125
- channel_id: z.string().nullable(),
4126
- payload: z.string()
4127
- });
4128
- const connectorProcessedEventSchema = z.object({
4129
- event_id: z.string(),
4130
- type: z.string(),
4131
- connector_id: z.string().nullable(),
4132
- channel_id: z.string().nullable(),
4133
- outcome: z.string(),
4134
- payload: z.string()
4135
- });
4136
- const connectorConnectionEventSchema = z.object({
4137
- type: z.string(),
4138
- connector_id: z.string().nullable(),
4139
- channel_id: z.string().nullable(),
4140
- status: z.enum(CONNECTOR_CONNECTION_STATUSES),
4141
- detail: z.string()
4142
- });
4143
- /**
4144
- * Three-table diagnostic log of everything a connector listener does, so
4145
- * "why was there no notification?" is answerable whichever way it failed:
4146
- * - `raw` — every inbound event, before any filtering, with the listener's
4147
- * untouched payload (the Slack Bolt event, the GH webhook, …)
4148
- * - `processed` — the verdict for that event: `outcome` (emitted, or the
4149
- * reason it was dropped) and, when emitted, the body that was delivered.
4150
- * Shares an `eventId` with its raw row, so the two join into one story.
4151
- * - `connection` — the listener's lifecycle (started, connected, dropped,
4152
- * auth-failed, stopped, errored). This is the half the event tables can't
4153
- * show: an event that never arrived leaves no raw row, but a listener that
4154
- * never connected leaves a `connection` trail that says so.
4155
- *
4156
- * The three are physically separate (independent retention and payload-size
4157
- * policy) so a query never crosses them by accident and a huge raw payload
4158
- * never bloats the verdict or lifecycle trails. None flow to WS clients or the
4159
- * MCP channel — this is a separate store from `FunnelEventLog` (replay) and
4160
- * exists solely for debugging.
4161
- *
4162
- * Implementations:
4163
- * - `SqliteConnectorDiagnosticLog` — the default; survives daemon restarts,
4164
- * bounded by per-table row/age caps.
4165
- * - `MemoryConnectorDiagnosticLog` — an in-process double for tests.
4166
- */
4167
- var ConnectorDiagnosticLog = class {};
4168
- //#endregion
4169
- //#region lib/gateway/sqlite-connector-diagnostic-log.ts
4170
- /**
4171
- * Cap on a raw payload kept verbatim. The point of the raw table is to see
4172
- * what Slack/Discord actually sent, and a typical event is a few KB — so 256
4173
- * KiB keeps essentially everything intact while bounding the rare giant
4174
- * payload (a huge Block Kit message, a file dump) that would otherwise let a
4175
- * single row bloat the debug database without limit.
4176
- */
4177
- const RAW_PAYLOAD_CAP = 256 * 1024;
4178
- /**
4179
- * Default `ConnectorDiagnosticLog`: three independent `LeucoLoggerSqliteSink`s, one
4180
- * per table (raw / processed / connection), in separate files. Each sink
4181
- * indexes the columns its queries filter on — `event_id` / `connector_id` /
4182
- * `channel_id` for raw, plus `outcome` for processed and `status` for
4183
- * connection — so those lookups are indexed scans (`type` is a fixed column
4184
- * the sink extracts separately, not an index, so filtering by it is a scan).
4185
- *
4186
- * The raw table offloads any payload over `RAW_PAYLOAD_CAP`: rather than
4187
- * truncating mid-string (which yields unparseable JSON), it replaces the
4188
- * body with a small JSON object that keeps the diagnostic essentials and
4189
- * records the dropped size under `_funnel_oversized`. Every stored payload
4190
- * therefore stays valid JSON.
4191
- */
4192
- var SqliteConnectorDiagnosticLog = class extends ConnectorDiagnosticLog {
4193
- raw;
4194
- processed;
4195
- connection;
4196
- now;
4197
- logger;
4198
- constructor(props) {
4199
- super();
4200
- this.now = props.now ?? (() => Date.now());
4201
- this.logger = props.logger;
4202
- const ageCap = props.maxAgeMs !== void 0 ? { maxAgeMs: props.maxAgeMs } : {};
4203
- const verdictCap = {
4204
- now: this.now,
4205
- ...ageCap,
4206
- ...props.maxRows !== void 0 ? { maxRows: props.maxRows } : {}
4207
- };
4208
- const rawMax = props.rawMaxRows ?? props.maxRows;
4209
- const rawCap = {
4210
- now: this.now,
4211
- ...ageCap,
4212
- ...rawMax !== void 0 ? { maxRows: rawMax } : {}
4213
- };
4214
- this.raw = new LeucoLoggerSqliteSink({
4215
- path: props.rawPath,
4216
- indexes: [
4217
- "event_id",
4218
- "connector_id",
4219
- "channel_id"
4220
- ],
4221
- extractIndexes: (event) => ({
4222
- event_id: event.event_id,
4223
- connector_id: event.connector_id,
4224
- channel_id: event.channel_id
4225
- }),
4226
- ...rawCap
4227
- });
4228
- this.processed = new LeucoLoggerSqliteSink({
4229
- path: props.processedPath,
4230
- indexes: [
4231
- "event_id",
4232
- "connector_id",
4233
- "channel_id",
4234
- "outcome"
4235
- ],
4236
- extractIndexes: (event) => ({
4237
- event_id: event.event_id,
4238
- connector_id: event.connector_id,
4239
- channel_id: event.channel_id,
4240
- outcome: event.outcome
4241
- }),
4242
- ...verdictCap
4243
- });
4244
- this.connection = new LeucoLoggerSqliteSink({
4245
- path: props.connectionPath,
4246
- indexes: [
4247
- "connector_id",
4248
- "channel_id",
4249
- "status"
4250
- ],
4251
- extractIndexes: (event) => ({
4252
- connector_id: event.connector_id,
4253
- channel_id: event.channel_id,
4254
- status: event.status
4255
- }),
4256
- ...verdictCap
4257
- });
4258
- restrictPermissions(props.rawPath);
4259
- restrictPermissions(props.processedPath);
4260
- restrictPermissions(props.connectionPath);
4261
- Object.freeze(this);
4262
- }
4263
- recordRaw(record) {
4264
- const event = {
4265
- event_id: record.eventId,
4266
- type: record.type,
4267
- connector_id: record.connectorId,
4268
- channel_id: record.channelId,
4269
- payload: capPayload(record.payload, record.type)
4270
- };
4271
- this.report("raw", this.raw.insert({
4272
- ts: this.now(),
4273
- event
4274
- }));
4275
- }
4276
- recordProcessed(record) {
4277
- const event = {
4278
- event_id: record.eventId,
4279
- type: record.type,
4280
- connector_id: record.connectorId,
4281
- channel_id: record.channelId,
4282
- outcome: record.outcome,
4283
- payload: record.payload
4284
- };
4285
- this.report("processed", this.processed.insert({
4286
- ts: this.now(),
4287
- event
4288
- }));
4289
- }
4290
- recordConnection(record) {
4291
- const event = {
4292
- type: record.type,
4293
- connector_id: record.connectorId,
4294
- channel_id: record.channelId,
4295
- status: record.status,
4296
- detail: record.detail
4297
- };
4298
- this.report("connection", this.connection.insert({
4299
- ts: this.now(),
4300
- event
4301
- }));
4302
- }
4303
- report(table, result) {
4304
- if (result instanceof Error) this.logger?.error("diagnostic log insert failed", {
4305
- table,
4306
- error: result.message
4307
- });
4308
- }
4309
- queryRaw(query) {
4310
- return this.raw.getRecords({
4311
- ...query.type !== void 0 ? { type: query.type } : {},
4312
- ...query.limit !== void 0 ? { limit: query.limit } : {},
4313
- where: buildWhere(query),
4314
- order: "desc"
4315
- }).map((record) => ({
4316
- seq: record.seq,
4317
- ts: record.ts,
4318
- eventId: record.event.event_id,
4319
- type: record.event.type,
4320
- connectorId: record.event.connector_id,
4321
- channelId: record.event.channel_id,
4322
- payload: record.event.payload
4323
- }));
4324
- }
4325
- queryProcessed(query) {
4326
- const where = buildWhere(query);
4327
- if (query.outcome !== void 0) where.outcome = query.outcome;
4328
- return this.processed.getRecords({
4329
- ...query.type !== void 0 ? { type: query.type } : {},
4330
- ...query.limit !== void 0 ? { limit: query.limit } : {},
4331
- where,
4332
- order: "desc"
4333
- }).map((record) => ({
4334
- seq: record.seq,
4335
- ts: record.ts,
4336
- eventId: record.event.event_id,
4337
- type: record.event.type,
4338
- connectorId: record.event.connector_id,
4339
- channelId: record.event.channel_id,
4340
- outcome: record.event.outcome,
4341
- payload: record.event.payload
4342
- }));
4343
- }
4344
- queryConnection(query) {
4345
- const where = buildWhere(query);
4346
- if (query.status !== void 0) where.status = query.status;
4347
- return this.connection.getRecords({
4348
- ...query.type !== void 0 ? { type: query.type } : {},
4349
- ...query.limit !== void 0 ? { limit: query.limit } : {},
4350
- where,
4351
- order: "desc"
4352
- }).map((record) => ({
4353
- seq: record.seq,
4354
- ts: record.ts,
4355
- type: record.event.type,
4356
- connectorId: record.event.connector_id,
4357
- channelId: record.event.channel_id,
4358
- status: statusOf(record.event.status),
4359
- detail: record.event.detail
4360
- }));
4361
- }
4362
- clear() {
4363
- this.raw.clear();
4364
- this.processed.clear();
4365
- this.connection.clear();
4366
- }
4367
- close() {
4368
- this.raw.close();
4369
- this.processed.close();
4370
- this.connection.close();
4371
- }
4372
- };
4373
- const restrictPermissions = (path) => {
4374
- if (path === ":memory:") return;
4375
- for (const suffix of [
4376
- "",
4377
- "-wal",
4378
- "-shm"
4379
- ]) try {
4380
- chmodSync(`${path}${suffix}`, 384);
4381
- } catch {}
4382
- };
4383
- const buildWhere = (query) => {
4384
- const where = {};
4385
- if (query.connectorId !== void 0) where.connector_id = query.connectorId;
4386
- if (query.channelId !== void 0) where.channel_id = query.channelId;
4387
- return where;
4388
- };
4389
- const statusField = connectorConnectionEventSchema.shape.status;
4390
- const statusOf = (value) => {
4391
- const parsed = statusField.safeParse(value);
4392
- return parsed.success ? parsed.data : "error";
4393
- };
4394
- const capPayload = (payload, type) => {
4395
- const size = Buffer.byteLength(payload, "utf8");
4396
- if (size <= RAW_PAYLOAD_CAP) return payload;
4397
- return JSON.stringify({
4398
- ...headFields(payload),
4399
- _funnel_oversized: size,
4400
- _funnel_type: type
4401
- });
4402
- };
4403
- const HEAD_KEYS = [
4404
- "type",
4405
- "subtype",
4406
- "ts",
4407
- "channel",
4408
- "channel_type",
4409
- "user",
4410
- "bot_id"
4411
- ];
4412
- const headFields = (payload) => {
4413
- try {
4414
- const parsed = JSON.parse(payload);
4415
- if (typeof parsed !== "object" || parsed === null) return {};
4416
- const source = parsed;
4417
- const head = {};
4418
- for (const key of HEAD_KEYS) if (source[key] !== void 0) head[key] = source[key];
4419
- return head;
4420
- } catch {
4421
- return {};
4422
- }
4423
- };
4424
- //#endregion
4425
- //#region lib/gateway/memory-connector-diagnostic-log.ts
4426
- /**
4427
- * In-process `ConnectorDiagnosticLog` backed by one array per table. Used by tests
4428
- * and embedders that do not need durability. Like the SQLite log it keeps
4429
- * `seq` per-table (each array's 1-based position) and returns the most recent
4430
- * `limit` rows oldest-first; unlike it, it never prunes and never offloads
4431
- * oversized payloads — it keeps whatever the caller hands it, which is fine
4432
- * for the bounded volumes a test produces. Payload-validity is therefore a
4433
- * SQLite-only guarantee; do not write a test that leans on this double
4434
- * rejecting a malformed payload.
4435
- */
4436
- var MemoryConnectorDiagnosticLog = class extends ConnectorDiagnosticLog {
4437
- raws = [];
4438
- processeds = [];
4439
- connections = [];
4440
- constructor(now = () => Date.now()) {
4441
- super();
4442
- this.now = now;
4443
- Object.freeze(this);
4444
- }
4445
- recordRaw(record) {
4446
- this.raws.push({
4447
- ...record,
4448
- seq: this.raws.length + 1,
4449
- ts: this.now()
4450
- });
4451
- }
4452
- recordProcessed(record) {
4453
- this.processeds.push({
4454
- ...record,
4455
- seq: this.processeds.length + 1,
4456
- ts: this.now()
4457
- });
4458
- }
4459
- recordConnection(record) {
4460
- this.connections.push({
4461
- ...record,
4462
- seq: this.connections.length + 1,
4463
- ts: this.now()
4464
- });
4465
- }
4466
- queryRaw(query) {
4467
- return takeRecent(this.raws.filter((event) => matches(event, query)), query.limit);
4468
- }
4469
- queryProcessed(query) {
4470
- return takeRecent(this.processeds.filter((event) => {
4471
- if (!matches(event, query)) return false;
4472
- if (query.outcome !== void 0 && event.outcome !== query.outcome) return false;
4473
- return true;
4474
- }), query.limit);
4475
- }
4476
- queryConnection(query) {
4477
- return takeRecent(this.connections.filter((event) => {
4478
- if (!matches(event, query)) return false;
4479
- if (query.status !== void 0 && event.status !== query.status) return false;
4480
- return true;
4481
- }), query.limit);
4482
- }
4483
- clear() {
4484
- this.raws.length = 0;
4485
- this.processeds.length = 0;
4486
- this.connections.length = 0;
4487
- }
4488
- close() {}
4489
- };
4490
- const matches = (event, query) => {
4491
- if (query.type !== void 0 && event.type !== query.type) return false;
4492
- if (query.connectorId !== void 0 && event.connectorId !== query.connectorId) return false;
4493
- if (query.channelId !== void 0 && event.channelId !== query.channelId) return false;
4494
- return true;
4495
- };
4496
- const takeRecent = (events, limit) => {
4497
- if (limit === void 0) return events;
4498
- if (limit <= 0) return [];
4499
- return events.slice(-limit);
4500
- };
4501
- //#endregion
4502
1305
  //#region lib/cli/factory.ts
4503
1306
  const factory = createFactory();
4504
1307
  //#endregion
@@ -6541,21 +3344,6 @@ examples:
6541
3344
  });
6542
3345
  return c.text(lines.join("\n"));
6543
3346
  });
6544
- //#endregion
6545
- //#region lib/engine/local-config/local-config-json-schema.ts
6546
- /**
6547
- * Generates the JSON Schema (draft 2020-12) for `funnel.json`. Useful for
6548
- * `$schema` references in committed `funnel.json` files so editors can give
6549
- * autocomplete and validation for channels[] (transport) and profiles[]
6550
- * (launch recipe) without anyone hand-maintaining a separate schema.
6551
- */
6552
- const funnelJsonSchema = () => {
6553
- return {
6554
- ...z.toJSONSchema(localConfigSchema, { target: "draft-2020-12" }),
6555
- title: "Funnel per-repo launch config",
6556
- 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."
6557
- };
6558
- };
6559
3347
  const schemaHandler = factory.createHandlers(zValidator$1("query", z.object({}), `funnel schema — print the JSON Schema for funnel.json
6560
3348
 
6561
3349
  usage: funnel schema