@interactive-inc/claude-funnel 0.8.1 → 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 (175) hide show
  1. package/README.md +179 -80
  2. package/dist/bin.js +724 -656
  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 +233 -183
  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 -36
  20. package/dist/index.js +6206 -3485
  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 +15 -0
  54. package/package.json +34 -23
  55. package/dist/cli/factory.d.ts +0 -7
  56. package/dist/cli/router/query-to-cli-args.d.ts +0 -1
  57. package/dist/cli/router/to-request.d.ts +0 -5
  58. package/dist/cli/router/validator.d.ts +0 -5
  59. package/dist/cli/routes/channels.$channel.connectors.$connector.d.ts +0 -42
  60. package/dist/cli/routes/channels.$channel.connectors.$connector.rename.$newName.d.ts +0 -46
  61. package/dist/cli/routes/channels.$channel.connectors.$connector.request.d.ts +0 -54
  62. package/dist/cli/routes/channels.$channel.connectors.$connector.schedules.add.$id.d.ts +0 -66
  63. package/dist/cli/routes/channels.$channel.connectors.$connector.schedules.d.ts +0 -42
  64. package/dist/cli/routes/channels.$channel.connectors.$connector.schedules.remove.$id.d.ts +0 -46
  65. package/dist/cli/routes/channels.$channel.connectors.add.$connector.d.ts +0 -90
  66. package/dist/cli/routes/channels.$channel.connectors.d.ts +0 -38
  67. package/dist/cli/routes/channels.$channel.connectors.remove.$connector.d.ts +0 -42
  68. package/dist/cli/routes/channels.$channel.connectors.set.$connector.d.ts +0 -62
  69. package/dist/cli/routes/channels.$channel.d.ts +0 -38
  70. package/dist/cli/routes/channels.$channel.rename.$newName.d.ts +0 -42
  71. package/dist/cli/routes/channels.$channel.set.delivery.$mode.d.ts +0 -28
  72. package/dist/cli/routes/channels.add.$channel.d.ts +0 -46
  73. package/dist/cli/routes/channels.d.ts +0 -16
  74. package/dist/cli/routes/channels.remove.$channel.d.ts +0 -38
  75. package/dist/cli/routes/claude.d.ts +0 -32
  76. package/dist/cli/routes/gateway.d.ts +0 -20
  77. package/dist/cli/routes/gateway.listeners.d.ts +0 -17
  78. package/dist/cli/routes/gateway.logs.d.ts +0 -24
  79. package/dist/cli/routes/gateway.restart.d.ts +0 -24
  80. package/dist/cli/routes/gateway.run.d.ts +0 -24
  81. package/dist/cli/routes/gateway.start.d.ts +0 -24
  82. package/dist/cli/routes/gateway.status.d.ts +0 -13
  83. package/dist/cli/routes/gateway.stop.d.ts +0 -16
  84. package/dist/cli/routes/index.d.ts +0 -1222
  85. package/dist/cli/routes/profiles.$profile.as-default.d.ts +0 -38
  86. package/dist/cli/routes/profiles.$profile.rename.$newName.d.ts +0 -42
  87. package/dist/cli/routes/profiles.$profile.run.d.ts +0 -46
  88. package/dist/cli/routes/profiles.add.$profile.d.ts +0 -54
  89. package/dist/cli/routes/profiles.d.ts +0 -16
  90. package/dist/cli/routes/profiles.remove.$profile.d.ts +0 -38
  91. package/dist/cli/routes/profiles.set.$profile.d.ts +0 -54
  92. package/dist/cli/routes/status.d.ts +0 -16
  93. package/dist/cli/routes/update.d.ts +0 -16
  94. package/dist/connectors/connector-adapter.d.ts +0 -8
  95. package/dist/connectors/connector-config-schema.d.ts +0 -43
  96. package/dist/connectors/connector-factory.d.ts +0 -32
  97. package/dist/connectors/connector-listener.d.ts +0 -17
  98. package/dist/connectors/discord-adapter.d.ts +0 -14
  99. package/dist/connectors/discord-connector-schema.d.ts +0 -10
  100. package/dist/connectors/discord-event-processor.d.ts +0 -26
  101. package/dist/connectors/discord-listener.d.ts +0 -17
  102. package/dist/connectors/gh-adapter.d.ts +0 -11
  103. package/dist/connectors/gh-connector-schema.d.ts +0 -10
  104. package/dist/connectors/gh-listener.d.ts +0 -26
  105. package/dist/connectors/match-cron.d.ts +0 -1
  106. package/dist/connectors/schedule-connector-schema.d.ts +0 -45
  107. package/dist/connectors/schedule-listener.d.ts +0 -30
  108. package/dist/connectors/schedule-state-store.d.ts +0 -19
  109. package/dist/connectors/slack-adapter.d.ts +0 -15
  110. package/dist/connectors/slack-connector-schema.d.ts +0 -11
  111. package/dist/connectors/slack-event-processor.d.ts +0 -27
  112. package/dist/connectors/slack-listener.d.ts +0 -17
  113. package/dist/engine/channels/channels.d.ts +0 -106
  114. package/dist/engine/claude/claude.d.ts +0 -49
  115. package/dist/engine/claude/gateway-controller.d.ts +0 -6
  116. package/dist/engine/fs/file-system.d.ts +0 -24
  117. package/dist/engine/fs/memory-file-system.d.ts +0 -31
  118. package/dist/engine/fs/node-file-system.d.ts +0 -15
  119. package/dist/engine/http/http-client.d.ts +0 -15
  120. package/dist/engine/http/memory-http-client.d.ts +0 -12
  121. package/dist/engine/http/node-http-client.d.ts +0 -5
  122. package/dist/engine/id/id-generator.d.ts +0 -7
  123. package/dist/engine/id/memory-id-generator.d.ts +0 -11
  124. package/dist/engine/id/node-id-generator.d.ts +0 -4
  125. package/dist/engine/logger/logger.d.ts +0 -11
  126. package/dist/engine/logger/memory-logger.d.ts +0 -14
  127. package/dist/engine/logger/node-logger.d.ts +0 -15
  128. package/dist/engine/logger/noop-logger.d.ts +0 -7
  129. package/dist/engine/mcp/channel-server.d.ts +0 -1
  130. package/dist/engine/mcp/mcp.d.ts +0 -22
  131. package/dist/engine/process/memory-process-runner.d.ts +0 -43
  132. package/dist/engine/process/node-process-runner.d.ts +0 -9
  133. package/dist/engine/process/process-runner.d.ts +0 -29
  134. package/dist/engine/profiles/profile-channel-checker.d.ts +0 -7
  135. package/dist/engine/profiles/profiles.d.ts +0 -31
  136. package/dist/engine/settings/mock-settings-reader.d.ts +0 -9
  137. package/dist/engine/settings/settings-reader.d.ts +0 -5
  138. package/dist/engine/settings/settings-schema.d.ts +0 -132
  139. package/dist/engine/settings/settings-store.d.ts +0 -18
  140. package/dist/engine/time/clock.d.ts +0 -9
  141. package/dist/engine/time/memory-clock.d.ts +0 -12
  142. package/dist/engine/time/node-clock.d.ts +0 -4
  143. package/dist/funnel.d.ts +0 -95
  144. package/dist/gateway/auth-middleware.d.ts +0 -14
  145. package/dist/gateway/broadcaster.d.ts +0 -122
  146. package/dist/gateway/daemon.d.ts +0 -2
  147. package/dist/gateway/factory.d.ts +0 -7
  148. package/dist/gateway/funnel-event-store.d.ts +0 -81
  149. package/dist/gateway/gateway-server.d.ts +0 -94
  150. package/dist/gateway/gateway-token.d.ts +0 -33
  151. package/dist/gateway/gateway.d.ts +0 -58
  152. package/dist/gateway/kill-competing-slack-gateways.d.ts +0 -9
  153. package/dist/gateway/listener-supervisor.d.ts +0 -85
  154. package/dist/gateway/listeners-client.d.ts +0 -53
  155. package/dist/gateway/resolve-daemon-script.d.ts +0 -11
  156. package/dist/gateway/routes/channels.connectors.call.d.ts +0 -41
  157. package/dist/gateway/routes/health.d.ts +0 -17
  158. package/dist/gateway/routes/index.d.ts +0 -209
  159. package/dist/gateway/routes/listeners.list.d.ts +0 -14
  160. package/dist/gateway/routes/listeners.restart.d.ts +0 -34
  161. package/dist/gateway/routes/listeners.start.d.ts +0 -34
  162. package/dist/gateway/routes/listeners.stop.d.ts +0 -34
  163. package/dist/gateway/routes/route-deps.d.ts +0 -10
  164. package/dist/gateway/routes/status.d.ts +0 -30
  165. package/dist/gateway/routes/validator.d.ts +0 -19
  166. package/dist/logger/leuco-human-file-writer.d.ts +0 -33
  167. package/dist/logger/leuco-human-logger.d.ts +0 -46
  168. package/dist/logger/leuco-human-record.d.ts +0 -15
  169. package/dist/logger/leuco-human-stdout-writer.d.ts +0 -20
  170. package/dist/logger/leuco-human-writer.d.ts +0 -13
  171. package/dist/logger/leuco-logger-memory-sink.d.ts +0 -33
  172. package/dist/logger/leuco-logger-record.d.ts +0 -13
  173. package/dist/logger/leuco-logger-sink.d.ts +0 -34
  174. package/dist/logger/leuco-logger-sqlite-sink.d.ts +0 -102
  175. package/dist/logger/leuco-logger.d.ts +0 -56
