@interactive-inc/claude-funnel 0.2.0 → 0.4.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 (97) hide show
  1. package/README.md +82 -26
  2. package/lib/funnel.ts +49 -5
  3. package/lib/index.ts +8 -2
  4. package/lib/modules/channels/channel-connector-ref-updater.ts +4 -0
  5. package/lib/modules/channels/funnel-channels.ts +50 -8
  6. package/lib/modules/claude/funnel-claude.ts +79 -1
  7. package/lib/modules/connectors/connector-config-schema.ts +16 -0
  8. package/lib/modules/connectors/connector-existence-checker.ts +3 -0
  9. package/lib/modules/connectors/discord-connector-schema.ts +9 -0
  10. package/lib/modules/connectors/funnel-callable-connector-store.ts +9 -0
  11. package/lib/modules/connectors/funnel-connector-stores.ts +24 -0
  12. package/lib/modules/connectors/funnel-connector-type-store.ts +24 -0
  13. package/lib/modules/connectors/funnel-connectors.ts +98 -77
  14. package/lib/modules/connectors/funnel-discord-adapter.ts +1 -1
  15. package/lib/modules/connectors/funnel-discord-listener.ts +1 -1
  16. package/lib/modules/connectors/funnel-discord-store.ts +84 -0
  17. package/lib/modules/connectors/funnel-gh-listener.ts +1 -1
  18. package/lib/modules/connectors/funnel-gh-store.ts +84 -0
  19. package/lib/modules/connectors/funnel-json-connector-store.ts +100 -0
  20. package/lib/modules/connectors/funnel-schedule-listener.ts +124 -0
  21. package/lib/modules/connectors/funnel-schedule-store.ts +178 -0
  22. package/lib/modules/connectors/funnel-slack-adapter.ts +1 -1
  23. package/lib/modules/connectors/funnel-slack-listener.ts +1 -1
  24. package/lib/modules/connectors/funnel-slack-store.ts +86 -0
  25. package/lib/modules/connectors/gh-connector-schema.ts +9 -0
  26. package/lib/modules/connectors/match-cron.ts +72 -0
  27. package/lib/modules/connectors/migrate-legacy-connectors.ts +77 -0
  28. package/lib/modules/connectors/schedule-connector-schema.ts +18 -0
  29. package/lib/modules/connectors/schedule-last-fired-store.ts +48 -0
  30. package/lib/modules/connectors/slack-connector-schema.ts +10 -0
  31. package/lib/modules/gateway/daemon.ts +30 -13
  32. package/lib/modules/mcp/channel-server.ts +1 -2
  33. package/lib/modules/profiles/funnel-profiles.ts +123 -0
  34. package/lib/modules/profiles/profile-channel-checker.ts +3 -0
  35. package/lib/modules/profiles/profile-channel-ref-updater.ts +3 -0
  36. package/lib/modules/repos/funnel-repositories.ts +4 -4
  37. package/lib/modules/router/to-request.ts +2 -5
  38. package/lib/modules/schedule/funnel-schedule.ts +34 -0
  39. package/lib/modules/settings/funnel-settings-store.ts +1 -2
  40. package/lib/modules/settings/mock-funnel-settings-reader.ts +1 -2
  41. package/lib/modules/settings/settings-schema.ts +3 -37
  42. package/lib/routes/claude/claude.help.ts +9 -4
  43. package/lib/routes/claude/claude.ts +44 -7
  44. package/lib/routes/connectors/add.help.ts +10 -4
  45. package/lib/routes/connectors/add.ts +10 -1
  46. package/lib/routes/connectors/routes.ts +6 -2
  47. package/lib/routes/connectors/schedules-add.help.ts +11 -0
  48. package/lib/routes/connectors/schedules-add.ts +33 -0
  49. package/lib/routes/connectors/schedules-group.help.ts +1 -0
  50. package/lib/routes/connectors/schedules-group.ts +38 -0
  51. package/lib/routes/connectors/schedules-remove.help.ts +3 -0
  52. package/lib/routes/connectors/schedules-remove.ts +17 -0
  53. package/lib/routes/connectors/set.ts +47 -5
  54. package/lib/routes/connectors/show.ts +9 -0
  55. package/lib/routes/profiles/add.help.ts +3 -0
  56. package/lib/routes/{agents → profiles}/add.ts +4 -4
  57. package/lib/routes/profiles/group.help.ts +16 -0
  58. package/lib/routes/profiles/group.ts +25 -0
  59. package/lib/routes/profiles/launch.help.ts +4 -0
  60. package/lib/routes/{agents → profiles}/launch.ts +9 -8
  61. package/lib/routes/profiles/remove.help.ts +3 -0
  62. package/lib/routes/{agents → profiles}/remove.ts +4 -4
  63. package/lib/routes/profiles/rename.help.ts +5 -0
  64. package/lib/routes/{agents → profiles}/rename.ts +4 -4
  65. package/lib/routes/profiles/routes.ts +18 -0
  66. package/lib/routes/profiles/set.help.ts +5 -0
  67. package/lib/routes/{agents → profiles}/set.ts +4 -4
  68. package/lib/routes/repos/add.help.ts +3 -2
  69. package/lib/routes/repos/add.ts +3 -2
  70. package/lib/routes/repos/group.help.ts +1 -1
  71. package/lib/routes/request/discord-help.ts +9 -0
  72. package/lib/routes/request/discord.help.ts +19 -0
  73. package/lib/routes/request/discord.ts +65 -0
  74. package/lib/routes/request/group.help.ts +15 -0
  75. package/lib/routes/request/group.ts +9 -0
  76. package/lib/routes/request/routes.ts +14 -0
  77. package/lib/routes/request/slack-help.ts +9 -0
  78. package/lib/routes/request/slack.help.ts +19 -0
  79. package/lib/routes/{connectors/call.ts → request/slack.ts} +24 -6
  80. package/lib/routes/status/status.help.ts +1 -1
  81. package/lib/routes/status/status.ts +7 -7
  82. package/lib/routes/update/routes.ts +4 -0
  83. package/lib/routes/update/update.help.ts +5 -0
  84. package/lib/routes/update/update.ts +21 -0
  85. package/lib/routes.ts +6 -2
  86. package/package.json +1 -1
  87. package/lib/modules/agents/funnel-agents.ts +0 -105
  88. package/lib/modules/connectors/resolve-listener.ts +0 -13
  89. package/lib/routes/agents/add.help.ts +0 -3
  90. package/lib/routes/agents/group.help.ts +0 -13
  91. package/lib/routes/agents/group.ts +0 -25
  92. package/lib/routes/agents/launch.help.ts +0 -3
  93. package/lib/routes/agents/remove.help.ts +0 -3
  94. package/lib/routes/agents/rename.help.ts +0 -5
  95. package/lib/routes/agents/routes.ts +0 -17
  96. package/lib/routes/agents/set.help.ts +0 -5
  97. package/lib/routes/connectors/call.help.ts +0 -17
