@interactive-inc/claude-funnel 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +179 -80
  2. package/dist/bin.js +693 -698
  3. package/dist/connector-adapter-CXB-q_XC.d.ts +11 -0
  4. package/dist/connector-adapter-D5Utumgz.js +4 -0
  5. package/dist/connectors/discord.d.ts +76 -0
  6. package/dist/connectors/discord.js +2 -0
  7. package/dist/connectors/gh.d.ts +38 -0
  8. package/dist/connectors/gh.js +2 -0
  9. package/dist/connectors/schedule.d.ts +53 -0
  10. package/dist/connectors/schedule.js +2 -0
  11. package/dist/connectors/slack.d.ts +62 -0
  12. package/dist/connectors/slack.js +2 -0
  13. package/dist/discord-connector-schema-Dww2I4zH.d.ts +14 -0
  14. package/dist/discord-connector-schema-ygf5Df-2.js +173 -0
  15. package/dist/file-system-Co60LrmR.d.ts +74 -0
  16. package/dist/gateway/daemon.js +243 -221
  17. package/dist/gh-connector-schema-2ml29MBC.js +218 -0
  18. package/dist/gh-connector-schema-BZFAS-p-.d.ts +45 -0
  19. package/dist/index.d.ts +3888 -0
  20. package/dist/index.js +6296 -0
  21. package/dist/logger-CTlXs7z4.d.ts +33 -0
  22. package/dist/node-logger-DQz_BGOD.js +61 -0
  23. package/dist/schedule-connector-schema-CkuIQ0JQ.js +325 -0
  24. package/dist/slack-connector-schema-Cd22WiHB.js +153 -0
  25. package/dist/slack-connector-schema-D7zAHN8k.d.ts +15 -0
  26. package/lib/bin.ts +1 -76
  27. package/lib/cli/index.ts +85 -0
  28. package/lib/cli/router/to-request.ts +1 -0
  29. package/lib/cli/routes/channels.$channel.publish.ts +52 -0
  30. package/lib/cli/routes/claude.ts +1 -0
  31. package/lib/cli/routes/index.ts +35 -18
  32. package/lib/cli/routes/profiles.add.$profile.ts +5 -2
  33. package/lib/cli/routes/profiles.set.$profile.ts +10 -11
  34. package/lib/connectors/discord.ts +4 -0
  35. package/lib/connectors/gh.ts +3 -0
  36. package/lib/connectors/schedule.ts +4 -0
  37. package/lib/connectors/slack.ts +4 -0
  38. package/lib/engine/claude/claude.ts +6 -0
  39. package/lib/engine/mcp/channel-server.ts +34 -115
  40. package/lib/engine/mcp/channel-subscriber.ts +82 -0
  41. package/lib/engine/mcp/read-channel-connectors.ts +34 -0
  42. package/lib/engine/mcp/read-gateway-token.ts +16 -0
  43. package/lib/engine/mcp/usage-hint-for-type.ts +15 -0
  44. package/lib/engine/settings/settings-schema.ts +2 -0
  45. package/lib/funnel.ts +162 -55
  46. package/lib/gateway/broadcaster.ts +1 -1
  47. package/lib/gateway/channel-publisher.ts +67 -0
  48. package/lib/gateway/gateway-server.ts +28 -16
  49. package/lib/gateway/publish-schema.ts +27 -0
  50. package/lib/gateway/routes/channels.publish.ts +44 -0
  51. package/lib/gateway/routes/index.ts +2 -0
  52. package/lib/gateway/routes/route-deps.ts +8 -0
  53. package/lib/index.ts +17 -0
  54. package/package.json +41 -25
