@interactive-inc/claude-funnel 0.40.0 → 0.49.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -5,7 +5,7 @@ import { a as ScheduleStateStore, i as FunnelScheduleListener, n as scheduleConn
5
5
  import { i as FunnelSlackAdapter, n as FunnelSlackListener, r as FunnelSlackEventProcessor, t as slackConnectorSchema } from "./slack-connector-schema-DDbSGPZn.js";
6
6
  import { dirname, join, resolve } from "node:path";
7
7
  import { hc } from "hono/client";
8
- import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync } from "node:fs";
8
+ import { appendFileSync, chmodSync, existsSync, mkdirSync } from "node:fs";
9
9
  import { z } from "zod";
10
10
  import { homedir, tmpdir } from "node:os";
11
11
  import { stderr, stdin } from "node:process";
@@ -15,9 +15,6 @@ import { createFactory } from "hono/factory";
15
15
  import { Database } from "bun:sqlite";
16
16
  import { HTTPException } from "hono/http-exception";
17
17
  import { zValidator } from "@hono/zod-validator";
18
- import { Server } from "@modelcontextprotocol/sdk/server/index.js";
19
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
20
- import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
21
18
  //#region lib/engine/id/id-generator.ts
22
19
  /**
23
20
  * ID generator boundary. Default NodeFunnelIdGenerator wraps `crypto.randomUUID()`;
@@ -125,7 +122,7 @@ function resolveFunnelPort() {
125
122
  }
126
123
  const FUNNEL_DIR = join(homedir(), ".funnel");
127
124
  const SETTINGS_PATH = join(FUNNEL_DIR, "settings.json");
128
- const defaultFs$5 = new NodeFunnelFileSystem();
125
+ const defaultFs$6 = new NodeFunnelFileSystem();
129
126
  const defaultIdGenerator$2 = new NodeFunnelIdGenerator();
130
127
  var FunnelSettingsStore = class extends FunnelSettingsReader {
131
128
  path;
@@ -134,7 +131,7 @@ var FunnelSettingsStore = class extends FunnelSettingsReader {
134
131
  constructor(deps = {}) {
135
132
  super();
136
133
  this.path = deps.path ?? SETTINGS_PATH;
137
- this.fs = deps.fs ?? defaultFs$5;
134
+ this.fs = deps.fs ?? defaultFs$6;
138
135
  this.idGenerator = deps.idGenerator ?? defaultIdGenerator$2;
139
136
  Object.freeze(this);
140
137
  }
@@ -205,8 +202,8 @@ var FunnelSettingsStore = class extends FunnelSettingsReader {
205
202
  };
206
203
  //#endregion
207
204
  //#region lib/connectors/connector-factory.ts
208
- const defaultFs$4 = new NodeFunnelFileSystem();
209
- const defaultProcess$3 = new NodeFunnelProcessRunner();
205
+ const defaultFs$5 = new NodeFunnelFileSystem();
206
+ const defaultProcess$4 = new NodeFunnelProcessRunner();
210
207
  /**
211
208
  * Pure factory for per-type listeners and adapters. The factory has no CRUD
212
209
  * responsibility — connector configs live inside settings.json under their
@@ -228,8 +225,8 @@ var FunnelConnectorFactory = class {
228
225
  slackListenerOptions;
229
226
  scheduleListenerOptions;
230
227
  constructor(deps = {}) {
231
- this.fs = deps.fs ?? defaultFs$4;
232
- this.process = deps.process ?? defaultProcess$3;
228
+ this.fs = deps.fs ?? defaultFs$5;
229
+ this.process = deps.process ?? defaultProcess$4;
233
230
  this.logger = deps.logger;
234
231
  this.diagnosticLog = deps.diagnosticLog;
235
232
  this.dir = deps.dir ?? FUNNEL_DIR;
@@ -382,7 +379,7 @@ var FunnelChannels = class {
382
379
  constructor(deps) {
383
380
  this.store = deps.store;
384
381
  this.factory = deps.factory;
385
- this.profileChecker = deps.profileChecker;
382
+ this.profileChecker = deps.profileChecker ?? null;
386
383
  this.clock = deps.clock ?? defaultClock$1;
387
384
  this.idGenerator = deps.idGenerator ?? defaultIdGenerator$1;
388
385
  Object.freeze(this);
@@ -420,7 +417,7 @@ var FunnelChannels = class {
420
417
  const index = settings.channels.findIndex((c) => c.name === name);
421
418
  if (index < 0) throw new Error(`channel "${name}" not found`);
422
419
  const channel = settings.channels[index];
423
- if (channel && this.profileChecker.hasChannelRef(channel.id)) throw new Error(`channel "${name}" is referenced by a profile`);
420
+ if (channel && this.profileChecker?.hasChannelRef(channel.id)) throw new Error(`channel "${name}" is referenced by a profile`);
424
421
  settings.channels.splice(index, 1);
425
422
  this.store.write(settings);
426
423
  }
@@ -639,41 +636,38 @@ var FunnelChannels = class {
639
636
  };
640
637
  //#endregion
641
638
  //#region lib/engine/claude/claude.ts
642
- const defaultProcess$2 = new NodeFunnelProcessRunner();
643
- const defaultFs$3 = new NodeFunnelFileSystem();
639
+ const defaultProcess$3 = new NodeFunnelProcessRunner();
644
640
  const defaultIdGenerator = new NodeFunnelIdGenerator();
645
641
  /**
646
642
  * Launches Claude Code with funnel pre-wired: ensures the gateway is running,
647
643
  * installs the funnel MCP into the target repo's `.mcp.json` if missing,
648
- * injects `FUNNEL_CHANNEL_ID` into the child env, and writes a per-profile
649
- * PID file to enforce singleton launches.
644
+ * injects `FUNNEL_CHANNEL_ID` into the child env, and delegates singleton
645
+ * enforcement to a ProcessGuard.
650
646
  */
651
647
  var FunnelClaude = class {
652
648
  channels;
653
649
  mcp;
654
650
  gateway;
655
- profiles;
651
+ sessions;
652
+ guard;
656
653
  process;
657
- fs;
658
654
  idGenerator;
659
655
  logger;
660
- pidDir;
661
656
  constructor(deps) {
662
657
  this.channels = deps.channels;
663
658
  this.mcp = deps.mcp;
664
659
  this.gateway = deps.gateway;
665
- this.profiles = deps.profiles;
666
- this.process = deps.process ?? defaultProcess$2;
667
- this.fs = deps.fs ?? defaultFs$3;
660
+ this.sessions = deps.sessions;
661
+ this.guard = deps.guard;
662
+ this.process = deps.process ?? defaultProcess$3;
668
663
  this.idGenerator = deps.idGenerator ?? defaultIdGenerator;
669
664
  this.logger = deps.logger;
670
- this.pidDir = join(deps.dir ?? FUNNEL_DIR, "claude");
671
665
  Object.freeze(this);
672
666
  }
673
667
  async launch(options) {
674
668
  const channel = this.channels.get(options.channel) ?? this.channels.getById(options.channel);
675
669
  if (!channel) throw new Error(`channel "${options.channel}" not found`);
676
- if (options.profileId && this.isRunning(options.profileId)) throw new Error(`profile "${options.profileId}" is already running`);
670
+ if (options.profileId && this.guard.isRunning(options.profileId)) throw new Error(`profile "${options.profileId}" is already running`);
677
671
  const cwd = options.cwd ?? globalThis.process.cwd();
678
672
  if ((options.installMcp ?? true) && !this.mcp.findInstalledName(cwd)) {
679
673
  this.mcp.install(cwd);
@@ -683,10 +677,7 @@ var FunnelClaude = class {
683
677
  this.logger?.info(`starting gateway automatically`);
684
678
  await this.gateway.start();
685
679
  }
686
- if (options.profileId) {
687
- this.writePidFile(options.profileId);
688
- this.installCleanup(options.profileId);
689
- }
680
+ if (options.profileId) this.guard.acquire(options.profileId);
690
681
  const session = (options.resume ?? false) && options.profileId ? this.resolveSession(options.profileId, cwd, options.userArgs ?? [], options.env ?? {}) : null;
691
682
  const claudeArgs = this.buildArgs(options.options ?? [], options.userArgs ?? [], cwd, session);
692
683
  const env = this.buildEnv(channel.id, options.env ?? {});
@@ -702,43 +693,9 @@ var FunnelClaude = class {
702
693
  onSpawned: options.onSpawned
703
694
  });
704
695
  } finally {
705
- if (options.profileId) this.removePidFile(options.profileId);
706
- }
707
- }
708
- isRunning(profileId) {
709
- const pid = this.readPid(profileId);
710
- if (!pid) return false;
711
- return this.isProcessAlive(pid);
712
- }
713
- pidPath(profileId) {
714
- return join(this.pidDir, `${profileId}.pid`);
715
- }
716
- readPid(profileId) {
717
- const path = this.pidPath(profileId);
718
- if (!this.fs.existsSync(path)) return null;
719
- try {
720
- const content = this.fs.readFileSync(path).trim();
721
- const pid = Number(content);
722
- if (!pid || pid <= 0) return null;
723
- return pid;
724
- } catch {
725
- return null;
696
+ if (options.profileId) this.guard.release(options.profileId);
726
697
  }
727
698
  }