package/README.md CHANGED
@@ -3,17 +3,32 @@
3
3
  [![npm](https://img.shields.io/npm/v/@interactive-inc/claude-funnel.svg)](https://www.npmjs.com/package/@interactive-inc/claude-funnel)
4
4
  [![license](https://img.shields.io/npm/l/@interactive-inc/claude-funnel.svg)](./LICENSE)
5
5
 
6
- A hub CLI that connects multiple Claude Code agents to external services (Slack / GitHub / Discord). External events flow through subscription "channels" into Claude Code sessions, and outbound API calls from Claude are funneled through the same connectors.
6
+ A hub CLI that connects multiple Claude Code agents to external services (Slack / GitHub / Discord) and time-based triggers (cron). External events flow through subscription "channels" into Claude Code sessions, and outbound API calls from Claude are funneled through the same connectors.
7
7
 
8
8
  The command is `funnel` or its shorthand `fnl`.
9
9
 
10
10
  ## Overview
11
11
 
12
12
  ```
13
- Slack/others Connectors Channels Claude Code
14
- (external APIs) ─→ (funnel) ─→ (subscription router) ──WS/MCP─→ (received as <channel> tags)
15
-
16
- funnel MCP server (funnel mcp)
13
+ External sources
14
+ (Slack / GitHub / Discord / cron)
15
+
16
+
17
+ Connectors
18
+ (per-type stores)
19
+
20
+
21
+ Channels
22
+ (subscription router)
23
+
24
+ ▼ WebSocket
25
+ Gateway daemon
26
+
27
+ ▼ MCP (stdio)
28
+ Claude Code
29
+ (events surfaced as <channel> tags;
30
+ outbound calls go back through the
31
+ same connectors via funnel MCP)
17
32
  ```
18
33
 
19
34
  ## Requirements
@@ -47,16 +62,36 @@ fnl gateway start
47
62
  fnl claude --channel my-inbox
48
63
  ```
49
64
 
65
+ Schedule (cron) trigger:
66
+
67
+ ```bash
68
+ # Register a schedule connector and a cron entry
69
+ fnl connectors add daily --type schedule
70
+ fnl connectors daily schedules add --cron "0 9 * * *" --prompt "morning standup"
71
+
72
+ # Attach it to a channel just like any other connector
73
+ fnl channels my-inbox connectors attach daily
74
+ ```
75
+
50
76
  ## Commands
51
77
 
52
78
  ```
53
79
  fnl connectors list
54
- fnl connectors add <name> --type <t> --bot-token <t> --app-token <t>
80
+ fnl connectors add <name> --type slack --bot-token xoxb-... --app-token xapp-...
81
+ fnl connectors add <name> --type gh [--poll-interval <sec>]
82
+ fnl connectors add <name> --type discord --bot-token <token>
83
+ fnl connectors add <name> --type schedule
55
84
  fnl connectors <name> show details
56
- fnl connectors <name> set [--bot-token ...] [--app-token ...]
85
+ fnl connectors <name> set [--bot-token ...] [--app-token ...] [--poll-interval ...]
57
86
  fnl connectors rename <old> <new>
58
87
  fnl connectors remove <name>
59
- fnl connectors <name> <method> <path> [body] call API (get/post/put/delete/...)
88
+
89
+ fnl connectors <name> schedules list cron entries
90
+ fnl connectors <name> schedules add --cron "<expr>" --prompt "<text>" [--disabled]
91
+ fnl connectors <name> schedules remove <id>
92
+
93
+ fnl request slack post <path> [body] --connector <name> call Slack Web API
94
+ fnl request discord <method> <path> [body] --connector <name> call Discord REST API
60
95
 
61
96
  fnl channels list
62
97
  fnl channels add <name>
@@ -66,27 +101,31 @@ fnl channels <name> connectors detach <connector>
66
101
  fnl channels rename <old> <new>
67
102
  fnl channels remove <name>
68
103
 
69
- fnl agents list agent presets (extra)
70
- fnl agents add <name> --channel <c> [--repo <r>] [--sub-agent <s>] [--env-file <f>]
71
- fnl agents <name> launch (sugar for fnl claude)
72
- fnl agents <name> set [--channel ...] [--repo ...] [--sub-agent ...] [--env-file ...]
73
- fnl agents rename <old> <new>
74
- fnl agents remove <name>
104
+ fnl profiles list launch profiles
105
+ fnl profiles add <name> --channel <c> [--repo <r>] [--sub-agent <s>] [--env-file <f>]
106
+ fnl profiles <name> run launch (sugar for fnl claude)
107
+ fnl profiles <name> launch (alias for run)
108
+ fnl profiles <name> set [--channel ...] [--repo ...] [--sub-agent ...] [--env-file ...]
109
+ fnl profiles rename <old> <new>
110
+ fnl profiles remove <name>
75
111
 
76
112
  fnl repos list repositories (extra)
77
- fnl repos add <name> --path <path> register funnel MCP into .mcp.json
113
+ fnl repos add <name> [--path <path>] register funnel MCP (path defaults to cwd)
78
114
  fnl repos <name> show details
79
115
  fnl repos <name> set [--path <path>]
80
116
  fnl repos rename <old> <new>
81
117
  fnl repos remove <name>
82
118
 
119
+ fnl claude launch the "default" profile
120
+ fnl claude --profile <name> launch a named profile
83
121
  fnl claude --channel <c> [--repo <r>] [--sub-agent <s>] [--env-file <f>]
84
- launch Claude Code
122
+ raw launch (no profile)
85
123
  fnl mcp run as an MCP server (invoked from .mcp.json)
86
124
 
87
125
  fnl gateway running status
88
126
  fnl gateway start / stop / restart / run / logs
89
- fnl status overall status (connectors / channels / agents / repos / gateway)
127
+ fnl update update funnel via bun i -g
128
+ fnl status overall status (connectors / channels / profiles / repos / gateway)
90
129
 
91
130
  fnl --version
92
131
  fnl --help (every subcommand has --help)
@@ -96,16 +135,29 @@ fnl --help (every subcommand has --help)
96
135
 
97
136
  ```
98
137
  Connector =
99
- | { type: "slack", name, botToken, appToken } Slack Socket Mode
100
- | { type: "gh", name, pollInterval? } GitHub (gh CLI)
101
- | { type: "discord", name, botToken } Discord Gateway
138
+ | { type: "slack", name, botToken, appToken }
139
+ Slack Socket Mode
140
+ | { type: "gh", name, pollInterval? }
141
+ GitHub (gh CLI)
142
+ | { type: "discord", name, botToken }
143
+ Discord Gateway
144
+ | { type: "schedule", name, entries[] }
145
+ cron-driven, entries = { id, cron, prompt, enabled }
146
+
147
+ Channel = { name, connectors[] }
148
+ subscription box
149
+
150
+ Repository = { name, path }
151
+ extra
152
+
153
+ Profile = { name, channel, repo?, subAgent?, envFiles? }
154
+ launch profile
102
155
 
103
- Channel = { name, connectors[] } subscription box
104
- Repository = { name, path } extra
105
- Agent = { name, channel, repo?, subAgent?, envFiles? } preset (extra)
156
+ Settings = { channels[], repositories[], profiles[] }
157
+ ~/.funnel/settings.json
106
158
 
107
- Settings = { connectors[], channels[], repositories[], agents[] }
108
- → ~/.funnel/settings.json
159
+ Connectors are stored per type, one file per connector:
160
+ → ~/.funnel/connectors/<type>/<name>.(json|jsonl)
109
161
  ```
110
162
 
111
163
  ## Discord bot setup
@@ -124,8 +176,12 @@ Settings = { connectors[], channels[], repositories[], agents[] }
124
176
 
125
177
  ## File layout
126
178
 
127
- - Config: `~/.funnel/settings.json`
179
+ - Config: `~/.funnel/settings.json` (channels / repositories / profiles)
180
+ - Connectors: `~/.funnel/connectors/<type>/<name>.(json|jsonl)`
181
+ - `slack/<name>.json`, `gh/<name>.json`, `discord/<name>.json`
182
+ - `schedule/<name>.jsonl` (one entry per line) and `schedule/<name>.state.json` (last-fired timestamps for catch-up)
128
183
  - PID: `~/.funnel/gateway.pid`
184
+ - Claude PIDs: `~/.funnel/claude/<profile>.pid`
129
185
  - Event log: `/tmp/funnel/events/*.jsonl` (auto-deleted after 30 days)
130
186
  - Process log: `/tmp/funnel/gateway.log`
131
187
 
package/lib/funnel.ts CHANGED
@@ -1,14 +1,23 @@
1
- import { FunnelAgents } from "@/modules/agents/funnel-agents"
2
1
  import { FunnelChannels } from "@/modules/channels/funnel-channels"
3
2
  import { FunnelClaude } from "@/modules/claude/funnel-claude"
3
+ import {
4
+ type ConnectorStoresBundle,
5
+ createConnectorStores,
6
+ } from "@/modules/connectors/funnel-connector-stores"
4
7
  import { FunnelConnectors } from "@/modules/connectors/funnel-connectors"
8
+ import type { FunnelFileSystem } from "@/modules/fs/funnel-file-system"
5
9
  import { FunnelGateway } from "@/modules/gateway/funnel-gateway"
6
10
  import { FunnelMcp } from "@/modules/mcp/funnel-mcp"
11
+ import { FunnelProfiles } from "@/modules/profiles/funnel-profiles"
7
12
  import { FunnelRepositories } from "@/modules/repos/funnel-repositories"
13
+ import { FunnelSchedule } from "@/modules/schedule/funnel-schedule"
8
14
  import { FunnelSettingsReader } from "@/modules/settings/funnel-settings-reader"
9
15
 
10
16
  type Props = {
11
17
  store: FunnelSettingsReader
18
+ fs?: FunnelFileSystem
19
+ dir?: string
20
+ connectorStores?: ConnectorStoresBundle
12
21
  }
13
22
 
14
23
  export class Funnel {
@@ -16,16 +25,51 @@ export class Funnel {
16
25
  Object.freeze(this)
17
26
  }
18
27
 
28
+ get stores(): ConnectorStoresBundle {
29
+ return (
30
+ this.props.connectorStores ??
31
+ createConnectorStores({ fs: this.props.fs, dir: this.props.dir })
32
+ )
33
+ }
34
+
19
35
  get connectors(): FunnelConnectors {
20
- return new FunnelConnectors({ store: this.props.store })
36
+ const stores = this.stores
37
+ const profiles = this.profiles
38
+ const channels: FunnelChannels = new FunnelChannels({
39
+ store: this.props.store,
40
+ connectorChecker: { has: (name) => connectors.has(name) },
41
+ profileChecker: profiles,
42
+ profileRefUpdater: profiles,
43
+ })
44
+ const connectors: FunnelConnectors = new FunnelConnectors({
45
+ ...stores,
46
+ refUpdater: channels,
47
+ })
48
+ return connectors
21
49
  }
22
50
 
23
51
  get channels(): FunnelChannels {
24
- return new FunnelChannels({ store: this.props.store })
52
+ const stores = this.stores
53
+ const profiles = this.profiles
54
+ const channels: FunnelChannels = new FunnelChannels({
55
+ store: this.props.store,
56
+ connectorChecker: { has: (name) => connectors.has(name) },
57
+ profileChecker: profiles,
58
+ profileRefUpdater: profiles,
59
+ })
60
+ const connectors: FunnelConnectors = new FunnelConnectors({
61
+ ...stores,
62
+ refUpdater: channels,
63
+ })
64
+ return channels
65
+ }
66
+
67
+ get schedule(): FunnelSchedule {
68
+ return new FunnelSchedule({ store: this.stores.schedule })
25
69
  }
26
70
 
27
- get agents(): FunnelAgents {
28
- return new FunnelAgents({ store: this.props.store })
71
+ get profiles(): FunnelProfiles {
72
+ return new FunnelProfiles({ store: this.props.store })
29
73
  }
30
74
 
31
75
  get repositories(): FunnelRepositories {
package/lib/index.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  #!/usr/bin/env bun
2
2
  import pkg from "../package.json" with { type: "json" }
3
+ import { createConnectorStores } from "@/modules/connectors/funnel-connector-stores"
4
+ import { migrateLegacyConnectors } from "@/modules/connectors/migrate-legacy-connectors"
3
5
  import { startChannelServer } from "@/modules/mcp/channel-server"
4
6
  import { toRequest } from "@/modules/router/to-request"
5
7
  import { launchTui } from "@/modules/tui/tui"
@@ -7,19 +9,23 @@ import { app } from "@/routes"
7
9
 
8
10
  process.title = "funnel"
9
11
 
12
+ migrateLegacyConnectors({ stores: createConnectorStores() })
13
+
10
14
  const HELP = `funnel — Open Claude Funnel
11
15
 
12
16
  usage: funnel [command]
13
17
 
14
18
  commands:
15
19
  (none) launch TUI
16
- claude --channel <c> launch Claude Code
20
+ claude launch Claude Code (default profile or --profile)
17
21
  connectors manage external connections (Slack, etc.)
18
22
  channels manage subscription boxes
19
- agents manage agent presets (extra)
23
+ profiles manage launch profiles
24
+ request send an outbound API call via a connector
20
25
  repos manage repositories (extra)
21
26
  gateway manage the gateway
22
27
  status show overall connection status
28
+ update update funnel to the latest version
23
29
  mcp run as an MCP server (invoked from .mcp.json)
24
30
 
25
31
  options:
@@ -0,0 +1,4 @@
1
+ export type ChannelConnectorRefUpdater = {
2
+ renameRef(oldName: string, newName: string): void
3
+ removeRef(connectorName: string): void
4
+ }
@@ -1,15 +1,27 @@
1
+ import type { ConnectorExistenceChecker } from "@/modules/connectors/connector-existence-checker"
2
+ import type { ProfileChannelChecker } from "@/modules/profiles/profile-channel-checker"
3
+ import type { ProfileChannelRefUpdater } from "@/modules/profiles/profile-channel-ref-updater"
1
4
  import { FunnelSettingsReader } from "@/modules/settings/funnel-settings-reader"
2
5
  import type { ChannelConfig } from "@/modules/settings/settings-schema"
3
6
 
4
7
  type Deps = {
5
8
  store: FunnelSettingsReader
9
+ connectorChecker: ConnectorExistenceChecker
10
+ profileChecker: ProfileChannelChecker
11
+ profileRefUpdater: ProfileChannelRefUpdater
6
12
  }
7
13
 
8
14
  export class FunnelChannels {
9
15
  private readonly store: FunnelSettingsReader
16
+ private readonly connectorChecker: ConnectorExistenceChecker
17
+ private readonly profileChecker: ProfileChannelChecker
18
+ private readonly profileRefUpdater: ProfileChannelRefUpdater
10
19
 
11
20
  constructor(deps: Deps) {
12
21
  this.store = deps.store
22
+ this.connectorChecker = deps.connectorChecker
23
+ this.profileChecker = deps.profileChecker
24
+ this.profileRefUpdater = deps.profileRefUpdater
13
25
  Object.freeze(this)
14
26
  }
15
27
 
@@ -29,7 +41,7 @@ export class FunnelChannels {
29
41
  }
30
42
 
31
43
  for (const connectorName of config.connectors) {
32
- if (!settings.connectors.some((c) => c.name === connectorName)) {
44
+ if (!this.connectorChecker.has(connectorName)) {
33
45
  throw new Error(`connector "${connectorName}" not found`)
34
46
  }
35
47
  }
@@ -46,8 +58,8 @@ export class FunnelChannels {
46
58
 
47
59
  if (index < 0) throw new Error(`channel "${name}" not found`)
48
60
 
49
- if (settings.agents.some((a) => a.channel === name)) {
50
- throw new Error(`channel "${name}" is referenced by an agent`)
61
+ if (this.profileChecker.hasChannelRef(name)) {
62
+ throw new Error(`channel "${name}" is referenced by a profile`)
51
63
  }
52
64
 
53
65
  settings.channels.splice(index, 1)
@@ -68,11 +80,9 @@ export class FunnelChannels {
68
80
 
69
81
  channel.name = newName
70
82
 
71
- for (const agent of settings.agents) {
72
- if (agent.channel === oldName) agent.channel = newName
73
- }
74
-
75
83
  this.store.write(settings)
84
+
85
+ this.profileRefUpdater.renameChannelRef(oldName, newName)
76
86
  }
77
87
 
78
88
  attachConnector(name: string, connectorName: string): void {
@@ -82,7 +92,7 @@ export class FunnelChannels {
82
92
 
83
93
  if (!channel) throw new Error(`channel "${name}" not found`)
84
94
 
85
- if (!settings.connectors.some((c) => c.name === connectorName)) {
95
+ if (!this.connectorChecker.has(connectorName)) {
86
96
  throw new Error(`connector "${connectorName}" not found`)
87
97
  }
88
98
 
@@ -110,4 +120,36 @@ export class FunnelChannels {
110
120
 
111
121
  this.store.write(settings)
112
122
  }
123
+
124
+ renameRef(oldName: string, newName: string): void {
125
+ const settings = this.store.read()
126
+ let changed = false
127
+
128
+ for (const channel of settings.channels) {
129
+ const i = channel.connectors.indexOf(oldName)
130
+
131
+ if (i >= 0) {
132
+ channel.connectors[i] = newName
133
+ changed = true
134
+ }
135
+ }
136
+
137
+ if (changed) this.store.write(settings)
138
+ }
139
+
140
+ removeRef(connectorName: string): void {
141
+ const settings = this.store.read()
142
+ let changed = false
143
+
144
+ for (const channel of settings.channels) {
145
+ const i = channel.connectors.indexOf(connectorName)
146
+
147
+ if (i >= 0) {
148
+ channel.connectors.splice(i, 1)
149
+ changed = true
150
+ }
151
+ }
152
+
153
+ if (changed) this.store.write(settings)
154
+ }
113
155
  }
@@ -1,3 +1,4 @@
1
+ import { join } from "node:path"
1
2
  import type { FunnelChannels } from "@/modules/channels/funnel-channels"
2
3
  import { FunnelFileSystem } from "@/modules/fs/funnel-file-system"
3
4
  import { NodeFunnelFileSystem } from "@/modules/fs/node-funnel-file-system"
@@ -7,6 +8,9 @@ import type { FunnelMcp } from "@/modules/mcp/funnel-mcp"
7
8
  import { FunnelProcessRunner } from "@/modules/process/funnel-process-runner"
8
9
  import { NodeFunnelProcessRunner } from "@/modules/process/node-funnel-process-runner"
9
10
  import type { FunnelRepositories } from "@/modules/repos/funnel-repositories"
11
+ import { FUNNEL_DIR } from "@/modules/settings/funnel-settings-store"
12
+
13
+ const CLAUDE_PID_DIR = join(FUNNEL_DIR, "claude")
10
14
 
11
15
  export type LaunchOptions = {
12
16
  channel: string
@@ -14,6 +18,7 @@ export type LaunchOptions = {
14
18
  subAgent?: string
15
19
  envFiles?: string[]
16
20
  userArgs?: string[]
21
+ profileName?: string
17
22
  }
18
23
 
19
24
  type Deps = {
@@ -53,6 +58,10 @@ export class FunnelClaude {
53
58
  throw new Error(`channel "${options.channel}" not found`)
54
59
  }
55
60
 
61
+ if (options.profileName && this.isRunning(options.profileName)) {
62
+ throw new Error(`profile "${options.profileName}" is already running`)
63
+ }
64
+
56
65
  const cwd = options.repo
57
66
  ? this.repositories.resolvePath(options.repo)
58
67
  : globalThis.process.cwd()
@@ -68,6 +77,11 @@ export class FunnelClaude {
68
77
  await this.gateway.start()
69
78
  }
70
79
 
80
+ if (options.profileName) {
81
+ this.writePidFile(options.profileName)
82
+ this.installCleanup(options.profileName)
83
+ }
84
+
71
85
  const claudeArgs = this.buildArgs(options, cwd)
72
86
  const env = this.buildEnv(options, cwd)
73
87
 
@@ -78,7 +92,71 @@ export class FunnelClaude {
78
92
  cwd,
79
93
  })
80
94
 
81
- return await this.process.attach(["claude", ...claudeArgs], { cwd, env })
95
+ try {
96
+ return await this.process.attach(["claude", ...claudeArgs], { cwd, env })
97
+ } finally {
98
+ if (options.profileName) this.removePidFile(options.profileName)
99
+ }
100
+ }
101
+
102
+ isRunning(profileName: string): boolean {
103
+ const pid = this.readPid(profileName)
104
+
105
+ if (!pid) return false
106
+
107
+ return this.isProcessAlive(pid)
108
+ }
109
+
110
+ private pidPath(profileName: string): string {
111
+ return join(CLAUDE_PID_DIR, `${profileName}.pid`)
112
+ }
113
+
114
+ private readPid(profileName: string): number | null {
115
+ const path = this.pidPath(profileName)
116
+
117
+ if (!this.fs.existsSync(path)) return null
118
+
119
+ try {
120
+ const content = this.fs.readFileSync(path).trim()
121
+ const pid = Number(content)
122
+
123
+ if (!pid || pid <= 0) return null
124
+
125
+ return pid
126
+ } catch {
127
+ return null
128
+ }
129
+ }
130
+
131
+ private writePidFile(profileName: string): void {
132
+ this.fs.mkdirSync(CLAUDE_PID_DIR, { recursive: true })
133
+ this.fs.writeFileSync(this.pidPath(profileName), String(globalThis.process.pid))
134
+ }
135
+
136
+ private removePidFile(profileName: string): void {
137
+ const path = this.pidPath(profileName)
138
+
139
+ if (this.fs.existsSync(path)) this.fs.unlink(path)
140
+ }
141
+
142
+ private installCleanup(profileName: string): void {
143
+ const cleanup = () => this.removePidFile(profileName)
144
+
145
+ globalThis.process.once("exit", cleanup)
146
+ globalThis.process.once("SIGINT", cleanup)
147
+ globalThis.process.once("SIGTERM", cleanup)
148
+ }
149
+
150
+ private isProcessAlive(pid: number): boolean {
151
+ const result = this.process.runSync(["ps", "-p", String(pid), "-o", "state="])
152
+
153
+ if (result.exitCode !== 0) return false
154
+
155
+ const state = result.stdout.trim()
156
+
157
+ if (!state) return false
158
+
159
+ return !state.startsWith("Z")
82
160
  }
83
161
 
84
162
  private buildArgs(options: LaunchOptions, cwd: string): string[] {
@@ -0,0 +1,16 @@
1
+ import { z } from "zod"
2
+ import { discordConnectorSchema } from "@/modules/connectors/discord-connector-schema"
3
+ import { ghConnectorSchema } from "@/modules/connectors/gh-connector-schema"
4
+ import { scheduleConnectorSchema } from "@/modules/connectors/schedule-connector-schema"
5
+ import { slackConnectorSchema } from "@/modules/connectors/slack-connector-schema"
6
+
7
+ export const connectorConfigSchema = z.discriminatedUnion("type", [
8
+ slackConnectorSchema,
9
+ ghConnectorSchema,
10
+ discordConnectorSchema,
11
+ scheduleConnectorSchema,
12
+ ])
13
+
14
+ export type ConnectorConfig = z.infer<typeof connectorConfigSchema>
15
+
16
+ export type ConnectorType = ConnectorConfig["type"]
@@ -0,0 +1,3 @@
1
+ export type ConnectorExistenceChecker = {
2
+ has(name: string): boolean
3
+ }
@@ -0,0 +1,9 @@
1
+ import { z } from "zod"
2
+
3
+ export const discordConnectorSchema = z.object({
4
+ type: z.literal("discord"),
5
+ name: z.string(),
6
+ botToken: z.string().min(10),
7
+ })
8
+
9
+ export type DiscordConnectorConfig = z.infer<typeof discordConnectorSchema>
@@ -0,0 +1,9 @@
1
+ import type { FunnelConnectorAdapter } from "@/modules/connectors/funnel-connector-adapter"
2
+ import { FunnelConnectorTypeStore } from "@/modules/connectors/funnel-connector-type-store"
3
+ import type { ConnectorConfig } from "@/modules/connectors/connector-config-schema"
4
+
5
+ export abstract class FunnelCallableConnectorStore<
6
+ TConfig extends ConnectorConfig,
7
+ > extends FunnelConnectorTypeStore<TConfig> {
8
+ abstract createAdapter(config: TConfig): FunnelConnectorAdapter
9
+ }
@@ -0,0 +1,24 @@
1
+ import { FunnelDiscordStore } from "@/modules/connectors/funnel-discord-store"
2
+ import { FunnelGhStore } from "@/modules/connectors/funnel-gh-store"
3
+ import { FunnelScheduleStore } from "@/modules/connectors/funnel-schedule-store"
4
+ import { FunnelSlackStore } from "@/modules/connectors/funnel-slack-store"
5
+ import { FunnelFileSystem } from "@/modules/fs/funnel-file-system"
6
+
7
+ export type ConnectorStoresBundle = {
8
+ slack: FunnelSlackStore
9
+ gh: FunnelGhStore
10
+ discord: FunnelDiscordStore
11
+ schedule: FunnelScheduleStore
12
+ }
13
+
14
+ type Deps = {
15
+ fs?: FunnelFileSystem
16
+ dir?: string
17
+ }
18
+
19
+ export const createConnectorStores = (deps: Deps = {}): ConnectorStoresBundle => ({
20
+ slack: new FunnelSlackStore(deps),
21
+ gh: new FunnelGhStore(deps),
22
+ discord: new FunnelDiscordStore(deps),
23
+ schedule: new FunnelScheduleStore(deps),
24
+ })
@@ -0,0 +1,24 @@
1
+ import type { FunnelConnectorListener } from "@/modules/connectors/funnel-connector-listener"
2
+ import type { ConnectorConfig } from "@/modules/connectors/connector-config-schema"
3
+
4
+ export abstract class FunnelConnectorTypeStore<TConfig extends ConnectorConfig> {
5
+ abstract readonly type: TConfig["type"]
6
+
7
+ abstract list(): TConfig[]
8
+
9
+ abstract get(name: string): TConfig | null
10
+
11
+ abstract has(name: string): boolean
12
+
13
+ abstract add(config: TConfig): void
14
+
15
+ abstract remove(name: string): void
16
+
17
+ abstract rename(oldName: string, newName: string): void
18
+
19
+ abstract createListener(config: TConfig): FunnelConnectorListener
20
+
21
+ createAllListeners(): { config: TConfig; listener: FunnelConnectorListener }[] {
22
+ return this.list().map((config) => ({ config, listener: this.createListener(config) }))
23
+ }
24
+ }