@@ -0,0 +1,15 @@
1
+ export const usageHintForType = (type: string): string => {
2
+ if (type === "slack") {
3
+ return "Slack Web API. method=POST path=chat.postMessage body={channel,text,thread_ts?}"
4
+ }
5
+
6
+ if (type === "discord") {
7
+ return "Discord REST API. method=POST path=/channels/<id>/messages body={content,...}"
8
+ }
9
+
10
+ if (type === "gh") {
11
+ return "GitHub REST via gh CLI. method=POST path=repos/owner/repo/issues/N/comments body={body}"
12
+ }
13
+
14
+ return "Generic adapter call."
15
+ }
@@ -30,6 +30,8 @@ export const profileConfigSchema = z.object({
30
30
  path: z.string(),
31
31
  subAgent: z.string(),
32
32
  channelId: z.string(),
33
+ /** Forwards `--brief` to claude on launch (enables the SendUserMessage tool). */
34
+ brief: z.boolean().optional(),
33
35
  })
34
36
 
35
37
  export type ProfileConfig = z.infer<typeof profileConfigSchema>
package/lib/funnel.ts CHANGED
@@ -2,22 +2,36 @@ import { join } from "node:path"
2
2
  import { FunnelConnectorFactory } from "@/connectors/connector-factory"
3
3
  import { FunnelChannels } from "@/engine/channels/channels"
4
4
  import { FunnelClaude } from "@/engine/claude/claude"
5
- import type { FunnelFileSystem } from "@/engine/fs/file-system"
6
- import type { FunnelIdGenerator } from "@/engine/id/id-generator"
5
+ import { FunnelFileSystem } from "@/engine/fs/file-system"
6
+ import { MemoryFunnelFileSystem } from "@/engine/fs/memory-file-system"
7
+ import { NodeFunnelFileSystem } from "@/engine/fs/node-file-system"
8
+ import { FunnelIdGenerator } from "@/engine/id/id-generator"
9
+ import { MemoryFunnelIdGenerator } from "@/engine/id/memory-id-generator"
10
+ import { NodeFunnelIdGenerator } from "@/engine/id/node-id-generator"
7
11
  import { FunnelLogger } from "@/engine/logger/logger"
12
+ import { MemoryFunnelLogger } from "@/engine/logger/memory-logger"
8
13
  import { NodeFunnelLogger } from "@/engine/logger/node-logger"
9
14
  import { FunnelMcp } from "@/engine/mcp/mcp"
10
15
  import { FunnelProcessRunner } from "@/engine/process/process-runner"
16
+ import { MemoryFunnelProcessRunner } from "@/engine/process/memory-process-runner"
11
17
  import { NodeFunnelProcessRunner } from "@/engine/process/node-process-runner"
12
18
  import { FunnelProfiles } from "@/engine/profiles/profiles"
19
+ import { MockFunnelSettingsReader } from "@/engine/settings/mock-settings-reader"
13
20
  import { FunnelSettingsReader } from "@/engine/settings/settings-reader"
14
21
  import { FUNNEL_DIR, FunnelSettingsStore } from "@/engine/settings/settings-store"
15
- import type { FunnelClock } from "@/engine/time/clock"
22
+ import { FunnelClock } from "@/engine/time/clock"
23
+ import { MemoryFunnelClock } from "@/engine/time/memory-clock"
24
+ import { NodeFunnelClock } from "@/engine/time/node-clock"
25
+ import { FunnelChannelPublisher } from "@/gateway/channel-publisher"
16
26
  import { FunnelGateway } from "@/gateway/gateway"
17
27
  import { FunnelGatewayServer } from "@/gateway/gateway-server"
18
28
  import { FunnelGatewayToken } from "@/gateway/gateway-token"
19
29
  import { FunnelListenersClient } from "@/gateway/listeners-client"
20
30
 