728
- writePidFile(profileId) {
729
- this.fs.mkdirSync(this.pidDir, { recursive: true });
730
- this.fs.writeFileSync(this.pidPath(profileId), String(globalThis.process.pid));
731
- }
732
- removePidFile(profileId) {
733
- const path = this.pidPath(profileId);
734
- if (this.fs.existsSync(path)) this.fs.unlink(path);
735
- }
736
- installCleanup(profileId) {
737
- globalThis.process.once("exit", () => this.removePidFile(profileId));
738
- }
739
- isProcessAlive(pid) {
740
- return this.process.isAlive(pid);
741
- }
742
699
  buildArgs(recipeOptions, userArgs, cwd, session) {
743
700
  const result = [...recipeOptions, ...userArgs];
744
701
  if (session !== null) if (session.mode === "resume") result.push("--resume", session.id);
@@ -772,31 +729,18 @@ var FunnelClaude = class {
772
729
  if (arg === "--resume" || arg.startsWith("--resume=")) return null;
773
730
  if (arg === "--session-id" || arg.startsWith("--session-id=")) return null;
774
731
  }
775
- const existing = this.profiles.getSessionId(profileId);
776
- if (existing !== null && this.sessionFileExists(cwd, existing, recipeEnv)) return {
732
+ const existing = this.sessions.getSessionId(profileId);
733
+ if (existing !== null && this.sessions.sessionFileExists(cwd, existing, recipeEnv)) return {
777
734
  id: existing,
778
735
  mode: "resume"
779
736
  };
780
737
  const fresh = this.idGenerator.generate();
781
- this.profiles.setSessionId(profileId, fresh);
738
+ this.sessions.setSessionId(profileId, fresh);
782
739
  return {
783
740
  id: fresh,
784
741
  mode: "new"
785
742
  };
786
743
  }
787
- /**
788
- * Mirrors claude's session storage path
789
- * (`<config-dir>/projects/<cwd-with-slashes-as-dashes>/<id>.jsonl`) to check
790
- * whether a recorded session still exists AND is non-empty. Reads the same
791
- * `CLAUDE_CONFIG_DIR` the child will run under so the check matches reality; a
792
- * wrong guess can only ever produce a false negative (start fresh), never a
793
- * bad resume.
794
- */
795
- sessionFileExists(cwd, sessionId, recipeEnv) {
796
- const path = join(recipeEnv.CLAUDE_CONFIG_DIR ?? globalThis.process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude"), "projects", cwd.replace(/\//g, "-"), `${sessionId}.jsonl`);
797
- if (!this.fs.existsSync(path)) return false;
798
- return this.fs.readFileSync(path).trim().length > 0;
799
- }
800
744
  buildEnv(channelId, recipeEnv) {
801
745
  const env = {};
802
746
  for (const [key, value] of Object.entries(recipeEnv)) env[key] = value;
@@ -807,91 +751,47 @@ var FunnelClaude = class {
807
751
  }
808
752
  };
809
753
  //#endregion
810
- //#region lib/engine/fs/memory-file-system.ts
811
- const SECRET_MODE = 384;
812
- var MemoryFunnelFileSystem = class extends FunnelFileSystem {
813
- dirs;
814
- files;
815
- mtimes;
816
- modes;
817
- now;
818
- constructor(props = {}) {
819
- super();
820
- this.dirs = new Set(props.dirs ?? []);
821
- this.files = new Map(Object.entries(props.files ?? {}));
822
- this.mtimes = new Map(Object.entries(props.mtimes ?? {}));
823
- this.modes = new Map(Object.entries(props.modes ?? {}));
824
- this.now = props.now ?? (() => Date.now());
825
- }
826
- existsSync(path) {
827
- return this.dirs.has(path) || this.files.has(path);
828
- }
829
- readFileSync(path) {
830
- return this.files.get(path) ?? "";
831
- }
832
- writeFileSync(path, data) {
833
- this.files.set(path, data);
834
- this.touch(path);
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);
835
766
  }
836
- writeSecretFileSync(path, data) {
837
- this.files.set(path, data);
838
- this.modes.set(path, SECRET_MODE);
839
- this.touch(path);
767
+ isRunning(profileId) {
768
+ const pid = this.readPid(profileId);
769
+ if (!pid) return false;
770
+ return this.process.isAlive(pid);
840
771
  }
841
- appendFileSync(path, data) {
842
- const prev = this.files.get(path) ?? "";
843
- this.files.set(path, prev + data);
844
- this.touch(path);
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));
845
776
  }
846
- unlink(path) {
847
- this.files.delete(path);
848
- this.mtimes.delete(path);
849
- this.modes.delete(path);
777
+ release(profileId) {
778
+ const path = this.pidPath(profileId);
779
+ if (this.fs.existsSync(path)) this.fs.unlink(path);
850
780
  }
851
- mkdirSync(path, options) {
852
- this.dirs.add(path);
781
+ pidPath(profileId) {
782
+ return join(this.pidDir, `${profileId}.pid`);
853
783
  }
854
- readdirSync(path) {
855
- const prefix = path.endsWith("/") ? path : `${path}/`;
856
- const names = [];
857
- for (const file of this.files.keys()) {
858
- if (!file.startsWith(prefix)) continue;
859
- const rest = file.slice(prefix.length);
860
- if (!rest.includes("/")) names.push(rest);
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;
861
794
  }
862
- return names;
863
- }
864
- statSync(path) {
865
- const mtimeMs = this.mtimes.get(path);
866
- if (mtimeMs === void 0) throw new Error(`not found: ${path}`);
867
- return {
868
- mtimeMs,
869
- mode: this.modes.get(path) ?? null
870
- };
871
- }
872
- setMtime(path, mtimeMs) {
873
- this.mtimes.set(path, mtimeMs);
874
- }
875
- setMode(path, mode) {
876
- this.modes.set(path, mode);
877
- }
878
- touch(path) {
879
- if (!this.mtimes.has(path)) this.mtimes.set(path, this.now());
880
- else this.mtimes.set(path, this.now());
881
- }
882
- };
883
- //#endregion
884
- //#region lib/engine/id/memory-id-generator.ts
885
- var MemoryFunnelIdGenerator = class extends FunnelIdGenerator {
886
- counter = 0;
887
- prefix;
888
- constructor(props = {}) {
889
- super();
890
- this.prefix = props.prefix ?? "id";
891
- }
892
- generate() {
893
- this.counter++;
894
- return `${this.prefix}-${this.counter}`;
895
795
  }
896
796
  };
897
797
  //#endregion
@@ -1247,85 +1147,13 @@ var FunnelLocalConfigSync = class {
1247
1147
  };
1248
1148
  }
1249
1149
  };
1250
- //#endregion
1251
- //#region lib/engine/local-config/local-config-writer.ts
1252
- const isRecord = (value) => {
1253
- return typeof value === "object" && value !== null && !Array.isArray(value);
1254
- };
1255
- const withIdFirst = (config, id) => {
1256
- const ordered = {};
1257
- if (config.$schema !== void 0) ordered.$schema = config.$schema;
1258
- ordered.id = id;
1259
- for (const key of Object.keys(config)) {
1260
- if (key === "$schema" || key === "id") continue;
1261
- ordered[key] = config[key];
1262
- }
1263
- return ordered;
1264
- };
1265
- /**
1266
- * The one path that mutates the repo-committed funnel.json, and it only ever
1267
- * inserts `id`. On first launch a repo has no `id`; funnel generates one and
1268
- * writes it back here so future launches resolve the same `~/.funnel/projects/<id>/`.
1269
- * Idempotent — a no-op once `id` is present. Kept separate from the read-only
1270
- * FunnelLocalConfig so reads stay side-effect free.
1271
- */
1272
- var FunnelLocalConfigWriter = class {
1273
- fs;
1274
- constructor(deps) {
1275
- this.fs = deps.fs;
1276
- Object.freeze(this);
1277
- }
1278
- ensureId(cwd, id) {
1279
- const path = join(cwd, LOCAL_CONFIG_FILENAME);
1280
- if (!this.fs.existsSync(path)) return;
1281
- const parsed = JSON.parse(this.fs.readFileSync(path));
1282
- if (!isRecord(parsed)) return;
1283
- if (typeof parsed.id === "string" && parsed.id !== "") return;
1284
- const ordered = withIdFirst(parsed, id);
1285
- this.fs.writeFileSync(path, `${JSON.stringify(ordered, null, 2)}\n`);
1286
- }
1287
- };
1288
- //#endregion
1289
- //#region lib/engine/logger/memory-logger.ts
1290
- var MemoryFunnelLogger = class extends FunnelLogger {
1291
- file = null;
1292
- entries = [];
1293
- info(message, meta) {
1294
- this.entries.push({
1295
- level: "info",
1296
- message,
1297
- meta
1298
- });
1299
- }
1300
- warn(message, meta) {
1301
- this.entries.push({
1302
- level: "warn",
1303
- message,
1304
- meta
1305
- });
1306
- }
1307
- error(message, meta) {
1308
- this.entries.push({
1309
- level: "error",
1310
- message,
1311
- meta
1312
- });
1313
- }
1314
- clear() {
1315
- this.entries.length = 0;
1316
- }
1317
- };
1318
- //#endregion
1319
- //#region lib/engine/mcp/mcp.ts
1320
- const FUNNEL_MCP_COMMAND = "bun";
1321
1150
  const FUNNEL_MCP_ARGS = ["funnel", "mcp"];
1322
- const FUNNEL_MCP_NAME = "funnel";
1323
1151
  const mcpEntrySchema = z.object({
1324
1152
  command: z.string().optional(),
1325
1153
  args: z.array(z.string()).optional()
1326
1154
  });
1327
1155
  const mcpConfigSchema = z.object({ mcpServers: z.record(z.string(), mcpEntrySchema).optional() });