@@ -0,0 +1,85 @@
1
+ import pkg from "@/../package.json" with { type: "json" }
2
+ import { startChannelServer } from "@/engine/mcp/channel-server"
3
+ import { toRequest } from "@/cli/router/to-request"
4
+ import { launchTui } from "@/tui/tui"
5
+ import { createCliApp } from "@/cli/routes"
6
+ import { Funnel } from "@/funnel"
7
+
8
+ process.title = "funnel"
9
+
10
+ const funnel = new Funnel()
11
+
12
+ const app = createCliApp(funnel)
13
+
14
+ const HELP = `funnel — Open Claude Funnel
15
+
16
+ usage: funnel [command]
17
+
18
+ commands:
19
+ (none) launch TUI
20
+ claude launch Claude Code (default profile or --profile)
21
+ channels manage subscription boxes (and their nested connectors)
22
+ profiles manage launch profiles
23
+ gateway manage the gateway daemon (HTTP + WS)
24
+ status show overall connection status
25
+ update update funnel to the latest version
26
+ mcp run as an MCP server (invoked from .mcp.json)
27
+
28
+ options:
29
+ --help, -h show help
30
+ --version, -v show version
31
+
32
+ more: funnel <command> --help`
33
+
34
+ const args = process.argv.slice(2)
35
+
36
+ if (args.length === 0) {
37
+ await launchTui(funnel)
38
+ process.exit(0)
39
+ }
40
+
41
+ if (args[0] === "--version" || args[0] === "-v") {
42
+ process.stdout.write(`${pkg.version}\n`)
43
+ process.exit(0)
44
+ }
45
+
46
+ if (args[0] === "mcp") {
47
+ await startChannelServer({ dir: funnel.paths.dir })
48
+ }
49
+
50
+ if (args[0] !== "mcp") {
51
+ const { method, url } = toRequest(args)
52
+
53
+ const parsed = new URL(url)
54
+
55
+ const wantsHelp = parsed.searchParams.has("help")
56
+
57
+ if (wantsHelp && parsed.pathname === "/") {
58
+ process.stdout.write(`${HELP}\n`)
59
+ process.exit(0)
60
+ }
61
+
62
+ const res = await app.request(url, { method })
63
+
64
+ if (res.ok) {
65
+ const body = await res.text()
66
+ if (body) process.stdout.write(`${body}\n`)
67
+ process.exit(0)
68
+ }
69
+
70
+ if (wantsHelp) {
71
+ const segments = parsed.pathname.split("/").filter(Boolean)
72
+ const group = segments[0]
73
+ const fallback = group
74
+ ? await app.request(`http://localhost/${group}?help=true`, { method: "GET" })
75
+ : null
76
+
77
+ const text = fallback?.ok ? await fallback.text() : HELP
78
+ process.stdout.write(`${text}\n`)
79
+ process.exit(0)
80
+ }
81
+
82
+ const text = await res.text()
83
+ if (text) process.stderr.write(`${text}\n`)
84
+ process.exit(1)
85
+ }
@@ -13,6 +13,7 @@ const METHOD_KEYWORDS = new Set([
13
13
  "rename",
14
14
  "as-default",
15
15
  "request",
16
+ "publish",
16
17
  ])