31
+ const DEFAULT_TMP_DIR = "/tmp/funnel"
32
+ const SANDBOX_DIR = "/sandbox/.funnel"
33
+ const SANDBOX_TMP_DIR = "/sandbox/tmp"
34
+
21
35
  type Props = {
22
36
  /** Settings persistence (channels with nested connectors / profiles). Defaults to a FunnelSettingsStore rooted at `dir`. */
23
37
  store?: FunnelSettingsReader
@@ -57,89 +71,180 @@ type Props = {
57
71
  * ```
58
72
  */
59
73
  export class Funnel {
74
+ private readonly cache = new Map<string, unknown>()
75
+
60
76
  constructor(private readonly props: Props = {}) {
61
77
  Object.freeze(this)
62
78
  }
63
79
 
64
- /** Settings reader. If not injected, a FunnelSettingsStore rooted at `dir` is created. */
65
- get store(): FunnelSettingsReader {
66
- return (
67
- this.props.store ??
68
- new FunnelSettingsStore({
69
- path: join(this.props.dir ?? FUNNEL_DIR, "settings.json"),
70
- fs: this.props.fs,
71
- })
72
- )
80
+ /**
81
+ * Sandboxed Funnel wired with in-memory implementations for every IO boundary.
82
+ * Touches no real disk, processes, wall-clock time, or UUIDs — safe for tests
83
+ * and ad-hoc experiments. Override individual fields by passing them in `props`.
84
+ */
85
+ static inMemory(props: Props = {}): Funnel {
86
+ return new Funnel({
87
+ store: props.store ?? new MockFunnelSettingsReader(),
88
+ fs: props.fs ?? new MemoryFunnelFileSystem(),
89
+ process: props.process ?? new MemoryFunnelProcessRunner(),
90
+ logger: props.logger ?? new MemoryFunnelLogger(),
91
+ clock: props.clock ?? new MemoryFunnelClock(),
92
+ idGenerator: props.idGenerator ?? new MemoryFunnelIdGenerator(),
93
+ dir: props.dir ?? SANDBOX_DIR,
94
+ tmpDir: props.tmpDir ?? SANDBOX_TMP_DIR,
95
+ })
96
+ }
97
+
98
+ private memo<T>(key: string, build: () => T): T {
99
+ if (this.cache.has(key)) return this.cache.get(key) as T
100
+
101
+ const value = build()
102
+ this.cache.set(key, value)
103
+
104
+ return value
105
+ }
106
+
107
+ /** Resolved on-disk paths the facade will read/write when methods are called. Pure compute, not memoized. */
108
+ get paths(): { dir: string; tmpDir: string; settings: string } {
109
+ const dir = this.props.dir ?? FUNNEL_DIR
110
+ const tmpDir = this.props.tmpDir ?? DEFAULT_TMP_DIR
111
+
112
+ return { dir, tmpDir, settings: join(dir, "settings.json") }
113
+ }
114
+
115
+ /** Filesystem boundary. Defaults to NodeFunnelFileSystem. */
116
+ get fs(): FunnelFileSystem {
117
+ return this.memo("fs", () => this.props.fs ?? new NodeFunnelFileSystem())
73
118
  }
74
119
 
75
120
  /** Process runner boundary. Defaults to NodeFunnelProcessRunner. */
76
121
  get process(): FunnelProcessRunner {
77
- return this.props.process ?? new NodeFunnelProcessRunner()
122
+ return this.memo("process", () => this.props.process ?? new NodeFunnelProcessRunner())
78
123
  }
79
124
 
80
125
  /** Logger boundary. Defaults to NodeFunnelLogger. */
81
126
  get logger(): FunnelLogger {
82
- return this.props.logger ?? new NodeFunnelLogger()
127
+ return this.memo("logger", () => this.props.logger ?? new NodeFunnelLogger())
128
+ }
129
+
130
+ /** Clock boundary. Defaults to NodeFunnelClock. */
131
+ get clock(): FunnelClock {
132
+ return this.memo("clock", () => this.props.clock ?? new NodeFunnelClock())
133
+ }
134
+
135
+ /** ID generator boundary. Defaults to NodeFunnelIdGenerator. */
136
+ get idGenerator(): FunnelIdGenerator {
137
+ return this.memo("idGenerator", () => this.props.idGenerator ?? new NodeFunnelIdGenerator())
138
+ }
139
+
140
+ /** Settings reader. If not injected, a FunnelSettingsStore rooted at `dir` is created. */
141
+ get store(): FunnelSettingsReader {
142
+ return this.memo(
143
+ "store",
144
+ () =>
145
+ this.props.store ??
146
+ new FunnelSettingsStore({
147
+ path: this.paths.settings,
148
+ fs: this.fs,
149
+ }),
150
+ )
83
151
  }
84
152
 
85
153
  /** Pure factory that constructs per-type listeners and adapters from connector configs. */
86
154
  get factory(): FunnelConnectorFactory {
87
- return new FunnelConnectorFactory({
88
- fs: this.props.fs,
89
- process: this.props.process,
90
- logger: this.props.logger,
91
- dir: this.props.dir,
92
- })
155
+ return this.memo(
156
+ "factory",
157
+ () =>
158
+ new FunnelConnectorFactory({
159
+ fs: this.fs,
160
+ process: this.process,
161
+ logger: this.logger,
162
+ dir: this.paths.dir,
163
+ }),
164
+ )
93
165
  }
94
166
 
95
167
  /** Channel CRUD + nested connector CRUD + schedule entries + listener/adapter dispatch. */
96
168
  get channels(): FunnelChannels {
97
- return new FunnelChannels({
98
- store: this.store,
99
- factory: this.factory,
100
- profileChecker: this.profiles,
101
- clock: this.props.clock,
102
- idGenerator: this.props.idGenerator,
103
- })
169
+ return this.memo(
170
+ "channels",
171
+ () =>
172
+ new FunnelChannels({
173
+ store: this.store,
174
+ factory: this.factory,
175
+ profileChecker: this.profiles,
176
+ clock: this.clock,
177
+ idGenerator: this.idGenerator,
178
+ }),
179
+ )
104
180
  }
105
181
 
106
182
  /** Launch profiles (named presets for `fnl claude`: path + sub-agent + channel id). */
107
183
  get profiles(): FunnelProfiles {
108
- return new FunnelProfiles({ store: this.store })
184
+ return this.memo("profiles", () => new FunnelProfiles({ store: this.store }))
109
185
  }
110
186
 
111
187
  /** funnel MCP installer (writes/removes `.mcp.json` entries in target repos). */
112
188
  get mcp(): FunnelMcp {
113
- return new FunnelMcp({ fs: this.props.fs })
189
+ return this.memo("mcp", () => new FunnelMcp({ fs: this.fs }))
114
190
  }
115
191
 
116
192
  /** Launch Claude Code with a channel injected via env, MCP installed, gateway ensured. */
117
193
  get claude(): FunnelClaude {
118
- return new FunnelClaude({
119
- channels: this.channels,
120
- mcp: this.mcp,
121
- gateway: this.gateway,
122
- fs: this.props.fs,
123
- process: this.props.process,
124
- logger: this.props.logger,
125
- dir: this.props.dir,
126
- })
194
+ return this.memo(
195
+ "claude",
196
+ () =>
197
+ new FunnelClaude({
198
+ channels: this.channels,
199
+ mcp: this.mcp,
200
+ gateway: this.gateway,
201
+ fs: this.fs,
202
+ process: this.process,
203
+ logger: this.logger,
204
+ dir: this.paths.dir,
205
+ }),
206
+ )
127
207
  }
128
208
 
129
209
  /** Gateway daemon controller (PID-file, start/stop the separate `bun daemon.ts` process). */
130
210
  get gateway(): FunnelGateway {
131
- return new FunnelGateway({
132
- fs: this.props.fs,
133
- process: this.props.process,
134
- clock: this.props.clock,
135
- dir: this.props.dir,
136
- tmpDir: this.props.tmpDir,
137
- })
211
+ return this.memo(
212
+ "gateway",
213
+ () =>
214
+ new FunnelGateway({
215
+ fs: this.fs,
216
+ process: this.process,
217
+ clock: this.clock,
218
+ dir: this.paths.dir,
219
+ tmpDir: this.paths.tmpDir,
220
+ }),
221
+ )
138
222
  }
139
223
 
140
224
  /** Read / generate the daemon's gateway token (mode 0600 file under `dir`). */
141
225
  get gatewayToken(): FunnelGatewayToken {
142
- return new FunnelGatewayToken({ fs: this.props.fs, dir: this.props.dir })
226
+ return this.memo(
227
+ "gatewayToken",
228
+ () => new FunnelGatewayToken({ fs: this.fs, dir: this.paths.dir }),
229
+ )
230
+ }
231
+
232
+ /**
233
+ * HTTP client for `POST /channels/:channel/publish` on the running gateway
234
+ * daemon. Use it to push arbitrary content into a channel from outside any
235
+ * connector. Returns `{ state: "offline" }` if the daemon isn't up.
236
+ */
237
+ get publisher(): FunnelChannelPublisher {
238
+ return this.memo("publisher", () => {
239
+ const gateway = this.gateway
240
+ const token = this.gatewayToken
241
+
242
+ return new FunnelChannelPublisher({
243
+ port: gateway.getPort(),
244
+ isDaemonRunning: () => gateway.isRunning(),
245
+ getToken: () => token.read(),
246
+ })
247
+ })
143
248
  }
144
249
 
145
250
  /**
@@ -148,13 +253,15 @@ export class Funnel {
148
253
  * paths stay write-only without parsing strings.
149
254
  */
150
255
  get listeners(): FunnelListenersClient {
151
- const gateway = this.gateway
152
- const token = this.gatewayToken
256
+ return this.memo("listeners", () => {
257
+ const gateway = this.gateway
258
+ const token = this.gatewayToken
153
259
 
154
- return new FunnelListenersClient({
155
- port: gateway.getPort(),
156
- isDaemonRunning: () => gateway.isRunning(),
157
- getToken: () => token.read(),
260
+ return new FunnelListenersClient({
261
+ port: gateway.getPort(),
262
+ isDaemonRunning: () => gateway.isRunning(),
263
+ getToken: () => token.read(),
264
+ })
158
265
  })
159
266
  }
160
267
 
@@ -177,9 +284,9 @@ export class Funnel {
177
284
  settings: this.store,
178
285
  port: options.port,
179
286
  logDir: options.logDir,
180
- process: this.props.process,
181
- clock: this.props.clock,
182
- logger: this.props.logger,
287
+ process: this.process,
288
+ clock: this.clock,
289
+ logger: this.logger,
183
290
  killCompetingSlack: options.killCompetingSlack,
184
291
  token: options.token ?? this.gatewayToken.ensure(),
185
292
  })
@@ -1,4 +1,4 @@
1
- import type { ServerWebSocket } from "bun"
1
+ import { type ServerWebSocket } from "bun"
2
2
  import { FunnelLogger } from "@/engine/logger/logger"
3
3
  import { NoopFunnelLogger } from "@/engine/logger/noop-logger"
4
4
 
@@ -0,0 +1,67 @@
1
+ import {
2
+ publishResponseSchema,
3
+ type PublishRequest,
4
+ type PublishResult,
5
+ } from "@/gateway/publish-schema"
6
+
7
+ type Deps = {
8
+ port: number
9
+ isDaemonRunning: () => boolean
10
+ /** Returns the daemon's gateway token, or null if unavailable. Sent as `Authorization: Bearer`. */
11
+ getToken?: () => string | null
12
+ }
13
+
14
+ const OFFLINE: PublishResult = { state: "offline" }
15
+
16
+ /**
17
+ * HTTP client for `POST /channels/:channel/publish` on a running gateway
18
+ * daemon. Returns `{ state: "offline" }` when the daemon isn't up so callers
19
+ * can branch without exceptions, mirroring `FunnelListenersClient`.
20
+ */
21
+ export class FunnelChannelPublisher {
22
+ private readonly port: number
23
+ private readonly isDaemonRunning: () => boolean
24
+ private readonly getToken: () => string | null
25
+
26
+ constructor(deps: Deps) {
27
+ this.port = deps.port
28
+ this.isDaemonRunning = deps.isDaemonRunning
29
+ this.getToken = deps.getToken ?? (() => null)
30
+ Object.freeze(this)
31
+ }
32
+
33
+ async publish(channelName: string, request: PublishRequest): Promise<PublishResult> {
34
+ if (!this.isDaemonRunning()) return OFFLINE
35
+
36
+ try {
37
+ const url = `http://localhost:${this.port}/channels/${encodeURIComponent(channelName)}/publish`
38
+ const res = await fetch(url, {
39
+ method: "POST",
40
+ headers: { ...this.authHeaders(), "content-type": "application/json" },
41
+ body: JSON.stringify(request),
42
+ })
43
+
44
+ if (!res.ok) {
45
+ const text = await res.text()
46
+
47
+ return { state: "error", reason: text || `HTTP ${res.status}` }
48
+ }
49
+
50
+ const parsed = publishResponseSchema.safeParse(await res.json())
51
+
52
+ if (!parsed.success) {
53
+ return { state: "error", reason: "malformed daemon response" }
54
+ }
55
+
56
+ return { state: "ok", offset: parsed.data.offset }
57
+ } catch (error) {
58
+ return { state: "error", reason: error instanceof Error ? error.message : String(error) }
59
+ }
60
+ }
61
+
62
+ private authHeaders(): Record<string, string> {
63
+ const token = this.getToken()
64
+
65
+ return token ? { authorization: `Bearer ${token}` } : {}
66
+ }
67
+ }
@@ -1,6 +1,6 @@
1
1
  import { existsSync, mkdirSync } from "node:fs"