1328
- const defaultFs$2 = new NodeFunnelFileSystem();
1156
+ const defaultFs$3 = new NodeFunnelFileSystem();
1329
1157
  /**
1330
1158
  * Installs/uninstalls the funnel MCP entry into a target repository's
1331
1159
  * `.mcp.json`. Detects an existing entry by command match so renaming is
@@ -1334,7 +1162,7 @@ const defaultFs$2 = new NodeFunnelFileSystem();
1334
1162
  var FunnelMcp = class {
1335
1163
  fs;
1336
1164
  constructor(deps = {}) {
1337
- this.fs = deps.fs ?? defaultFs$2;
1165
+ this.fs = deps.fs ?? defaultFs$3;
1338
1166
  Object.freeze(this);
1339
1167
  }
1340
1168
  install(repoPath) {
@@ -1403,107 +1231,8 @@ var FunnelMcp = class {
1403
1231
  }
1404
1232
  };
1405
1233
  //#endregion
1406
- //#region lib/engine/process/memory-process-runner.ts
1407
- const empty = {
1408
- exitCode: 0,
1409
- stdout: "",
1410
- stderr: ""
1411
- };
1412
- var MemoryFunnelProcessRunner = class extends FunnelProcessRunner {
1413
- calls = [];
1414
- killed = [];
1415
- handler = () => empty;
1416
- syncHandler = () => empty;
1417
- aliveStub = null;
1418
- listStub = null;
1419
- on(handler) {
1420
- this.handler = handler;
1421
- return this;
1422
- }
1423
- onSync(handler) {
1424
- this.syncHandler = handler;
1425
- return this;
1426
- }
1427
- onIsAlive(stub) {
1428
- this.aliveStub = stub;
1429
- return this;
1430
- }
1431
- onListProcessesContaining(stub) {
1432
- this.listStub = stub;
1433
- return this;
1434
- }
1435
- async run(command, options = {}) {
1436
- this.calls.push({
1437
- kind: "run",
1438
- command,
1439
- options
1440
- });
1441
- const result = await this.handler(command);
1442
- return {
1443
- exitCode: result.exitCode ?? 0,
1444
- stdout: result.stdout ?? "",
1445
- stderr: result.stderr ?? ""
1446
- };
1447
- }
1448
- runSync(command) {
1449
- this.calls.push({
1450
- kind: "runSync",
1451
- command
1452
- });
1453
- const result = this.syncHandler(command);
1454
- return {
1455
- exitCode: result.exitCode ?? 0,
1456
- stdout: result.stdout ?? "",
1457
- stderr: result.stderr ?? ""
1458
- };
1459
- }
1460
- async attach(command, options = {}) {
1461
- this.calls.push({
1462
- kind: "attach",
1463
- command,
1464
- options
1465
- });
1466
- if (options.onSpawned) options.onSpawned(1);
1467
- return (await this.handler(command)).exitCode ?? 0;
1468
- }
1469
- detach(command, options = {}) {
1470
- this.calls.push({
1471
- kind: "detach",
1472
- command,
1473
- options
1474
- });
1475
- }
1476
- kill(pid, signal = "SIGTERM") {
1477
- this.calls.push({
1478
- kind: "kill",
1479
- command: [String(pid), signal]
1480
- });
1481
- this.killed.push({
1482
- pid,
1483
- signal
1484
- });
1485
- }
1486
- isAlive(pid) {
1487
- if (this.aliveStub) return this.aliveStub(pid);
1488
- const result = this.syncHandler([
1489
- "ps",
1490
- "-p",
1491
- String(pid),
1492
- "-o",
1493
- "state="
1494
- ]);
1495
- if ((result.exitCode ?? 0) !== 0) return false;
1496
- const state = (result.stdout ?? "").trim();
1497
- if (!state) return false;
1498
- return !state.startsWith("Z");
1499
- }
1500
- listProcessesContaining(marker) {
1501
- if (this.listStub) return this.listStub(marker);
1502
- return [];
1503
- }
1504
- };
1505
- //#endregion
1506
1234
  //#region lib/engine/profiles/profiles.ts
1235
+ const defaultFs$2 = new NodeFunnelFileSystem();
1507
1236
  /**
1508
1237
  * Named launch presets for `fnl claude`. Each profile bundles a working
1509
1238
  * directory, the channel id its Claude instance subscribes to, and the launch
@@ -1524,9 +1253,11 @@ var MemoryFunnelProcessRunner = class extends FunnelProcessRunner {
1524
1253
  var FunnelProfiles = class {
1525
1254
  store;
1526
1255
  idGenerator;
1256
+ fs;
1527
1257
  constructor(deps) {
1528
1258
  this.store = deps.store;
1529
1259
  this.idGenerator = deps.idGenerator;
1260
+ this.fs = deps.fs ?? defaultFs$2;
1530
1261
  Object.freeze(this);
1531
1262
  }
1532
1263
  list() {
@@ -1596,6 +1327,19 @@ var FunnelProfiles = class {
1596
1327
  profile.sessionId = sessionId;
1597
1328
  this.store.write(settings);
1598
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
+ }
1599
1343
  update(name, fields) {
1600
1344
  const settings = this.store.read();
1601
1345
  const profile = settings.profiles.find((p) => p.name === name);
@@ -1641,42 +1385,260 @@ var NodeFunnelTokenPrompter = class extends FunnelTokenPrompter {
1641
1385
  stderr.write(LF);
1642
1386
  }
1643
1387
  }
1644
- readSecret() {
1645
- return new Promise((resolve, reject) => {
1646
- let buffer = "";
1647
- const onData = (chunk) => {
1648
- for (const byte of chunk) {
1649
- const char = String.fromCharCode(byte);
1650
- if (char === LF || char === CR) {
1651
- stdin.off("data", onData);
1652
- resolve(buffer);
1653
- return;
1654
- }
1655
- if (char === CTRL_C) {
1656
- stdin.off("data", onData);
1657
- reject(/* @__PURE__ */ new Error("prompt cancelled"));
1658
- return;
1659
- }
1660
- if (char === CTRL_D) {
1661
- stdin.off("data", onData);
1662
- if (buffer.length === 0) reject(/* @__PURE__ */ new Error("prompt cancelled"));
1663
- else resolve(buffer);
1664
- return;
1665
- }
1666
- if (char === BACKSPACE || char === DEL) {
1667
- if (buffer.length > 0) {
1668
- buffer = buffer.slice(0, -1);
1669
- stderr.write("\b \b");
1670
- }
1671
- continue;
1672
- }
1673
- buffer += char;
1674
- stderr.write(STAR);
1675
- }
1676
- };
1677
- stdin.on("data", onData);
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
1530
+ });
1531
+ }
1532
+ error(message, meta) {
1533
+ this.entries.push({
1534
+ level: "error",
1535
+ message,
1536
+ meta
1537
+ });
1538
+ }
1539
+ clear() {
1540
+ this.entries.length = 0;
1541
+ }
1542
+ };
1543
+ //#endregion
1544
+ //#region lib/engine/process/memory-process-runner.ts
1545
+ const empty = {
1546
+ exitCode: 0,
1547
+ stdout: "",
1548
+ stderr: ""
1549
+ };
1550
+ var MemoryFunnelProcessRunner = class extends FunnelProcessRunner {
1551
+ calls = [];
1552
+ killed = [];
1553
+ handler = () => empty;
1554
+ syncHandler = () => empty;
1555
+ aliveStub = null;
1556
+ listStub = null;
1557
+ on(handler) {
1558
+ this.handler = handler;
1559
+ return this;
1560
+ }
1561
+ onSync(handler) {
1562
+ this.syncHandler = handler;
1563
+ return this;
1564
+ }
1565
+ onIsAlive(stub) {
1566
+ this.aliveStub = stub;
1567
+ return this;
1568
+ }
1569
+ onListProcessesContaining(stub) {
1570
+ this.listStub = stub;
1571
+ return this;
1572
+ }
1573
+ async run(command, options = {}) {
1574
+ this.calls.push({
1575
+ kind: "run",
1576
+ command,
1577
+ options
1578
+ });
1579
+ const result = await this.handler(command);
1580
+ return {
1581
+ exitCode: result.exitCode ?? 0,
1582
+ stdout: result.stdout ?? "",
1583
+ stderr: result.stderr ?? ""
1584
+ };
1585
+ }
1586
+ runSync(command) {
1587
+ this.calls.push({
1588
+ kind: "runSync",
1589
+ command
1590
+ });
1591
+ const result = this.syncHandler(command);
1592
+ return {
1593
+ exitCode: result.exitCode ?? 0,
1594
+ stdout: result.stdout ?? "",
1595
+ stderr: result.stderr ?? ""
1596
+ };
1597
+ }
1598
+ async attach(command, options = {}) {
1599
+ this.calls.push({
1600
+ kind: "attach",
1601
+ command,
1602
+ options
1603
+ });
1604
+ if (options.onSpawned) options.onSpawned(1);
1605
+ return (await this.handler(command)).exitCode ?? 0;
1606
+ }
1607
+ detach(command, options = {}) {
1608
+ this.calls.push({
1609
+ kind: "detach",
1610
+ command,
1611
+ options
1612
+ });
1613
+ }
1614
+ kill(pid, signal = "SIGTERM") {
1615
+ this.calls.push({
1616
+ kind: "kill",
1617
+ command: [String(pid), signal]
1618
+ });
1619
+ this.killed.push({
1620
+ pid,
1621
+ signal
1678
1622
  });
1679
1623
  }
1624
+ isAlive(pid) {
1625
+ if (this.aliveStub) return this.aliveStub(pid);
1626
+ const result = this.syncHandler([
1627
+ "ps",
1628
+ "-p",
1629
+ String(pid),
1630
+ "-o",
1631
+ "state="
1632
+ ]);
1633
+ if ((result.exitCode ?? 0) !== 0) return false;
1634
+ const state = (result.stdout ?? "").trim();
1635
+ if (!state) return false;
1636
+ return !state.startsWith("Z");
1637
+ }
1638
+ listProcessesContaining(marker) {
1639
+ if (this.listStub) return this.listStub(marker);
1640
+ return [];
1641
+ }
1680
1642
  };
1681
1643
  //#endregion
1682
1644
  //#region lib/engine/settings/mock-settings-reader.ts
@@ -1743,9 +1705,8 @@ const publishRequestSchema = z.object({
1743
1705
  connector: z.string().min(1).optional(),
1744
1706
  /**
1745
1707
  * Address the event to a single subscriber. When set, only the WS client that
1746
- * declared this id at upgrade time (`?id=<subscriberId>`) receives it among the
1747
- * channel's regular subscribers; tap=all observers still see it. Omit for the
1748
- * default fanout. The route surfaces it to subscribers as `meta.target`.
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`.
1749
1710
  */
1750
1711
  target: z.string().min(1).optional()
1751
1712
  });
@@ -2107,7 +2068,6 @@ var FunnelBroadcaster = class {
2107
2068
  return [...persisted.filter((event) => event.offset < cutoff), ...fromMemory];
2108
2069
  }
