@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/README.md +24 -14
- package/dist/bin.js +428 -429
- package/dist/connectors/discord.js +1 -1
- package/dist/connectors/gh.js +1 -1
- package/dist/connectors/schedule.js +1 -1
- package/dist/connectors/slack.js +1 -1
- package/dist/{discord-connector-schema-CR8RJ08_.js → discord-connector-schema-CpuI6rmE.js} +8 -9
- package/dist/gateway/daemon.js +181 -182
- package/dist/{gh-connector-schema-CAC24s0r.js → gh-connector-schema-CQRIvPpz.js} +5 -6
- package/dist/index.d.ts +245 -200
- package/dist/index.js +363 -248
- package/dist/logger-D1A3_JXV.js +27 -0
- package/dist/{schedule-connector-schema-BZpH6ZmR.js → schedule-connector-schema-CuCjP7z4.js} +4 -5
- package/dist/{slack-connector-schema-B0NyhxqQ.js → slack-connector-schema-BWL7dWlY.js} +8 -6
- package/funnel.schema.json +0 -4
- package/package.json +2 -3
- package/dist/node-logger-B97ZiGwj.js +0 -74
package/dist/index.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
import { i as FunnelDiscordAdapter, n as FunnelDiscordListener, t as discordConnectorSchema } from "./discord-connector-schema-
|
|
2
|
-
import {
|
|
3
|
-
import { a as FunnelProcessRunner, i as NodeFunnelProcessRunner, n as FunnelGhListener, r as FunnelGhAdapter, t as ghConnectorSchema } from "./gh-connector-schema-
|
|
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-
|
|
5
|
-
import { i as FunnelSlackAdapter, n as FunnelSlackListener, r as FunnelSlackEventProcessor, t as slackConnectorSchema } from "./slack-connector-schema-
|
|
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
|
|
65
|
-
*
|
|
66
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
591
|
+
this.profiles = deps.profiles;
|
|
557
592
|
this.process = deps.process ?? defaultProcess$2;
|
|
558
593
|
this.fs = deps.fs ?? defaultFs$3;
|
|
559
|
-
this.
|
|
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.
|
|
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
|
|
606
|
+
this.logger?.info(`added funnel MCP to .mcp.json`, { cwd });
|
|
571
607
|
}
|
|
572
608
|
if (!this.gateway.isRunning()) {
|
|
573
|
-
this.logger
|
|
609
|
+
this.logger?.info(`starting gateway automatically`);
|
|
574
610
|
await this.gateway.start();
|
|
575
611
|
}
|
|
576
|
-
if (options.
|
|
577
|
-
this.writePidFile(options.
|
|
578
|
-
this.installCleanup(options.
|
|
612
|
+
if (options.profileId) {
|
|
613
|
+
this.writePidFile(options.profileId);
|
|
614
|
+
this.installCleanup(options.profileId);
|
|
579
615
|
}
|
|
580
|
-
const session = options.resume ??
|
|
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
|
|
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.
|
|
631
|
+
if (options.profileId) this.removePidFile(options.profileId);
|
|
596
632
|
}
|
|
597
633
|
}
|
|
598
|
-
isRunning(
|
|
599
|
-
const pid = this.readPid(
|
|
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(
|
|
604
|
-
return join(this.pidDir, `${
|
|
639
|
+
pidPath(profileId) {
|
|
640
|
+
return join(this.pidDir, `${profileId}.pid`);
|
|
605
641
|
}
|
|
606
|
-
readPid(
|
|
607
|
-
const path = this.pidPath(
|
|
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(
|
|
654
|
+
writePidFile(profileId) {
|
|
619
655
|
this.fs.mkdirSync(this.pidDir, { recursive: true });
|
|
620
|
-
this.fs.writeFileSync(this.pidPath(
|
|
656
|
+
this.fs.writeFileSync(this.pidPath(profileId), String(globalThis.process.pid));
|
|
621
657
|
}
|
|
622
|
-
removePidFile(
|
|
623
|
-
const path = this.pidPath(
|
|
658
|
+
removePidFile(profileId) {
|
|
659
|
+
const path = this.pidPath(profileId);
|
|
624
660
|
if (this.fs.existsSync(path)) this.fs.unlink(path);
|
|
625
661
|
}
|
|
626
|
-
installCleanup(
|
|
627
|
-
globalThis.process.once("exit", () => this.removePidFile(
|
|
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
|
|
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(
|
|
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.
|
|
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:
|
|
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
|
|
791
|
-
* to
|
|
792
|
-
*
|
|
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
|
-
*
|
|
1418
|
-
*
|
|
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`
|
|
1764
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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-
|
|
2450
|
+
//#region lib/gateway/sqlite-funnel-event-log.ts
|
|
2418
2451
|
const MAX_CONTENT_CHARS = 2e3;
|
|
2419
2452
|
/**
|
|
2420
|
-
*
|
|
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
|
|
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(
|
|
2492
|
+
record(record) {
|
|
2473
2493
|
const event = {
|
|
2474
|
-
type:
|
|
2475
|
-
content: truncate$1(
|
|
2476
|
-
channel_id:
|
|
2477
|
-
connector_id:
|
|
2478
|
-
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:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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` + `
|
|
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 `
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
3011
|
-
|
|
3012
|
-
|
|
3013
|
-
|
|
3014
|
-
|
|
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.
|
|
3040
|
+
persistentReplay: this.eventLog
|
|
3021
3041
|
});
|
|
3022
|
-
this.broadcaster.seedLatestOffset(this.
|
|
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
|
-
|
|
3076
|
-
return this.
|
|
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
|
|
3120
|
-
} else this.logger
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
3220
|
-
this.logger
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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({
|
|
3526
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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,
|
|
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 };
|