2
2
  import { join } from "node:path"
3
- import type { Server, ServerWebSocket } from "bun"
3
+ import { type Server, type ServerWebSocket } from "bun"
4
4
  import type { Hono } from "hono"
5
5
  import type { FunnelChannels } from "@/engine/channels/channels"
6
6
  import { constantTimeEqual, requireBearerToken } from "@/gateway/auth-middleware"
@@ -104,8 +104,9 @@ export class FunnelGatewayServer {
104
104
  this.supervisor = new FunnelListenerSupervisor({
105
105
  channels: this.channels,
106
106
  logger: this.logger,
107
- notify: (channelName, connectorName, content, meta) =>
108
- this.notify(channelName, connectorName, content, meta),
107
+ notify: async (channelName, connectorName, content, meta) => {
108
+ this.emit({ channel: channelName, connector: connectorName, content, meta })
109
+ },
109
110
  now: this.nowMs,
110
111
  })
111
112
  }
@@ -278,6 +279,7 @@ export class FunnelGatewayServer {
278
279
  supervisor: this.supervisor,
279
280
  channels: this.channels,
280
281
  uptimeMs: () => (this.startedAt ? this.nowMs() - this.startedAt : 0),
282
+ emit: (input) => this.emit(input),
281
283
  })
282
284
 
