@interactive-inc/claude-funnel 0.10.1 → 0.15.2

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
@@ -1,12 +1,13 @@
1
1
  import { i as FunnelDiscordAdapter, n as FunnelDiscordListener, t as discordConnectorSchema } from "./discord-connector-schema-ygf5Df-2.js";
2
2
  import { n as FunnelLogger, r as FunnelConnectorListener, t as NodeFunnelLogger } from "./node-logger-DQz_BGOD.js";
3
3
  import { a as FunnelProcessRunner, i as NodeFunnelProcessRunner, n as FunnelGhListener, r as FunnelGhAdapter, t as ghConnectorSchema } from "./gh-connector-schema-2ml29MBC.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-CkuIQ0JQ.js";
5
- import { i as FunnelSlackAdapter, n as FunnelSlackListener, r as FunnelSlackEventProcessor, t as slackConnectorSchema } from "./slack-connector-schema-Cd22WiHB.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-FxP7LPlx.js";
5
+ import { i as FunnelSlackAdapter, n as FunnelSlackListener, r as FunnelSlackEventProcessor, t as slackConnectorSchema } from "./slack-connector-schema-B4hsf3AY.js";
6
6
  import { dirname, join, resolve } from "node:path";
7
7
  import { existsSync, mkdirSync, readFileSync } from "node:fs";
8
8
  import { z } from "zod";
9
9
  import { homedir } from "node:os";
10
+ import { stderr, stdin } from "node:process";
10
11
  import { fileURLToPath } from "node:url";
11
12
  import { timingSafeEqual } from "node:crypto";
12
13
  import { createFactory } from "hono/factory";
@@ -133,23 +134,33 @@ const defaultLogger$5 = new NodeFunnelLogger();
133
134
  *
134
135
  * `dir` is the funnel home (defaults to ~/.funnel); per-connector state files
135
136
  * land at `<dir>/channels/<channel-id>/connectors/<connector-id>/state.json`.
137
+ *
138
+ * Host integrations can supply per-type listener hooks via
139
+ * `slackListenerOptions` / `scheduleListenerOptions` — e.g. to attach a
140
+ * Bolt `app.action` handler or to drop one-shot schedule entries on fire.
136
141
  */
137
142
  var FunnelConnectorFactory = class {
138
143
  fs;
139
144
  process;
140
145
  logger;
141
146
  dir;
147
+ slackListenerOptions;
148
+ scheduleListenerOptions;
142
149
  constructor(deps = {}) {
143
150
  this.fs = deps.fs ?? defaultFs$4;
144
151
  this.process = deps.process ?? defaultProcess$3;
145
152
  this.logger = deps.logger ?? defaultLogger$5;
146
153
  this.dir = deps.dir ?? FUNNEL_DIR;
154
+ this.slackListenerOptions = deps.slackListenerOptions ?? {};
155
+ this.scheduleListenerOptions = deps.scheduleListenerOptions ?? {};
147
156
  Object.freeze(this);
148
157
  }
149
158
  createListener(channelId, config) {
150
159
  if (config.type === "slack") return new FunnelSlackListener({
151
160
  config,
152
- logger: this.logger
161
+ logger: this.logger,
162
+ onAppCreated: this.slackListenerOptions.onAppCreated,
163
+ preprocessEvent: this.slackListenerOptions.preprocessEvent
153
164
  });
154
165
  if (config.type === "gh") return new FunnelGhListener({
155
166
  config,
@@ -166,7 +177,8 @@ var FunnelConnectorFactory = class {
166
177
  path: join(this.connectorDir(channelId, config.id), "state.json"),
167
178
  fs: this.fs
168
179
  }),
169
- logger: this.logger
180
+ logger: this.logger,
181
+ onFired: this.scheduleListenerOptions.onFired
170
182
  });
171
183
  }