2109
2070
  matchesClient(event, data) {
2110
- if (data.tapAll) return true;
2111
2071
  const target = event.meta?.target;
2112
2072
  if (target && target !== data.subscriberId) return false;
2113
2073
  const channelId = event.meta?.channelId;
@@ -2117,25 +2077,18 @@ var FunnelBroadcaster = class {
2117
2077
  return data.connectors.includes(connector);
2118
2078
  }
2119
2079
  /**
2120
- * Returns the list of WS clients that should receive `event`. Tap=all clients always
2121
- * receive (passive observation). For each per-channel group:
2080
+ * Returns the list of WS clients that should receive `event`. For each per-channel group:
2122
2081
  * - fanout → every matching client receives
2123
2082
  * - exclusive → exactly one client receives, picked round-robin per channel
2124
2083
  *
2125
- * `meta.target` narrows the regular (non-tap) recipient set first via
2126
- * `matchesClient`: only the subscriber whose `subscriberId` equals `target`
2127
- * stays in the running, so a targeted event reaches one named instance while
2128
- * still being observable by tap=all clients.
2084
+ * `meta.target` narrows the recipient set via `matchesClient`: only the subscriber
2085
+ * whose `subscriberId` equals `target` receives a targeted event.
2129
2086
  */
2130
2087
  pickRecipients(event) {
2131
2088
  const exclusiveByChannel = /* @__PURE__ */ new Map();
2132
2089
  const recipients = [];
2133
2090
  for (const [ws, data] of this.clients) {
2134
2091
  if (!this.matchesClient(event, data)) continue;
2135
- if (data.tapAll) {
2136
- recipients.push(ws);
2137
- continue;
2138
- }
2139
2092
  if (data.delivery === "exclusive") {
2140
2093
  const list = exclusiveByChannel.get(data.channel) ?? [];
2141
2094
  list.push(ws);
@@ -3340,7 +3293,6 @@ const defaultOnError = () => {};
3340
3293
  */
3341
3294
  var FunnelGatewayServer = class {
3342
3295
  channels;
3343
- settings;
3344
3296
  port;
3345
3297
  hostname;
3346
3298
  dbPath;
@@ -3360,7 +3312,6 @@ var FunnelGatewayServer = class {
3360
3312
  server = null;
3361
3313
  constructor(deps) {
3362
3314
  this.channels = deps.channels;
3363
- this.settings = deps.settings;
3364
3315
  this.port = deps.port ?? resolveFunnelPort();
3365
3316
  this.hostname = deps.hostname ?? DEFAULT_HOST;
3366
3317
  this.dbPath = deps.dbPath ?? defaultDbPath();
@@ -3461,11 +3412,10 @@ var FunnelGatewayServer = class {
3461
3412
  const url = new URL(request.url);
3462
3413
  if (url.pathname === "/ws" && request.headers.get("upgrade") === "websocket") {
3463
3414
  if (this.token && !this.tokenMatchesUpgrade(request)) return new Response("unauthorized", { status: 401 });
3464
- const tapAll = url.searchParams.get("tap") === "all";
3465
- const requestedChannel = tapAll ? "" : url.searchParams.get("channel") ?? "";
3466
- const channel = !tapAll && requestedChannel ? this.resolveChannel(requestedChannel) : null;
3467
- const channelId = tapAll ? "" : channel?.id ?? requestedChannel;
3468
- const channelName = tapAll ? null : channel?.name ?? null;
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;
3469
3419
  const connectors = channel?.connectors ?? [];
3470
3420
  const delivery = channel?.delivery ?? "fanout";
3471
3421
  const sinceRaw = url.searchParams.get("since");
@@ -3476,7 +3426,6 @@ var FunnelGatewayServer = class {
3476
3426
  channel: channelId,
3477
3427
  channelName,
3478
3428
  connectors,
3479
- tapAll,
3480
3429
  delivery,
3481
3430
  subscriberId,
3482
3431
  since
@@ -3491,36 +3440,24 @@ var FunnelGatewayServer = class {
3491
3440
  for (const event of replay) ws.send(JSON.stringify(event));
3492
3441
  }
3493
3442
  this.broadcaster.addClient(ws, ws.data);
3494
- if (ws.data.channelName) {
3495
- const meta = {
3496
- event_type: "system",
3497
- action: "channel_connect",
3498
- channel: ws.data.channelName,
3499
- channelId: ws.data.channel,
3500
- connectors: ws.data.connectors.join(","),
3501
- total: String(this.broadcaster.getClientCount())
3502
- };
3503
- this.logger?.info("channel connected", meta);
3504
- } else this.logger?.info("tap-all client connected", {
3443
+ this.logger?.info("channel connected", {
3505
3444
  event_type: "system",
3506
- action: "tap_connect",
3445
+ action: "channel_connect",
3446
+ channel: ws.data.channelName ?? "",
3447
+ channelId: ws.data.channel,
3448
+ connectors: ws.data.connectors.join(","),
3507
3449
  total: String(this.broadcaster.getClientCount())
3508
3450
  });
3509
3451
  }
3510
3452
  handleWsClose(ws) {
3511
3453
  this.broadcaster.removeClient(ws);
3512
- if (ws.data.channelName) this.logger?.info("channel disconnected", {
3454
+ this.logger?.info("channel disconnected", {
3513
3455
  event_type: "system",
3514
3456
  action: "channel_disconnect",
3515
- channel: ws.data.channelName,
3457
+ channel: ws.data.channelName ?? "",
3516
3458
  channelId: ws.data.channel,
3517
3459
  total: String(this.broadcaster.getClientCount())
3518
3460
  });
3519
- else this.logger?.info("tap-all client disconnected", {
3520
- event_type: "system",
3521
- action: "tap_disconnect",
3522
- total: String(this.broadcaster.getClientCount())
3523
- });
3524
3461
  }
3525
3462
  logServerStarted() {
3526
3463
  this.logger?.info("gateway started", {
@@ -3570,7 +3507,7 @@ var FunnelGatewayServer = class {
3570
3507
  return false;
3571
3508
  }
3572
3509
  resolveChannel(requested) {
3573
- const channel = this.settings.read()?.channels.find((c) => c.id === requested || c.name === requested);
3510
+ const channel = this.channels.get(requested) ?? this.channels.getById(requested);
3574
3511
  if (!channel) return null;
3575
3512
  return {
3576
3513
  id: channel.id,
@@ -3632,10 +3569,10 @@ var FunnelGatewayServer = class {
3632
3569
  return { offset: event.offset };
3633
3570
  }
3634
3571
  lookupChannelId(channelName) {
3635
- return this.settings.read().channels.find((c) => c.name === channelName)?.id ?? null;
3572
+ return this.channels.get(channelName)?.id ?? null;
3636
3573
  }
3637
3574
  lookupConnectorId(channelId, connectorName) {
3638
- return (this.settings.read().channels.find((c) => c.id === channelId)?.connectors.find((c) => c.name === connectorName))?.id ?? null;
3575
+ return this.channels.getById(channelId)?.connectors.find((c) => c.name === connectorName)?.id ?? null;
3639
3576
  }
3640
3577
  };
3641
3578
  //#endregion
@@ -3827,7 +3764,7 @@ const buildFunnelDebugReport = async (deps, channelFilter) => {
3827
3764
  errors: listenerEntry.errors,
3828
3765
  lastEventAt: listenerEntry.lastEventAt
3829
3766
  } : null;
3830
- const claudeClients = (gatewayData?.clients ?? []).filter((cl) => !cl.tapAll && (cl.channelName === ch.name || cl.channel === ch.name));
3767
+ const claudeClients = (gatewayData?.clients ?? []).filter((cl) => cl.channelName === ch.name || cl.channel === ch.name);
3831
3768
  report.channels.push({
3832
3769
  name: ch.name,
3833
3770
  connectors: ch.connectors.map((conn) => conn.name),
@@ -3870,15 +3807,14 @@ const SANDBOX_DIR = "/sandbox/.funnel";
3870
3807
  const SANDBOX_TMP_DIR = "/sandbox/tmp";
3871
3808
  const noopOnError = () => {};
3872
3809
  /**
3873
- * Facade exposing every funnel facet as a getter.
3810
+ * Facade that wires every funnel facet together and exposes the public surface.
3874
3811
  *
3875
- * The same `Funnel` is used by the CLI and as a programmable library.
3876
- * All side-effecting boundaries (filesystem, process, logger, clock, id, paths) are
3877
- * injectable via `Props` — passing memory implementations gives a fully sandboxed
3812
+ * All side-effecting boundaries (filesystem, process, logger, clock, id, paths)
3813
+ * are injected via Props passing memory implementations gives a fully sandboxed
3878
3814
  * Funnel that touches no real disk, processes, or wall-clock time.
3879
3815
  *
3880
- * Connectors live nested inside their owning channel (channels[].connectors[]),
3881
- * so connector CRUD is reached via `funnel.channels.addConnector(...)` etc.
3816
+ * Fully immutable: all fields are resolved in the constructor and frozen.
3817
+ * No lazy initialisation — every dependency is wired at construction time.
3882
3818
  *
3883
3819
  * @example
3884
3820
  * ```ts
@@ -3889,9 +3825,104 @@ const noopOnError = () => {};
3889
3825
  * ```
3890
3826
  */
3891
3827
  var Funnel = class Funnel {
3892
- memos = {};
3828
+ paths;
3829
+ channels;
3830
+ gateway;
3831
+ gatewayToken;
3832
+ publisher;
3833
+ listeners;
3834
+ claude;
3835
+ profiles;
3836
+ localConfig;
3837
+ localConfigSync;
3838
+ fs;
3839
+ process;
3840
+ logger;
3841
+ clock;
3842
+ onError;
3893
3843
  constructor(props = {}) {
3894
- this.props = props;
3844
+ const dir = props.dir ?? resolveFunnelDir();
3845
+ const tmpDir = props.tmpDir ?? funnelTmpDir();
3846
+ const fs = props.fs ?? new NodeFunnelFileSystem();
3847
+ const process = props.process ?? new NodeFunnelProcessRunner();
3848
+ const clock = props.clock ?? new NodeFunnelClock();
3849
+ const idGenerator = props.idGenerator ?? new NodeFunnelIdGenerator();
3850
+ this.paths = {
3851
+ dir,
3852
+ tmpDir,
3853
+ settings: join(dir, "settings.json")
3854
+ };
3855
+ this.fs = fs;
3856
+ this.process = process;
3857
+ this.logger = props.logger;
3858
+ this.clock = clock;
3859
+ this.onError = props.onError ?? noopOnError;
3860
+ const store = props.store ?? new FunnelSettingsStore({
3861
+ path: this.paths.settings,
3862
+ fs,
3863
+ idGenerator
3864
+ });
3865
+ const factory = new FunnelConnectorFactory({
3866
+ fs,
3867
+ process,
3868
+ logger: this.logger,
3869
+ diagnosticLog: props.diagnosticLog,
3870
+ dir,
3871
+ slackListenerOptions: props.slackListenerOptions,
3872
+ scheduleListenerOptions: props.scheduleListenerOptions
3873
+ });
3874
+ this.channels = new FunnelChannels({
3875
+ store,
3876
+ factory,
3877
+ clock,
3878
+ idGenerator
3879
+ });
3880
+ this.gateway = new FunnelGateway({
3881
+ fs,
3882
+ process,
3883
+ clock,
3884
+ dir,
3885
+ tmpDir,
3886
+ port: props.port
3887
+ });
3888
+ this.gatewayToken = new FunnelGatewayToken({
3889
+ fs,
3890
+ dir
3891
+ });
3892
+ this.publisher = new FunnelChannelPublisher({
3893
+ port: this.gateway.getPort(),
3894
+ isDaemonRunning: () => this.gateway.isRunning(),
3895
+ getToken: () => this.gatewayToken.read()
3896
+ });
3897
+ this.listeners = new FunnelListenersClient({
3898
+ port: this.gateway.getPort(),
3899
+ isDaemonRunning: () => this.gateway.isRunning(),
3900
+ getToken: () => this.gatewayToken.read()
3901
+ });
3902
+ const mcp = new FunnelMcp({ fs });
3903
+ this.profiles = new FunnelProfiles({
3904
+ store,
3905
+ idGenerator,
3906
+ fs
3907
+ });
3908
+ this.localConfig = new FunnelLocalConfig({ fs });
3909
+ this.localConfigSync = new FunnelLocalConfigSync({
3910
+ channels: this.channels,
3911
+ prompter: props.tokenPrompter ?? new NodeFunnelTokenPrompter()
3912
+ });
3913
+ this.claude = new FunnelClaude({
3914
+ channels: this.channels,
3915
+ mcp,
3916
+ gateway: this.gateway,
3917
+ sessions: this.profiles,
3918
+ guard: new FileProcessGuard({
3919
+ fs,
3920
+ process,
3921
+ dir
3922
+ }),
3923
+ process,
3924
+ logger: this.logger
3925
+ });
3895
3926
  Object.freeze(this);
3896
3927
  }
3897
3928
  /**
@@ -3901,6 +3932,7 @@ var Funnel = class Funnel {
3901
3932
  */
3902
3933
  static inMemory(props = {}) {
3903
3934
  return new Funnel({
3935
+ ...props,
3904
3936
  store: props.store ?? new MockFunnelSettingsReader(),
3905
3937
  fs: props.fs ?? new MemoryFunnelFileSystem(),
3906
3938
  process: props.process ?? new MemoryFunnelProcessRunner(),
@@ -3911,183 +3943,6 @@ var Funnel = class Funnel {
3911
3943
  tmpDir: props.tmpDir ?? SANDBOX_TMP_DIR
3912
3944
  });
3913
3945
  }
3914
- /** Resolved on-disk paths the facade will read/write when methods are called. Pure compute, not memoized. */
3915
- get paths() {
3916
- const dir = this.props.dir ?? resolveFunnelDir();
3917
- return {
3918
- dir,
3919
- tmpDir: this.props.tmpDir ?? funnelTmpDir(),
3920
- settings: join(dir, "settings.json")
3921
- };
3922
- }
3923
- /** Filesystem boundary. Defaults to NodeFunnelFileSystem. */
3924
- get fs() {
3925
- if (!this.memos.fs) this.memos.fs = this.props.fs ?? new NodeFunnelFileSystem();
3926
- return this.memos.fs;
3927
- }
3928
- /** Process runner boundary. Defaults to NodeFunnelProcessRunner. */
3929
- get process() {
3930
- if (!this.memos.process) this.memos.process = this.props.process ?? new NodeFunnelProcessRunner();
3931
- return this.memos.process;
3932
- }
3933
- /** Logger boundary. Optional — when no logger is injected, every facet's `this.logger?.x` call is a silent no-op. Production entry points (cli, daemon) inject a NodeFunnelLogger. */
3934
- get logger() {
3935
- return this.props.logger;
3936
- }
3937
- /** Clock boundary. Defaults to NodeFunnelClock. */
3938
- get clock() {
3939
- if (!this.memos.clock) this.memos.clock = this.props.clock ?? new NodeFunnelClock();
3940
- return this.memos.clock;
3941
- }
3942
- /**
3943
- * Error hook. Forwards Funnel-internal exceptions that would otherwise be
3944
- * swallowed. Defaults to a no-op when no host hook was passed.
3945
- */
3946
- get onError() {
3947
- return this.props.onError ?? noopOnError;
3948
- }
3949
- /** ID generator boundary. Defaults to NodeFunnelIdGenerator. */
3950
- get idGenerator() {
3951
- if (!this.memos.idGenerator) this.memos.idGenerator = this.props.idGenerator ?? new NodeFunnelIdGenerator();
3952
- return this.memos.idGenerator;
3953
- }
3954
- /** Settings reader. If not injected, a FunnelSettingsStore rooted at `dir` is created. */
3955
- get store() {
3956
- if (!this.memos.store) this.memos.store = this.props.store ?? new FunnelSettingsStore({
3957
- path: this.paths.settings,
3958
- fs: this.fs,
3959
- idGenerator: this.idGenerator
3960
- });
3961
- return this.memos.store;
3962
- }
3963
- /** Pure factory that constructs per-type listeners and adapters from connector configs. */
3964
- get factory() {
3965
- if (!this.memos.factory) this.memos.factory = new FunnelConnectorFactory({
3966
- fs: this.fs,
3967
- process: this.process,
3968
- logger: this.logger,
3969
- diagnosticLog: this.props.diagnosticLog,
3970
- dir: this.paths.dir,
3971
- slackListenerOptions: this.props.slackListenerOptions,
3972
- scheduleListenerOptions: this.props.scheduleListenerOptions
3973
- });
3974
- return this.memos.factory;
3975
- }
3976
- /** Channel CRUD + nested connector CRUD + schedule entries + listener/adapter dispatch. */
3977
- get channels() {
3978
- if (!this.memos.channels) this.memos.channels = new FunnelChannels({
3979
- store: this.store,
3980
- factory: this.factory,
3981
- profileChecker: this.profiles,
3982
- clock: this.clock,
3983
- idGenerator: this.idGenerator
3984
- });
3985
- return this.memos.channels;
3986
- }
3987
- /** Launch profiles (named presets for `fnl claude`: path + sub-agent + channel id). */
3988
- get profiles() {
3989
- if (!this.memos.profiles) this.memos.profiles = new FunnelProfiles({
3990
- store: this.store,
3991
- idGenerator: this.idGenerator
3992
- });
3993
- return this.memos.profiles;
3994
- }
3995
- /** Reads `funnel.json` from a cwd. `fnl claude` consults it before falling back to the default profile. */
3996
- get localConfig() {
3997
- if (!this.memos.localConfig) this.memos.localConfig = new FunnelLocalConfig({ fs: this.fs });
3998
- return this.memos.localConfig;
3999
- }
4000
- /** Writes the stable `id` into funnel.json on first launch so state can be scoped to `~/.funnel/projects/<id>/`. */
4001
- get localConfigWriter() {
4002
- if (!this.memos.localConfigWriter) this.memos.localConfigWriter = new FunnelLocalConfigWriter({ fs: this.fs });
4003
- return this.memos.localConfigWriter;
4004
- }
4005
- /** Secret prompter. Defaults to a TTY-only stdin reader; tests inject MemoryFunnelTokenPrompter. */
4006
- get tokenPrompter() {
4007
- if (!this.memos.tokenPrompter) this.memos.tokenPrompter = this.props.tokenPrompter ?? new NodeFunnelTokenPrompter();
4008
- return this.memos.tokenPrompter;
4009
- }
4010
- /** Reconciles funnel.json's channel + connectors with `~/.funnel/settings.json` on launch. */
4011
- get localConfigSync() {
4012
- if (!this.memos.localConfigSync) this.memos.localConfigSync = new FunnelLocalConfigSync({
4013
- channels: this.channels,
4014
- prompter: this.tokenPrompter
4015
- });
4016
- return this.memos.localConfigSync;
4017
- }
4018
- /** funnel MCP installer (writes/removes `.mcp.json` entries in target repos). */
4019
- get mcp() {
4020
- if (!this.memos.mcp) this.memos.mcp = new FunnelMcp({ fs: this.fs });
4021
- return this.memos.mcp;
4022
- }
4023
- /** Launch Claude Code with a channel injected via env, MCP installed, gateway ensured. */
4024
- get claude() {
4025
- if (!this.memos.claude) this.memos.claude = new FunnelClaude({
4026
- channels: this.channels,
4027
- mcp: this.mcp,
4028
- gateway: this.gateway,
4029
- profiles: this.profiles,
4030
- fs: this.fs,
4031
- process: this.process,
4032
- idGenerator: this.idGenerator,
4033
- logger: this.logger,
4034
- dir: this.paths.dir
4035
- });
4036
- return this.memos.claude;
4037
- }
4038
- /** Gateway daemon controller (PID-file, start/stop the separate `bun daemon.ts` process). */
4039
- get gateway() {
4040
- if (!this.memos.gateway) this.memos.gateway = new FunnelGateway({
4041
- fs: this.fs,
4042
- process: this.process,
4043
- clock: this.clock,
4044
- dir: this.paths.dir,
4045
- tmpDir: this.paths.tmpDir
4046
- });
4047
- return this.memos.gateway;
4048
- }
4049
- /** Read / generate the daemon's gateway token (mode 0600 file under `dir`). */
4050
- get gatewayToken() {
4051
- if (!this.memos.gatewayToken) this.memos.gatewayToken = new FunnelGatewayToken({
4052
- fs: this.fs,
4053
- dir: this.paths.dir
4054
- });
4055
- return this.memos.gatewayToken;
4056
- }
4057
- /**
4058
- * HTTP client for `POST /channels/:channel/publish` on the running gateway
4059
- * daemon. Use it to push arbitrary content into a channel from outside any
4060
- * connector. Returns `{ state: "offline" }` if the daemon isn't up.
4061
- */
4062
- get publisher() {
4063
- if (!this.memos.publisher) {
4064
- const gateway = this.gateway;
4065
- const token = this.gatewayToken;
4066
- this.memos.publisher = new FunnelChannelPublisher({
4067
- port: gateway.getPort(),
4068
- isDaemonRunning: () => gateway.isRunning(),
4069
- getToken: () => token.read()
4070
- });
4071
- }
4072
- return this.memos.publisher;
4073
- }
4074
- /**
4075
- * HTTP client for listener operations on the running gateway daemon.
4076
- * Returns `{ state: "offline" }` when the daemon is offline so hot-reload
4077
- * paths stay write-only without parsing strings.
4078
- */
4079
- get listeners() {
4080
- if (!this.memos.listeners) {
4081
- const gateway = this.gateway;
4082
- const token = this.gatewayToken;
4083
- this.memos.listeners = new FunnelListenersClient({
4084
- port: gateway.getPort(),
4085
- isDaemonRunning: () => gateway.isRunning(),
4086
- getToken: () => token.read()
4087
- });
4088
- }
4089
- return this.memos.listeners;
4090
- }
4091
3946
  /**
4092
3947
  * In-process gateway server. Unlike `gateway.start()` (which spawns a daemon),
4093
3948
  * this returns a class that runs `Bun.serve` + listeners inside the current process —
@@ -4096,7 +3951,6 @@ var Funnel = class Funnel {
4096
3951
  gatewayServer(options = {}) {
4097
3952
  return new FunnelGatewayServer({
4098
3953
  channels: this.channels,
4099
- settings: this.store,
4100
3954
  port: options.port,
4101
3955
  hostname: options.hostname,
4102
3956
  dbPath: options.dbPath,
@@ -4111,6 +3965,20 @@ var Funnel = class Funnel {
4111
3965
  extraRoutes: options.extraRoutes
4112
3966
  });
4113
3967
  }
3968
+ /**
3969
+ * Run the gateway daemon in the foreground (tied to this terminal).
3970
+ * For background daemon management, use `funnel.gateway.start()` instead.
3971
+ */
3972
+ async runGatewayForeground(options = {}) {
3973
+ const gatewayScript = resolveDaemonScript();
3974
+ const command = options.caffeinate !== false && globalThis.process.platform === "darwin" ? [
3975
+ "caffeinate",
3976
+ "-is",
3977
+ "bun",
3978
+ gatewayScript
3979
+ ] : ["bun", gatewayScript];
3980
+ return this.process.attach(command);
3981
+ }
4114
3982
  async debug(channelName) {
4115
3983
  return buildFunnelDebugReport({
4116
3984
  gateway: this.gateway,
@@ -4124,331 +3992,6 @@ var Funnel = class Funnel {
4124
3992
  }
4125
3993
  };
4126
3994
  //#endregion
4127
- //#region lib/engine/mcp/channel-subscriber.ts
4128
- const RECONNECT_DELAY = 1e3;
4129
- const MAX_RECONNECT_DELAY = 1e4;
4130
- /**
4131
- * Subscribes to the gateway WebSocket for a single channel and forwards
4132
- * incoming events to the MCP server as `notifications/claude/channel`.
4133
- * Reconnects with exponential backoff and replays missed events via `?since=<offset>`.
4134
- */
4135
- var FunnelChannelSubscriber = class {
4136
- state = {
4137
- reconnectDelay: RECONNECT_DELAY,
4138
- lastOffset: 0
4139
- };
4140
- constructor(props) {
4141
- this.props = props;
4142
- Object.freeze(this);
4143
- }
4144
- start() {
4145
- this.connect();
4146
- }
4147
- connect() {
4148
- const sinceQuery = this.state.lastOffset > 0 ? `&since=${this.state.lastOffset}` : "";
4149
- const wsUrl = `${this.props.baseUrl}${sinceQuery}`;
4150
- const ws = new WebSocket(wsUrl, this.props.protocols);
4151
- ws.addEventListener("open", () => {
4152
- this.state.reconnectDelay = RECONNECT_DELAY;
4153
- process.stderr.write(`funnel: connected (${wsUrl})\n`);
4154
- });
4155
- ws.addEventListener("message", (event) => this.handleMessage(event));
4156
- ws.addEventListener("close", () => {
4157
- process.stderr.write(`funnel: disconnected, reconnecting in ${this.state.reconnectDelay}ms\n`);
4158
- setTimeout(() => this.connect(), this.state.reconnectDelay);
4159
- this.state.reconnectDelay = Math.min(this.state.reconnectDelay * 2, MAX_RECONNECT_DELAY);
4160
- });
4161
- ws.addEventListener("error", () => {});
4162
- }
4163
- async handleMessage(event) {
4164
- try {
4165
- const payload = JSON.parse(String(event.data));
4166
- const eventType = payload.meta?.event_type ?? "unknown";
4167
- if (typeof payload.offset === "number" && payload.offset > this.state.lastOffset) this.state.lastOffset = payload.offset;
4168
- process.stderr.write(`funnel: received event (${eventType})\n`);
4169
- await this.props.server.notification({
4170
- method: "notifications/claude/channel",
4171
- params: {
4172
- content: payload.content,
4173
- meta: payload.meta
4174
- }
4175
- });
4176
- } catch (error) {
4177
- process.stderr.write(`funnel: error: ${error instanceof Error ? error.message : String(error)}\n`);
4178
- }
4179
- }
4180
- };
4181
- //#endregion
4182
- //#region lib/engine/mcp/read-channel-connectors.ts
4183
- const TOOL_CONNECTOR_TYPES = new Set([
4184
- "slack",
4185
- "gh",
4186
- "discord"
4187
- ]);
4188
- const readChannelConnectors = (dir, channelId) => {
4189
- const settingsPath = join(dir, "settings.json");
4190
- if (!existsSync(settingsPath)) return null;
4191
- const raw = JSON.parse(readFileSync(settingsPath, "utf-8"));
4192
- const parsed = settingsSchema.safeParse(raw);
4193
- if (!parsed.success) return null;
4194
- const channel = parsed.data.channels.find((c) => c.id === channelId);
4195
- if (!channel) return null;
4196
- const connectors = channel.connectors.filter((c) => TOOL_CONNECTOR_TYPES.has(c.type)).map((c) => ({
4197
- name: c.name,
4198
- type: c.type
4199
- }));
4200
- return {
4201
- channelName: channel.name,
4202
- connectors
4203
- };
4204
- };
4205
- //#endregion
4206
- //#region lib/engine/mcp/read-gateway-token.ts
4207
- const readGatewayToken = (dir) => {
4208
- const fromEnv = process.env.FUNNEL_GATEWAY_TOKEN;
4209
- if (fromEnv && fromEnv.length > 0) return fromEnv;
4210
- const path = join(dir, "gateway.token");
4211
- if (!existsSync(path)) return null;
4212
- const value = readFileSync(path, "utf-8").trim();
4213
- return value.length > 0 ? value : null;
4214
- };
4215
- //#endregion
4216
- //#region lib/engine/mcp/usage-hint-for-type.ts
4217
- const usageHintForType = (type) => {
4218
- if (type === "slack") return [
4219
- "Slack Web API.",
4220
- "To reply in the same thread: method=POST path=chat.postMessage body={ channel: meta.channel_id, text: \"...\", thread_ts: meta.thread_ts }",
4221
- "To react: method=POST path=reactions.add body={ channel: meta.channel_id, timestamp: meta.thread_ts, name: \"thumbsup\" }",
4222
- "Use meta fields from the incoming event: channel_id (Slack channel), thread_ts (thread anchor), user_id (sender)."
4223
- ].join(" ");
4224
- if (type === "discord") return [
4225
- "Discord REST API.",
4226
- "To reply: method=POST path=/channels/<meta.channel_id>/messages body={ content: \"...\" }",
4227
- "Use meta fields: channel_id (Discord channel), user_id (sender), guild_id."
4228
- ].join(" ");
4229
- if (type === "gh") return [
4230
- "GitHub REST via gh CLI.",
4231
- "To comment: method=POST path=repos/<meta.repository>/issues/<number>/comments body={ body: \"...\" }",
4232
- "Parse <number> from meta.subject_url. meta fields: repository (owner/repo), subject_type, subject_url, reason."
4233
- ].join(" ");
4234
- return "Generic adapter call.";
4235
- };
4236
- //#endregion
4237
- //#region lib/engine/mcp/channel-server.ts
4238
- const DEFAULT_FUNNEL_DIR = join(homedir(), ".funnel");
4239
- const BUILTIN_TOOL_NAMES = ["fnl_status", "fnl_debug"];
4240
- const isBuiltinTool = (name) => BUILTIN_TOOL_NAMES.includes(name);
4241
- const readAllChannels = (dir) => {
4242
- const settingsPath = join(dir, "settings.json");
4243
- if (!existsSync(settingsPath)) return [];
4244
- try {
4245
- const raw = JSON.parse(readFileSync(settingsPath, "utf-8"));
4246
- const parsed = settingsSchema.safeParse(raw);
4247
- if (!parsed.success) return [];
4248
- return parsed.data.channels.map((c) => ({
4249
- id: c.id,
4250
- name: c.name
4251
- }));
4252
- } catch {
4253
- return [];
4254
- }
4255
- };
4256
- const startChannelServer = async (options = {}) => {
4257
- const dir = options.dir ?? DEFAULT_FUNNEL_DIR;
4258
- const gatewayBaseUrl = options.gatewayUrl ?? process.env.FUNNEL_GATEWAY_URL ?? `http://127.0.0.1:${resolveFunnelPort()}`;
4259
- const gatewayWsUrl = `${gatewayBaseUrl.replace(/^http/, "ws")}/ws`;
4260
- const channelId = options.channelId ?? process.env.FUNNEL_CHANNEL_ID;
4261
- const channel = channelId ? readChannelConnectors(dir, channelId) : null;
4262
- const token = options.token ?? readGatewayToken(dir);
4263
- const allChannels = readAllChannels(dir);
4264
- const currentChannelName = channel?.channelName ?? null;
4265
- const channelContext = allChannels.length > 0 ? [
4266
- "",
4267
- "Configured channels (use as the `channel` argument to fnl_debug):",
4268
- ...allChannels.map((ch) => ` ${ch.name}${ch.name === currentChannelName ? " ← this session" : ""}`)
4269
- ].join("\n") : "";
4270
- const server = new Server({
4271
- name: FUNNEL_MCP_NAME,
4272
- version: "1.0.0"
4273
- }, {
4274
- capabilities: {
4275
- experimental: { "claude/channel": {} },
4276
- tools: {}
4277
- },
4278
- instructions: [
4279
- `Events arrive as notifications (method: notifications/claude/channel) with two fields:`,
4280
- ` content — the event payload as a JSON string (parse it to read the message)`,
4281
- ` meta — key/value strings describing the event`,
4282
- "",
4283
- "meta fields by event_type:",
4284
- " slack: event_type=slack channel_id=C… thread_ts=1234.5678 user_id=U… mentioned=true|false",
4285
- " gh: event_type=gh repository=owner/repo subject_type=Issue|PullRequest subject_url=… reason=…",
4286
- " discord: event_type=discord channel_id=… user_id=… guild_id=… mentioned=true|false",
4287
- " schedule: event_type=schedule entry_id=…",
4288
- "",
4289
- "To reply to a Slack message in the same thread, call the connector tool with:",
4290
- ` method: POST`,
4291
- ` path: chat.postMessage`,
4292
- ` body: { channel: meta.channel_id, text: "your reply", thread_ts: meta.thread_ts }`,
4293
- "",
4294
- "To comment on a GitHub issue/PR (extract from subject_url in meta):",
4295
- ` method: POST`,
4296
- ` path: repos/<meta.repository>/issues/<number>/comments (parse number from meta.subject_url)`,
4297
- ` body: { body: "your reply" }`,
4298
- "",
4299
- "Built-in diagnostic tools — call proactively when events seem missing or delayed:",
4300
- " fnl_status — gateway running state, all listeners alive/dead, Claude WS clients",
4301
- " fnl_debug — per-channel diagnosis with last 10 events, rootCause, suggestedActions",
4302
- " omit channel arg to diagnose all channels; check summary.suggestedActions first",
4303
- channelContext
4304
- ].join("\n")
4305
- });
4306
- server.setRequestHandler(ListToolsRequestSchema, async () => {
4307
- const connectorTools = (channel?.connectors ?? []).map((c) => ({
4308
- name: c.name,
4309
- description: `Call the "${c.name}" (${c.type}) connector. ${usageHintForType(c.type)}`,
4310
- inputSchema: {
4311
- type: "object",
4312
- properties: {
4313
- method: {
4314
- type: "string",
4315
- description: "HTTP verb or API method (e.g. POST, chat.postMessage)"
4316
- },
4317
- path: {
4318
- type: "string",
4319
- description: "API path or method name (adapter-specific)"
4320
- },
4321
- body: {
4322
- type: "object",
4323
- description: "Request body / params (adapter-specific)"
4324
- }
4325
- },
4326
- required: ["method", "path"]
4327
- }
4328
- }));
4329
- const channelEnum = allChannels.length > 0 ? allChannels.map((ch) => ch.name) : void 0;
4330
- const builtinTools = [{
4331
- name: "fnl_status",
4332
- description: "Return the current funnel gateway status as JSON — gateway running state, listener alive/dead per channel, and connected Claude WS clients. Call this when you need to check whether the gateway is up or why events stopped arriving.",
4333
- inputSchema: {
4334
- type: "object",
4335
- properties: {}
4336
- }
4337
- }, {
4338
- name: "fnl_debug",
4339
- description: "Return a full channel diagnosis as JSON — gateway health, listener state, Claude WS connection, last 10 inbound events with outcome, connectionErrors (when listener is dead), and diagnosis.rootCause. Call this first when debugging missing events. Omit `channel` to diagnose all channels at once.",
4340
- inputSchema: {
4341
- type: "object",
4342
- properties: { channel: channelEnum ? {
4343
- type: "string",
4344
- description: `Channel name to inspect. One of: ${channelEnum.join(", ")}. Omit to get all channels.`,
4345
- enum: channelEnum
4346
- } : {
4347
- type: "string",
4348
- description: "Channel name to inspect. Omit to get all channels."
4349
- } }
4350
- }
4351
- }];
4352
- return { tools: [...connectorTools, ...builtinTools] };
4353
- });
4354
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
4355
- const toolName = request.params.name;
4356
- if (isBuiltinTool(toolName)) return handleBuiltinTool(toolName, request.params.arguments, gatewayBaseUrl, token, allChannels);
4357
- if (!channel) throw new Error("FUNNEL_CHANNEL_ID is not set or channel not found in settings.json");
4358
- const args = request.params.arguments ?? {};
4359
- const method = typeof args.method === "string" ? args.method : "";
4360
- const path = typeof args.path === "string" ? args.path : "";
4361
- const body = args.body ?? {};
4362
- if (!method || !path) throw new Error("`method` and `path` are required");
4363
- const url = `${gatewayBaseUrl}/channels/${encodeURIComponent(channel.channelName)}/connectors/${encodeURIComponent(toolName)}/call`;
4364
- const headers = { "content-type": "application/json" };
4365
- if (token) headers.authorization = `Bearer ${token}`;
4366
- const res = await fetch(url, {
4367
- method: "POST",
4368
- headers,
4369
- body: JSON.stringify({
4370
- method,
4371
- path,
4372
- body
4373
- })
4374
- });
4375
- const text = await res.text();
4376
- if (!res.ok) throw new Error(`gateway call failed (${res.status}): ${text}`);
4377
- return { content: [{
4378
- type: "text",
4379
- text
4380
- }] };
4381
- });
4382
- const transport = new StdioServerTransport();
4383
- await server.connect(transport);
4384
- if (!channelId) return;
4385
- new FunnelChannelSubscriber({
4386
- server,
4387
- baseUrl: `${gatewayWsUrl}?channel=${encodeURIComponent(channelId)}`,
4388
- protocols: token ? [`funnel.token.${token}`] : void 0
4389
- }).start();
4390
- };
4391
- const handleBuiltinTool = async (name, args, gatewayBaseUrl, token, allChannels) => {
4392
- const headers = {};
4393
- if (token) headers.authorization = `Bearer ${token}`;
4394
- if (name === "fnl_status") {
4395
- const res = await fetch(`${gatewayBaseUrl}/status`, { headers }).catch(() => null);
4396
- if (!res) return { content: [{
4397
- type: "text",
4398
- text: JSON.stringify({
4399
- running: false,
4400
- error: "gateway unreachable",
4401
- hint: "run: fnl gateway start",
4402
- knownChannels: allChannels.map((ch) => ch.name)
4403
- })
4404
- }] };
4405
- const body = await res.json();
4406
- return { content: [{
4407
- type: "text",
4408
- text: JSON.stringify(body)
4409
- }] };
4410
- }
4411
- const channelArg = typeof args?.channel === "string" ? args.channel : null;
4412
- const url = channelArg ? `${gatewayBaseUrl}/debug?channel=${encodeURIComponent(channelArg)}` : `${gatewayBaseUrl}/debug`;
4413
- const res = await fetch(url, { headers }).catch(() => null);
4414
- if (!res) return { content: [{
4415
- type: "text",
4416
- text: JSON.stringify({
4417
- gateway: { running: false },
4418
- channels: allChannels.map((ch) => ({
4419
- id: ch.id,
4420
- name: ch.name,
4421
- diagnosis: {
4422
- status: "error",
4423
- message: "gateway is not running",
4424
- nextAction: "fnl gateway start",
4425
- rootCause: null
4426
- }
4427
- }))
4428
- })
4429
- }] };
4430
- const body = await res.json();
4431
- return { content: [{
4432
- type: "text",
4433
- text: JSON.stringify(body)
4434
- }] };
4435
- };
4436
- //#endregion
4437
- //#region lib/engine/local-config/local-config-json-schema.ts
4438
- /**
4439
- * Generates the JSON Schema (draft 2020-12) for `funnel.json`. Useful for
4440
- * `$schema` references in committed `funnel.json` files so editors can give
4441
- * autocomplete and validation for channels[] (transport) and profiles[]
4442
- * (launch recipe) without anyone hand-maintaining a separate schema.
4443
- */
4444
- const funnelJsonSchema = () => {
4445
- return {
4446
- ...z.toJSONSchema(localConfigSchema, { target: "draft-2020-12" }),
4447
- title: "Funnel per-repo launch config",
4448
- 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."
4449
- };
4450
- };
4451
- //#endregion
4452
3995
  //#region lib/engine/logger/node-logger.ts
4453
3996
  const defaultLogFile = () => join(funnelTmpDir(), "funnel.log");
4454
3997
  var NodeFunnelLogger = class extends FunnelLogger {
@@ -4489,26 +4032,6 @@ var NoopFunnelLogger = class extends FunnelLogger {
4489
4032
  error() {}
4490
4033
  };
4491
4034
  //#endregion
4492
- //#region lib/engine/token-prompter/memory-token-prompter.ts
4493
- /**
4494
- * Pre-seeded answers keyed by prompt label. Tests configure the map up front;
4495
- * unmapped labels throw so the test surfaces unexpected prompts loudly.
4496
- */
4497
- var MemoryFunnelTokenPrompter = class extends FunnelTokenPrompter {
4498
- answers;
4499
- asked = [];
4500
- constructor(props = {}) {
4501
- super();
4502
- this.answers = new Map(Object.entries(props.answers ?? {}));
4503
- }
4504
- async promptSecret(label) {
4505
- this.asked.push(label);
4506
- const answer = this.answers.get(label);
4507
- if (answer === void 0) throw new Error(`no answer seeded for prompt "${label}"`);
4508
- return answer;
4509
- }
4510
- };
4511
- //#endregion
4512
4035
  //#region lib/gateway/memory-funnel-event-log.ts
4513
4036
  /**
4514
4037
  * In-process `FunnelEventLog` backed by a plain array. Used by tests and by
@@ -5464,7 +4987,6 @@ modes:
5464
4987
  fanout every connected WS client receives every event (default)
5465
4988
  exclusive each event is delivered to exactly one connected client (round-robin)
5466
4989
 
5467
- tap=all clients (TUI dashboard, debugging) always receive regardless of mode.
5468
4990
  `), (c) => {
5469
4991
  const param = c.req.valid("param");
5470
4992
  c.env.funnel.channels.setDelivery(param.channel, param.mode);
@@ -5661,19 +5183,19 @@ const claudeHandler = factory.createHandlers(zValidator$1("query", z.object({
5661
5183
  channel: z.string().optional()
5662
5184
  }).passthrough(), claudeHelp), async (c) => {
5663
5185
  const query = c.req.valid("query");
5664
- const funnel = c.env.funnel;
5186
+ const { funnel, claude, profiles, localConfig, localConfigSync } = c.env;
5665
5187
  const userArgs = queryToCliArgs(c.req.url, RESERVED_KEYS$1);
5666
5188
  if (query.channel && !query.profile) {
5667
- const exitCode = await funnel.claude.launch({
5189
+ const exitCode = await claude.launch({
5668
5190
  channel: query.channel,
5669
5191
  userArgs
5670
5192
  });
5671
5193
  process.exit(exitCode);
5672
5194
  }
5673
5195
  if (query.profile) {
5674
- const profile = funnel.profiles.get(query.profile);
5196
+ const profile = profiles.get(query.profile);
5675
5197
  if (!profile) throw new HTTPException(404, { message: `profile "${query.profile}" not found` });
5676
- const exitCode = await funnel.claude.launch({
5198
+ const exitCode = await claude.launch({
5677
5199
  channel: profile.channelId,
5678
5200
  cwd: profile.path,
5679
5201
  userArgs,
@@ -5685,24 +5207,24 @@ const claudeHandler = factory.createHandlers(zValidator$1("query", z.object({
5685
5207
  process.exit(exitCode);
5686
5208
  }
5687
5209
  const cwd = process.cwd();
5688
- const local = funnel.localConfig.read(cwd);
5210
+ const local = localConfig.read(cwd);
5689
5211
  if (local) {
5690
5212
  const picked = query.channel !== void 0 ? local.channels.find((c) => c.name === query.channel) : local.channels[0];
5691
5213
  if (!picked) throw new HTTPException(404, { message: query.channel ? `channel "${query.channel}" is not declared in funnel.json` : `funnel.json declares no channels` });
5692
- const synced = await funnel.localConfigSync.ensure(picked);
5214
+ const synced = await localConfigSync.ensure(picked);
5693
5215
  for (const outcome of synced.touched) if (outcome.changed) await funnel.listeners.restart(picked.name, outcome.name);
5694
5216
  else await funnel.listeners.start(picked.name, outcome.name);
5695
5217
  for (const name of synced.removed) await funnel.listeners.stop(picked.name, name);
5696
- const exitCode = await funnel.claude.launch({
5218
+ const exitCode = await claude.launch({
5697
5219
  channel: picked.name,
5698
5220
  cwd,
5699
5221
  userArgs
5700
5222
  });
5701
5223
  process.exit(exitCode);
5702
5224
  }
5703
- const defaultProfile = funnel.profiles.getDefault();
5225
+ const defaultProfile = profiles.getDefault();
5704
5226
  if (!defaultProfile) return c.text(claudeHelp);
5705
- const exitCode = await funnel.claude.launch({
5227
+ const exitCode = await claude.launch({
5706
5228
  channel: defaultProfile.channelId,
5707
5229
  cwd: defaultProfile.path,
5708
5230
  userArgs,
@@ -6195,7 +5717,7 @@ const buildChannelReport = async (targetChannel, gatewayStatus, gatewayBodyOrNul
6195
5717
  errors: l.errors,
6196
5718
  lastEventAt: l.lastEventAt
6197
5719
  }));
6198
- baseReport.claudeClients = gatewayBodyOrNull.clients.filter((cl) => !cl.tapAll && cl.channelName === targetChannelName).length;
5720
+ baseReport.claudeClients = gatewayBodyOrNull.clients.filter((cl) => cl.channelName === targetChannelName).length;
6199
5721
  }
6200
5722
  if (store) {
6201
5723
  const evRows = queryRows(new ConnectorDiagnosticSqlReader(store), "SELECT seq, ts, type, outcome, payload FROM processed WHERE channel_id = ? ORDER BY seq DESC LIMIT ?", [targetChannel.id, limit]);
@@ -6705,15 +6227,7 @@ examples:
6705
6227
  funnel gateway run
6706
6228
  funnel gateway run --no-caffeine`), async (c) => {
6707
6229
  const query = c.req.valid("query");
6708
- const funnel = c.env.funnel;
6709
- const gatewayScript = resolveDaemonScript();
6710
- const command = query["no-caffeine"] !== "true" && process.platform === "darwin" ? [
6711
- "caffeinate",
6712
- "-is",
6713
- "bun",
6714
- gatewayScript
6715
- ] : ["bun", gatewayScript];
6716
- const exitCode = await funnel.process.attach(command);
6230
+ const exitCode = await c.env.funnel.runGatewayForeground({ caffeinate: query["no-caffeine"] !== "true" });
6717
6231
  process.exit(exitCode);
6718
6232
  });
6719
6233
  //#endregion
@@ -6857,10 +6371,11 @@ const profilesAddHandler = factory.createHandlers(zValidator$1("param", z.object
6857
6371
  const param = c.req.valid("param");
6858
6372
  const query = c.req.valid("query");
6859
6373
  const funnel = c.env.funnel;
6374
+ const { profiles, claude } = c.env;
6860
6375
  const channel = funnel.channels.get(query.channel);
6861
6376
  if (!channel) throw new HTTPException(400, { message: `channel "${query.channel}" not found` });
6862
6377
  const recipe = parseProfileRecipe(query);
6863
- funnel.profiles.add({
6378
+ profiles.add({
6864
6379
  name: param.profile,
6865
6380
  path: query.path,
6866
6381
  channelId: channel.id,
@@ -6876,7 +6391,9 @@ usage: funnel profiles <name> as-default
6876
6391
 
6877
6392
  the first profile in the list is treated as the default for fnl claude.`), (c) => {
6878
6393
  const param = c.req.valid("param");
6879
- c.env.funnel.profiles.asDefault(param.profile);
6394
+ c.env.funnel;
6395
+ const { profiles, claude } = c.env;
6396
+ profiles.asDefault(param.profile);
6880
6397
  return c.text(`profile "${param.profile}" is now the default`);
6881
6398
  });
6882
6399
  //#endregion
@@ -6902,7 +6419,9 @@ const profilesRenameHandler = factory.createHandlers(zValidator$1("param", z.obj
6902
6419
  newName: z.string()
6903
6420
  })), zValidator$1("query", z.object({})), (c) => {
6904
6421
  const param = c.req.valid("param");
6905
- c.env.funnel.profiles.rename(param.profile, param.newName);
6422
+ c.env.funnel;
6423
+ const { profiles, claude } = c.env;
6424
+ profiles.rename(param.profile, param.newName);
6906
6425
  return c.text(`renamed profile "${param.profile}" to "${param.newName}"`);
6907
6426
  });
6908
6427
  //#endregion
@@ -6914,10 +6433,11 @@ usage: funnel profiles <name> run [additional claude args...]
6914
6433
  const RESERVED_KEYS = [];
6915
6434
  const profilesLaunchHandler = factory.createHandlers(zValidator$1("param", z.object({ profile: z.string() })), zValidator$1("query", z.object({}).passthrough(), launchHelp), async (c) => {
6916
6435
  const param = c.req.valid("param");
6917
- const funnel = c.env.funnel;
6918
- const profile = funnel.profiles.get(param.profile);
6436
+ c.env.funnel;
6437
+ const { profiles, claude } = c.env;
6438
+ const profile = profiles.get(param.profile);
6919
6439
  if (!profile) throw new HTTPException(404, { message: `profile "${param.profile}" not found` });
6920
- const exitCode = await funnel.claude.launch({
6440
+ const exitCode = await claude.launch({
6921
6441
  channel: profile.channelId,
6922
6442
  cwd: profile.path,
6923
6443
  userArgs: queryToCliArgs(c.req.url, RESERVED_KEYS),
@@ -6938,7 +6458,9 @@ const profilesRemoveHelpHandler = factory.createHandlers((c) => c.text(help$1));
6938
6458
  //#region lib/cli/routes/profiles.remove.$profile.ts
6939
6459
  const profilesRemoveHandler = factory.createHandlers(zValidator$1("param", z.object({ profile: z.string() })), zValidator$1("query", z.object({})), (c) => {
6940
6460
  const param = c.req.valid("param");
6941
- c.env.funnel.profiles.remove(param.profile);
6461
+ c.env.funnel;
6462
+ const { profiles, claude } = c.env;
6463
+ profiles.remove(param.profile);
6942
6464
  return c.text(`removed profile "${param.profile}"`);
6943
6465
  });
6944
6466
  //#endregion
@@ -6972,10 +6494,11 @@ const profilesSetHandler = factory.createHandlers(zValidator$1("param", z.object
6972
6494
  const param = c.req.valid("param");
6973
6495
  const query = c.req.valid("query");
6974
6496
  const funnel = c.env.funnel;
6497
+ const { profiles, claude } = c.env;
6975
6498
  const channel = query.channel !== void 0 ? funnel.channels.get(query.channel) : null;
6976
6499
  if (query.channel !== void 0 && !channel) throw new HTTPException(400, { message: `channel "${query.channel}" not found` });
6977
6500
  const recipe = parseProfileRecipe(query);
6978
- funnel.profiles.update(param.profile, {
6501
+ profiles.update(param.profile, {
6979
6502
  path: query.path,
6980
6503
  channelId: channel?.id,
6981
6504
  options: recipe.options,
@@ -7006,9 +6529,11 @@ examples:
7006
6529
  funnel profiles add cto --path /repo/myapp --channel prod-inbox --agent pm --options "--brief"
7007
6530
  funnel profiles cto as-default
7008
6531
  funnel profiles cto run`), (c) => {
7009
- const profiles = c.env.funnel.profiles.list();
7010
- if (profiles.length === 0) return c.text("no profiles");
7011
- const lines = profiles.map((profile, index) => {
6532
+ c.env.funnel;
6533
+ const { profiles } = c.env;
6534
+ const profileList = profiles.list();
6535
+ if (profileList.length === 0) return c.text("no profiles");
6536
+ const lines = profileList.map((profile, index) => {
7012
6537
  const tag = index === 0 ? " (default)" : "";
7013
6538
  const recipe = profile.options.length > 0 ? `, options=${profile.options.join(" ")}` : "";
7014
6539
  const session = profile.resume ? "" : ", resume=false";
@@ -7016,6 +6541,21 @@ examples:
7016
6541
  });
7017
6542
  return c.text(lines.join("\n"));
7018
6543
  });
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
+ };
7019
6559
  const schemaHandler = factory.createHandlers(zValidator$1("query", z.object({}), `funnel schema — print the JSON Schema for funnel.json
7020
6560
 
7021
6561
  usage: funnel schema
@@ -7060,9 +6600,9 @@ const isGatewayStatus = (value) => {
7060
6600
  if (!("listeners" in value) || !Array.isArray(value.listeners)) return false;
7061
6601
  return true;
7062
6602
  };
7063
- const buildStatusLines = async (funnel) => {
6603
+ const buildStatusLines = async (funnel, profiles) => {
7064
6604
  const channels = funnel.channels.list();
7065
- const profiles = funnel.profiles.list();
6605
+ const profileList = profiles.list();
7066
6606
  const gatewayStatus = funnel.gateway.getStatus();
7067
6607
  const lines = [];
7068
6608
  lines.push("= funnel status =");
@@ -7088,7 +6628,6 @@ const buildStatusLines = async (funnel) => {
7088
6628
  const listenerAliveByChannel = /* @__PURE__ */ new Map();
7089
6629
  if (gatewayData) {
7090
6630
  for (const client of gatewayData.clients) {
7091
- if (client.tapAll) continue;
7092
6631
  const key = client.channelName ?? client.channel;
7093
6632
  clientsByChannel.set(key, (clientsByChannel.get(key) ?? 0) + 1);
7094
6633
  }
@@ -7109,8 +6648,8 @@ const buildStatusLines = async (funnel) => {
7109
6648
  lines.push(` ${indicator} ${paddedName} [${connectorLabel}]${claudeLabel}`);
7110
6649
  }
7111
6650
  lines.push("");
7112
- lines.push(`profiles: ${profiles.length}`);
7113
- for (const [index, profile] of profiles.entries()) {
6651
+ lines.push(`profiles: ${profileList.length}`);
6652
+ for (const [index, profile] of profileList.entries()) {
7114
6653
  const tag = index === 0 ? " (default)" : "";
7115
6654
  const channel = funnel.channels.getById(profile.channelId);
7116
6655
  const channelLabel = channel ? channel.name : `id:${profile.channelId}`;
@@ -7131,11 +6670,11 @@ const statusHandler = factory.createHandlers(zValidator$1("query", z.object({
7131
6670
  const isWatch = query.watch === "true" || query.watch === "";
7132
6671
  const intervalSec = Math.min(60, Math.max(1, query.interval ? Number(query.interval) : 3));
7133
6672
  if (!isWatch) {
7134
- const lines = await buildStatusLines(funnel);
6673
+ const lines = await buildStatusLines(funnel, c.env.profiles);
7135
6674
  return c.text(lines.join("\n"));
7136
6675
  }
7137
6676
  const render = async () => {
7138
- const lines = await buildStatusLines(funnel);
6677
+ const lines = await buildStatusLines(funnel, c.env.profiles);
7139
6678
  const ts = (/* @__PURE__ */ new Date()).toISOString().slice(11, 19);
7140
6679
  process.stdout.write("\x1B[2J\x1B[H");
7141
6680
  process.stdout.write(lines.join("\n"));
@@ -7177,4 +6716,4 @@ const routes = factory.createApp().onError((error, c) => {
7177
6716
  return c.text(`error: ${error instanceof Error ? error.message : String(error)}`, 400);
7178
6717
  }).get("/claude", ...claudeHandler).get("/channels", ...channelsGroupHandler).post("/channels/add", ...channelsAddHelpHandler).post("/channels/add/:channel", ...channelsAddHandler).post("/channels/remove", ...channelsRemoveHelpHandler).post("/channels/remove/:channel", ...channelsRemoveHandler).post("/channels/rename/:channel/:newName", ...channelsRenameHandler).post("/channels/:channel/rename/:newName", ...channelsRenameHandler).post("/channels/rename", ...channelsRenameHelpHandler).post("/channels/:channel/rename", ...channelsChannelRenameHelpHandler).post("/channels/:channel/set/delivery/:mode", ...channelsSetDeliveryHandler).post("/channels/publish", ...channelsPublishHelpHandler).post("/channels/:channel/publish", ...channelsPublishHandler).get("/channels/:channel/validate", ...channelsValidateHandler).get("/channels/validate", ...channelsValidateHelpHandler).get("/channels/:channel", ...channelsShowHandler).get("/channels/:channel/connectors", ...channelsConnectorsGroupHandler).post("/channels/:channel/connectors/add", ...channelsConnectorsAddHelpHandler).post("/channels/:channel/connectors/add/:connector", ...channelsConnectorsAddHandler).post("/channels/:channel/connectors/remove", ...channelsConnectorsRemoveHelpHandler).post("/channels/:channel/connectors/remove/:connector", ...channelsConnectorsRemoveHandler).post("/channels/:channel/connectors/set", ...channelsConnectorsSetHelpHandler).post("/channels/:channel/connectors/set/:connector", ...channelsConnectorsSetHandler).post("/channels/:channel/connectors/rename/:connector/:newName", ...channelsConnectorsRenameHandler).post("/channels/:channel/connectors/:connector/rename/:newName", ...channelsConnectorsRenameHandler).post("/channels/:channel/connectors/rename", ...channelsConnectorsRenameHelpHandler).post("/channels/:channel/connectors/:connector/rename", ...channelsConnectorRenameHelpHandler).post("/channels/:channel/connectors/:connector/request", ...channelsConnectorsRequestHandler).get("/channels/:channel/connectors/:connector", ...channelsConnectorsShowHandler).get("/channels/:channel/connectors/:connector/schedules", ...channelsConnectorsSchedulesGroupHandler).post("/channels/:channel/connectors/:connector/schedules/add", ...channelsConnectorSchedulesAddHelpHandler).post("/channels/:channel/connectors/:connector/schedules/add/:id", ...channelsConnectorsSchedulesAddHandler).post("/channels/:channel/connectors/:connector/schedules/remove", ...channelsConnectorSchedulesRemoveHelpHandler).post("/channels/:channel/connectors/:connector/schedules/remove/:id", ...channelsConnectorsSchedulesRemoveHandler).get("/profiles", ...profilesGroupHandler).post("/profiles/add", ...profilesAddHelpHandler).post("/profiles/add/:profile", ...profilesAddHandler).post("/profiles/set", ...profilesSetHelpHandler).post("/profiles/set/:profile", ...profilesSetHandler).post("/profiles/remove", ...profilesRemoveHelpHandler).post("/profiles/remove/:profile", ...profilesRemoveHandler).post("/profiles/rename/:profile/:newName", ...profilesRenameHandler).post("/profiles/:profile/rename/:newName", ...profilesRenameHandler).post("/profiles/rename", ...profilesRenameHelpHandler).post("/profiles/:profile/rename", ...profilesProfileRenameHelpHandler).post("/profiles/:profile/as-default", ...profilesAsDefaultHandler).get("/profiles/:profile/run", ...profilesLaunchHandler).get("/profiles/:profile", ...profilesLaunchHandler).get("/gateway", ...gatewayGroupHandler).get("/gateway/status", ...gatewayStatusHandler).get("/gateway/start", ...gatewayStartHandler).get("/gateway/stop", ...gatewayStopHandler).get("/gateway/restart", ...gatewayRestartHandler).get("/gateway/run", ...gatewayRunHandler).get("/gateway/logs", ...gatewayLogsHandler).get("/gateway/sql", ...gatewaySqlHandler).get("/gateway/listeners", ...gatewayListenersHandler).get("/debug", ...debugHandler).get("/debug/events", ...debugEventsHandler).get("/debug/dropped", ...debugDroppedHandler).get("/debug/errors", ...debugErrorsHandler).get("/debug/replay", ...debugReplayHandler).get("/schema", ...schemaHandler).get("/status", ...statusHandler).get("/update", ...updateHandler);
7179
6718
  //#endregion
7180
- export { CONNECTOR_CONNECTION_STATUSES, ConnectorDiagnosticLog, ConnectorDiagnosticSqlReader, DEFAULT_GATEWAY_PORT, DEFAULT_GATEWAY_TOKEN_PATH, FUNNEL_DIR, FUNNEL_MCP_ARGS, FUNNEL_MCP_COMMAND, FUNNEL_MCP_NAME, Funnel, FunnelBroadcaster, FunnelChannelPublisher, FunnelChannels, FunnelClaude, FunnelClock, FunnelConnectorFactory, FunnelConnectorListener, FunnelEventLog, FunnelFileSystem, FunnelGateway, FunnelGatewayServer, FunnelGatewayToken, FunnelIdGenerator, FunnelListenerSupervisor, FunnelListenersClient, FunnelLocalConfig, FunnelLocalConfigSync, FunnelLocalConfigWriter, FunnelLogger, FunnelMcp, FunnelProcessRunner, FunnelProfiles, FunnelSettingsReader, FunnelSettingsStore, FunnelSlackEventProcessor, FunnelTokenPrompter, LOCAL_CONFIG_FILENAME, MemoryConnectorDiagnosticLog, MemoryFunnelClock, MemoryFunnelEventLog, MemoryFunnelFileSystem, MemoryFunnelIdGenerator, MemoryFunnelLogger, MemoryFunnelProcessRunner, MemoryFunnelTokenPrompter, MockFunnelSettingsReader, NodeFunnelClock, NodeFunnelFileSystem, NodeFunnelIdGenerator, NodeFunnelLogger, NodeFunnelProcessRunner, NodeFunnelTokenPrompter, NoopFunnelLogger, SETTINGS_PATH, SETTINGS_VERSION, SqliteConnectorDiagnosticLog, SqliteFunnelEventLog, channelConfigSchema, channelDeliveryModeSchema, channelSpecSchema, routes as cliRoutes, connectorConfigSchema, connectorConnectionEventSchema, connectorProcessedEventSchema, connectorRawEventSchema, connectorSpecSchema, createSettings, discordConnectorSchema, factory, funnelEventSchema, funnelJsonSchema, ghConnectorSchema, localConfigSchema, profileConfigSchema, profileSpecSchema, publishRequestSchema, publishResponseSchema, queryToCliArgs, resolveFunnelDir, resolveFunnelPort, scheduleCatchupPolicySchema, scheduleConnectorSchema, scheduleEntrySchema, settingsSchema, slackConnectorSchema, startChannelServer, toRequest };
6719
+ export { CONNECTOR_CONNECTION_STATUSES, ConnectorDiagnosticLog, ConnectorDiagnosticSqlReader, DEFAULT_GATEWAY_PORT, DEFAULT_GATEWAY_TOKEN_PATH, FUNNEL_DIR, Funnel, FunnelBroadcaster, FunnelChannelPublisher, FunnelChannels, FunnelClock, FunnelConnectorFactory, FunnelConnectorListener, FunnelEventLog, FunnelFileSystem, FunnelGateway, FunnelGatewayServer, FunnelGatewayToken, FunnelIdGenerator, FunnelListenerSupervisor, FunnelListenersClient, FunnelLogger, FunnelProcessRunner, FunnelSettingsReader, FunnelSettingsStore, FunnelSlackEventProcessor, MemoryConnectorDiagnosticLog, MemoryFunnelClock, MemoryFunnelEventLog, MemoryFunnelFileSystem, MemoryFunnelIdGenerator, MemoryFunnelLogger, MemoryFunnelProcessRunner, MockFunnelSettingsReader, NodeFunnelClock, NodeFunnelFileSystem, NodeFunnelIdGenerator, NodeFunnelLogger, NodeFunnelProcessRunner, NoopFunnelLogger, SETTINGS_PATH, SETTINGS_VERSION, SqliteConnectorDiagnosticLog, SqliteFunnelEventLog, channelConfigSchema, channelDeliveryModeSchema, routes as cliRoutes, connectorConfigSchema, connectorConnectionEventSchema, connectorProcessedEventSchema, connectorRawEventSchema, createSettings, discordConnectorSchema, factory, funnelEventSchema, ghConnectorSchema, profileConfigSchema, publishRequestSchema, publishResponseSchema, queryToCliArgs, resolveFunnelDir, resolveFunnelPort, scheduleCatchupPolicySchema, scheduleConnectorSchema, scheduleEntrySchema, settingsSchema, slackConnectorSchema, toRequest };