@interactive-inc/claude-funnel 0.25.1 → 0.26.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
@@ -1,12 +1,12 @@
1
- import { i as FunnelDiscordAdapter, n as FunnelDiscordListener, t as discordConnectorSchema } from "./discord-connector-schema-CR8RJ08_.js";
2
- import { i as FunnelConnectorListener, n as funnelTmpDir, r as FunnelLogger, t as NodeFunnelLogger } from "./node-logger-B97ZiGwj.js";
3
- import { a as FunnelProcessRunner, i as NodeFunnelProcessRunner, n as FunnelGhListener, r as FunnelGhAdapter, t as ghConnectorSchema } from "./gh-connector-schema-CAC24s0r.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-BZpH6ZmR.js";
5
- import { i as FunnelSlackAdapter, n as FunnelSlackListener, r as FunnelSlackEventProcessor, t as slackConnectorSchema } from "./slack-connector-schema-B0NyhxqQ.js";
1
+ import { i as FunnelDiscordAdapter, n as FunnelDiscordListener, t as discordConnectorSchema } from "./discord-connector-schema-CpuI6rmE.js";
2
+ import { n as FunnelConnectorListener, t as FunnelLogger } from "./logger-D1A3_JXV.js";
3
+ import { a as FunnelProcessRunner, i as NodeFunnelProcessRunner, n as FunnelGhListener, r as FunnelGhAdapter, t as ghConnectorSchema } from "./gh-connector-schema-CQRIvPpz.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-CuCjP7z4.js";
5
+ import { i as FunnelSlackAdapter, n as FunnelSlackListener, r as FunnelSlackEventProcessor, t as slackConnectorSchema } from "./slack-connector-schema-BWL7dWlY.js";
6
6
  import { dirname, join, resolve } from "node:path";
7
- import { existsSync, mkdirSync, readFileSync } from "node:fs";
8
- import { homedir } from "node:os";
7
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
9
8
  import { z } from "zod";
9
+ import { homedir, tmpdir } from "node:os";
10
10
  import { stderr, stdin } from "node:process";
11
11
  import { fileURLToPath } from "node:url";
12
12
  import { timingSafeEqual } from "node:crypto";
@@ -22,6 +22,20 @@ import { TextAttributes, createCliRenderer } from "@opentui/core";
22
22
  import { createRoot, useKeyboard, useRenderer } from "@opentui/react";
23
23
  import { createContext, useContext, useEffect, useId, useState } from "react";
24
24
  import { Fragment, jsx, jsxs } from "@opentui/react/jsx-runtime";
25
+ //#region lib/engine/id/id-generator.ts
26
+ /**
27
+ * ID generator boundary. Default NodeFunnelIdGenerator wraps `crypto.randomUUID()`;
28
+ * MemoryFunnelIdGenerator emits `<prefix>-1, <prefix>-2, ...` for deterministic tests.
29
+ */
30
+ var FunnelIdGenerator = class {};
31
+ //#endregion
32
+ //#region lib/engine/id/node-id-generator.ts
33
+ var NodeFunnelIdGenerator = class extends FunnelIdGenerator {
34
+ generate() {
35
+ return crypto.randomUUID();
36
+ }
37
+ };
38
+ //#endregion
25
39
  //#region lib/engine/settings/settings-reader.ts
26
40
  var FunnelSettingsReader = class {};
27
41
  //#endregion
@@ -53,6 +67,11 @@ const channelConfigSchema = z.object({
53
67
  connectors: z.array(connectorConfigSchema).default([])
54
68
  });