17
18
 
18
19
  const API_CALL_METHODS = new Set(["get", "post", "put", "patch", "delete", "head", "options"])
@@ -0,0 +1,52 @@
1
+ import { HTTPException } from "hono/http-exception"
2
+ import { z } from "zod"
3
+ import { factory } from "@/cli/factory"
4
+ import { zValidator } from "@/cli/router/validator"
5
+
6
+ export const publishHelp = `funnel channels <channel> publish — push arbitrary content into a channel
7
+
8
+ usage: funnel channels <channel> publish --content="<text>" [--connector=<name>] [--meta-<key>=<value> ...]
9
+
10
+ options:
11
+ --content Required. The event body delivered to subscribers.
12
+ --connector Optional. Stamp the event with a connector name (resolved to id when found).
13
+ --meta-<key> Optional. Repeatable. Added to meta. Example: --meta-source=cron`
14
+
15
+ const querySchema = z
16
+ .object({
17
+ content: z.string().min(1, { message: "--content is required" }),
18
+ connector: z.string().min(1).optional(),
19
+ })
20
+ .passthrough()
21
+
22
+ export const channelsPublishHandler = factory.createHandlers(
23
+ zValidator("param", z.object({ channel: z.string() })),
24
+ zValidator("query", querySchema, publishHelp),
25
+ async (c) => {
26
+ const param = c.req.valid("param")
27
+ const query = c.req.valid("query")
28
+ const funnel = c.var.funnel
29
+
30
+ const meta: Record<string, string> = {}
31
+
32
+ for (const [k, v] of new URL(c.req.url).searchParams) {
33
+ if (k.startsWith("meta-")) meta[k.slice("meta-".length)] = v
34
+ }
35
+
36
+ const result = await funnel.publisher.publish(param.channel, {
37
+ content: query.content,
38
+ connector: query.connector,
39
+ meta: Object.keys(meta).length > 0 ? meta : undefined,
40
+ })
41
+
42
+ if (result.state === "offline") {
43
+ throw new HTTPException(503, { message: "gateway daemon is not running — start it with `fnl gateway start`" })
44
+ }
45
+
46
+ if (result.state === "error") {
47
+ throw new HTTPException(502, { message: result.reason })
48
+ }
49
+
50
+ return c.text(`published (offset=${result.offset})`)
51
+ },
52
+ )
@@ -62,6 +62,7 @@ export const claudeHandler = factory.createHandlers(
62
62
  subAgent: profile.subAgent,
63
63
  userArgs: queryToCliArgs(c.req.url, RESERVED_KEYS),
64
64
  profileName: profile.name,
65
+ brief: profile.brief,
65
66
  })