172
184
  createAdapter(config) {
@@ -556,7 +568,7 @@ var FunnelClaude = class {
556
568
  this.installCleanup(options.profileName);
557
569
  }
558
570
  const claudeArgs = this.buildArgs(options, cwd);
559
- const env = this.buildEnv(channel.id);
571
+ const env = this.buildEnv(channel.id, options.extraEnv);
560
572
  this.logger.info(`claude launch`, {
561
573
  channel: options.channel,
562
574
  channelId: channel.id,
@@ -624,8 +636,9 @@ var FunnelClaude = class {
624
636
  if (options.brief && !result.includes("--brief")) result.push("--brief");
625
637
  return result;
626
638
  }
627
- buildEnv(channelId) {
639
+ buildEnv(channelId, extraEnv) {
628
640
  const env = {};
641
+ if (extraEnv) for (const [key, value] of Object.entries(extraEnv)) env[key] = value;
629
642
  for (const [key, value] of Object.entries(globalThis.process.env)) if (typeof value === "string") env[key] = value;
630
643
  env.FUNNEL_CHANNEL_ID = channelId;
631
644
  return env;
@@ -720,6 +733,332 @@ var MemoryFunnelIdGenerator = class extends FunnelIdGenerator {
720
733
  }
721
734
  };
722
735
  //#endregion
736
+ //#region lib/engine/local-config/local-config-schema.ts
737
+ /**
738
+ * Per-repo launch config (`funnel.json`).
739
+ *
740
+ * `fnl claude` reads this when no --profile / --channel is given and uses it
741
+ * to set the channel binding, sub-agent, and brief flag. When `connectors`
742
+ * is declared, missing channels/connectors are materialized into the local
743
+ * `~/.funnel/settings.json` on launch.
744
+ *
745
+ * Token fields per connector resolve in this order:
746
+ *
747
+ * 1. Literal value at the field itself (e.g. `botToken: "xoxb-..."`)
748
+ * 2. Env-var reference at `env.<field>` (e.g. `env: { botToken: "SLACK_BOT_TOKEN" }`);
749
+ * resolved from process.env first, then ./.env.local
750
+ * 3. Field omitted everywhere → prompted for once on a TTY and persisted to
751
+ * `~/.funnel/settings.json`; non-TTY launches fail fast.
752
+ *
753
+ * `funnel.json` itself is never written to. Only `channel` is required.
754
+ */
755
+ const slackEnvSchema = z.object({
756
+ botToken: z.string().optional(),
757
+ appToken: z.string().optional()
758
+ }).optional();
759
+ const slackConnectorSpecSchema = z.object({
760
+ type: z.literal("slack"),
761
+ name: z.string(),
762
+ botToken: z.string().optional(),
763
+ appToken: z.string().optional(),
764
+ env: slackEnvSchema
765
+ });
766
+ const discordEnvSchema = z.object({ botToken: z.string().optional() }).optional();
767
+ const discordConnectorSpecSchema = z.object({
768
+ type: z.literal("discord"),
769
+ name: z.string(),
770
+ botToken: z.string().optional(),
771
+ env: discordEnvSchema
772
+ });
773
+ const ghConnectorSpecSchema = z.object({
774
+ type: z.literal("gh"),
775
+ name: z.string(),
776
+ pollInterval: z.number().int().positive().optional()
777
+ });
778
+ const scheduleConnectorSpecSchema = z.object({
779
+ type: z.literal("schedule"),
780
+ name: z.string()
781
+ });
782
+ const connectorSpecSchema = z.discriminatedUnion("type", [
783
+ slackConnectorSpecSchema,
784
+ discordConnectorSpecSchema,
785
+ ghConnectorSpecSchema,
786
+ scheduleConnectorSpecSchema
787
+ ]);
788
+ const localConfigSchema = z.object({
789
+ $schema: z.string().optional(),
790
+ channel: z.string(),
791
+ /** Extra args forwarded to the claude CLI. Prepended before user-supplied CLI args so user args still win on collision (e.g. --model, --agent, --brief, --resume, positional session ids). */
792
+ options: z.array(z.string()).optional(),
793
+ env: z.record(z.string(), z.string()).optional(),
794
+ connectors: z.array(connectorSpecSchema).optional()
795
+ });
796
+ const LOCAL_CONFIG_FILENAME = "funnel.json";
797
+ const LOCAL_ENV_FILENAME = ".env.local";
798
+ //#endregion
799
+ //#region lib/engine/local-config/dotenv-reader.ts
800
+ const VARIABLE_LINE = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/;
801
+ const unquote = (value) => {
802
+ if (value.length < 2) return value;
803
+ const first = value[0];
804
+ const last = value[value.length - 1];
805
+ if (first === "\"" && last === "\"") return value.slice(1, -1);
806
+ if (first === "'" && last === "'") return value.slice(1, -1);
807
+ return value;
808
+ };
809
+ /**
810
+ * Minimal `.env.local` parser. Supports `KEY=value` lines, blank lines, and
811
+ * `#` comments. Strips matching surrounding single or double quotes. No
812
+ * interpolation, no `export` prefix — anything fancier should live in a real
813
+ * env file loaded by the shell.
814
+ */
815
+ var FunnelDotenvReader = class {
816
+ fs;
817
+ constructor(deps) {
818
+ this.fs = deps.fs;
819
+ Object.freeze(this);
820
+ }
821
+ read(cwd) {
822
+ const path = join(cwd, LOCAL_ENV_FILENAME);
823
+ if (!this.fs.existsSync(path)) return {};
824
+ const raw = this.fs.readFileSync(path);
825
+ const out = {};
826
+ for (const line of raw.split("\n")) {
827
+ const trimmed = line.trim();
828
+ if (trimmed === "" || trimmed.startsWith("#")) continue;
829
+ const match = trimmed.match(VARIABLE_LINE);
830
+ if (!match) continue;
831
+ const key = match[1];
832
+ const value = match[2];
833
+ if (!key) continue;
834
+ out[key] = unquote(value ?? "");
835
+ }
836
+ return out;
837
+ }
838
+ };
839
+ //#endregion
840
+ //#region lib/engine/local-config/local-config.ts
841
+ /**
842
+ * Reads `funnel.json` from a directory. Returns `null` when the file is
843
+ * absent so callers can fall through to other resolution paths (default
844
+ * profile, help). Throws on present-but-invalid files so misconfiguration
845
+ * surfaces loudly instead of silently launching the wrong channel.
846
+ */
847
+ var FunnelLocalConfig = class {
848
+ fs;
849
+ constructor(deps) {
850
+ this.fs = deps.fs;
851
+ Object.freeze(this);
852
+ }
853
+ read(cwd) {
854
+ const path = join(cwd, LOCAL_CONFIG_FILENAME);
855
+ if (!this.fs.existsSync(path)) return null;
856
+ const raw = this.fs.readFileSync(path);
857
+ const parsed = (() => {
858
+ try {
859
+ return JSON.parse(raw);
860
+ } catch (error) {
861
+ const message = error instanceof Error ? error.message : String(error);
862
+ throw new Error(`${LOCAL_CONFIG_FILENAME} is not valid JSON: ${message}`);
863
+ }
864
+ })();
865
+ const result = localConfigSchema.safeParse(parsed);
866
+ if (!result.success) throw new Error(`${LOCAL_CONFIG_FILENAME} is invalid: ${result.error.message}`);
867
+ return result.data;
868
+ }
869
+ };
870
+ //#endregion
871
+ //#region lib/engine/token-prompter/token-prompter.ts
872
+ /**
873
+ * Asks the user for a secret value on stdin. Used as a last resort when a
874
+ * funnel.json token field is absent and not present in `~/.funnel`. The Node
875
+ * implementation refuses to prompt when stdin is not a TTY so non-interactive
876
+ * launches (CI, agent spawning agent, daemons) fail fast instead of hanging.
877
+ */
878
+ var FunnelTokenPrompter = class {};
879
+ //#endregion
880
+ //#region lib/engine/local-config/local-config-sync.ts
881
+ /**
882
+ * Reconciles a `funnel.json` spec with `~/.funnel/settings.json`. The spec
883
+ * is the source of truth for the channel it declares:
884
+ *
885
+ * - missing channel → created
886
+ * - declared connector matched by name → tokens reconciled
887
+ * - declared connector matched by token in the same channel under a
888
+ * different name → renamed in place (then tokens reconciled)
889
+ * - declared connector with no match → added
890
+ * - any connector left in the channel that the spec did not touch → removed
891
+ *
892
+ * Removal only fires when funnel.json has a `connectors` field. An absent
893
+ * field means "do not manage connectors from here" and leaves everything in
894
+ * `~/.funnel` alone.
895
+ */
896
+ var FunnelLocalConfigSync = class {
897
+ channels;
898
+ dotenv;
899
+ prompter;
900
+ env;
901
+ constructor(deps) {
902
+ this.channels = deps.channels;
903
+ this.dotenv = deps.dotenv;
904
+ this.prompter = deps.prompter;
905
+ this.env = deps.env ?? process.env;
906
+ Object.freeze(this);
907
+ }
908
+ async ensure(local, cwd) {
909
+ if (!this.channels.get(local.channel)) this.channels.add({ name: local.channel });
910
+ if (local.connectors === void 0) return;
911
+ const dotenv = this.dotenv.read(cwd);
912
+ const touched = /* @__PURE__ */ new Set();
913
+ for (const spec of local.connectors) {
914
+ const id = await this.ensureConnector(local.channel, spec, dotenv);
915
+ touched.add(id);
916
+ }
917
+ this.removeExtras(local.channel, touched);
918
+ }
919
+ async ensureConnector(channelName, spec, dotenv) {
920
+ if (spec.type === "slack") return await this.ensureSlack(channelName, spec, dotenv);
921
+ if (spec.type === "discord") return await this.ensureDiscord(channelName, spec, dotenv);
922
+ if (spec.type === "gh") return this.ensureGh(channelName, spec);
923
+ return this.ensureSchedule(channelName, spec);
924
+ }
925
+ async ensureSlack(channelName, spec, dotenv) {
926
+ const byName = this.findExistingSlack(channelName, spec.name);
927
+ const botToken = await this.resolveField({
928
+ literal: spec.botToken,
929
+ envVar: spec.env?.botToken,
930
+ dotenv,
931
+ label: `${spec.name}.botToken`,
932
+ existing: byName?.botToken
933
+ });
934
+ const appToken = await this.resolveField({
935
+ literal: spec.appToken,
936
+ envVar: spec.env?.appToken,
937
+ dotenv,
938
+ label: `${spec.name}.appToken`,
939
+ existing: byName?.appToken
940
+ });
941
+ if (byName) {
942
+ if (byName.botToken !== botToken || byName.appToken !== appToken) this.channels.updateSlackConnector(channelName, spec.name, {
943
+ botToken,
944
+ appToken
945
+ });
946
+ return byName.id;
947
+ }
948
+ const byToken = this.findSlackByToken(channelName, [botToken, appToken]);
949
+ if (byToken) {
950
+ this.channels.renameConnector(channelName, byToken.name, spec.name);
951
+ if (byToken.botToken !== botToken || byToken.appToken !== appToken) this.channels.updateSlackConnector(channelName, spec.name, {
952
+ botToken,
953
+ appToken
954
+ });
955
+ return byToken.id;
956
+ }
957
+ return this.channels.addConnector(channelName, {
958
+ type: "slack",
959
+ name: spec.name,
960
+ botToken,
961
+ appToken
962
+ }).id;
963
+ }
964
+ async ensureDiscord(channelName, spec, dotenv) {
965
+ const byName = this.findExistingDiscord(channelName, spec.name);
966
+ const botToken = await this.resolveField({
967
+ literal: spec.botToken,
968
+ envVar: spec.env?.botToken,
969
+ dotenv,
970
+ label: `${spec.name}.botToken`,
971
+ existing: byName?.botToken
972
+ });
973
+ if (byName) {
974
+ if (byName.botToken !== botToken) this.channels.updateDiscordConnector(channelName, spec.name, { botToken });
975
+ return byName.id;
976
+ }
977
+ const byToken = this.findDiscordByToken(channelName, botToken);
978
+ if (byToken) {
979
+ this.channels.renameConnector(channelName, byToken.name, spec.name);
980
+ if (byToken.botToken !== botToken) this.channels.updateDiscordConnector(channelName, spec.name, { botToken });
981
+ return byToken.id;
982
+ }
983
+ return this.channels.addConnector(channelName, {
984
+ type: "discord",
985
+ name: spec.name,
986
+ botToken
987
+ }).id;
988
+ }
989
+ ensureGh(channelName, spec) {
990
+ const existing = this.channels.getConnector(channelName, spec.name);
991
+ if (existing && existing.type !== "gh") throw new Error(`connector "${spec.name}" exists in channel "${channelName}" with type "${existing.type}", funnel.json declares "gh"`);
992
+ if (existing && existing.type === "gh") {
993
+ if (spec.pollInterval !== void 0 && existing.pollInterval !== spec.pollInterval) this.channels.updateGhConnector(channelName, spec.name, { pollInterval: spec.pollInterval });
994
+ return existing.id;
995
+ }
996
+ return this.channels.addConnector(channelName, {
997
+ type: "gh",
998
+ name: spec.name,
999
+ ...spec.pollInterval !== void 0 ? { pollInterval: spec.pollInterval } : {}
1000
+ }).id;
1001
+ }
1002
+ ensureSchedule(channelName, spec) {
1003
+ const existing = this.channels.getConnector(channelName, spec.name);
1004
+ if (existing && existing.type !== "schedule") throw new Error(`connector "${spec.name}" exists in channel "${channelName}" with type "${existing.type}", funnel.json declares "schedule"`);
1005
+ if (existing && existing.type === "schedule") return existing.id;
1006
+ return this.channels.addConnector(channelName, {
1007
+ type: "schedule",
1008
+ name: spec.name
1009
+ }).id;
1010
+ }
1011
+ findExistingSlack(channelName, connectorName) {
1012
+ const existing = this.channels.getConnector(channelName, connectorName);
1013
+ if (!existing) return null;
1014
+ if (existing.type !== "slack") throw new Error(`connector "${connectorName}" exists in channel "${channelName}" with type "${existing.type}", funnel.json declares "slack"`);
1015
+ return existing;
1016
+ }
1017
+ findExistingDiscord(channelName, connectorName) {
1018
+ const existing = this.channels.getConnector(channelName, connectorName);
1019
+ if (!existing) return null;
1020
+ if (existing.type !== "discord") throw new Error(`connector "${connectorName}" exists in channel "${channelName}" with type "${existing.type}", funnel.json declares "discord"`);
1021
+ return existing;
1022
+ }
1023
+ findSlackByToken(channelName, tokens) {
1024
+ const channel = this.channels.get(channelName);
1025
+ if (!channel) return null;
1026
+ for (const connector of channel.connectors) {
1027
+ if (connector.type !== "slack") continue;
1028
+ if (tokens.includes(connector.botToken) || tokens.includes(connector.appToken)) return connector;
1029
+ }
1030
+ return null;
1031
+ }
1032
+ findDiscordByToken(channelName, token) {
1033
+ const channel = this.channels.get(channelName);
1034
+ if (!channel) return null;
1035
+ for (const connector of channel.connectors) {
1036
+ if (connector.type !== "discord") continue;
1037
+ if (connector.botToken === token) return connector;
1038
+ }
1039
+ return null;
1040
+ }
1041
+ removeExtras(channelName, touched) {
1042
+ const channel = this.channels.get(channelName);
1043
+ if (!channel) return;
1044
+ const stale = channel.connectors.filter((c) => !touched.has(c.id));
1045
+ for (const connector of stale) this.channels.removeConnector(channelName, connector.name);
1046
+ }
1047
+ async resolveField(input) {
1048
+ if (input.literal !== void 0 && input.envVar !== void 0) throw new Error(`${input.label} is set both as a literal and as env.${input.label.split(".").pop()}; pick one`);
1049
+ if (input.literal !== void 0 && input.literal !== "") return input.literal;
1050
+ if (input.envVar !== void 0 && input.envVar !== "") {
1051
+ const fromProcessEnv = this.env[input.envVar];
1052
+ if (fromProcessEnv) return fromProcessEnv;
1053
+ const fromDotenv = input.dotenv[input.envVar];
1054
+ if (fromDotenv) return fromDotenv;
1055
+ throw new Error(`${input.label} references env var "${input.envVar}" but it is not set in process env or .env.local`);
1056
+ }
1057
+ if (input.existing) return input.existing;
1058
+ return await this.prompter.promptSecret(input.label);
1059
+ }
1060
+ };
1061
+ //#endregion
723
1062
  //#region lib/engine/logger/memory-logger.ts
724
1063
  var MemoryFunnelLogger = class extends FunnelLogger {
725
1064
  file = null;
@@ -977,6 +1316,73 @@ var FunnelProfiles = class {
977
1316
  }
978
1317
  };
979
1318
  //#endregion
1319
+ //#region lib/engine/token-prompter/node-token-prompter.ts
1320
+ const STAR = "*";
1321
+ const CR = "\r";
1322
+ const LF = "\n";
1323
+ const BACKSPACE = String.fromCharCode(8);
1324
+ const DEL = String.fromCharCode(127);
1325
+ const CTRL_C = String.fromCharCode(3);
1326
+ const CTRL_D = String.fromCharCode(4);
1327
+ /**
1328
+ * Reads a secret from stdin in raw mode. Echoes a `*` per byte so the user
1329
+ * can see progress without exposing the token. Refuses to prompt when stdin
1330
+ * is not a TTY — callers should surface the resulting error with a hint
1331
+ * pointing at the corresponding env var or CLI command.
1332
+ */
1333
+ var NodeFunnelTokenPrompter = class extends FunnelTokenPrompter {
1334
+ async promptSecret(label) {
1335
+ 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.`);
1336
+ stderr.write(`${label}: `);
1337
+ const wasRaw = stdin.isRaw;
1338
+ stdin.setRawMode(true);
1339
+ stdin.resume();
1340
+ try {
1341
+ return await this.readSecret();
1342
+ } finally {
1343
+ stdin.setRawMode(wasRaw);
1344
+ stdin.pause();
1345
+ stderr.write(LF);
1346
+ }
1347
+ }
1348
+ readSecret() {
1349
+ return new Promise((resolve, reject) => {
1350
+ let buffer = "";
1351
+ const onData = (chunk) => {
1352
+ for (const byte of chunk) {
1353
+ const char = String.fromCharCode(byte);
1354
+ if (char === LF || char === CR) {
1355
+ stdin.off("data", onData);
1356
+ resolve(buffer);
1357
+ return;
1358
+ }
1359
+ if (char === CTRL_C) {
1360
+ stdin.off("data", onData);
1361
+ reject(/* @__PURE__ */ new Error("prompt cancelled"));
1362
+ return;
1363
+ }
1364
+ if (char === CTRL_D) {
1365
+ stdin.off("data", onData);
1366
+ if (buffer.length === 0) reject(/* @__PURE__ */ new Error("prompt cancelled"));
1367
+ else resolve(buffer);
1368
+ return;
1369
+ }
1370
+ if (char === BACKSPACE || char === DEL) {
1371
+ if (buffer.length > 0) {
1372
+ buffer = buffer.slice(0, -1);
1373
+ stderr.write("\b \b");
1374
+ }
1375
+ continue;
1376
+ }
1377
+ buffer += char;
1378
+ stderr.write(STAR);
1379
+ }
1380
+ };
1381
+ stdin.on("data", onData);
1382
+ });
1383
+ }
1384
+ };
1385
+ //#endregion
980
1386
  //#region lib/engine/settings/mock-settings-reader.ts
981
1387
  const createSettings = (partial = {}) => ({
982
1388
  version: 1,
@@ -1135,6 +1541,7 @@ var FunnelGateway = class {
1135
1541
  process;
1136
1542
  fs;
1137
1543
  clock;
1544
+ dir;
1138
1545
  pidFile;
1139
1546
  logDir;
1140
1547
  gatewayLog;
@@ -1145,9 +1552,9 @@ var FunnelGateway = class {
1145
1552
  this.process = deps.process ?? defaultProcess$1;
1146
1553
  this.fs = deps.fs ?? defaultFs$1;
1147
1554
  this.clock = deps.clock ?? defaultClock;
1148
- const baseDir = deps.dir ?? FUNNEL_DIR;
1555
+ this.dir = deps.dir ?? FUNNEL_DIR;
1149
1556
  this.tmpDir = deps.tmpDir ?? DEFAULT_TMP_DIR$1;
1150
- this.pidFile = join(baseDir, "gateway.pid");
1557
+ this.pidFile = join(this.dir, "gateway.pid");
1151
1558
  this.logDir = join(this.tmpDir, "events");
1152
1559
  this.gatewayLog = join(this.tmpDir, "gateway.log");
1153
1560
  this.port = deps.port ?? DEFAULT_PORT$1;
@@ -1186,7 +1593,7 @@ var FunnelGateway = class {
1186
1593
  return this.isRunning();
1187
1594
  }
1188
1595
  buildStartCommand(gatewayScript, options = {}) {
1189
- return `nohup ${options.caffeinate !== false && globalThis.process.platform === "darwin" ? "caffeinate -i " : ""}bun ${gatewayScript} >> ${this.gatewayLog} 2>&1 &`;
1596
+ return `nohup ${options.caffeinate !== false && globalThis.process.platform === "darwin" ? "caffeinate -i " : ""}bun ${gatewayScript} ${`funnel-gateway[${this.dir}]`} >> ${this.gatewayLog} 2>&1 &`;
1190
1597
  }
1191
1598
  async stop() {
1192
1599
  const pid = this.readPid();
@@ -2087,12 +2494,15 @@ var FunnelListenerSupervisor = class FunnelListenerSupervisor {
2087
2494
  //#region lib/gateway/kill-competing-slack-gateways.ts
2088
2495
  const defaultProcess = new NodeFunnelProcessRunner();
2089
2496
  const defaultLogger$1 = new NodeFunnelLogger();
2090
- const isBun = (args) => {
2091
- return args.includes("bun ") || /\/bun(\s|$)/.test(args);
2092
- };
2093
- const looksLikeSlackGateway = (args) => {
2094
- return /(gateway|bolt|slack)/i.test(args);
2095
- };
2497
+ const titleFor = (dir) => `funnel-gateway[${dir}]`;
2498
+ /**
2499
+ * Kills other funnel daemon processes that share the SAME funnel home dir,
2500
+ * which is the only situation that causes a real conflict (duplicate Slack
2501
+ * Socket Mode connections with the same tokens). Daemons rooted at a
2502
+ * different `~/.funnel/` are left alone — they hold different tokens and
2503
+ * speak to different Slack apps. The daemon advertises its dir via
2504
+ * `process.title = "funnel-gateway[<dir>]"`, which this routine matches.
2505
+ */
2096
2506
  const killCompetingSlackGateways = async (props) => {
2097
2507
  const runner = props.process ?? defaultProcess;
2098
2508
  const logger = props.logger ?? defaultLogger$1;
@@ -2103,6 +2513,7 @@ const killCompetingSlackGateways = async (props) => {
2103
2513
  "pid=,args="
2104
2514
  ]);
2105
2515
  if (result.exitCode !== 0) return [];
2516
+ const expectedTitle = titleFor(props.dir);
2106
2517
  const killed = [];
2107
2518
  for (const raw of result.stdout.split("\n")) {
2108
2519
  const line = raw.trim();
@@ -2113,8 +2524,7 @@ const killCompetingSlackGateways = async (props) => {
2113
2524
  const args = match[2];
2114
2525
  if (!Number.isInteger(pid) || pid <= 0) continue;
2115
2526
  if (pid === props.selfPid) continue;
2116
- if (!isBun(args)) continue;
2117
- if (!looksLikeSlackGateway(args)) continue;
2527
+ if (!args.includes(expectedTitle)) continue;
2118
2528
  runner.kill(pid, "SIGTERM");
2119
2529
  killed.push(pid);
2120
2530
  logger.info("killed competing Slack gateway process", {
@@ -2306,12 +2716,14 @@ var FunnelGatewayServer = class {
2306
2716
  process;
2307
2717
  logger;
2308
2718
  selfPid;
2719
+ dir;
2309
2720
  killCompetingSlack;
2310
2721
  token;
2311
2722
  broadcaster;
2312
2723
  eventStore;
2313
2724
  supervisor;
2314
2725
  nowMs;
2726
+ extraRoutes;
2315
2727
  startedAt = null;
2316
2728
  server = null;
2317
2729
  constructor(deps) {
@@ -2322,8 +2734,10 @@ var FunnelGatewayServer = class {
2322
2734
  this.process = deps.process;
2323
2735
  this.logger = deps.logger ?? defaultLogger;
2324
2736
  this.selfPid = deps.selfPid ?? globalThis.process.pid;
2737
+ this.dir = deps.dir ?? FUNNEL_DIR;
2325
2738
  this.killCompetingSlack = deps.killCompetingSlack ?? true;
2326
2739
  this.token = deps.token ?? "";
2740
+ this.extraRoutes = deps.extraRoutes ?? null;
2327
2741
  const clock = deps.clock;
2328
2742
  this.nowMs = clock ? () => clock.millis() : () => Date.now();
2329
2743
  if (!existsSync(this.logDir)) mkdirSync(this.logDir, { recursive: true });
@@ -2485,7 +2899,7 @@ var FunnelGatewayServer = class {
2485
2899
  base.use("/status", requireBearerToken({ expected: this.token }));
2486
2900
  base.use("/channels/*", requireBearerToken({ expected: this.token }));
2487
2901
  }
2488
- return base.route("/", gatewayRoutes);
2902
+ return (this.extraRoutes ? base.route("/", this.extraRoutes) : base).route("/", gatewayRoutes);
2489
2903
  }
2490
2904
  /**
2491
2905
  * Reads the bearer token from the WebSocket upgrade request. Accepts:
@@ -2515,6 +2929,7 @@ var FunnelGatewayServer = class {
2515
2929
  if (this.killCompetingSlack && allConnectors.some((c) => c.type === "slack")) {
2516
2930
  const killed = await killCompetingSlackGateways({
2517
2931
  selfPid: this.selfPid,
2932
+ dir: this.dir,
2518
2933
  process: this.process,
2519
2934
  logger: this.logger
2520
2935
  });
@@ -2811,7 +3226,9 @@ var Funnel = class Funnel {
2811
3226
  fs: this.fs,
2812
3227
  process: this.process,
2813
3228
  logger: this.logger,
2814
- dir: this.paths.dir
3229
+ dir: this.paths.dir,
3230
+ slackListenerOptions: this.props.slackListenerOptions,
3231
+ scheduleListenerOptions: this.props.scheduleListenerOptions
2815
3232
  });
2816
3233
  return this.memos.factory;
2817
3234
  }
@@ -2831,6 +3248,30 @@ var Funnel = class Funnel {
2831
3248
  if (!this.memos.profiles) this.memos.profiles = new FunnelProfiles({ store: this.store });
2832
3249
  return this.memos.profiles;
2833
3250
  }
3251
+ /** Reads `funnel.json` from a cwd. `fnl claude` consults it before falling back to the default profile. */
3252
+ get localConfig() {
3253
+ if (!this.memos.localConfig) this.memos.localConfig = new FunnelLocalConfig({ fs: this.fs });
3254
+ return this.memos.localConfig;
3255
+ }
3256
+ /** Parses `.env.local` from a cwd (used by sync to back $VAR references). */
3257
+ get dotenv() {
3258
+ if (!this.memos.dotenv) this.memos.dotenv = new FunnelDotenvReader({ fs: this.fs });
3259
+ return this.memos.dotenv;
3260
+ }
3261
+ /** Secret prompter. Defaults to a TTY-only stdin reader; tests inject MemoryFunnelTokenPrompter. */
3262
+ get tokenPrompter() {
3263
+ if (!this.memos.tokenPrompter) this.memos.tokenPrompter = this.props.tokenPrompter ?? new NodeFunnelTokenPrompter();
3264
+ return this.memos.tokenPrompter;
3265
+ }
3266
+ /** Reconciles funnel.json's channel + connectors with `~/.funnel/settings.json` on launch. */
3267
+ get localConfigSync() {
3268
+ if (!this.memos.localConfigSync) this.memos.localConfigSync = new FunnelLocalConfigSync({
3269
+ channels: this.channels,
3270
+ dotenv: this.dotenv,
3271
+ prompter: this.tokenPrompter
3272
+ });
3273
+ return this.memos.localConfigSync;
3274
+ }
2834
3275
  /** funnel MCP installer (writes/removes `.mcp.json` entries in target repos). */
2835
3276
  get mcp() {
2836
3277
  if (!this.memos.mcp) this.memos.mcp = new FunnelMcp({ fs: this.fs });
@@ -2917,7 +3358,8 @@ var Funnel = class Funnel {
2917
3358
  clock: this.clock,
2918
3359
  logger: this.logger,
2919
3360
  killCompetingSlack: options.killCompetingSlack,
2920
- token: options.token ?? this.gatewayToken.ensure()
3361
+ token: options.token ?? this.gatewayToken.ensure(),
3362
+ extraRoutes: options.extraRoutes
2921
3363
  });
2922
3364
  }
2923
3365
  };
@@ -3104,6 +3546,41 @@ const startChannelServer = async (options = {}) => {
3104
3546
  }).start();
3105
3547
  };
3106
3548
  //#endregion
3549
+ //#region lib/engine/local-config/local-config-json-schema.ts
3550
+ /**
3551
+ * Generates the JSON Schema (draft 2020-12) for `funnel.json`. Useful for
3552
+ * `$schema` references in committed `funnel.json` files so editors can give
3553
+ * autocomplete and validation for channel / subAgent / env / connectors[]
3554
+ * without anyone hand-maintaining a separate schema.
3555
+ */
3556
+ const funnelJsonSchema = () => {
3557
+ return {
3558
+ ...z.toJSONSchema(localConfigSchema, { target: "draft-2020-12" }),
3559
+ title: "Funnel per-repo launch config",
3560
+ description: "Used by `fnl claude` when no --profile / --channel is given. Declares the channel to subscribe to, optional sub-agent and brief flag, environment variables to layer under process.env, and optional connectors to materialize into ~/.funnel/settings.json on launch."
3561
+ };
3562
+ };
3563
+ //#endregion
3564
+ //#region lib/engine/token-prompter/memory-token-prompter.ts
3565
+ /**
3566
+ * Pre-seeded answers keyed by prompt label. Tests configure the map up front;
3567
+ * unmapped labels throw so the test surfaces unexpected prompts loudly.
3568
+ */
3569
+ var MemoryFunnelTokenPrompter = class extends FunnelTokenPrompter {
3570
+ answers;
3571
+ asked = [];
3572
+ constructor(props = {}) {
3573
+ super();
3574
+ this.answers = new Map(Object.entries(props.answers ?? {}));
3575
+ }
3576
+ async promptSecret(label) {
3577
+ this.asked.push(label);
3578
+ const answer = this.answers.get(label);
3579
+ if (answer === void 0) throw new Error(`no answer seeded for prompt "${label}"`);
3580
+ return answer;
3581
+ }
3582
+ };
3583
+ //#endregion
3107
3584
  //#region lib/cli/factory.ts
3108
3585
  const factory = createFactory();
3109
3586
  //#endregion
@@ -3598,16 +4075,23 @@ examples:
3598
4075
  const claudeHelp = `funnel claude — launch Claude Code
3599
4076
 
3600
4077
  usage:
3601
- funnel claude launch the default profile (first in the list)
4078
+ funnel claude launch using funnel.json in cwd, or the default profile
3602
4079
  funnel claude -p <name> launch a named profile
3603
4080
  funnel claude --profile <name> (long form)
3604
4081
  funnel claude --channel <name> raw launch (no profile, cwd = current dir)
4082
+ funnel claude [...] any other argument is forwarded to the claude CLI
3605
4083
 
3606
- options:
4084
+ resolution order when no --profile / --channel is given:
4085
+ 1. ./funnel.json in the current directory
4086
+ 2. the default profile (first entry in fnl profiles)
4087
+
4088
+ funnel-specific options (everything else passes through to claude verbatim):
3607
4089
  -p, --profile profile name to launch
3608
4090
  --channel channel name (raw launch, ignored when --profile is given)
4091
+ -h, --help show this help
3609
4092
 
3610
- Any other arguments are forwarded to the claude CLI.
4093
+ Positional args, unknown short flags (e.g. -c, -r), and claude's own flags
4094
+ (--agent, --resume, --model, --print, --output-format ...) are all forwarded.
3611
4095
  On launch the FUNNEL_CHANNEL_ID env var is set and MCP connects to the gateway.`;
3612
4096
  const RESERVED_KEYS$1 = ["profile", "channel"];
3613
4097
  const claudeHandler = factory.createHandlers(zValidator$1("query", z.object({
@@ -3616,25 +4100,48 @@ const claudeHandler = factory.createHandlers(zValidator$1("query", z.object({
3616
4100
  }).passthrough(), claudeHelp), async (c) => {
3617
4101
  const query = c.req.valid("query");
3618
4102
  const funnel = c.var.funnel;
4103
+ const userArgs = queryToCliArgs(c.req.url, RESERVED_KEYS$1);
3619
4104
  if (query.channel && !query.profile) {
3620
4105
  const exitCode = await funnel.claude.launch({
3621
4106
  channel: query.channel,
3622
- userArgs: queryToCliArgs(c.req.url, RESERVED_KEYS$1)
4107
+ userArgs
3623
4108
  });
3624
4109
  process.exit(exitCode);
3625
4110
  }
3626
- const profile = query.profile ? funnel.profiles.get(query.profile) : funnel.profiles.getDefault();
3627
- if (!profile) {
3628
- if (query.profile) throw new HTTPException(404, { message: `profile "${query.profile}" not found` });
3629
- return c.text(claudeHelp);
4111
+ if (query.profile) {
4112
+ const profile = funnel.profiles.get(query.profile);
4113
+ if (!profile) throw new HTTPException(404, { message: `profile "${query.profile}" not found` });
4114
+ const exitCode = await funnel.claude.launch({
4115
+ channel: profile.channelId,
4116
+ cwd: profile.path,
4117
+ subAgent: profile.subAgent,
4118
+ userArgs,
4119
+ profileName: profile.name,
4120
+ brief: profile.brief
4121
+ });
4122
+ process.exit(exitCode);
3630
4123
  }
4124
+ const cwd = process.cwd();
4125
+ const local = funnel.localConfig.read(cwd);
4126
+ if (local) {
4127
+ await funnel.localConfigSync.ensure(local, cwd);
4128
+ const exitCode = await funnel.claude.launch({
4129
+ channel: local.channel,
4130
+ cwd,
4131
+ userArgs: [...local.options ?? [], ...userArgs],
4132
+ extraEnv: local.env
4133
+ });
4134
+ process.exit(exitCode);
4135
+ }
4136
+ const defaultProfile = funnel.profiles.getDefault();
4137
+ if (!defaultProfile) return c.text(claudeHelp);
3631
4138
  const exitCode = await funnel.claude.launch({
3632
- channel: profile.channelId,
3633
- cwd: profile.path,
3634
- subAgent: profile.subAgent,
3635
- userArgs: queryToCliArgs(c.req.url, RESERVED_KEYS$1),
3636
- profileName: profile.name,
3637
- brief: profile.brief
4139
+ channel: defaultProfile.channelId,
4140
+ cwd: defaultProfile.path,
4141
+ subAgent: defaultProfile.subAgent,
4142
+ userArgs,
4143
+ profileName: defaultProfile.name,
4144
+ brief: defaultProfile.brief
3638
4145
  });
3639
4146
  process.exit(exitCode);
3640
4147
  });
@@ -3998,6 +4505,24 @@ examples:
3998
4505
  });
3999
4506
  return c.text(lines.join("\n"));
4000
4507
  });
4508
+ const schemaHandler = factory.createHandlers(zValidator$1("query", z.object({}), `funnel schema — print the JSON Schema for funnel.json
4509
+
4510
+ usage: funnel schema
4511
+
4512
+ Outputs the draft 2020-12 JSON Schema describing the per-repo funnel.json
4513
+ file. Pipe it into a local file and reference it from funnel.json so editors
4514
+ can validate and autocomplete the config:
4515
+
4516
+ fnl schema > funnel.schema.json
4517
+
4518
+ # funnel.json
4519
+ {
4520
+ "$schema": "./funnel.schema.json",
4521
+ "channel": "ops"
4522
+ }`), async (c) => {
4523
+ const schema = funnelJsonSchema();
4524
+ return c.text(`${JSON.stringify(schema, null, 2)}\n`);
4525
+ });
4001
4526
  //#endregion
4002
4527
  //#region lib/cli/routes/status.ts
4003
4528
  const statusHelp = `funnel status — show overall connection status
@@ -4091,7 +4616,7 @@ const createCliApp = (funnel) => {
4091
4616
  if (error instanceof HTTPException) return c.text(`error: ${error.message}`, error.status);
4092
4617
  return c.text(`error: ${error instanceof Error ? error.message : String(error)}`, 400);
4093
4618
  });
4094
- return base.get("/claude", ...claudeHandler).get("/channels", ...channelsGroupHandler).post("/channels/add", ...helpRoute(addHelp$3)).post("/channels/add/:channel", ...channelsAddHandler).post("/channels/remove", ...helpRoute(removeHelp$1)).post("/channels/remove/:channel", ...channelsRemoveHandler).post("/channels/rename/:channel/:newName", ...channelsRenameHandler).post("/channels/:channel/rename/:newName", ...channelsRenameHandler).post("/channels/rename", ...helpRoute(renameHelp$1)).post("/channels/:channel/rename", ...helpRoute(renameHelp$1)).post("/channels/:channel/set/delivery/:mode", ...channelsSetDeliveryHandler).post("/channels/publish", ...helpRoute(publishHelp)).post("/channels/:channel/publish", ...channelsPublishHandler).get("/channels/:channel", ...channelsShowHandler).get("/channels/:channel/connectors", ...channelsConnectorsGroupHandler).post("/channels/:channel/connectors/add", ...helpRoute(addHelp$2)).post("/channels/:channel/connectors/add/:connector", ...channelsConnectorsAddHandler).post("/channels/:channel/connectors/remove", ...helpRoute(removeHelp$3)).post("/channels/:channel/connectors/remove/:connector", ...channelsConnectorsRemoveHandler).post("/channels/:channel/connectors/set", ...helpRoute(setHelp$1)).post("/channels/:channel/connectors/set/:connector", ...channelsConnectorsSetHandler).post("/channels/:channel/connectors/rename/:connector/:newName", ...channelsConnectorsRenameHandler).post("/channels/:channel/connectors/:connector/rename/:newName", ...channelsConnectorsRenameHandler).post("/channels/:channel/connectors/rename", ...helpRoute(renameHelp$2)).post("/channels/:channel/connectors/:connector/rename", ...helpRoute(renameHelp$2)).post("/channels/:channel/connectors/:connector/request", ...channelsConnectorsRequestHandler).get("/channels/:channel/connectors/:connector", ...channelsConnectorsShowHandler).get("/channels/:channel/connectors/:connector/schedules", ...channelsConnectorsSchedulesGroupHandler).post("/channels/:channel/connectors/:connector/schedules/add", ...helpRoute(addHelp$1)).post("/channels/:channel/connectors/:connector/schedules/add/:id", ...channelsConnectorsSchedulesAddHandler).post("/channels/:channel/connectors/:connector/schedules/remove", ...helpRoute(removeHelp$2)).post("/channels/:channel/connectors/:connector/schedules/remove/:id", ...channelsConnectorsSchedulesRemoveHandler).get("/profiles", ...profilesGroupHandler).post("/profiles/add", ...helpRoute(addHelp)).post("/profiles/add/:profile", ...profilesAddHandler).post("/profiles/set", ...helpRoute(setHelp)).post("/profiles/set/:profile", ...profilesSetHandler).post("/profiles/remove", ...helpRoute(removeHelp)).post("/profiles/remove/:profile", ...profilesRemoveHandler).post("/profiles/rename/:profile/:newName", ...profilesRenameHandler).post("/profiles/:profile/rename/:newName", ...profilesRenameHandler).post("/profiles/rename", ...helpRoute(renameHelp)).post("/profiles/:profile/rename", ...helpRoute(renameHelp)).post("/profiles/:profile/as-default", ...profilesAsDefaultHandler).get("/profiles/:profile/run", ...profilesLaunchHandler).get("/profiles/:profile", ...profilesLaunchHandler).get("/gateway", ...gatewayGroupHandler).get("/gateway/status", ...gatewayStatusHandler).get("/gateway/start", ...gatewayStartHandler).get("/gateway/stop", ...gatewayStopHandler).get("/gateway/restart", ...gatewayRestartHandler).get("/gateway/run", ...gatewayRunHandler).get("/gateway/logs", ...gatewayLogsHandler).get("/gateway/listeners", ...gatewayListenersHandler).get("/status", ...statusHandler).get("/update", ...updateHandler);
4619
+ return base.get("/claude", ...claudeHandler).get("/channels", ...channelsGroupHandler).post("/channels/add", ...helpRoute(addHelp$3)).post("/channels/add/:channel", ...channelsAddHandler).post("/channels/remove", ...helpRoute(removeHelp$1)).post("/channels/remove/:channel", ...channelsRemoveHandler).post("/channels/rename/:channel/:newName", ...channelsRenameHandler).post("/channels/:channel/rename/:newName", ...channelsRenameHandler).post("/channels/rename", ...helpRoute(renameHelp$1)).post("/channels/:channel/rename", ...helpRoute(renameHelp$1)).post("/channels/:channel/set/delivery/:mode", ...channelsSetDeliveryHandler).post("/channels/publish", ...helpRoute(publishHelp)).post("/channels/:channel/publish", ...channelsPublishHandler).get("/channels/:channel", ...channelsShowHandler).get("/channels/:channel/connectors", ...channelsConnectorsGroupHandler).post("/channels/:channel/connectors/add", ...helpRoute(addHelp$2)).post("/channels/:channel/connectors/add/:connector", ...channelsConnectorsAddHandler).post("/channels/:channel/connectors/remove", ...helpRoute(removeHelp$3)).post("/channels/:channel/connectors/remove/:connector", ...channelsConnectorsRemoveHandler).post("/channels/:channel/connectors/set", ...helpRoute(setHelp$1)).post("/channels/:channel/connectors/set/:connector", ...channelsConnectorsSetHandler).post("/channels/:channel/connectors/rename/:connector/:newName", ...channelsConnectorsRenameHandler).post("/channels/:channel/connectors/:connector/rename/:newName", ...channelsConnectorsRenameHandler).post("/channels/:channel/connectors/rename", ...helpRoute(renameHelp$2)).post("/channels/:channel/connectors/:connector/rename", ...helpRoute(renameHelp$2)).post("/channels/:channel/connectors/:connector/request", ...channelsConnectorsRequestHandler).get("/channels/:channel/connectors/:connector", ...channelsConnectorsShowHandler).get("/channels/:channel/connectors/:connector/schedules", ...channelsConnectorsSchedulesGroupHandler).post("/channels/:channel/connectors/:connector/schedules/add", ...helpRoute(addHelp$1)).post("/channels/:channel/connectors/:connector/schedules/add/:id", ...channelsConnectorsSchedulesAddHandler).post("/channels/:channel/connectors/:connector/schedules/remove", ...helpRoute(removeHelp$2)).post("/channels/:channel/connectors/:connector/schedules/remove/:id", ...channelsConnectorsSchedulesRemoveHandler).get("/profiles", ...profilesGroupHandler).post("/profiles/add", ...helpRoute(addHelp)).post("/profiles/add/:profile", ...profilesAddHandler).post("/profiles/set", ...helpRoute(setHelp)).post("/profiles/set/:profile", ...profilesSetHandler).post("/profiles/remove", ...helpRoute(removeHelp)).post("/profiles/remove/:profile", ...profilesRemoveHandler).post("/profiles/rename/:profile/:newName", ...profilesRenameHandler).post("/profiles/:profile/rename/:newName", ...profilesRenameHandler).post("/profiles/rename", ...helpRoute(renameHelp)).post("/profiles/:profile/rename", ...helpRoute(renameHelp)).post("/profiles/:profile/as-default", ...profilesAsDefaultHandler).get("/profiles/:profile/run", ...profilesLaunchHandler).get("/profiles/:profile", ...profilesLaunchHandler).get("/gateway", ...gatewayGroupHandler).get("/gateway/status", ...gatewayStatusHandler).get("/gateway/start", ...gatewayStartHandler).get("/gateway/stop", ...gatewayStopHandler).get("/gateway/restart", ...gatewayRestartHandler).get("/gateway/run", ...gatewayRunHandler).get("/gateway/logs", ...gatewayLogsHandler).get("/gateway/listeners", ...gatewayListenersHandler).get("/schema", ...schemaHandler).get("/status", ...statusHandler).get("/update", ...updateHandler);
4095
4620
  };
4096
4621
  /** CLI Hono app wired to a default `new Funnel()`. For embedding with a custom Funnel use `createCliApp`. */
4097
4622
  const app = createCliApp(new Funnel());
@@ -6306,4 +6831,4 @@ async function launchTui(funnel) {
6306
6831
  });
6307
6832
  }
6308
6833
  //#endregion
6309
- export { DEFAULT_GATEWAY_TOKEN_PATH, FUNNEL_DIR, FUNNEL_MCP_COMMAND, FUNNEL_MCP_NAME, Funnel, FunnelBroadcaster, FunnelChannelPublisher, FunnelChannels, FunnelClaude, FunnelClock, FunnelConnectorFactory, FunnelConnectorListener, FunnelEventStore, FunnelFileSystem, FunnelGateway, FunnelGatewayServer, FunnelGatewayToken, FunnelIdGenerator, FunnelListenerSupervisor, FunnelListenersClient, FunnelLogger, FunnelMcp, FunnelProcessRunner, FunnelProfiles, FunnelSettingsReader, FunnelSettingsStore, FunnelSlackEventProcessor, MemoryFunnelClock, MemoryFunnelFileSystem, MemoryFunnelIdGenerator, MemoryFunnelLogger, MemoryFunnelProcessRunner, MockFunnelSettingsReader, NodeFunnelClock, NodeFunnelFileSystem, NodeFunnelIdGenerator, NodeFunnelLogger, NodeFunnelProcessRunner, NoopFunnelLogger, SETTINGS_PATH, SETTINGS_VERSION, channelConfigSchema, channelDeliveryModeSchema, app as cliApp, connectorConfigSchema, createCliApp, createSettings, discordConnectorSchema, factory, funnelEventSchema, ghConnectorSchema, launchTui, profileConfigSchema, publishRequestSchema, publishResponseSchema, queryToCliArgs, scheduleCatchupPolicySchema, scheduleConnectorSchema, scheduleEntrySchema, settingsSchema, slackConnectorSchema, startChannelServer, toRequest };
6834
+ export { DEFAULT_GATEWAY_TOKEN_PATH, FUNNEL_DIR, FUNNEL_MCP_COMMAND, FUNNEL_MCP_NAME, Funnel, FunnelBroadcaster, FunnelChannelPublisher, FunnelChannels, FunnelClaude, FunnelClock, FunnelConnectorFactory, FunnelConnectorListener, FunnelDotenvReader, FunnelEventStore, FunnelFileSystem, FunnelGateway, FunnelGatewayServer, FunnelGatewayToken, FunnelIdGenerator, FunnelListenerSupervisor, FunnelListenersClient, FunnelLocalConfig, FunnelLocalConfigSync, FunnelLogger, FunnelMcp, FunnelProcessRunner, FunnelProfiles, FunnelSettingsReader, FunnelSettingsStore, FunnelSlackEventProcessor, FunnelTokenPrompter, LOCAL_CONFIG_FILENAME, LOCAL_ENV_FILENAME, MemoryFunnelClock, MemoryFunnelFileSystem, MemoryFunnelIdGenerator, MemoryFunnelLogger, MemoryFunnelProcessRunner, MemoryFunnelTokenPrompter, MockFunnelSettingsReader, NodeFunnelClock, NodeFunnelFileSystem, NodeFunnelIdGenerator, NodeFunnelLogger, NodeFunnelProcessRunner, NodeFunnelTokenPrompter, NoopFunnelLogger, SETTINGS_PATH, SETTINGS_VERSION, channelConfigSchema, channelDeliveryModeSchema, app as cliApp, connectorConfigSchema, connectorSpecSchema, createCliApp, createSettings, discordConnectorSchema, factory, funnelEventSchema, funnelJsonSchema, ghConnectorSchema, launchTui, localConfigSchema, profileConfigSchema, publishRequestSchema, publishResponseSchema, queryToCliArgs, scheduleCatchupPolicySchema, scheduleConnectorSchema, scheduleEntrySchema, settingsSchema, slackConnectorSchema, startChannelServer, toRequest };