55
69
  const profileConfigSchema = z.object({
70
+ /** Stable identity (uuid). The primary key everything internal resolves to;
71
+ * survives renames. CLI surfaces still address profiles by `name`. */
72
+ id: z.string(),
73
+ /** Human-facing label used only at the CLI/TUI surface (`--profile <name>`).
74
+ * Renameable; never used as a storage key. */
56
75
  name: z.string(),
57
76
  path: z.string(),
58
77
  channelId: z.string(),
@@ -61,11 +80,20 @@ const profileConfigSchema = z.object({
61
80
  /** Env vars layered under the launched claude process. process.env wins on collision. */
62
81
  env: z.record(z.string(), z.string()).default({}),
63
82
  /**
64
- * When true (the default), funnel injects `--session-id <uuid>` so that
65
- * relaunching from the same cwd resumes the previous claude session.
66
- * Set to false for profiles that should always start a fresh session.
83
+ * When true (the default), funnel resumes this profile's previous claude
84
+ * session via `--session-id`/`--resume`. The id lives in `sessionId` below,
85
+ * scoped to this profile so an unrelated session in the same repo can't bleed
86
+ * in. Set to false for profiles that should always start fresh.
87
+ */
88
+ resume: z.boolean().default(true),
89
+ /**
90
+ * Execution state, not config: the claude session id this profile last
91
+ * launched. Written by the launcher, read on the next resume. Absent until
92
+ * the first launch; kept inside the profile (rather than a separate file) so
93
+ * the session belongs to the profile by identity and the transport layer
94
+ * (channels) never has to know profiles exist.
67
95
  */
68
- resume: z.boolean().default(true)
96
+ sessionId: z.string().optional()
69
97
  });
70
98
  const SETTINGS_VERSION = 1;
71
99
  const settingsSchema = z.object({
@@ -79,13 +107,16 @@ const settingsSchema = z.object({
79
107
  const FUNNEL_DIR = join(homedir(), ".funnel");
80
108
  const SETTINGS_PATH = join(FUNNEL_DIR, "settings.json");
81
109
  const defaultFs$5 = new NodeFunnelFileSystem();
110
+ const defaultIdGenerator$2 = new NodeFunnelIdGenerator();
82
111
  var FunnelSettingsStore = class extends FunnelSettingsReader {
83
112
  path;
84
113
  fs;
114
+ idGenerator;
85
115
  constructor(deps = {}) {
86
116
  super();
87
117
  this.path = deps.path ?? SETTINGS_PATH;
88
118
  this.fs = deps.fs ?? defaultFs$5;
119
+ this.idGenerator = deps.idGenerator ?? defaultIdGenerator$2;
89
120
  Object.freeze(this);
90
121
  }
91
122
  read() {
@@ -98,6 +129,7 @@ var FunnelSettingsStore = class extends FunnelSettingsReader {
98
129
  const parsed = JSON.parse(content);
99
130
  if (this.looksLikeLegacy(parsed)) throw new Error(`legacy settings.json detected at ${this.path}. The schema changed (channel.connectors are now nested objects with ids; profile fields renamed). Migration is intentionally not provided. Back up and remove the old file:\n mv ${this.path} ${this.path}.bak`);
100
131
  if (parsed && typeof parsed === "object" && "version" in parsed && parsed.version !== 1) throw new Error(`unsupported settings.json version (${this.path}): expected 1, got ${String(parsed.version)}`);
132
+ this.backfillProfileIds(parsed);
101
133
  const result = settingsSchema.safeParse(parsed);
102
134
  if (!result.success) throw new Error(`invalid settings.json (${this.path}): ${result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(", ")}`);
103
135
  return result.data;
@@ -120,6 +152,23 @@ var FunnelSettingsStore = class extends FunnelSettingsReader {
120
152
  }
121
153
  return false;
122
154
  }
155
+ /**
156
+ * Non-destructive migration for profiles written before `id` existed. The id
157
+ * is a later addition to an otherwise-compatible schema, so rather than
158
+ * rejecting the file we mint a uuid for each profile that lacks one; the next
159
+ * `write` persists it. Mutates `parsed` in place (it is freshly JSON-parsed
160
+ * and discarded after the schema parse, so no shared state is touched).
161
+ */
162
+ backfillProfileIds(parsed) {
163
+ if (!parsed || typeof parsed !== "object") return;
164
+ const obj = parsed;
165
+ if (!Array.isArray(obj.profiles)) return;
166
+ for (const profile of obj.profiles) {
167
+ if (!profile || typeof profile !== "object") continue;
168
+ const p = profile;
169
+ if (typeof p.id !== "string") p.id = this.idGenerator.generate();
170
+ }
171
+ }
123
172
  write(settings) {
124
173
  this.fs.mkdirSync(dirname(this.path), { recursive: true });
125
174
  const versioned = {
@@ -133,7 +182,6 @@ var FunnelSettingsStore = class extends FunnelSettingsReader {
133
182
  //#region lib/connectors/connector-factory.ts
134
183
  const defaultFs$4 = new NodeFunnelFileSystem();
135
184
  const defaultProcess$3 = new NodeFunnelProcessRunner();
136
- const defaultLogger$5 = new NodeFunnelLogger();
137
185
  /**
138
186
  * Pure factory for per-type listeners and adapters. The factory has no CRUD
139
187
  * responsibility — connector configs live inside settings.json under their
@@ -156,7 +204,7 @@ var FunnelConnectorFactory = class {
156
204
  constructor(deps = {}) {
157
205
  this.fs = deps.fs ?? defaultFs$4;
158
206
  this.process = deps.process ?? defaultProcess$3;
159
- this.logger = deps.logger ?? defaultLogger$5;
207
+ this.logger = deps.logger;
160
208
  this.dir = deps.dir ?? FUNNEL_DIR;
161
209
  this.slackListenerOptions = deps.slackListenerOptions ?? {};
162
210
  this.scheduleListenerOptions = deps.scheduleListenerOptions ?? {};
@@ -257,23 +305,9 @@ var NodeFunnelClock = class extends FunnelClock {
257
305
  }
258
306
  };
259
307
  //#endregion
260
- //#region lib/engine/id/id-generator.ts
261
- /**
262
- * ID generator boundary. Default NodeFunnelIdGenerator wraps `crypto.randomUUID()`;
263
- * MemoryFunnelIdGenerator emits `<prefix>-1, <prefix>-2, ...` for deterministic tests.
264
- */
265
- var FunnelIdGenerator = class {};
266
- //#endregion
267
- //#region lib/engine/id/node-id-generator.ts
268
- var NodeFunnelIdGenerator = class extends FunnelIdGenerator {
269
- generate() {
270
- return crypto.randomUUID();
271
- }
272
- };
273
- //#endregion
274
308
  //#region lib/engine/channels/channels.ts
275
309
  const defaultClock$1 = new NodeFunnelClock();
276
- const defaultIdGenerator = new NodeFunnelIdGenerator();
310
+ const defaultIdGenerator$1 = new NodeFunnelIdGenerator();
277
311
  /**
278
312
  * Channels own their connectors. Each channel has a stable id (UUID); the
279
313
  * `name` is the human-facing label used by the CLI. Connectors live nested
@@ -293,7 +327,7 @@ var FunnelChannels = class {
293
327
  this.factory = deps.factory;
294
328
  this.profileChecker = deps.profileChecker;
295
329
  this.clock = deps.clock ?? defaultClock$1;
296
- this.idGenerator = deps.idGenerator ?? defaultIdGenerator;
330
+ this.idGenerator = deps.idGenerator ?? defaultIdGenerator$1;
297
331
  Object.freeze(this);
298
332
  }
299
333
  list() {
@@ -533,7 +567,7 @@ var FunnelChannels = class {
533
567
  //#region lib/engine/claude/claude.ts
534
568
  const defaultProcess$2 = new NodeFunnelProcessRunner();
535
569
  const defaultFs$3 = new NodeFunnelFileSystem();
536
- const defaultLogger$4 = new NodeFunnelLogger();
570
+ const defaultIdGenerator = new NodeFunnelIdGenerator();
537
571
  /**
538
572
  * Launches Claude Code with funnel pre-wired: ensures the gateway is running,
539
573
  * installs the funnel MCP into the target repo's `.mcp.json` if missing,
@@ -544,43 +578,45 @@ var FunnelClaude = class {
544
578
  channels;
545
579
  mcp;
546
580
  gateway;
547
- sessions;
581
+ profiles;
548
582
  process;
549
583
  fs;
584
+ idGenerator;
550
585
  logger;
551
586
  pidDir;
552
587
  constructor(deps) {
553
588
  this.channels = deps.channels;
554
589
  this.mcp = deps.mcp;
555
590
  this.gateway = deps.gateway;
556
- this.sessions = deps.sessions;
591
+ this.profiles = deps.profiles;
557
592
  this.process = deps.process ?? defaultProcess$2;
558
593
  this.fs = deps.fs ?? defaultFs$3;
559
- this.logger = deps.logger ?? defaultLogger$4;
594
+ this.idGenerator = deps.idGenerator ?? defaultIdGenerator;
595
+ this.logger = deps.logger;
560
596
  this.pidDir = join(deps.dir ?? FUNNEL_DIR, "claude");
561
597
  Object.freeze(this);
562
598
  }
563
599
  async launch(options) {
564
600
  const channel = this.channels.get(options.channel) ?? this.channels.getById(options.channel);
565
601
  if (!channel) throw new Error(`channel "${options.channel}" not found`);
566
- if (options.profileName && this.isRunning(options.profileName)) throw new Error(`profile "${options.profileName}" is already running`);
602
+ if (options.profileId && this.isRunning(options.profileId)) throw new Error(`profile "${options.profileId}" is already running`);
567
603
  const cwd = options.cwd ?? globalThis.process.cwd();
568
604
  if ((options.installMcp ?? true) && !this.mcp.findInstalledName(cwd)) {
569
605
  this.mcp.install(cwd);
570
- this.logger.info(`added funnel MCP to .mcp.json`, { cwd });
606
+ this.logger?.info(`added funnel MCP to .mcp.json`, { cwd });
571
607
  }
572
608
  if (!this.gateway.isRunning()) {
573
- this.logger.info(`starting gateway automatically`);
609
+ this.logger?.info(`starting gateway automatically`);
574
610
  await this.gateway.start();
575
611
  }
576
- if (options.profileName) {
577
- this.writePidFile(options.profileName);
578
- this.installCleanup(options.profileName);
612
+ if (options.profileId) {
613
+ this.writePidFile(options.profileId);
614
+ this.installCleanup(options.profileId);
579
615
  }
580
- const session = options.resume ?? true ? this.resolveSession(channel.id, cwd, options.userArgs ?? [], options.env ?? {}) : null;
616
+ const session = (options.resume ?? false) && options.profileId ? this.resolveSession(options.profileId, cwd, options.userArgs ?? [], options.env ?? {}) : null;
581
617
  const claudeArgs = this.buildArgs(options.options ?? [], options.userArgs ?? [], cwd, session);
582
618
  const env = this.buildEnv(channel.id, options.env ?? {});
583
- this.logger.info(`claude launch`, {
619
+ this.logger?.info(`claude launch`, {
584
620
  channel: options.channel,
585
621
  channelId: channel.id,
586
622
  cwd
@@ -592,19 +628,19 @@ var FunnelClaude = class {
592
628
  onSpawned: options.onSpawned
593
629
  });
594
630
  } finally {
595
- if (options.profileName) this.removePidFile(options.profileName);
631
+ if (options.profileId) this.removePidFile(options.profileId);
596
632
  }
597
633
  }
598
- isRunning(profileName) {
599
- const pid = this.readPid(profileName);
634
+ isRunning(profileId) {
635
+ const pid = this.readPid(profileId);
600
636
  if (!pid) return false;
601
637
  return this.isProcessAlive(pid);
602
638
  }
603
- pidPath(profileName) {
604
- return join(this.pidDir, `${profileName}.pid`);
639
+ pidPath(profileId) {
640
+ return join(this.pidDir, `${profileId}.pid`);
605
641
  }
606
- readPid(profileName) {
607
- const path = this.pidPath(profileName);
642
+ readPid(profileId) {
643
+ const path = this.pidPath(profileId);
608
644
  if (!this.fs.existsSync(path)) return null;
609
645
  try {
610
646
  const content = this.fs.readFileSync(path).trim();
@@ -615,16 +651,16 @@ var FunnelClaude = class {
615
651
  return null;
616
652
  }
617
653
  }
618
- writePidFile(profileName) {
654
+ writePidFile(profileId) {
619
655
  this.fs.mkdirSync(this.pidDir, { recursive: true });
620
- this.fs.writeFileSync(this.pidPath(profileName), String(globalThis.process.pid));
656
+ this.fs.writeFileSync(this.pidPath(profileId), String(globalThis.process.pid));
621
657
  }
622
- removePidFile(profileName) {
623
- const path = this.pidPath(profileName);
658
+ removePidFile(profileId) {
659
+ const path = this.pidPath(profileId);
624
660
  if (this.fs.existsSync(path)) this.fs.unlink(path);
625
661
  }
626
- installCleanup(profileName) {
627
- globalThis.process.once("exit", () => this.removePidFile(profileName));
662
+ installCleanup(profileId) {
663
+ globalThis.process.once("exit", () => this.removePidFile(profileId));
628
664
  }
629
665
  isProcessAlive(pid) {
630
666
  return this.process.isAlive(pid);
@@ -643,26 +679,34 @@ var FunnelClaude = class {
643
679
  * session-shaping flag, since combining them would either confuse claude
644
680
  * or override the explicit user intent.
645
681
  *
682
+ * The session is owned by the profile (by id), not by cwd: two profiles
683
+ * pointing at the same repo each keep their own conversation, and a launch
684
+ * with no profile never resumes — so an unrelated session in the same repo
685
+ * can't bleed in. The channel never enters into it; sessions belong to the
686
+ * launch layer (profiles), keeping the transport layer ignorant of them.
687
+ *
646
688
  * A persisted id is only resumed when its session jsonl still exists on
647
689
  * disk. claude errors out on `--resume <id>` for a missing conversation, and
648
690
  * a persisted id can outlive its jsonl (claude pruned it, or the very first
649
- * launch was aborted after `create` wrote the id but before the jsonl
691
+ * launch was aborted after the id was written but before the jsonl
650
692
  * appeared). When the file is gone we mint a fresh session instead, which
651
693
  * overwrites the dangling entry — so the store self-heals.
652
694
  */
653
- resolveSession(channelId, cwd, userArgs, recipeEnv) {
695
+ resolveSession(profileId, cwd, userArgs, recipeEnv) {
654
696
  for (const arg of userArgs) {
655
697
  if (arg === "-c" || arg === "--continue") return null;
656
698
  if (arg === "--resume" || arg.startsWith("--resume=")) return null;
657
699
  if (arg === "--session-id" || arg.startsWith("--session-id=")) return null;
658
700
  }
659
- const existing = this.sessions.get(channelId, cwd);
701
+ const existing = this.profiles.getSessionId(profileId);
660
702
  if (existing !== null && this.sessionFileExists(cwd, existing, recipeEnv)) return {
661
703
  id: existing,
662
704
  mode: "resume"
663
705
  };
706
+ const fresh = this.idGenerator.generate();
707
+ this.profiles.setSessionId(profileId, fresh);
664
708
  return {
665
- id: this.sessions.create(channelId, cwd),
709
+ id: fresh,
666
710
  mode: "new"
667
711
  };
668
712
  }
@@ -787,9 +831,9 @@ var MemoryFunnelIdGenerator = class extends FunnelIdGenerator {
787
831
  *
788
832
  * The launch recipe (`options` / `env` / `resume`) lives on `profiles[]`, not on
789
833
  * the channel: a channel only describes where events come from. `fnl claude`
790
- * applies the first profile bound to the chosen channel (or `--profile <name>`
791
- * to pick another); the recipe is passed straight to the launcher and is not
792
- * persisted into the global profile list.
834
+ * applies the first profile bound to the chosen channel; the recipe is passed
835
+ * straight to the launcher and is not persisted into the global profile list.
836
+ * These profiles are selected by their `channel` binding, not by name.
793
837
  */
794
838
  const slackEnvSchema = z.object({
795
839
  botToken: z.string().optional(),
@@ -831,7 +875,6 @@ const channelSpecSchema = z.object({
831
875
  connectors: z.array(connectorSpecSchema).optional()
832
876
  });
833
877
  const profileSpecSchema = z.object({
834
- name: z.string(),
835
878
  /** Name of the channel (declared in `channels[]`) this profile subscribes to. */
836
879
  channel: z.string(),
837
880
  /** Args prepended to the claude argv on every launch through this profile. */
@@ -924,8 +967,20 @@ var FunnelLocalConfig = class {
924
967
  })();
925
968
  const result = localConfigSchema.safeParse(parsed);
926
969
  if (!result.success) throw new Error(`${LOCAL_CONFIG_FILENAME} is invalid: ${result.error.message}`);
970
+ this.assertProfilesValid(result.data);
927
971
  return result.data;
928
972
  }
973
+ assertProfilesValid(config) {
974
+ const profiles = config.profiles ?? [];
975
+ if (profiles.length === 0) return;
976
+ const channelNames = new Set(config.channels.map((channel) => channel.name));
977
+ const boundChannels = /* @__PURE__ */ new Set();
978
+ for (const profile of profiles) {
979
+ if (!channelNames.has(profile.channel)) throw new Error(`${LOCAL_CONFIG_FILENAME} is invalid: a profile binds channel "${profile.channel}", which is not declared in channels[]`);
980
+ if (boundChannels.has(profile.channel)) throw new Error(`${LOCAL_CONFIG_FILENAME} is invalid: channel "${profile.channel}" has more than one profile; only the first is applied — remove the extras`);
981
+ boundChannels.add(profile.channel);
982
+ }
983
+ }
929
984
  };
930
985
  //#endregion
931
986
  //#region lib/engine/token-prompter/token-prompter.ts
@@ -1414,16 +1469,22 @@ var MemoryFunnelProcessRunner = class extends FunnelProcessRunner {
1414
1469
  * process, `resume` toggling session reuse). Implements ProfileChannelChecker
1415
1470
  * so FunnelChannels can refuse to remove a channel that is still referenced.
1416
1471
  *
1417
- * The first entry in the persisted array is treated as the default profile;
1418
- * `asDefault` reorders the array to put a named profile first.
1472
+ * Each profile has a stable `id` (uuid) minted at `add`. That id is the unit
1473
+ * everything internal keys on — the PID file, the resumable session id — so a
1474
+ * rename never strands either. `name` is purely the CLI/TUI handle; the CRUD
1475
+ * methods here take it because that is what the user types, but resolve to the
1476
+ * id before touching id-keyed state. The first array entry is the default
1477
+ * profile; `asDefault` reorders to put one first.
1419
1478
  *
1420
1479
  * `channelId` always stores the channel's stable id (uuid). CLI surfaces
1421
1480
  * resolve channel name → id before calling `add`/`update` here.
1422
1481
  */
1423
1482
  var FunnelProfiles = class {
1424
1483
  store;
1484
+ idGenerator;
1425
1485
  constructor(deps) {
1426
1486
  this.store = deps.store;
1487
+ this.idGenerator = deps.idGenerator;
1427
1488
  Object.freeze(this);
1428
1489
  }
1429
1490
  list() {
@@ -1432,6 +1493,9 @@ var FunnelProfiles = class {
1432
1493
  get(name) {
1433
1494
  return this.list().find((p) => p.name === name) ?? null;
1434
1495
  }
1496
+ getById(id) {
1497
+ return this.list().find((p) => p.id === id) ?? null;
1498
+ }
1435
1499
  getDefault() {
1436
1500
  return this.list()[0] ?? null;
1437
1501
  }
@@ -1440,6 +1504,7 @@ var FunnelProfiles = class {
1440
1504
  if (settings.profiles.some((p) => p.name === input.name)) throw new Error(`profile "${input.name}" already exists`);
1441
1505
  if (!settings.channels.some((c) => c.id === input.channelId)) throw new Error(`channel id "${input.channelId}" not found`);
1442
1506
  settings.profiles.push({
1507
+ id: this.idGenerator.generate(),
1443
1508
  name: input.name,
1444
1509
  path: input.path,
1445
1510
  channelId: input.channelId,
@@ -1477,6 +1542,18 @@ var FunnelProfiles = class {
1477
1542
  hasChannelRef(channelId) {
1478
1543
  return this.store.read().profiles.some((p) => p.channelId === channelId);
1479
1544
  }
1545
+ /** Resumable claude session id last launched by this profile (by id), or null. */
1546
+ getSessionId(id) {
1547
+ return this.getById(id)?.sessionId ?? null;
1548
+ }
1549
+ /** Records the claude session id this profile launched, overwriting any prior one. */
1550
+ setSessionId(id, sessionId) {
1551
+ const settings = this.store.read();
1552
+ const profile = settings.profiles.find((p) => p.id === id);
1553
+ if (!profile) throw new Error(`profile id "${id}" not found`);
1554
+ profile.sessionId = sessionId;
1555
+ this.store.write(settings);
1556
+ }
1480
1557
  update(name, fields) {
1481
1558
  const settings = this.store.read();
1482
1559
  const profile = settings.profiles.find((p) => p.name === name);
@@ -1493,84 +1570,6 @@ var FunnelProfiles = class {
1493
1570
  }
1494
1571
  };
1495
1572
  //#endregion
1496
- //#region lib/engine/sessions/sessions.ts
1497
- const sessionsMapSchema = z.record(z.string(), z.string());
1498
- /**
1499
- * Per-channel persistent Claude Code session IDs, keyed by the cwd the
1500
- * channel was launched from. The whole point is to give each (channel, cwd)
1501
- * its own stable conversation: relaunching from the same path resumes the
1502
- * previous claude session via `--resume <uuid>`, while a different cwd (or
1503
- * a different channel) gets an independent one — so sessions never silently
1504
- * bleed across workspaces the way claude's `-c` does.
1505
- *
1506
- * `get` and `create` are intentionally separate: claude's `--session-id`
1507
- * only accepts a fresh UUID (it errors if the session jsonl already
1508
- * exists), so callers must check `get` first and fall back to `create`
1509
- * only when there is nothing to resume.
1510
- *
1511
- * Storage lives under `<dir>/channels/<channel-id>/sessions.json` (channel
1512
- * id, not name, so renames don't lose history). The file is a flat
1513
- * `{ cwd: uuid }` map; the channel directory itself is created lazily.
1514
- */
1515
- var FunnelSessions = class {
1516
- fs;
1517
- idGenerator;
1518
- dir;
1519
- constructor(deps) {
1520
- this.fs = deps.fs;
1521
- this.idGenerator = deps.idGenerator;
1522
- this.dir = deps.dir;
1523
- Object.freeze(this);
1524
- }
1525
- /** Returns the existing session id for (channelId, cwd) or null. */
1526
- get(channelId, cwd) {
1527
- return this.readMap(channelId)[cwd] ?? null;
1528
- }
1529
- /** Generates a new session id for (channelId, cwd) and persists it, overwriting any prior entry. */
1530
- create(channelId, cwd) {
1531
- const map = this.readMap(channelId);
1532
- const sessionId = this.idGenerator.generate();
1533
- map[cwd] = sessionId;
1534
- this.writeMap(channelId, map);
1535
- return sessionId;
1536
- }
1537
- /** Drops the recorded session id for (channelId, cwd). No-op if absent. */
1538
- clear(channelId, cwd) {
1539
- const map = this.readMap(channelId);
1540
- if (!(cwd in map)) return;
1541
- delete map[cwd];
1542
- this.writeMap(channelId, map);
1543
- }
1544
- /** Drops the whole session map for the channel (e.g. when the channel is deleted). */
1545
- clearAll(channelId) {
1546
- const path = this.pathFor(channelId);
1547
- if (this.fs.existsSync(path)) this.fs.unlink(path);
1548
- }
1549
- readMap(channelId) {
1550
- const path = this.pathFor(channelId);
1551
- if (!this.fs.existsSync(path)) return {};
1552
- const raw = this.fs.readFileSync(path);
1553
- try {
1554
- const parsed = sessionsMapSchema.safeParse(JSON.parse(raw));
1555
- return parsed.success ? parsed.data : {};
1556
- } catch {
1557
- return {};
1558
- }
1559
- }
1560
- writeMap(channelId, map) {
1561
- const path = this.pathFor(channelId);
1562
- const channelDir = this.channelDir(channelId);
1563
- if (!this.fs.existsSync(channelDir)) this.fs.mkdirSync(channelDir, { recursive: true });
1564
- this.fs.writeFileSync(path, `${JSON.stringify(map, null, 2)}\n`);
1565
- }
1566
- channelDir(channelId) {
1567
- return join(this.dir, "channels", channelId);
1568
- }
1569
- pathFor(channelId) {
1570
- return join(this.channelDir(channelId), "sessions.json");
1571
- }
1572
- };
1573
- //#endregion
1574
1573
  //#region lib/engine/token-prompter/node-token-prompter.ts
1575
1574
  const STAR = "*";
1576
1575
  const CR = "\r";
@@ -1659,6 +1658,18 @@ var MockFunnelSettingsReader = class extends FunnelSettingsReader {
1659
1658
  }
1660
1659
  };
1661
1660
  //#endregion
1661
+ //#region lib/engine/settings/tmp-dir.ts
1662
+ /**
1663
+ * Resolves the funnel temp/log root for the current OS. Defaults to
1664
+ * `<os.tmpdir()>/funnel` so Windows lands under `%TEMP%\funnel` and POSIX
1665
+ * lands under `/tmp/funnel`. Callers may override via `FUNNEL_TMP_DIR`.
1666
+ */
1667
+ function funnelTmpDir() {
1668
+ const override = process.env.FUNNEL_TMP_DIR;
1669
+ if (override && override.length > 0) return override;
1670
+ return join(tmpdir(), "funnel");
1671
+ }
1672
+ //#endregion
1662
1673
  //#region lib/engine/time/memory-clock.ts
1663
1674
  var MemoryFunnelClock = class extends FunnelClock {
1664
1675
  current;
@@ -1760,8 +1771,9 @@ var FunnelChannelPublisher = class {
1760
1771
  * 3. bundled: when this helper is inlined into dist/bin.js, the helper's dir is dist/,
1761
1772
  * and daemon.js lives at dist/gateway/daemon.js
1762
1773
  *
1763
- * `import.meta.url` works in both Bun and Node test runners; `import.meta.dir`
1764
- * is Bun-only and breaks vitest.
1774
+ * Uses `fileURLToPath(import.meta.url)` rather than `import.meta.dir` so the
1775
+ * same helper resolves correctly whether run from source, the built sibling,
1776
+ * or inlined into the bundle.
1765
1777
  */
1766
1778
  const resolveDaemonScript = () => {
1767
1779
  const here = dirname(fileURLToPath(import.meta.url));
@@ -1960,14 +1972,6 @@ const constantTimeEqual = (a, b) => {
1960
1972
  //#region lib/gateway/factory.ts
1961
1973
  const factory$1 = createFactory();
1962
1974
  //#endregion
1963
- //#region lib/engine/logger/noop-logger.ts
1964
- var NoopFunnelLogger = class extends FunnelLogger {
1965
- file = null;
1966
- info() {}
1967
- warn() {}
1968
- error() {}
1969
- };
1970
- //#endregion
1971
1975
  //#region lib/gateway/broadcaster.ts
1972
1976
  const byteLengthOf = (event) => {
1973
1977
  let bytes = Buffer.byteLength(event.content, "utf-8");
@@ -1977,7 +1981,6 @@ const byteLengthOf = (event) => {
1977
1981
  const DEFAULT_MAX_BUFFERED_BYTES = 1024 * 1024;
1978
1982
  const DEFAULT_REPLAY_BUFFER_SIZE = 200;
1979
1983
  const DEFAULT_REPLAY_BUFFER_MAX_BYTES = 4 * 1024 * 1024;
1980
- const defaultLogger$3 = new NoopFunnelLogger();
1981
1984
  const defaultOnError$2 = () => {};
1982
1985
  /**
1983
1986
  * In-process pub/sub for connector events.
@@ -1994,8 +1997,7 @@ const defaultOnError$2 = () => {};
1994
1997
  * `replayBufferSize` events are kept in memory; reconnecting WS clients can
1995
1998
  * pass `?since=<offset>` and the broadcaster resends matching events before
1996
1999
  * resuming the live stream. The in-memory ring covers short reconnects;
1997
- * older history is served from the SQLite event store wired in as
1998
- * `persistentReplay`.
2000
+ * older history is served from the event log wired in as `persistentReplay`.
1999
2001
  */
2000
2002
  var FunnelBroadcaster = class {
2001
2003
  clients = /* @__PURE__ */ new Map();
@@ -2015,7 +2017,7 @@ var FunnelBroadcaster = class {
2015
2017
  lastBroadcastAt = null;
2016
2018
  latestOffset = 0;
2017
2019
  constructor(deps = {}) {
2018
- this.logger = deps.logger ?? defaultLogger$3;
2020
+ this.logger = deps.logger;
2019
2021
  this.onError = deps.onError ?? defaultOnError$2;
2020
2022
  this.maxBufferedBytes = deps.maxBufferedBytes ?? DEFAULT_MAX_BUFFERED_BYTES;
2021
2023
  this.now = deps.now ?? (() => Date.now());
@@ -2041,8 +2043,8 @@ var FunnelBroadcaster = class {
2041
2043
  * Two-tier lookup:
2042
2044
  * 1. The in-memory ring buffer (covers short reconnects, last `replayBufferSize` events).
2043
2045
  * 2. If `since` predates the oldest in-memory entry and a persistent replay source
2044
- * is wired in (SQLite), the gap is filled from disk. This covers reconnects across
2045
- * daemon restarts where the in-memory buffer was lost.
2046
+ * is wired in (SQLite by default), the gap is filled from it. This covers reconnects
2047
+ * across daemon restarts where the in-memory buffer was lost.
2046
2048
  *
2047
2049
  * Result is sorted ascending by offset and de-duplicated against the in-memory buffer.
2048
2050
  */
@@ -2138,7 +2140,7 @@ var FunnelBroadcaster = class {
2138
2140
  const buffered = ws.getBufferedAmount();
2139
2141
  if (buffered > this.maxBufferedBytes) {
2140
2142
  const data = this.clients.get(ws);
2141
- this.logger.warn("dropping slow WS client (backpressure)", {
2143
+ this.logger?.warn("dropping slow WS client (backpressure)", {
2142
2144
  channel: data?.channel,
2143
2145
  buffered,
2144
2146
  max: this.maxBufferedBytes
@@ -2156,7 +2158,7 @@ var FunnelBroadcaster = class {
2156
2158
  handler(event);
2157
2159
  } catch (error) {
2158
2160
  const err = error instanceof Error ? error : new Error(String(error));
2159
- this.logger.error("broadcast subscriber threw", { error: err.message });
2161
+ this.logger?.error("broadcast subscriber threw", { error: err.message });
2160
2162
  this.onError(err, {
2161
2163
  component: "broadcaster.subscriber",
2162
2164
  offset: event.offset,
@@ -2172,6 +2174,37 @@ var FunnelBroadcaster = class {
2172
2174
  }
2173
2175
  };
2174
2176
  //#endregion
2177
+ //#region lib/gateway/funnel-event-log.ts
2178
+ /**
2179
+ * Replayable event payload persisted by the gateway. Domain events the
2180
+ * broadcaster emits to WS clients land here so reconnects across daemon
2181
+ * restarts can be served from disk. System events (gateway start, channel
2182
+ * connected, etc.) are routed to `FunnelLogger` instead — they never go
2183
+ * through this log, which keeps the offset space clean for replay.
2184
+ */
2185
+ const funnelEventSchema = z.object({
2186
+ type: z.string(),
2187
+ content: z.string(),
2188
+ channel_id: z.string().nullable(),
2189
+ connector_id: z.string().nullable(),
2190
+ meta: z.record(z.string(), z.string()).nullable()
2191
+ });
2192
+ /**
2193
+ * Durable, append-only log of broadcaster events keyed by the offset the
2194
+ * broadcaster assigns. The gateway persists every domain event here, and
2195
+ * across restarts it both seeds the broadcaster's offset counter
2196
+ * (`findMaxOffset`) and serves reconnect replay (`loadSince`) from it.
2197
+ *
2198
+ * `loadSince` is the only method the broadcaster itself needs, which makes
2199
+ * any implementation assignable to the broadcaster's narrow `ReplaySource`.
2200
+ *
2201
+ * Implementations:
2202
+ * - `SqliteFunnelEventLog` — the default; durable across daemon restarts.
2203
+ * - `MemoryFunnelEventLog` — an in-process double for tests and embedders
2204
+ * that do not need durability (replay is lost when the process exits).
2205
+ */
2206
+ var FunnelEventLog = class {};
2207
+ //#endregion
2175
2208
  //#region lib/logger/leuco-logger-sqlite-sink.ts
2176
2209
  /** Conservative whitelist for column names interpolated into SQL. */
2177
2210
  const COLUMN_NAME_RE = /^[a-z_][a-z0-9_]*$/;
@@ -2414,24 +2447,10 @@ function toRecord(row) {
2414
2447
  };
2415
2448
  }
2416
2449
  //#endregion
2417
- //#region lib/gateway/funnel-event-store.ts
2450
+ //#region lib/gateway/sqlite-funnel-event-log.ts
2418
2451
  const MAX_CONTENT_CHARS = 2e3;
2419
2452
  /**
2420
- * Replayable event payload persisted by the gateway. Domain events the
2421
- * broadcaster emits to WS clients land here so reconnects across daemon
2422
- * restarts can be served from disk. System events (gateway start, channel
2423
- * connected, etc.) are routed to `FunnelLogger` instead — they never go
2424
- * through this store, which keeps the seq space clean for replay.
2425
- */
2426
- const funnelEventSchema = z.object({
2427
- type: z.string(),
2428
- content: z.string(),
2429
- channel_id: z.string().nullable(),
2430
- connector_id: z.string().nullable(),
2431
- meta: z.record(z.string(), z.string()).nullable()
2432
- });
2433
- /**
2434
- * SQLite-backed event store. One indexed table holds every broadcaster
2453
+ * SQLite-backed `FunnelEventLog`. One indexed table holds every broadcaster
2435
2454
  * event with `channel_id` and `connector_id` as dedicated columns, so
2436
2455
  * per-channel and per-connector replay is an indexed range scan.
2437
2456
  *
@@ -2447,10 +2466,11 @@ const funnelEventSchema = z.object({
2447
2466
  * broadcaster traffic. This is what makes the broadcaster's seq seeding
2448
2467
  * (`getMaxSeq()` at startup) correct without per-event coordination.
2449
2468
  */
2450
- var FunnelEventStore = class {
2469
+ var SqliteFunnelEventLog = class extends FunnelEventLog {
2451
2470
  sink;
2452
2471
  now;
2453
2472
  constructor(props) {
2473
+ super();
2454
2474
  this.now = props.now ?? (() => Date.now());
2455
2475
  this.sink = new LeucoLoggerSqliteSink({
2456
2476
  path: props.path,
@@ -2469,16 +2489,16 @@ var FunnelEventStore = class {
2469
2489
  * (the gateway-server) supplies the offset from `broadcaster.broadcast()`
2470
2490
  * so this store and the broadcaster's in-memory ring stay aligned.
2471
2491
  */
2472
- record(props) {
2492
+ record(record) {
2473
2493
  const event = {
2474
- type: props.meta?.event_type ?? "unknown",
2475
- content: truncate$1(props.content),
2476
- channel_id: props.channelId,
2477
- connector_id: props.connectorId,
2478
- meta: props.meta
2494
+ type: record.meta?.event_type ?? "unknown",
2495
+ content: truncate$1(record.content),
2496
+ channel_id: record.channelId,
2497
+ connector_id: record.connectorId,
2498
+ meta: record.meta
2479
2499
  };
2480
2500
  this.sink.write({
2481
- seq: props.offset,
2501
+ seq: record.offset,
2482
2502
  ts: this.now(),
2483
2503
  event
2484
2504
  });
@@ -2532,7 +2552,6 @@ function truncate$1(content) {
2532
2552
  }
2533
2553
  //#endregion
2534
2554
  //#region lib/gateway/listener-supervisor.ts
2535
- const defaultLogger$2 = new NodeFunnelLogger();
2536
2555
  const defaultOnError$1 = () => {};
2537
2556
  const DEFAULT_HEALTH_INTERVAL_MS = 3e4;
2538
2557
  const DEFAULT_MAX_BACKOFF_MS = 6e4;
@@ -2568,7 +2587,7 @@ var FunnelListenerSupervisor = class FunnelListenerSupervisor {
2568
2587
  constructor(deps) {
2569
2588
  this.channels = deps.channels;
2570
2589
  this.notify = deps.notify;
2571
- this.logger = deps.logger ?? defaultLogger$2;
2590
+ this.logger = deps.logger;
2572
2591
  this.onError = deps.onError ?? defaultOnError$1;
2573
2592
  this.healthCheckIntervalMs = deps.healthCheckIntervalMs ?? DEFAULT_HEALTH_INTERVAL_MS;
2574
2593
  this.maxBackoffMs = deps.maxBackoffMs ?? DEFAULT_MAX_BACKOFF_MS;
@@ -2626,14 +2645,14 @@ var FunnelListenerSupervisor = class FunnelListenerSupervisor {
2626
2645
  listener: created.listener
2627
2646
  });
2628
2647
  this.ensureStats(key);
2629
- this.logger.info(`${created.config.type} listener started`, {
2648
+ this.logger?.info(`${created.config.type} listener started`, {
2630
2649
  channel: channelName,
2631
2650
  connector: connectorName
2632
2651
  });
2633
2652
  return { ok: true };
2634
2653
  } catch (error) {
2635
2654
  const err = error instanceof Error ? error : new Error(String(error));
2636
- this.logger.error(`${created.config.type} listener failed to start`, {
2655
+ this.logger?.error(`${created.config.type} listener failed to start`, {
2637
2656
  channel: channelName,
2638
2657
  connector: connectorName,
2639
2658
  error: err.message
@@ -2661,14 +2680,14 @@ var FunnelListenerSupervisor = class FunnelListenerSupervisor {
2661
2680
  await entry.listener.stop();
2662
2681
  this.running.delete(key);
2663
2682
  this.failureCounts.delete(key);
2664
- this.logger.info(`${entry.config.type} listener stopped`, {
2683
+ this.logger?.info(`${entry.config.type} listener stopped`, {
2665
2684
  channel: channelName,
2666
2685
  connector: connectorName
2667
2686
  });
2668
2687
  return { ok: true };
2669
2688
  } catch (error) {
2670
2689
  const err = error instanceof Error ? error : new Error(String(error));
2671
- this.logger.error(`${entry.config.type} listener failed to stop`, {
2690
+ this.logger?.error(`${entry.config.type} listener failed to stop`, {
2672
2691
  channel: channelName,
2673
2692
  connector: connectorName,
2674
2693
  error: err.message
@@ -2750,7 +2769,7 @@ var FunnelListenerSupervisor = class FunnelListenerSupervisor {
2750
2769
  const key = FunnelListenerSupervisor.keyOf(channelName, connectorName);
2751
2770
  const failureCount = this.failureCounts.get(key) ?? 0;
2752
2771
  const backoffMs = Math.min(1e3 * 2 ** failureCount, this.maxBackoffMs);
2753
- this.logger.warn(`${type} listener unhealthy, restarting`, {
2772
+ this.logger?.warn(`${type} listener unhealthy, restarting`, {
2754
2773
  channel: channelName,
2755
2774
  connector: connectorName,
2756
2775
  attempt: failureCount + 1,
@@ -2760,7 +2779,7 @@ var FunnelListenerSupervisor = class FunnelListenerSupervisor {
2760
2779
  await this.sleep(backoffMs);
2761
2780
  if ((await this.start(channelName, connectorName)).ok) {
2762
2781
  this.failureCounts.delete(key);
2763
- this.logger.info(`${type} listener recovered`, {
2782
+ this.logger?.info(`${type} listener recovered`, {
2764
2783
  channel: channelName,
2765
2784
  connector: connectorName
2766
2785
  });
@@ -2770,7 +2789,6 @@ var FunnelListenerSupervisor = class FunnelListenerSupervisor {
2770
2789
  //#endregion
2771
2790
  //#region lib/gateway/kill-competing-slack-gateways.ts
2772
2791
  const defaultProcess = new NodeFunnelProcessRunner();
2773
- const defaultLogger$1 = new NodeFunnelLogger();
2774
2792
  const titleFor = (dir) => `funnel-gateway[${dir}]`;
2775
2793
  /**
2776
2794
  * Kills other funnel daemon processes that share the SAME funnel home dir,
@@ -2784,7 +2802,7 @@ const titleFor = (dir) => `funnel-gateway[${dir}]`;
2784
2802
  */
2785
2803
  const killCompetingSlackGateways = async (props) => {
2786
2804
  const runner = props.process ?? defaultProcess;
2787
- const logger = props.logger ?? defaultLogger$1;
2805
+ const logger = props.logger;
2788
2806
  const expectedTitle = titleFor(props.dir);
2789
2807
  const snapshots = runner.listProcessesContaining(expectedTitle);
2790
2808
  const killed = [];
@@ -2792,7 +2810,7 @@ const killCompetingSlackGateways = async (props) => {
2792
2810
  if (snapshot.pid === props.selfPid) continue;
2793
2811
  runner.kill(snapshot.pid, "SIGTERM");
2794
2812
  killed.push(snapshot.pid);
2795
- logger.info("killed competing Slack gateway process", {
2813
+ logger?.info("killed competing Slack gateway process", {
2796
2814
  pid: snapshot.pid,
2797
2815
  args: snapshot.command.slice(0, 160)
2798
2816
  });
@@ -2854,7 +2872,7 @@ const channelsConnectorsCallHandler = factory$1.createHandlers(zParam(z.object({
2854
2872
  * POST /channels/:channel/publish
2855
2873
  *
2856
2874
  * Inject arbitrary content into a channel. Mirrors the connector-driven `notify`
2857
- * path: events go through `broadcaster.broadcast` + `eventStore.record`, so
2875
+ * path: events go through `broadcaster.broadcast` + `eventLog.record`, so
2858
2876
  * subscribers see them exactly as if a listener had produced them.
2859
2877
  *
2860
2878
  * Body validation is Zod-shared with the client (`publishRequestSchema`); the
@@ -2961,12 +2979,11 @@ const gatewayRoutes = factory$1.createApp().get("/health", ...healthHandler).get
2961
2979
  //#region lib/gateway/gateway-server.ts
2962
2980
  const DEFAULT_PORT = 9742;
2963
2981
  const defaultDbPath = () => join(funnelTmpDir(), "events.db");
2964
- const defaultLogger = new NodeFunnelLogger();
2965
2982
  const defaultOnError = () => {};
2966
2983
  /**
2967
2984
  * In-process gateway: runs `Bun.serve` (HTTP + WebSocket /ws), boots connector
2968
2985
  * listeners through `FunnelListenerSupervisor`, fans events out via
2969
- * `FunnelBroadcaster`, and persists them via `FunnelEventStore` (SQLite).
2986
+ * `FunnelBroadcaster`, and persists them via a `FunnelEventLog` (SQLite by default).
2970
2987
  * System events (gateway lifecycle, connect/disconnect) flow to `FunnelLogger`
2971
2988
  * instead — keeping the SQLite seq space exclusive to broadcaster traffic so
2972
2989
  * the broadcaster's offset counter and `getMaxSeq()` stay aligned without
@@ -2986,7 +3003,7 @@ var FunnelGatewayServer = class {
2986
3003
  killCompetingSlack;
2987
3004
  token;
2988
3005
  broadcaster;
2989
- eventStore;
3006
+ eventLog;
2990
3007
  supervisor;
2991
3008
  nowMs;
2992
3009
  extraRoutes;
@@ -2998,7 +3015,7 @@ var FunnelGatewayServer = class {
2998
3015
  this.port = deps.port ?? DEFAULT_PORT;
2999
3016
  this.dbPath = deps.dbPath ?? defaultDbPath();
3000
3017
  this.process = deps.process;
3001
- this.logger = deps.logger ?? defaultLogger;
3018
+ this.logger = deps.logger;
3002
3019
  this.onError = deps.onError ?? defaultOnError;
3003
3020
  this.selfPid = deps.selfPid ?? globalThis.process.pid;
3004
3021
  this.dir = deps.dir ?? FUNNEL_DIR;
@@ -3007,19 +3024,22 @@ var FunnelGatewayServer = class {
3007
3024
  this.extraRoutes = deps.extraRoutes ?? null;
3008
3025
  const clock = deps.clock;
3009
3026
  this.nowMs = clock ? () => clock.millis() : () => Date.now();
3010
- const dbDir = dirname(this.dbPath);
3011
- if (!existsSync(dbDir)) mkdirSync(dbDir, { recursive: true });
3012
- this.eventStore = new FunnelEventStore({
3013
- path: this.dbPath,
3014
- now: this.nowMs
3015
- });
3027
+ if (deps.eventLog) this.eventLog = deps.eventLog;
3028
+ else {
3029
+ const dbDir = dirname(this.dbPath);
3030
+ if (!existsSync(dbDir)) mkdirSync(dbDir, { recursive: true });
3031
+ this.eventLog = new SqliteFunnelEventLog({
3032
+ path: this.dbPath,
3033
+ now: this.nowMs
3034
+ });
3035
+ }
3016
3036
  this.broadcaster = new FunnelBroadcaster({
3017
3037
  logger: this.logger,
3018
3038
  onError: this.onError,
3019
3039
  now: this.nowMs,
3020
- persistentReplay: this.eventStore
3040
+ persistentReplay: this.eventLog
3021
3041
  });
3022
- this.broadcaster.seedLatestOffset(this.eventStore.findMaxOffset());
3042
+ this.broadcaster.seedLatestOffset(this.eventLog.findMaxOffset());
3023
3043
  this.supervisor = new FunnelListenerSupervisor({
3024
3044
  channels: this.channels,
3025
3045
  logger: this.logger,
@@ -3072,8 +3092,18 @@ var FunnelGatewayServer = class {
3072
3092
  getSupervisor() {
3073
3093
  return this.supervisor;
3074
3094
  }
3075
- getEventStore() {
3076
- return this.eventStore;
3095
+ getEventLog() {
3096
+ return this.eventLog;
3097
+ }
3098
+ /**
3099
+ * Register an in-process observer for every broadcast event. Fires after
3100
+ * the event is fanned out to WS clients and recorded in the event log.
3101
+ * Returns an unsubscribe function. Only meaningful in-process (embedded
3102
+ * hosts / `new Funnel(...)` running their own gateway-server); a separate
3103
+ * daemon process cannot be observed this way — use a WS client for that.
3104
+ */
3105
+ onEvent(handler) {
3106
+ return this.broadcaster.subscribe(handler);
3077
3107
  }
3078
3108
  handleFetch(request, server, app) {
3079
3109
  const url = new URL(request.url);
@@ -3116,8 +3146,8 @@ var FunnelGatewayServer = class {
3116
3146
  connectors: ws.data.connectors.join(","),
3117
3147
  total: String(this.broadcaster.getClientCount())
3118
3148
  };
3119
- this.logger.info("channel connected", meta);
3120
- } else this.logger.info("tap-all client connected", {
3149
+ this.logger?.info("channel connected", meta);
3150
+ } else this.logger?.info("tap-all client connected", {
3121
3151
  event_type: "system",
3122
3152
  action: "tap_connect",
3123
3153
  total: String(this.broadcaster.getClientCount())
@@ -3125,27 +3155,27 @@ var FunnelGatewayServer = class {
3125
3155
  }
3126
3156
  handleWsClose(ws) {
3127
3157
  this.broadcaster.removeClient(ws);
3128
- if (ws.data.channelName) this.logger.info("channel disconnected", {
3158
+ if (ws.data.channelName) this.logger?.info("channel disconnected", {
3129
3159
  event_type: "system",
3130
3160
  action: "channel_disconnect",
3131
3161
  channel: ws.data.channelName,
3132
3162
  channelId: ws.data.channel,
3133
3163
  total: String(this.broadcaster.getClientCount())
3134
3164
  });
3135
- else this.logger.info("tap-all client disconnected", {
3165
+ else this.logger?.info("tap-all client disconnected", {
3136
3166
  event_type: "system",
3137
3167
  action: "tap_disconnect",
3138
3168
  total: String(this.broadcaster.getClientCount())
3139
3169
  });
3140
3170
  }
3141
3171
  logServerStarted() {
3142
- this.logger.info("gateway started", {
3172
+ this.logger?.info("gateway started", {
3143
3173
  event_type: "system",
3144
3174
  action: "gateway_start",
3145
3175
  port: String(this.port),
3146
3176
  pid: String(this.selfPid)
3147
3177
  });
3148
- this.logger.info("funnel gateway listening", {
3178
+ this.logger?.info("funnel gateway listening", {
3149
3179
  url: `http://localhost:${this.port}`,
3150
3180
  websocket: `ws://localhost:${this.port}/ws`,
3151
3181
  health: `http://localhost:${this.port}/health`
@@ -3203,21 +3233,21 @@ var FunnelGatewayServer = class {
3203
3233
  process: this.process,
3204
3234
  logger: this.logger
3205
3235
  });
3206
- if (killed.length > 0) this.logger.info("killed competing Slack gateway processes", {
3236
+ if (killed.length > 0) this.logger?.info("killed competing Slack gateway processes", {
3207
3237
  event_type: "system",
3208
3238
  action: "kill_competing",
3209
3239
  pids: killed.join(",")
3210
3240
  });
3211
3241
  }
3212
3242
  await this.supervisor.startAll();
3213
- for (const entry of this.supervisor.list()) this.logger.info(`${entry.type} listener started: ${entry.name}`, {
3243
+ for (const entry of this.supervisor.list()) this.logger?.info(`${entry.type} listener started: ${entry.name}`, {
3214
3244
  event_type: "system",
3215
3245
  action: `${entry.type}_connect`,
3216
3246
  channel: entry.channelName,
3217
3247
  connector: entry.name
3218
3248
  });
3219
- this.logger.info(`event store: ${this.dbPath}`);
3220
- this.logger.info("funnel gateway running");
3249
+ this.logger?.info(`event store: ${this.dbPath}`);
3250
+ this.logger?.info("funnel gateway running");
3221
3251
  }
3222
3252
  /**
3223
3253
  * Broadcast `content` to subscribers of `channel`, persisting the event in
@@ -3237,7 +3267,7 @@ var FunnelGatewayServer = class {
3237
3267
  if (channelId) enriched.channelId = channelId;
3238
3268
  if (connectorId) enriched.connectorId = connectorId;
3239
3269
  const event = this.broadcaster.broadcast(input.content, enriched);
3240
- this.eventStore.record({
3270
+ this.eventLog.record({
3241
3271
  content: input.content,
3242
3272
  channelId: channelId ?? null,
3243
3273
  connectorId: connectorId ?? null,
@@ -3467,10 +3497,9 @@ var Funnel = class Funnel {
3467
3497
  if (!this.memos.process) this.memos.process = this.props.process ?? new NodeFunnelProcessRunner();
3468
3498
  return this.memos.process;
3469
3499
  }
3470
- /** Logger boundary. Defaults to NodeFunnelLogger. */
3500
+ /** 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. */
3471
3501
  get logger() {
3472
- if (!this.memos.logger) this.memos.logger = this.props.logger ?? new NodeFunnelLogger();
3473
- return this.memos.logger;
3502
+ return this.props.logger;
3474
3503
  }
3475
3504
  /** Clock boundary. Defaults to NodeFunnelClock. */
3476
3505
  get clock() {
@@ -3493,7 +3522,8 @@ var Funnel = class Funnel {
3493
3522
  get store() {
3494
3523
  if (!this.memos.store) this.memos.store = this.props.store ?? new FunnelSettingsStore({
3495
3524
  path: this.paths.settings,
3496
- fs: this.fs
3525
+ fs: this.fs,
3526
+ idGenerator: this.idGenerator
3497
3527
  });
3498
3528
  return this.memos.store;
3499
3529
  }
@@ -3522,17 +3552,11 @@ var Funnel = class Funnel {
3522
3552
  }
3523
3553
  /** Launch profiles (named presets for `fnl claude`: path + sub-agent + channel id). */
3524
3554
  get profiles() {
3525
- if (!this.memos.profiles) this.memos.profiles = new FunnelProfiles({ store: this.store });
3526
- return this.memos.profiles;
3527
- }
3528
- /** Per-(channel, cwd) claude session-id store. Backs `--session-id` injection on launch. */
3529
- get sessions() {
3530
- if (!this.memos.sessions) this.memos.sessions = new FunnelSessions({
3531
- fs: this.fs,
3532
- idGenerator: this.idGenerator,
3533
- dir: this.paths.dir
3555
+ if (!this.memos.profiles) this.memos.profiles = new FunnelProfiles({
3556
+ store: this.store,
3557
+ idGenerator: this.idGenerator
3534
3558
  });
3535
- return this.memos.sessions;
3559
+ return this.memos.profiles;
3536
3560
  }
3537
3561
  /** Reads `funnel.json` from a cwd. `fnl claude` consults it before falling back to the default profile. */
3538
3562
  get localConfig() {
@@ -3569,9 +3593,10 @@ var Funnel = class Funnel {
3569
3593
  channels: this.channels,
3570
3594
  mcp: this.mcp,
3571
3595
  gateway: this.gateway,
3572
- sessions: this.sessions,
3596
+ profiles: this.profiles,
3573
3597
  fs: this.fs,
3574
3598
  process: this.process,
3599
+ idGenerator: this.idGenerator,
3575
3600
  logger: this.logger,
3576
3601
  dir: this.paths.dir
3577
3602
  });
@@ -3641,6 +3666,7 @@ var Funnel = class Funnel {
3641
3666
  settings: this.store,
3642
3667
  port: options.port,
3643
3668
  dbPath: options.dbPath,
3669
+ eventLog: options.eventLog,
3644
3670
  process: this.process,
3645
3671
  clock: this.clock,
3646
3672
  logger: this.logger,
@@ -3850,6 +3876,46 @@ const funnelJsonSchema = () => {
3850
3876
  };
3851
3877
  };
3852
3878
  //#endregion
3879
+ //#region lib/engine/logger/node-logger.ts
3880
+ const defaultLogFile = () => join(funnelTmpDir(), "funnel.log");
3881
+ var NodeFunnelLogger = class extends FunnelLogger {
3882
+ file;
3883
+ now;
3884
+ constructor(props = {}) {
3885
+ super();
3886
+ this.file = props.file ?? defaultLogFile();
3887
+ this.now = props.now ?? (() => /* @__PURE__ */ new Date());
3888
+ Object.freeze(this);
3889
+ }
3890
+ info(message, meta) {
3891
+ this.write("info", message, meta);
3892
+ }
3893
+ warn(message, meta) {
3894
+ this.write("warn", message, meta);
3895
+ }
3896
+ error(message, meta) {
3897
+ this.write("error", message, meta);
3898
+ }
3899
+ write(level, message, meta) {
3900
+ mkdirSync(dirname(this.file), { recursive: true });
3901
+ const entry = {
3902
+ time: this.now().toISOString(),
3903
+ level,
3904
+ message,
3905
+ ...meta ? { meta } : {}
3906
+ };
3907
+ appendFileSync(this.file, `${JSON.stringify(entry)}\n`);
3908
+ }
3909
+ };
3910
+ //#endregion
3911
+ //#region lib/engine/logger/noop-logger.ts
3912
+ var NoopFunnelLogger = class extends FunnelLogger {
3913
+ file = null;
3914
+ info() {}
3915
+ warn() {}
3916
+ error() {}
3917
+ };
3918
+ //#endregion
3853
3919
  //#region lib/engine/token-prompter/memory-token-prompter.ts
3854
3920
  /**
3855
3921
  * Pre-seeded answers keyed by prompt label. Tests configure the map up front;
@@ -3870,6 +3936,46 @@ var MemoryFunnelTokenPrompter = class extends FunnelTokenPrompter {
3870
3936
  }
3871
3937
  };
3872
3938
  //#endregion
3939
+ //#region lib/gateway/memory-funnel-event-log.ts
3940
+ /**
3941
+ * In-process `FunnelEventLog` backed by a plain array. Used by tests and by
3942
+ * embedders that do not need durability — replay works within the process
3943
+ * lifetime but is lost when the process exits. Unlike the SQLite log it does
3944
+ * not truncate content or prune, so it is not meant for unbounded production
3945
+ * traffic.
3946
+ */
3947
+ var MemoryFunnelEventLog = class extends FunnelEventLog {
3948
+ events = [];
3949
+ constructor() {
3950
+ super();
3951
+ Object.freeze(this);
3952
+ }
3953
+ record(record) {
3954
+ this.events.push({
3955
+ offset: record.offset,
3956
+ content: record.content,
3957
+ meta: record.meta ?? void 0,
3958
+ channelId: record.channelId,
3959
+ connectorId: record.connectorId
3960
+ });
3961
+ }
3962
+ loadSince(since) {
3963
+ const out = [];
3964
+ for (const event of this.events) if (event.offset > since) out.push({
3965
+ content: event.content,
3966
+ meta: event.meta,
3967
+ offset: event.offset
3968
+ });
3969
+ return out;
3970
+ }
3971
+ findMaxOffset() {
3972
+ let max = 0;
3973
+ for (const event of this.events) if (event.offset > max) max = event.offset;
3974
+ return max;
3975
+ }
3976
+ close() {}
3977
+ };
3978
+ //#endregion
3873
3979
  //#region lib/cli/factory.ts
3874
3980
  const factory = createFactory();
3875
3981
  //#endregion
@@ -4407,7 +4513,10 @@ const claudeHandler = factory.createHandlers(zValidator$1("query", z.object({
4407
4513
  channel: profile.channelId,
4408
4514
  cwd: profile.path,
4409
4515
  userArgs,
4410
- profileName: profile.name
4516
+ profileId: profile.id,
4517
+ options: profile.options,
4518
+ env: profile.env,
4519
+ resume: profile.resume
4411
4520
  });
4412
4521
  process.exit(exitCode);
4413
4522
  }
@@ -4433,7 +4542,10 @@ const claudeHandler = factory.createHandlers(zValidator$1("query", z.object({
4433
4542
  channel: defaultProfile.channelId,
4434
4543
  cwd: defaultProfile.path,
4435
4544
  userArgs,
4436
- profileName: defaultProfile.name
4545
+ profileId: defaultProfile.id,
4546
+ options: defaultProfile.options,
4547
+ env: defaultProfile.env,
4548
+ resume: defaultProfile.resume
4437
4549
  });
4438
4550
  process.exit(exitCode);
4439
4551
  });
@@ -4778,7 +4890,7 @@ const profilesLaunchHandler = factory.createHandlers(zValidator$1("param", z.obj
4778
4890
  channel: profile.channelId,
4779
4891
  cwd: profile.path,
4780
4892
  userArgs: queryToCliArgs(c.req.url, RESERVED_KEYS),
4781
- profileName: profile.name,
4893
+ profileId: profile.id,
4782
4894
  options: profile.options,
4783
4895
  env: profile.env,
4784
4896
  resume: profile.resume
@@ -6319,7 +6431,7 @@ function ChannelsView(props) {
6319
6431
  if (next && next !== channel.name) props.funnel.channels.rename(channel.name, next);
6320
6432
  }
6321
6433
  } catch (error) {
6322
- props.funnel.logger.error(error instanceof Error ? error.message : String(error));
6434
+ props.funnel.logger?.error(error instanceof Error ? error.message : String(error));
6323
6435
  }
6324
6436
  props.setFocusedKey(null);
6325
6437
  props.refresh();
@@ -6328,7 +6440,7 @@ function ChannelsView(props) {
6328
6440
  try {
6329
6441
  props.funnel.channels.remove(name);
6330
6442
  } catch (error) {
6331
- props.funnel.logger.error(error instanceof Error ? error.message : String(error));
6443
+ props.funnel.logger?.error(error instanceof Error ? error.message : String(error));
6332
6444
  }
6333
6445
  props.setFocusedKey(null);
6334
6446
  props.refresh();
@@ -6339,7 +6451,7 @@ function ChannelsView(props) {
6339
6451
  const created = props.funnel.channels.add({ name });
6340
6452
  props.setFocusedKey(fieldKey$1(created.name, "name"));
6341
6453
  } catch (error) {
6342
- props.funnel.logger.error(error instanceof Error ? error.message : String(error));
6454
+ props.funnel.logger?.error(error instanceof Error ? error.message : String(error));
6343
6455
  }
6344
6456
  props.refresh();
6345
6457
  };
@@ -6400,7 +6512,7 @@ function ConnectorsView(props) {
6400
6512
  const connectors = props.snapshot.connectors;
6401
6513
  const targetChannel = props.snapshot.channels[0] ?? null;
6402
6514
  const logError = (error) => {
6403
- props.funnel.logger.error(error instanceof Error ? error.message : String(error));
6515
+ props.funnel.logger?.error(error instanceof Error ? error.message : String(error));
6404
6516
  };
6405
6517
  const removeConnector = (connector) => {
6406
6518
  props.funnel.listeners.stop(connector.channelName, connector.name).catch(logError);
@@ -6812,7 +6924,7 @@ function ProfilesView(props) {
6812
6924
  if (next) props.funnel.profiles.update(profile.name, { path: next });
6813
6925
  }
6814
6926
  } catch (error) {
6815
- props.funnel.logger.error(error instanceof Error ? error.message : String(error));
6927
+ props.funnel.logger?.error(error instanceof Error ? error.message : String(error));
6816
6928
  }
6817
6929
  props.setFocusedKey(null);
6818
6930
  props.refresh();
@@ -6821,7 +6933,7 @@ function ProfilesView(props) {
6821
6933
  try {
6822
6934
  props.funnel.profiles.remove(name);
6823
6935
  } catch (error) {
6824
- props.funnel.logger.error(error instanceof Error ? error.message : String(error));
6936
+ props.funnel.logger?.error(error instanceof Error ? error.message : String(error));
6825
6937
  }
6826
6938
  props.setFocusedKey(null);
6827
6939
  props.refresh();
@@ -6837,7 +6949,7 @@ function ProfilesView(props) {
6837
6949
  });
6838
6950
  props.setFocusedKey(fieldKey(name, "name"));
6839
6951
  } catch (error) {
6840
- props.funnel.logger.error(error instanceof Error ? error.message : String(error));
6952
+ props.funnel.logger?.error(error instanceof Error ? error.message : String(error));
6841
6953
  }
6842
6954
  props.refresh();
6843
6955
  };
@@ -6991,7 +7103,10 @@ function App(props) {
6991
7103
  await props.funnel.claude.launch({
6992
7104
  channel: profile.channelId,
6993
7105
  cwd: profile.path,
6994
- profileName: profile.name
7106
+ profileId: profile.id,
7107
+ options: profile.options,
7108
+ env: profile.env,
7109
+ resume: profile.resume
6995
7110
  });
6996
7111
  } catch (error) {
6997
7112
  process.stderr.write(`error: ${error instanceof Error ? error.message : String(error)}\n`);
@@ -7180,4 +7295,4 @@ async function launchTui(funnel) {
7180
7295
  });
7181
7296
  }
7182
7297
  //#endregion
7183
- 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, FunnelSessions, 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, channelSpecSchema, app as cliApp, connectorConfigSchema, connectorSpecSchema, createCliApp, createSettings, discordConnectorSchema, factory, funnelEventSchema, funnelJsonSchema, ghConnectorSchema, launchTui, localConfigSchema, profileConfigSchema, profileSpecSchema, publishRequestSchema, publishResponseSchema, queryToCliArgs, scheduleCatchupPolicySchema, scheduleConnectorSchema, scheduleEntrySchema, settingsSchema, slackConnectorSchema, startChannelServer, toRequest };
7298
+ export { DEFAULT_GATEWAY_TOKEN_PATH, FUNNEL_DIR, FUNNEL_MCP_COMMAND, FUNNEL_MCP_NAME, Funnel, FunnelBroadcaster, FunnelChannelPublisher, FunnelChannels, FunnelClaude, FunnelClock, FunnelConnectorFactory, FunnelConnectorListener, FunnelDotenvReader, FunnelEventLog, FunnelFileSystem, FunnelGateway, FunnelGatewayServer, FunnelGatewayToken, FunnelIdGenerator, FunnelListenerSupervisor, FunnelListenersClient, FunnelLocalConfig, FunnelLocalConfigSync, FunnelLogger, FunnelMcp, FunnelProcessRunner, FunnelProfiles, FunnelSettingsReader, FunnelSettingsStore, FunnelSlackEventProcessor, FunnelTokenPrompter, LOCAL_CONFIG_FILENAME, LOCAL_ENV_FILENAME, MemoryFunnelClock, MemoryFunnelEventLog, MemoryFunnelFileSystem, MemoryFunnelIdGenerator, MemoryFunnelLogger, MemoryFunnelProcessRunner, MemoryFunnelTokenPrompter, MockFunnelSettingsReader, NodeFunnelClock, NodeFunnelFileSystem, NodeFunnelIdGenerator, NodeFunnelLogger, NodeFunnelProcessRunner, NodeFunnelTokenPrompter, NoopFunnelLogger, SETTINGS_PATH, SETTINGS_VERSION, SqliteFunnelEventLog, channelConfigSchema, channelDeliveryModeSchema, channelSpecSchema, app as cliApp, connectorConfigSchema, connectorSpecSchema, createCliApp, createSettings, discordConnectorSchema, factory, funnelEventSchema, funnelJsonSchema, ghConnectorSchema, launchTui, localConfigSchema, profileConfigSchema, profileSpecSchema, publishRequestSchema, publishResponseSchema, queryToCliArgs, scheduleCatchupPolicySchema, scheduleConnectorSchema, scheduleEntrySchema, settingsSchema, slackConnectorSchema, startChannelServer, toRequest };