283
285
  return next()
@@ -371,32 +373,42 @@ export class FunnelGatewayServer {
371
373
  this.logger.info("funnel gateway running")
372
374
  }
373
375
 
374
- private async notify(
375
- channelName: string,
376
- connectorName: string,
377
- content: string,
378
- meta?: Record<string, string>,
379
- ): Promise<void> {
380
- const channelId = this.lookupChannelId(channelName)
381
- const connectorId = channelId ? this.lookupConnectorId(channelId, connectorName) : null
376
+ /**
377
+ * Broadcast `content` to subscribers of `channel`, persisting the event in
378
+ * the SQLite store and stamping `meta.channel{,Id}` / `meta.connector{,Id}`
379
+ * when they resolve. Used by both the connector-listener path (via the
380
+ * supervisor's `notify` callback) and the public `/channels/:channel/publish`
381
+ * route. Returns the assigned event offset.
382
+ */
383
+ emit(input: {
384
+ channel: string
385
+ connector?: string
386
+ content: string
387
+ meta?: Record<string, string>
388
+ }): { offset: number } {
389
+ const channelId = this.lookupChannelId(input.channel)
390
+ const connectorId =
391
+ channelId && input.connector ? this.lookupConnectorId(channelId, input.connector) : null
382
392
  const enriched: Record<string, string> = {
383
- ...meta,
384
- channel: channelName,
385
- connector: connectorName,
393
+ ...input.meta,
394
+ channel: input.channel,
386
395
  }
387
396
 
397
+ if (input.connector) enriched.connector = input.connector
388
398
  if (channelId) enriched.channelId = channelId
389
399
  if (connectorId) enriched.connectorId = connectorId
390
400
 
391
- const event = this.broadcaster.broadcast(content, enriched)
401
+ const event = this.broadcaster.broadcast(input.content, enriched)
392
402
 
393
403
  this.eventStore.record({
394
- content,
404
+ content: input.content,
395
405
  channelId: channelId ?? null,
396
406
  connectorId: connectorId ?? null,
397
407
  meta: enriched,
398
408
  offset: event.offset,
399
409
  })
410
+
411
+ return { offset: event.offset }
400
412
  }
401
413
 
402
414
  private lookupChannelId(channelName: string): string | null {
@@ -0,0 +1,27 @@
1
+ import { z } from "zod"
2
+
3
+ /**
4
+ * Shared schema for `POST /channels/:channel/publish` — used by both the
5
+ * gateway route handler (input validation) and the CLI / programmable client
6
+ * (request shape). The route resolves `channel` from the path; this body
7
+ * covers everything else.
8
+ */
9
+ export const publishRequestSchema = z.object({
10
+ content: z.string().min(1),
11
+ meta: z.record(z.string(), z.string()).optional(),
12
+ connector: z.string().min(1).optional(),
13
+ })
14
+
15
+ export type PublishRequest = z.infer<typeof publishRequestSchema>
16
+
17
+ export const publishResponseSchema = z.object({
18
+ ok: z.literal(true),
19
+ offset: z.number().int().nonnegative(),
20
+ })
21
+
22
+ export type PublishResponse = z.infer<typeof publishResponseSchema>
23
+
24
+ export type PublishResult =
25
+ | { state: "ok"; offset: number }
26
+ | { state: "offline" }
27
+ | { state: "error"; reason: string }
@@ -0,0 +1,44 @@
1
+ import { zValidator } from "@hono/zod-validator"
2
+ import { z } from "zod"
3
+ import { factory } from "@/gateway/factory"
4
+ import { publishRequestSchema } from "@/gateway/publish-schema"
5
+ import type { PublishResponse } from "@/gateway/publish-schema"
6
+ import { zParam } from "@/gateway/routes/validator"
7
+
8
+ /**
9
+ * POST /channels/:channel/publish
10
+ *
11
+ * Inject arbitrary content into a channel. Mirrors the connector-driven `notify`
12
+ * path: events go through `broadcaster.broadcast` + `eventStore.record`, so
13
+ * subscribers see them exactly as if a listener had produced them.
14
+ *
15
+ * Body validation is Zod-shared with the client (`publishRequestSchema`); the
16
+ * response (`publishResponseSchema`) carries the assigned offset so callers can
17
+ * correlate with the persistent event store.
18
+ */
19
+ export const channelsPublishHandler = factory.createHandlers(
20
+ zParam(z.object({ channel: z.string().min(1) })),
21
+ zValidator("json", publishRequestSchema, (result, c) => {
22
+ if (result.success) return
23
+
24
+ const issue = result.error.issues[0]
25
+ const reason = issue ? `${issue.path.join(".")}: ${issue.message}` : "invalid body"
26
+
27
+ return c.json({ ok: false, reason }, 400)
28
+ }),
29
+ (c) => {
30
+ const param = c.req.valid("param")
31
+ const body = c.req.valid("json")
32
+
33
+ const event = c.var.deps.emit({
34
+ channel: param.channel,
35
+ connector: body.connector,
36
+ content: body.content,
37
+ meta: body.meta,
38
+ })
39
+
40
+ const response: PublishResponse = { ok: true, offset: event.offset }
41
+
42
+ return c.json(response)
43
+ },
44
+ )
@@ -1,5 +1,6 @@
1
1
  import { factory } from "@/gateway/factory"
2
2
  import { channelsConnectorsCallHandler } from "@/gateway/routes/channels.connectors.call"
3
+ import { channelsPublishHandler } from "@/gateway/routes/channels.publish"
3
4
  import { healthHandler } from "@/gateway/routes/health"
4
5
  import { listenersListHandler } from "@/gateway/routes/listeners.list"
5
6
  import { listenersRestartHandler } from "@/gateway/routes/listeners.restart"
@@ -22,3 +23,4 @@ export const gatewayRoutes = factory
22
23
  .delete("/listeners/:channel/:connector", ...listenersStopHandler)
23
24
  .post("/listeners/:channel/:connector/restart", ...listenersRestartHandler)
24
25
  .post("/channels/:channel/connectors/:connector/call", ...channelsConnectorsCallHandler)
26
+ .post("/channels/:channel/publish", ...channelsPublishHandler)
@@ -2,10 +2,18 @@ import type { FunnelChannels } from "@/engine/channels/channels"
2
2
  import type { FunnelBroadcaster } from "@/gateway/broadcaster"
3
3
  import type { FunnelListenerSupervisor } from "@/gateway/listener-supervisor"
4
4
 
5
+ export type GatewayEmitInput = {
6
+ channel: string
7
+ connector?: string
8
+ content: string
9
+ meta?: Record<string, string>
10
+ }
11
+
5
12
  export type GatewayRouteDeps = {
6
13
  selfPid: number
7
14
  broadcaster: FunnelBroadcaster
8
15
  supervisor: FunnelListenerSupervisor
9
16
  channels: FunnelChannels
10
17
  uptimeMs: () => number
18
+ emit: (input: GatewayEmitInput) => { offset: number }
11
19
  }
package/lib/index.ts CHANGED
@@ -8,6 +8,7 @@ export * from "@/funnel"
8
8
  export * from "@/engine/channels/channels"
9
9
  export * from "@/engine/claude/claude"
10
10
  export * from "@/engine/mcp/mcp"
11
+ export * from "@/engine/mcp/channel-server"
11
12
  export * from "@/engine/profiles/profiles"
12
13
  export * from "@/engine/settings/settings-reader"
13
14
  export * from "@/engine/settings/settings-store"
@@ -39,12 +40,28 @@ export * from "@/engine/id/memory-id-generator"
39
40
  // Connectors
40
41
  export * from "@/connectors/connector-factory"
41
42
  export * from "@/connectors/connector-config-schema"
43
+ export * from "@/connectors/connector-listener"
44
+ export * from "@/connectors/discord-connector-schema"
45
+ export * from "@/connectors/gh-connector-schema"
42
46
  export * from "@/connectors/schedule-connector-schema"
47
+ export * from "@/connectors/slack-connector-schema"
43
48
 
44
49
  // Gateway
45
50
  export * from "@/gateway/gateway"
46
51
  export * from "@/gateway/gateway-server"
52
+ export * from "@/gateway/gateway-token"
47
53
  export * from "@/gateway/broadcaster"
54
+ export * from "@/gateway/channel-publisher"
48
55
  export * from "@/gateway/funnel-event-store"
49
56
  export * from "@/gateway/listener-supervisor"
50
57
  export * from "@/gateway/listeners-client"
58
+ export * from "@/gateway/publish-schema"
59
+
60
+ // CLI — embeddable Hono app + argv translator
61
+ export * from "@/cli/factory"
62
+ export * from "@/cli/router/to-request"
63
+ export * from "@/cli/router/query-to-cli-args"
64
+ export { app as cliApp, createCliApp } from "@/cli/routes"
65
+
66
+ // TUI — launcher (consumers can spawn the OpenTUI dashboard with their own Funnel)
67
+ export * from "@/tui/tui"