66
67
 
67
68
  process.exit(exitCode)
@@ -32,6 +32,10 @@ import {
32
32
  channelsConnectorsSchedulesRemoveHandler,
33
33
  removeHelp as channelsConnectorsSchedulesRemoveHelp,
34
34
  } from "@/cli/routes/channels.$channel.connectors.$connector.schedules.remove.$id"
35
+ import {
36
+ channelsPublishHandler,
37
+ publishHelp as channelsPublishHelp,
38
+ } from "@/cli/routes/channels.$channel.publish"
35
39
  import {
36
40
  channelsRemoveHandler,
37
41
  removeHelp as channelsRemoveHelp,
@@ -75,30 +79,37 @@ import { statusHandler } from "@/cli/routes/status"
75
79
  import { updateHandler } from "@/cli/routes/update"
76
80
  import { Funnel } from "@/funnel"
77
81
 
78
- const base = factory.createApp()
82
+ const helpRoute = (text: string) => factory.createHandlers((c) => c.text(text))
79
83
 
80
- base.use((c, next) => {
81
- c.set("funnel", new Funnel())
84
+ /**
85
+ * Build the CLI Hono app wired to a specific Funnel instance.
86
+ * Exposed so library consumers can mount the same routes their `fnl` CLI
87
+ * uses against a custom Funnel (e.g. one with sandboxed boundaries).
88
+ *
89
+ * All CLI verbs (`add` / `remove` / `set` / `rename` / `as-default` / `request`) map to POST in
90
+ * to-request.ts and stay in the URL as a literal segment. Read paths (list / show / launch) keep GET.
91
+ * Help shortcuts at parameterless URLs return the help text directly so `funnel <verb>` (no args) is
92
+ * informative instead of 404.
93
+ */
94
+ export const createCliApp = (funnel: Funnel) => {
95
+ const base = factory.createApp()
82
96
 
83
- return next()
84
- })
97
+ base.use((c, next) => {
98
+ c.set("funnel", funnel)
85
99
 
86
- base.onError((error, c) => {
87
- if (error instanceof HTTPException) {
88
- return c.text(`error: ${error.message}`, error.status)
89
- }
100
+ return next()
101
+ })
90
102
 
91
- return c.text(`error: ${error instanceof Error ? error.message : String(error)}`, 400)
92
- })
103
+ base.onError((error, c) => {
104
+ if (error instanceof HTTPException) {
105
+ return c.text(`error: ${error.message}`, error.status)
106
+ }
93
107
 
94
- const helpRoute = (text: string) => factory.createHandlers((c) => c.text(text))
108
+ return c.text(`error: ${error instanceof Error ? error.message : String(error)}`, 400)
109
+ })
95
110
 
96
- // All CLI verbs (`add` / `remove` / `set` / `rename` / `as-default` / `request`) map to POST in
97
- // to-request.ts and stay in the URL as a literal segment. Read paths (list / show / launch) keep GET.
98
- // Help shortcuts at parameterless URLs return the help text directly so `funnel <verb>` (no args) is
99
- // informative instead of 404.
100
- export const app = base
101
- .get("/claude", ...claudeHandler)
111
+ return base
112
+ .get("/claude", ...claudeHandler)
102
113
  .get("/channels", ...channelsGroupHandler)
103
114
  .post("/channels/add", ...helpRoute(channelsAddHelp))
104
115
  .post("/channels/add/:channel", ...channelsAddHandler)
@@ -109,6 +120,8 @@ export const app = base
109
120
  .post("/channels/rename", ...helpRoute(channelsRenameHelp))
110
121
  .post("/channels/:channel/rename", ...helpRoute(channelsRenameHelp))
111
122
  .post("/channels/:channel/set/delivery/:mode", ...channelsSetDeliveryHandler)
123
+ .post("/channels/publish", ...helpRoute(channelsPublishHelp))
124
+ .post("/channels/:channel/publish", ...channelsPublishHandler)
112
125
  .get("/channels/:channel", ...channelsShowHandler)
113
126
  .get("/channels/:channel/connectors", ...channelsConnectorsGroupHandler)
114
127
  .post(
@@ -200,3 +213,7 @@ export const app = base
200
213
  .get("/gateway/listeners", ...gatewayListenersHandler)
201
214
  .get("/status", ...statusHandler)
202
215
  .get("/update", ...updateHandler)
216
+ }
217
+
218
+ /** CLI Hono app wired to a default `new Funnel()`. For embedding with a custom Funnel use `createCliApp`. */
219
+ export const app = createCliApp(new Funnel())
@@ -5,12 +5,13 @@ import { zValidator } from "@/cli/router/validator"
5
5
 
6
6
  export const addHelp = `funnel profiles add — add a profile
7
7
 
8
- usage: funnel profiles add <name> --path <path> --sub-agent <agent> --channel <channel-name>
8
+ usage: funnel profiles add <name> --path <path> --sub-agent <agent> --channel <channel-name> [--brief]
9
9
 
10
10
  options:
11
11
  --path working directory passed to claude as cwd
12
12
  --sub-agent sub-agent name passed to claude --agent
13
- --channel channel name (resolved to channel id internally)`
13
+ --channel channel name (resolved to channel id internally)
14
+ --brief forward --brief to claude on launch (enables SendUserMessage tool)`
14
15
 
15
16
  export const profilesAddHandler = factory.createHandlers(
16
17
  zValidator("param", z.object({ profile: z.string() })),
@@ -20,6 +21,7 @@ export const profilesAddHandler = factory.createHandlers(
20
21
  path: z.string(),
21
22
  "sub-agent": z.string(),
22
23
  channel: z.string(),
24
+ brief: z.coerce.boolean().optional(),
23
25
  }),
24
26
  addHelp,
25
27
  ),
@@ -39,6 +41,7 @@ export const profilesAddHandler = factory.createHandlers(
39
41
  path: query.path,
40
42
  subAgent: query["sub-agent"],
41
43
  channelId: channel.id,
44
+ ...(query.brief !== undefined ? { brief: query.brief } : {}),
42
45
  })
43
46
 
44
47
  return c.text(`added profile "${param.profile}"`)
@@ -5,7 +5,7 @@ import { zValidator } from "@/cli/router/validator"
5
5
 
6
6
  export const setHelp = `funnel profiles <name> set — update a profile
7
7
 
8
- usage: funnel profiles <name> set [--path <path>] [--sub-agent <agent>] [--channel <channel-name>]`
8
+ usage: funnel profiles <name> set [--path <path>] [--sub-agent <agent>] [--channel <channel-name>] [--brief | --no-brief]`
9
9
 
10
10
  export const profilesSetHandler = factory.createHandlers(
11
11
  zValidator("param", z.object({ profile: z.string() })),
@@ -15,6 +15,8 @@ export const profilesSetHandler = factory.createHandlers(
15
15
  path: z.string().optional(),
16
16
  "sub-agent": z.string().optional(),
17
17
  channel: z.string().optional(),
18
+ brief: z.coerce.boolean().optional(),
19
+ "no-brief": z.coerce.boolean().optional(),
18
20
  }),
19
21
  setHelp,
20
22
  ),
@@ -23,22 +25,19 @@ export const profilesSetHandler = factory.createHandlers(
23
25
  const query = c.req.valid("query")
24
26
  const funnel = c.var.funnel
25
27
 
26
- let channelId: string | undefined
28
+ const channel = query.channel !== undefined ? funnel.channels.get(query.channel) : null
27
29
 
28
- if (query.channel !== undefined) {
29
- const channel = funnel.channels.get(query.channel)
30
-
31
- if (!channel) {
32
- throw new HTTPException(400, { message: `channel "${query.channel}" not found` })
33
- }
34
-
35
- channelId = channel.id
30
+ if (query.channel !== undefined && !channel) {
31
+ throw new HTTPException(400, { message: `channel "${query.channel}" not found` })
36
32
  }
37
33
 
34
+ const brief = query["no-brief"] ? false : query.brief
35
+
38
36
  funnel.profiles.update(param.profile, {
39
37
  path: query.path,
40
38
  subAgent: query["sub-agent"],
41
- channelId,
39
+ channelId: channel?.id,
40
+ ...(brief !== undefined ? { brief } : {}),
42
41
  })
43
42
 
44
43
  return c.text(`updated profile "${param.profile}"`)
@@ -0,0 +1,4 @@
1
+ export * from "@/connectors/discord-adapter"
2
+ export * from "@/connectors/discord-connector-schema"
3
+ export * from "@/connectors/discord-event-processor"
4
+ export * from "@/connectors/discord-listener"
@@ -0,0 +1,3 @@
1
+ export * from "@/connectors/gh-adapter"
2
+ export * from "@/connectors/gh-connector-schema"
3
+ export * from "@/connectors/gh-listener"
@@ -0,0 +1,4 @@
1
+ export * from "@/connectors/match-cron"
2
+ export * from "@/connectors/schedule-connector-schema"
3
+ export * from "@/connectors/schedule-listener"
4
+ export * from "@/connectors/schedule-state-store"
@@ -0,0 +1,4 @@
1
+ export * from "@/connectors/slack-adapter"
2
+ export * from "@/connectors/slack-connector-schema"
3
+ export * from "@/connectors/slack-event-processor"
4
+ export * from "@/connectors/slack-listener"
@@ -16,6 +16,8 @@ export type LaunchOptions = {
16
16
  subAgent?: string
17
17
  userArgs?: string[]
18
18
  profileName?: string
19
+ /** Forward `--brief` to claude on launch (enables the SendUserMessage tool). */
20
+ brief?: boolean
19
21
  }
20
22
 
21
23
  type Deps = {
@@ -182,6 +184,10 @@ export class FunnelClaude {
182
184
  result.push("--agent", options.subAgent)
183
185
  }
184
186
 
187
+ if (options.brief && !result.includes("--brief")) {
188
+ result.push("--brief")
189
+ }
190
+
185
191
  return result
186
192
  }
187
193
 
@@ -1,4 +1,3 @@
1
- import { existsSync, readFileSync } from "node:fs"
2
1
  import { homedir } from "node:os"
3
2
  import { join } from "node:path"
4
3
  import { Server } from "@modelcontextprotocol/sdk/server/index.js"
@@ -7,71 +6,36 @@ import {
7
6
  CallToolRequestSchema,
8
7
  ListToolsRequestSchema,
9
8
  } from "@modelcontextprotocol/sdk/types.js"
9
+ import { FunnelChannelSubscriber } from "@/engine/mcp/channel-subscriber"
10
10
  import { FUNNEL_MCP_NAME } from "@/engine/mcp/mcp"
11
- import { settingsSchema } from "@/engine/settings/settings-schema"
12
-
13
- const GATEWAY_BASE_URL = process.env.FUNNEL_GATEWAY_URL ?? "http://localhost:9742"
14
- const GATEWAY_WS_URL = `${GATEWAY_BASE_URL.replace(/^http/, "ws")}/ws`
15
- const RECONNECT_DELAY = 1000
16
- const MAX_RECONNECT_DELAY = 10000
17
- const SETTINGS_PATH = join(homedir(), ".funnel", "settings.json")
18
- const TOOL_CONNECTOR_TYPES = new Set(["slack", "gh", "discord"])
19
-
20
- const readGatewayToken = (): string | null => {
21
- const fromEnv = process.env.FUNNEL_GATEWAY_TOKEN
22
-
23
- if (fromEnv && fromEnv.length > 0) return fromEnv
24
-
25
- const path = join(homedir(), ".funnel", "gateway.token")
26
-
27
- if (!existsSync(path)) return null
28
-
29
- const value = readFileSync(path, "utf-8").trim()
30
-
31
- return value.length > 0 ? value : null
32
- }
33
-
34
- const readChannelConnectors = (
35
- channelId: string,
36
- ): { channelName: string; connectors: { name: string; type: string }[] } | null => {
37
- if (!existsSync(SETTINGS_PATH)) return null
38
-
39
- const raw = JSON.parse(readFileSync(SETTINGS_PATH, "utf-8"))
40
- const parsed = settingsSchema.safeParse(raw)
41
-
42
- if (!parsed.success) return null
43
-
44
- const channel = parsed.data.channels.find((c) => c.id === channelId)
45
-
46
- if (!channel) return null
47
-
48
- const connectors = channel.connectors
49
- .filter((c) => TOOL_CONNECTOR_TYPES.has(c.type))
50
- .map((c) => ({ name: c.name, type: c.type }))
51
-
52
- return { channelName: channel.name, connectors }
11
+ import { readChannelConnectors } from "@/engine/mcp/read-channel-connectors"
12
+ import { readGatewayToken } from "@/engine/mcp/read-gateway-token"
13
+ import { usageHintForType } from "@/engine/mcp/usage-hint-for-type"
14
+
15
+ const DEFAULT_FUNNEL_DIR = join(homedir(), ".funnel")
16
+ const DEFAULT_GATEWAY_BASE_URL = "http://localhost:9742"
17
+
18
+ export type ChannelServerOptions = {
19
+ /** Funnel home directory (settings.json + gateway.token). Defaults to ~/.funnel. */
20
+ dir?: string
21
+ /** Gateway base URL. Defaults to `$FUNNEL_GATEWAY_URL` or `http://localhost:9742`. */
22
+ gatewayUrl?: string
23
+ /** Channel id to subscribe to. Defaults to `$FUNNEL_CHANNEL_ID`. */
24
+ channelId?: string
25
+ /** Auth token. Defaults to `$FUNNEL_GATEWAY_TOKEN` then `<dir>/gateway.token`. */
26
+ token?: string
53
27
  }
54
28
 
55
- const usageHintForType = (type: string): string => {
56
- if (type === "slack") {
57
- return "Slack Web API. method=POST path=chat.postMessage body={channel,text,thread_ts?}"
58
- }
59
-
60
- if (type === "discord") {
61
- return "Discord REST API. method=POST path=/channels/<id>/messages body={content,...}"
62
- }
63
-
64
- if (type === "gh") {
65
- return "GitHub REST via gh CLI. method=POST path=repos/owner/repo/issues/N/comments body={body}"
66
- }
67
-
68
- return "Generic adapter call."
69
- }
70
-
71
- export const startChannelServer = async (): Promise<void> => {
72
- const channelId = process.env.FUNNEL_CHANNEL_ID
73
- const channel = channelId ? readChannelConnectors(channelId) : null
74
- const token = readGatewayToken()
29
+ export const startChannelServer = async (
30
+ options: ChannelServerOptions = {},
31
+ ): Promise<void> => {
32
+ const dir = options.dir ?? DEFAULT_FUNNEL_DIR
33
+ const gatewayBaseUrl =
34
+ options.gatewayUrl ?? process.env.FUNNEL_GATEWAY_URL ?? DEFAULT_GATEWAY_BASE_URL
35
+ const gatewayWsUrl = `${gatewayBaseUrl.replace(/^http/, "ws")}/ws`
36
+ const channelId = options.channelId ?? process.env.FUNNEL_CHANNEL_ID
37
+ const channel = channelId ? readChannelConnectors(dir, channelId) : null
38
+ const token = options.token ?? readGatewayToken(dir)
75
39
 
76
40
  const server = new Server(
77
41
  { name: FUNNEL_MCP_NAME, version: "1.0.0" },
@@ -121,7 +85,7 @@ export const startChannelServer = async (): Promise<void> => {
121
85
  throw new Error("`method` and `path` are required")
122
86
  }
123
87
 
124
- const url = `${GATEWAY_BASE_URL}/channels/${encodeURIComponent(channel.channelName)}/connectors/${encodeURIComponent(connectorName)}/call`
88
+ const url = `${gatewayBaseUrl}/channels/${encodeURIComponent(channel.channelName)}/connectors/${encodeURIComponent(connectorName)}/call`
125
89
  const headers: Record<string, string> = { "content-type": "application/json" }
126
90
 
127
91
  if (token) headers.authorization = `Bearer ${token}`
@@ -149,56 +113,11 @@ export const startChannelServer = async (): Promise<void> => {
149
113
 
150
114
  if (!channelId) return
151
115
 
152
- const baseUrl = `${GATEWAY_WS_URL}?channel=${encodeURIComponent(channelId)}`
153
- const protocols = token ? [`funnel.token.${token}`] : undefined
154
- let reconnectDelay = RECONNECT_DELAY
155
- let lastOffset = 0
156
-
157
- const connect = () => {
158
- const sinceQuery = lastOffset > 0 ? `&since=${lastOffset}` : ""
159
- const wsUrl = `${baseUrl}${sinceQuery}`
160
- const ws = new WebSocket(wsUrl, protocols)
161
-
162
- ws.addEventListener("open", () => {
163
- reconnectDelay = RECONNECT_DELAY
164
- process.stderr.write(`funnel: connected (${wsUrl})\n`)
165
- })
166
-
167
- ws.addEventListener("message", async (event) => {
168
- try {
169
- const payload = JSON.parse(String(event.data))
170
- const eventType = payload.meta?.event_type ?? "unknown"
171
-
172
- if (typeof payload.offset === "number" && payload.offset > lastOffset) {
173
- lastOffset = payload.offset
174
- }
175
-
176
- process.stderr.write(`funnel: received event (${eventType})\n`)
177
-
178
- await server.notification({
179
- method: "notifications/claude/channel",
180
- params: {
181
- content: payload.content,
182
- meta: payload.meta,
183
- },
184
- })
185
- } catch (error) {
186
- process.stderr.write(
187
- `funnel: error: ${error instanceof Error ? error.message : String(error)}\n`,
188
- )
189
- }
190
- })
191
-
192
- ws.addEventListener("close", () => {
193
- process.stderr.write(`funnel: disconnected, reconnecting in ${reconnectDelay}ms\n`)
194
- setTimeout(connect, reconnectDelay)
195
- reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY)
196
- })
197
-
198
- ws.addEventListener("error", () => {
199
- // close handler will reconnect
200
- })
201
- }
116
+ const subscriber = new FunnelChannelSubscriber({
117
+ server,
118
+ baseUrl: `${gatewayWsUrl}?channel=${encodeURIComponent(channelId)}`,
119
+ protocols: token ? [`funnel.token.${token}`] : undefined,
120
+ })
202
121
 
203
- connect()
122
+ subscriber.start()
204
123
  }
@@ -0,0 +1,82 @@
1
+ import type { Server } from "@modelcontextprotocol/sdk/server/index.js"
2
+
3
+ const RECONNECT_DELAY = 1000
4
+ const MAX_RECONNECT_DELAY = 10000
5
+
6
+ type Props = {
7
+ server: Server
8
+ baseUrl: string
9
+ protocols: string[] | undefined
10
+ }
11
+
12
+ type State = {
13
+ reconnectDelay: number
14
+ lastOffset: number
15
+ }
16
+
17
+ /**
18
+ * Subscribes to the gateway WebSocket for a single channel and forwards
19
+ * incoming events to the MCP server as `notifications/claude/channel`.
20
+ * Reconnects with exponential backoff and replays missed events via `?since=<offset>`.
21
+ */
22
+ export class FunnelChannelSubscriber {
23
+ private readonly state: State = { reconnectDelay: RECONNECT_DELAY, lastOffset: 0 }
24
+
25
+ constructor(private readonly props: Props) {
26
+ Object.freeze(this)
27
+ }
28
+
29
+ start(): void {
30
+ this.connect()
31
+ }
32
+
33
+ private connect(): void {
34
+ const sinceQuery = this.state.lastOffset > 0 ? `&since=${this.state.lastOffset}` : ""
35
+ const wsUrl = `${this.props.baseUrl}${sinceQuery}`
36
+ const ws = new WebSocket(wsUrl, this.props.protocols)
37
+
38
+ ws.addEventListener("open", () => {
39
+ this.state.reconnectDelay = RECONNECT_DELAY
40
+ process.stderr.write(`funnel: connected (${wsUrl})\n`)
41
+ })
42
+
43
+ ws.addEventListener("message", (event) => this.handleMessage(event))
44
+
45
+ ws.addEventListener("close", () => {
46
+ process.stderr.write(
47
+ `funnel: disconnected, reconnecting in ${this.state.reconnectDelay}ms\n`,
48
+ )
49
+ setTimeout(() => this.connect(), this.state.reconnectDelay)
50
+ this.state.reconnectDelay = Math.min(this.state.reconnectDelay * 2, MAX_RECONNECT_DELAY)
51
+ })
52
+
53
+ ws.addEventListener("error", () => {
54
+ // close handler will reconnect
55
+ })
56
+ }
57
+
58
+ private async handleMessage(event: MessageEvent): Promise<void> {
59
+ try {
60
+ const payload = JSON.parse(String(event.data))
61
+ const eventType = payload.meta?.event_type ?? "unknown"
62
+
63
+ if (typeof payload.offset === "number" && payload.offset > this.state.lastOffset) {
64
+ this.state.lastOffset = payload.offset
65
+ }
66
+
67
+ process.stderr.write(`funnel: received event (${eventType})\n`)
68
+
69
+ await this.props.server.notification({
70
+ method: "notifications/claude/channel",
71
+ params: {
72
+ content: payload.content,
73
+ meta: payload.meta,
74
+ },
75
+ })
76
+ } catch (error) {
77
+ process.stderr.write(
78
+ `funnel: error: ${error instanceof Error ? error.message : String(error)}\n`,
79
+ )
80
+ }
81
+ }
82
+ }
@@ -0,0 +1,34 @@
1
+ import { existsSync, readFileSync } from "node:fs"
2
+ import { join } from "node:path"
3
+ import { settingsSchema } from "@/engine/settings/settings-schema"
4
+
5
+ const TOOL_CONNECTOR_TYPES = new Set(["slack", "gh", "discord"])
6
+
7
+ export type ChannelConnectorsView = {
8
+ channelName: string
9
+ connectors: { name: string; type: string }[]
10
+ }
11
+
12
+ export const readChannelConnectors = (
13
+ dir: string,
14
+ channelId: string,
15
+ ): ChannelConnectorsView | null => {
16
+ const settingsPath = join(dir, "settings.json")
17
+
18
+ if (!existsSync(settingsPath)) return null
19
+
20
+ const raw = JSON.parse(readFileSync(settingsPath, "utf-8"))
21
+ const parsed = settingsSchema.safeParse(raw)
22
+
23
+ if (!parsed.success) return null
24
+
25
+ const channel = parsed.data.channels.find((c) => c.id === channelId)
26
+
27
+ if (!channel) return null
28
+
29
+ const connectors = channel.connectors
30
+ .filter((c) => TOOL_CONNECTOR_TYPES.has(c.type))
31
+ .map((c) => ({ name: c.name, type: c.type }))
32
+
33
+ return { channelName: channel.name, connectors }
34
+ }
@@ -0,0 +1,16 @@
1
+ import { existsSync, readFileSync } from "node:fs"
2
+ import { join } from "node:path"
3
+
4
+ export const readGatewayToken = (dir: string): string | null => {
5
+ const fromEnv = process.env.FUNNEL_GATEWAY_TOKEN
6
+
7
+ if (fromEnv && fromEnv.length > 0) return fromEnv
8
+
9
+ const path = join(dir, "gateway.token")
10
+
11
+ if (!existsSync(path)) return null
12
+
13
+ const value = readFileSync(path, "utf-8").trim()
14
+
15
+ return value.length > 0 ? value : null
16
+ }