claude-code-mux 0.1.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 ADDED
@@ -0,0 +1,271 @@
1
+ # claude-mux
2
+
3
+ One messaging client, many Claude Code agents. A multiplexer that routes messages between a messaging platform (Telegram, Discord, etc.) and multiple Claude Code sessions running on different worktrees.
4
+
5
+ <img src="demo.png" width="400" alt="claude-mux demo — Telegram chat showing /list, /status, /help commands and agent switching" />
6
+
7
+ ## Architecture
8
+
9
+ ```
10
+ Telegram Bot
11
+ |
12
+ v
13
+ Router (standalone process, localhost:9900)
14
+ - Owns the bot token
15
+ - Handles /list, /switch, /status commands
16
+ - Fuzzy-matches agent names on /switch
17
+ - Natural language switching via LLM (Anthropic, OpenAI, or Google)
18
+ - Routes messages to the active agent
19
+ | (WebSocket)
20
+ v
21
+ Bridge (MCP channel plugin, one per Claude Code session)
22
+ - Auto-detects agent name from git (repo/branch)
23
+ - Registers with router on startup
24
+ - Pushes messages into Claude Code session
25
+ - Sends replies + notifications back through router
26
+ ```
27
+
28
+ ## Install
29
+
30
+ ```bash
31
+ npm install -g claude-mux
32
+ ```
33
+
34
+ ## Prerequisites
35
+
36
+ - Claude Code v2.1.80+
37
+ - Node.js 20+
38
+ - A Telegram bot token (see [Create a Telegram Bot](#create-a-telegram-bot) below)
39
+
40
+ ## Create a Telegram Bot
41
+
42
+ 1. Message **@BotFather** on Telegram → `/newbot` → pick a name and username
43
+ 2. Copy the **bot token** (looks like `123456789:ABCdef...`)
44
+ 3. Keep the token secret — never commit it to git
45
+
46
+ ## Setup Router
47
+
48
+ The router is a standalone process that owns the Telegram bot and runs once on your machine (or a server). All agents connect to it.
49
+
50
+ ### 1. Configure environment
51
+
52
+ Create a `.env` file (the router loads it automatically):
53
+
54
+ ```env
55
+ # Required — your bot token from BotFather
56
+ TELEGRAM_BOT_TOKEN=123456789:ABCdef...
57
+
58
+ # Optional — enables LLM-powered natural language switching
59
+ # Pick a provider and set the corresponding API key:
60
+ ROUTER_MODEL=anthropic:claude-haiku-4-5-20251001
61
+ ANTHROPIC_API_KEY=sk-ant-...
62
+
63
+ # Or use OpenAI:
64
+ # ROUTER_MODEL=openai:gpt-5.4-nano
65
+ # OPENAI_API_KEY=sk-...
66
+
67
+ # Or Google:
68
+ # ROUTER_MODEL=google:gemini-3.1-flash-lite-preview
69
+ # GOOGLE_GENERATIVE_AI_API_KEY=...
70
+ ```
71
+
72
+ See [Configuration > Router](#router) for all available options.
73
+
74
+ ### 2. Start the router
75
+
76
+ ```bash
77
+ claude-mux-router
78
+ ```
79
+
80
+ Or without global install:
81
+
82
+ ```bash
83
+ npx claude-mux-router
84
+ ```
85
+
86
+ The router loads `.env` automatically and starts listening on `ws://127.0.0.1:9900/ws`.
87
+
88
+ ### 3. Pair your Telegram account
89
+
90
+ Send any message to your bot in Telegram. The router auto-pairs with the first sender and remembers the chat ID for all future replies. If you set `ALLOWED_USER_IDS` in `.env`, only those users can interact with the bot.
91
+
92
+ ## Setup Agent
93
+
94
+ Each Claude Code session needs the bridge MCP plugin to connect to the router. You register it once globally, then every agent picks it up automatically.
95
+
96
+ ### 1. Register the bridge as a global MCP server
97
+
98
+ Use `claude mcp add` with the `-s user` flag so the bridge is available in **all** Claude Code sessions:
99
+
100
+ ```bash
101
+ claude mcp add -s user claude-mux-bridge -- npx claude-mux-bridge
102
+ ```
103
+
104
+ ### 2. Enable autoApprove for bridge tools
105
+
106
+ Without this, you'd get a permission prompt every time the agent tries to reply or send a notification — which defeats the purpose of async communication. Open `~/.claude.json` and add `autoApprove` to the bridge entry:
107
+
108
+ ```json
109
+ {
110
+ "mcpServers": {
111
+ "claude-mux-bridge": {
112
+ "command": "npx",
113
+ "args": ["claude-mux-bridge"],
114
+ "autoApprove": ["reply", "notify"]
115
+ }
116
+ }
117
+ }
118
+ ```
119
+
120
+ ### 3. Launch Claude Code sessions
121
+
122
+ Just `cd` into any worktree and start Claude Code. The agent name is auto-detected from git (repo/branch), or falls back to the directory name:
123
+
124
+ ```bash
125
+ # Terminal 1 — auto-detects as "myapp/feature-nav"
126
+ cd ~/projects/myapp-feature-nav
127
+ claude --dangerously-load-development-channels server:claude-mux-bridge
128
+
129
+ # Terminal 2 — auto-detects as "monots/encore"
130
+ cd ~/worktrees/monots-encore
131
+ claude --dangerously-load-development-channels server:claude-mux-bridge
132
+
133
+ # Terminal 3 — auto-detects as "myapp/fix-auth-bug"
134
+ cd ~/projects/myapp-fix-auth-bug
135
+ claude --dangerously-load-development-channels server:claude-mux-bridge
136
+ ```
137
+
138
+ No per-session env vars needed. Each session registers with the router automatically. When you spin up a new worktree, just start Claude Code in it — no extra setup required.
139
+
140
+ ## Usage (from Telegram)
141
+
142
+ ### Commands
143
+
144
+ | Command | Description |
145
+ |---------|-------------|
146
+ | `/list` | Show all connected agents |
147
+ | `/switch <agent>` | Switch to an agent by repo, branch, directory, or description |
148
+ | `/status` | Show which agent is active |
149
+ | `/help` | Show all commands |
150
+
151
+ ### Smart switching
152
+
153
+ You don't need to type the exact agent name. The router first tries token-based fuzzy matching (instant, no API call). If that's ambiguous, and you've configured a `ROUTER_MODEL`, it falls back to the LLM to interpret natural language:
154
+
155
+ ```
156
+ You: /list
157
+ Bot: Connected agents:
158
+ monots/encore (active) — up 32m
159
+ myapp/feature-nav — up 15m
160
+ myapp/fix-auth-bug — up 5m
161
+
162
+ You: /switch monots encore
163
+ Bot: Switched to monots/encore
164
+
165
+ You: /switch nav
166
+ Bot: Switched to myapp/feature-nav
167
+
168
+ You: /switch myapp
169
+ Bot: Multiple matches for "myapp":
170
+ 1. myapp/feature-nav
171
+ 2. myapp/fix-auth-bug
172
+ Be more specific, or use /switch with the full name.
173
+
174
+ You: /switch fix auth
175
+ Bot: Switched to myapp/fix-auth-bug
176
+
177
+ You: switch to the one working on the bug fix
178
+ Bot: Switched to myapp/fix-auth-bug
179
+ ```
180
+
181
+ The last example doesn't use a `/` command at all. Any message containing the word "switch" triggers the LLM to classify whether it's a switch intent or a regular message to the agent. This means "switch to the one working on the bug fix" gets intercepted and resolved by the LLM, while "can you switch the database driver to postgres" passes through to the agent as a normal message. Without a `ROUTER_MODEL` configured, only `/switch` commands with fuzzy matching are available.
182
+
183
+ ### Chatting with agents
184
+
185
+ Any non-command message goes to the active agent. If only one agent is connected, it's auto-selected.
186
+
187
+ ```
188
+ You: can you add pagination to the /users endpoint?
189
+ Bot: [monots/encore] I'll add pagination to the /users endpoint...
190
+ ```
191
+
192
+ ### Notifications
193
+
194
+ Agents proactively notify you when tasks complete:
195
+
196
+ ```
197
+ Bot: [myapp/feature-nav] Finished refactoring the nav component.
198
+ Changed 3 files, all 24 tests passing.
199
+ Ready for your review.
200
+ ```
201
+
202
+ ### Permission relay
203
+
204
+ When an agent needs permission to run a tool, you'll get a prompt:
205
+
206
+ ```
207
+ Bot: [monots/encore] Permission request:
208
+ Tool: Bash
209
+ Action: Run npm test
210
+ Reply "yes abcde" or "no abcde"
211
+
212
+ You: yes abcde
213
+ ```
214
+
215
+ ## Dynamic worktrees
216
+
217
+ The whole point of this tool: you don't create new bots or set env vars when you spin up a new worktree. Just start Claude Code in the directory:
218
+
219
+ ```bash
220
+ cd ~/worktrees/myapp-hotfix-123
221
+ claude --dangerously-load-development-channels server:claude-mux-bridge
222
+ ```
223
+
224
+ The bridge detects `myapp/hotfix-123` from git and registers automatically. When you close the session, it deregisters and your Telegram shows a disconnect notification.
225
+
226
+ ## Configuration
227
+
228
+ ### Router
229
+
230
+ Set these in `.env` (or as environment variables) where you run `pnpm router`:
231
+
232
+ | Env var | Default | Description |
233
+ |---------|---------|-------------|
234
+ | `TELEGRAM_BOT_TOKEN` | (required) | Bot token from BotFather |
235
+ | `ROUTER_MODEL` | (optional) | LLM for smart switching, format `provider:model-id` (e.g. `anthropic:claude-haiku-4-5-20251001`, `openai:gpt-5.4-nano`, `google:gemini-3.1-flash-lite-preview`) |
236
+ | `ANTHROPIC_API_KEY` | (optional) | API key for Anthropic models. If set without `ROUTER_MODEL`, defaults to `anthropic:claude-haiku-4-5-20251001` |
237
+ | `OPENAI_API_KEY` | (optional) | API key for OpenAI models |
238
+ | `GOOGLE_GENERATIVE_AI_API_KEY` | (optional) | API key for Google models |
239
+ | `ROUTER_PORT` | `9900` | Port the router listens on |
240
+ | `ROUTER_HOST` | `127.0.0.1` | Bind address — use `0.0.0.0` to allow remote bridge connections |
241
+ | `ALLOWED_USER_IDS` | auto-pair | Comma-separated Telegram user IDs |
242
+
243
+ ### Bridge
244
+
245
+ The agent name is detected automatically: git repo + branch (e.g. `myapp/feature-nav`), or the current directory name if not in a git repo. Most bridge env vars are optional — the defaults work when router and bridge run on the same machine.
246
+
247
+ | Env var | Default | Description |
248
+ |---------|---------|-------------|
249
+ | `AGENT_NAME` | auto-detect | Override the agent name (default: git repo/branch, or directory name) |
250
+ | `ROUTER_PORT` | `9900` | Must match the router's `ROUTER_PORT` |
251
+ | `ROUTER_URL` | `ws://127.0.0.1:{ROUTER_PORT}/ws` | Full WebSocket URL — set this when the router is on a different machine |
252
+
253
+ ## Troubleshooting
254
+
255
+ **"Conflict: terminated by other getUpdates request"**
256
+ Only one process can poll a Telegram bot token at a time. If you installed the official `telegram@claude-plugins-official` plugin, uninstall it first: `/plugin uninstall telegram@claude-plugins-official` inside Claude Code.
257
+
258
+ **"no MCP server configured with that name"**
259
+ The bridge was likely registered to a project scope instead of user scope. Re-register with the `-s user` flag:
260
+ ```bash
261
+ claude mcp add -s user claude-mux-bridge -- npx claude-mux-bridge
262
+ ```
263
+
264
+ **Bridge tools keep asking for permission**
265
+ Add `autoApprove` to the MCP server config in `~/.claude.json`:
266
+ ```json
267
+ "autoApprove": ["reply", "notify"]
268
+ ```
269
+
270
+ **Agent name shows as the directory name instead of repo/branch**
271
+ The bridge couldn't detect git info. Make sure you `cd` into a git repo before starting Claude Code. If there's no git remote, it uses the repo root directory + branch. If it's not a git repo at all, it falls back to the current directory name.
package/demo.png ADDED
Binary file
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * claude-mux bridge
4
+ *
5
+ * MCP channel plugin that runs inside a Claude Code session.
6
+ * Connects to the router via WebSocket and bridges messages
7
+ * between Telegram (via router) and the Claude Code session.
8
+ *
9
+ * Agent name is auto-detected from git (repo/branch), or can be
10
+ * overridden with the AGENT_NAME env var.
11
+ *
12
+ * Usage:
13
+ * claude --dangerously-load-development-channels server:claude-mux-bridge
14
+ *
15
+ * Agent name is detected automatically:
16
+ * 1. git remote repo name + current branch (e.g. "myapp/feature-nav")
17
+ * 2. git repo root directory + branch (if no remote)
18
+ * 3. current directory name (if not a git repo)
19
+ *
20
+ * Optional env vars:
21
+ * ROUTER_PORT — port to connect to (default: 9900, shared with router)
22
+ * ROUTER_URL — full WebSocket URL, overrides ROUTER_PORT (default: ws://127.0.0.1:{ROUTER_PORT}/ws)
23
+ */
24
+ export {};
package/dist/bridge.js ADDED
@@ -0,0 +1,221 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * claude-mux bridge
4
+ *
5
+ * MCP channel plugin that runs inside a Claude Code session.
6
+ * Connects to the router via WebSocket and bridges messages
7
+ * between Telegram (via router) and the Claude Code session.
8
+ *
9
+ * Agent name is auto-detected from git (repo/branch), or can be
10
+ * overridden with the AGENT_NAME env var.
11
+ *
12
+ * Usage:
13
+ * claude --dangerously-load-development-channels server:claude-mux-bridge
14
+ *
15
+ * Agent name is detected automatically:
16
+ * 1. git remote repo name + current branch (e.g. "myapp/feature-nav")
17
+ * 2. git repo root directory + branch (if no remote)
18
+ * 3. current directory name (if not a git repo)
19
+ *
20
+ * Optional env vars:
21
+ * ROUTER_PORT — port to connect to (default: 9900, shared with router)
22
+ * ROUTER_URL — full WebSocket URL, overrides ROUTER_PORT (default: ws://127.0.0.1:{ROUTER_PORT}/ws)
23
+ */
24
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
25
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
26
+ import { z } from 'zod';
27
+ import { execSync } from 'child_process';
28
+ import { WebSocket } from 'ws';
29
+ // ---------- git detection ----------
30
+ function detectAgentName() {
31
+ try {
32
+ // get the repo name from the remote origin, or fall back to directory name
33
+ let repoName;
34
+ try {
35
+ const remoteUrl = execSync('git remote get-url origin', { encoding: 'utf-8' }).trim();
36
+ // extract repo name from URLs like:
37
+ // git@github.com:user/repo.git
38
+ // https://github.com/user/repo.git
39
+ repoName = remoteUrl.split('/').pop()?.replace(/\.git$/, '') ?? '';
40
+ }
41
+ catch {
42
+ // no remote — use the repo root directory name
43
+ const topLevel = execSync('git rev-parse --show-toplevel', { encoding: 'utf-8' }).trim();
44
+ repoName = topLevel.split('/').pop() ?? '';
45
+ }
46
+ // get the current branch
47
+ const branch = execSync('git branch --show-current', { encoding: 'utf-8' }).trim();
48
+ if (repoName && branch) {
49
+ return `${repoName}/${branch}`;
50
+ }
51
+ if (repoName) {
52
+ return repoName;
53
+ }
54
+ }
55
+ catch {
56
+ // not in a git repo
57
+ }
58
+ // last resort: use the current directory name
59
+ const cwd = process.cwd();
60
+ return cwd.split('/').pop() ?? 'unknown';
61
+ }
62
+ // ---------- config ----------
63
+ const AGENT_NAME = process.env.AGENT_NAME ?? detectAgentName();
64
+ console.error(`[bridge] Agent name: ${AGENT_NAME}`);
65
+ const ROUTER_PORT = process.env.ROUTER_PORT ?? '9900';
66
+ const ROUTER_URL = process.env.ROUTER_URL ?? `ws://127.0.0.1:${ROUTER_PORT}/ws`;
67
+ // ---------- MCP server ----------
68
+ const mcp = new McpServer({ name: `claude-mux-bridge-${AGENT_NAME}`, version: '0.1.0' }, {
69
+ capabilities: {
70
+ experimental: {
71
+ 'claude/channel': {},
72
+ 'claude/channel/permission': {},
73
+ },
74
+ },
75
+ instructions: [
76
+ `Messages arrive as <channel source="claude-mux-bridge-${AGENT_NAME}" sender="..." chat_id="...">. `,
77
+ 'Reply using the "reply" tool, passing the chat_id from the tag. ',
78
+ 'When you finish a task, ALWAYS send a completion summary via the "notify" tool so the user gets a notification. ',
79
+ `You are agent "${AGENT_NAME}". If asked who you are or which agent this is, identify yourself by this name.`,
80
+ ].join(''),
81
+ });
82
+ // ---------- tools ----------
83
+ mcp.registerTool('reply', {
84
+ description: 'Reply to a message. Use this when responding to a user message that arrived via the channel.',
85
+ inputSchema: {
86
+ chat_id: z.string().describe('The chat_id from the inbound <channel> tag'),
87
+ text: z.string().describe('The reply text to send'),
88
+ },
89
+ }, async ({ text }) => {
90
+ sendToRouter({ type: 'reply', text });
91
+ return { content: [{ type: 'text', text: 'sent' }] };
92
+ });
93
+ mcp.registerTool('notify', {
94
+ description: 'Send a proactive notification to the user. Use this when a task completes, an error occurs, or you need to alert the user about something without them asking.',
95
+ inputSchema: {
96
+ text: z.string().describe('The notification text to send'),
97
+ },
98
+ }, async ({ text }) => {
99
+ sendToRouter({ type: 'notify', text });
100
+ return { content: [{ type: 'text', text: 'notification sent' }] };
101
+ });
102
+ // ---------- permission relay ----------
103
+ const PermissionRequestSchema = z.object({
104
+ method: z.literal('notifications/claude/channel/permission_request'),
105
+ params: z.object({
106
+ request_id: z.string(),
107
+ tool_name: z.string(),
108
+ description: z.string(),
109
+ input_preview: z.string(),
110
+ }),
111
+ });
112
+ mcp.server.setNotificationHandler(PermissionRequestSchema, async ({ params }) => {
113
+ sendToRouter({
114
+ type: 'reply',
115
+ text: [
116
+ `Permission request:`,
117
+ `Tool: ${params.tool_name}`,
118
+ `Action: ${params.description}`,
119
+ '',
120
+ `Reply "yes ${params.request_id}" or "no ${params.request_id}"`,
121
+ ].join('\n'),
122
+ });
123
+ });
124
+ // ---------- WebSocket connection to router ----------
125
+ let ws = null;
126
+ let reconnectTimer = null;
127
+ const RECONNECT_DELAY = 3000;
128
+ // permission reply detection (same pattern as official plugins)
129
+ const PERMISSION_REPLY_RE = /^\s*(y|yes|n|no)\s+([a-km-z]{5})\s*$/i;
130
+ function sendToRouter(msg) {
131
+ if (ws?.readyState === WebSocket.OPEN) {
132
+ ws.send(JSON.stringify(msg));
133
+ }
134
+ }
135
+ function connectToRouter() {
136
+ try {
137
+ ws = new WebSocket(ROUTER_URL);
138
+ ws.onopen = () => {
139
+ console.error(`[bridge:${AGENT_NAME}] Connected to router`);
140
+ // register ourselves
141
+ sendToRouter({ type: 'register', name: AGENT_NAME });
142
+ // clear any reconnect timer
143
+ if (reconnectTimer) {
144
+ clearTimeout(reconnectTimer);
145
+ reconnectTimer = null;
146
+ }
147
+ };
148
+ ws.onmessage = async (event) => {
149
+ try {
150
+ const msg = JSON.parse(String(event.data));
151
+ if (msg.type === 'registered') {
152
+ console.error(`[bridge:${AGENT_NAME}] Registered with router as "${msg.name}"`);
153
+ }
154
+ if (msg.type === 'message') {
155
+ const text = String(msg.text ?? '');
156
+ const chatId = String(msg.chatId ?? '');
157
+ const senderName = String(msg.senderName ?? 'user');
158
+ // check for permission reply
159
+ const m = PERMISSION_REPLY_RE.exec(text);
160
+ if (m) {
161
+ await mcp.server.notification({
162
+ method: 'notifications/claude/channel/permission',
163
+ params: {
164
+ request_id: m[2].toLowerCase(),
165
+ behavior: m[1].toLowerCase().startsWith('y') ? 'allow' : 'deny',
166
+ },
167
+ });
168
+ return;
169
+ }
170
+ // forward as channel notification to Claude
171
+ await mcp.server.notification({
172
+ method: 'notifications/claude/channel',
173
+ params: {
174
+ content: text,
175
+ meta: {
176
+ sender: senderName,
177
+ chat_id: chatId,
178
+ },
179
+ },
180
+ });
181
+ }
182
+ }
183
+ catch (err) {
184
+ console.error(`[bridge:${AGENT_NAME}] Error handling message:`, err);
185
+ }
186
+ };
187
+ ws.onclose = () => {
188
+ console.error(`[bridge:${AGENT_NAME}] Disconnected from router, reconnecting in ${RECONNECT_DELAY}ms...`);
189
+ scheduleReconnect();
190
+ };
191
+ ws.onerror = (err) => {
192
+ console.error(`[bridge:${AGENT_NAME}] WebSocket error:`, err);
193
+ };
194
+ }
195
+ catch (err) {
196
+ console.error(`[bridge:${AGENT_NAME}] Failed to connect:`, err);
197
+ scheduleReconnect();
198
+ }
199
+ }
200
+ function scheduleReconnect() {
201
+ if (reconnectTimer)
202
+ return;
203
+ reconnectTimer = setTimeout(() => {
204
+ reconnectTimer = null;
205
+ connectToRouter();
206
+ }, RECONNECT_DELAY);
207
+ }
208
+ // ---------- startup ----------
209
+ // connect to Claude Code over stdio
210
+ await mcp.connect(new StdioServerTransport());
211
+ // connect to the router
212
+ connectToRouter();
213
+ // cleanup on exit
214
+ process.on('SIGINT', () => {
215
+ ws?.close();
216
+ process.exit(0);
217
+ });
218
+ process.on('SIGTERM', () => {
219
+ ws?.close();
220
+ process.exit(0);
221
+ });
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Telegram messaging client for claude-mux router.
3
+ *
4
+ * Handles Telegram Bot API polling, auto-pairing, sender allowlist,
5
+ * and message chunking (4096 char limit).
6
+ */
7
+ import type { MessagingClient, OnMessageCallback } from '../messaging-client.js';
8
+ export declare class TelegramClient implements MessagingClient {
9
+ private readonly tgApi;
10
+ private readonly allowedUserIds;
11
+ private pairedChatId;
12
+ private messageCallback;
13
+ private offset;
14
+ constructor(opts: {
15
+ botToken: string;
16
+ allowedUserIds?: number[];
17
+ });
18
+ /** The paired chat ID, if any. Useful for the router to know where to send unsolicited messages. */
19
+ get chatId(): string | null;
20
+ onMessage(callback: OnMessageCallback): void;
21
+ sendMessage(chatId: string, text: string): Promise<void>;
22
+ start(): Promise<void>;
23
+ private tgCall;
24
+ private pollOnce;
25
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Telegram messaging client for claude-mux router.
3
+ *
4
+ * Handles Telegram Bot API polling, auto-pairing, sender allowlist,
5
+ * and message chunking (4096 char limit).
6
+ */
7
+ // ---------- client ----------
8
+ export class TelegramClient {
9
+ tgApi;
10
+ allowedUserIds;
11
+ pairedChatId = null;
12
+ messageCallback = null;
13
+ offset = 0;
14
+ constructor(opts) {
15
+ this.tgApi = `https://api.telegram.org/bot${opts.botToken}`;
16
+ this.allowedUserIds = new Set(opts.allowedUserIds ?? []);
17
+ }
18
+ /** The paired chat ID, if any. Useful for the router to know where to send unsolicited messages. */
19
+ get chatId() {
20
+ return this.pairedChatId !== null ? String(this.pairedChatId) : null;
21
+ }
22
+ onMessage(callback) {
23
+ this.messageCallback = callback;
24
+ }
25
+ async sendMessage(chatId, text) {
26
+ const numericChatId = parseInt(chatId, 10);
27
+ // split long messages (telegram limit 4096 chars)
28
+ const chunks = [];
29
+ let remaining = text;
30
+ while (remaining.length > 0) {
31
+ if (remaining.length <= 4096) {
32
+ chunks.push(remaining);
33
+ break;
34
+ }
35
+ let breakAt = remaining.lastIndexOf('\n', 4096);
36
+ if (breakAt < 2000)
37
+ breakAt = 4096;
38
+ chunks.push(remaining.slice(0, breakAt));
39
+ remaining = remaining.slice(breakAt);
40
+ }
41
+ for (const chunk of chunks) {
42
+ await this.tgCall('sendMessage', { chat_id: numericChatId, text: chunk });
43
+ }
44
+ }
45
+ async start() {
46
+ console.log('[telegram] Starting poll loop...');
47
+ while (true) {
48
+ await this.pollOnce();
49
+ }
50
+ }
51
+ // ---------- internals ----------
52
+ async tgCall(method, body) {
53
+ const resp = await fetch(`${this.tgApi}/${method}`, {
54
+ method: 'POST',
55
+ headers: { 'Content-Type': 'application/json' },
56
+ body: body ? JSON.stringify(body) : undefined,
57
+ });
58
+ const json = (await resp.json());
59
+ if (!json.ok) {
60
+ console.error(`Telegram API error (${method}):`, json.description);
61
+ }
62
+ return json.result;
63
+ }
64
+ async pollOnce() {
65
+ try {
66
+ const result = await this.tgCall('getUpdates', {
67
+ offset: this.offset,
68
+ timeout: 30,
69
+ allowed_updates: ['message'],
70
+ });
71
+ if (!result)
72
+ return;
73
+ for (const update of result) {
74
+ this.offset = update.update_id + 1;
75
+ const msg = update.message;
76
+ if (!msg?.text)
77
+ continue;
78
+ const senderId = msg.from.id;
79
+ const chatId = msg.chat.id;
80
+ // sender gating
81
+ if (this.allowedUserIds.size > 0 && !this.allowedUserIds.has(senderId)) {
82
+ continue;
83
+ }
84
+ // auto-pair: remember the chat id for replies
85
+ if (this.pairedChatId === null) {
86
+ this.pairedChatId = chatId;
87
+ if (this.allowedUserIds.size === 0) {
88
+ this.allowedUserIds.add(senderId);
89
+ }
90
+ await this.sendMessage(String(chatId), `Paired! Your user ID ${senderId} is now allowed.\nUse /help to see commands.`);
91
+ continue;
92
+ }
93
+ console.log(`[telegram] Received: "${msg.text}" from ${msg.from.first_name ?? msg.from.username ?? senderId}`);
94
+ // deliver to router
95
+ if (this.messageCallback) {
96
+ await this.messageCallback({
97
+ text: msg.text,
98
+ chatId: String(chatId),
99
+ senderId: String(senderId),
100
+ senderName: msg.from.first_name ?? msg.from.username ?? String(senderId),
101
+ });
102
+ }
103
+ }
104
+ }
105
+ catch (err) {
106
+ console.error('[telegram] Poll error:', err);
107
+ }
108
+ }
109
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Core router for claude-mux.
3
+ *
4
+ * Handles agent management, command handling, fuzzy/LLM matching,
5
+ * and the WebSocket server for bridges. Platform-agnostic — communicates
6
+ * with users through a MessagingClient interface.
7
+ */
8
+ import { type LanguageModel } from 'ai';
9
+ import type { MessagingClient } from './messaging-client.js';
10
+ export declare class Router {
11
+ private readonly client;
12
+ private readonly model;
13
+ private readonly port;
14
+ private readonly host;
15
+ private readonly agents;
16
+ private readonly wsBySocket;
17
+ private activeAgent;
18
+ private defaultChatId;
19
+ constructor(opts: {
20
+ client: MessagingClient;
21
+ model?: LanguageModel;
22
+ port?: number;
23
+ host?: string;
24
+ });
25
+ start(): Promise<void>;
26
+ private handleInboundMessage;
27
+ private llmMatchAgent;
28
+ private detectSwitchIntent;
29
+ private listAgents;
30
+ private fuzzyMatchAgent;
31
+ private switchAgent;
32
+ private statusMessage;
33
+ private parseCommand;
34
+ private handleCommand;
35
+ private handleHttpRequest;
36
+ private handleWsMessage;
37
+ private handleWsClose;
38
+ }
@@ -0,0 +1,375 @@
1
+ /**
2
+ * Core router for claude-mux.
3
+ *
4
+ * Handles agent management, command handling, fuzzy/LLM matching,
5
+ * and the WebSocket server for bridges. Platform-agnostic — communicates
6
+ * with users through a MessagingClient interface.
7
+ */
8
+ import { generateText, generateObject } from 'ai';
9
+ import { z } from 'zod';
10
+ import { createServer } from 'http';
11
+ import { WebSocketServer } from 'ws';
12
+ // ---------- router ----------
13
+ export class Router {
14
+ client;
15
+ model;
16
+ port;
17
+ host;
18
+ agents = new Map();
19
+ wsBySocket = new Map();
20
+ activeAgent = null;
21
+ defaultChatId = null;
22
+ constructor(opts) {
23
+ this.client = opts.client;
24
+ this.model = opts.model ?? null;
25
+ this.port = opts.port ?? 9900;
26
+ this.host = opts.host ?? '127.0.0.1';
27
+ if (this.model) {
28
+ const modelId = typeof this.model === 'string' ? this.model : this.model.modelId;
29
+ console.log(`[router] LLM routing enabled (${modelId})`);
30
+ }
31
+ else {
32
+ console.log('[router] LLM routing disabled (no model provided). Using fuzzy match only.');
33
+ }
34
+ }
35
+ async start() {
36
+ // wire up inbound messages from the client
37
+ this.client.onMessage(this.handleInboundMessage.bind(this));
38
+ // start HTTP + WebSocket server
39
+ const httpServer = createServer(this.handleHttpRequest.bind(this));
40
+ const wss = new WebSocketServer({ server: httpServer, path: '/ws' });
41
+ wss.on('connection', (ws) => {
42
+ console.log('[router] Bridge connected');
43
+ ws.on('message', (raw) => this.handleWsMessage(ws, String(raw)));
44
+ ws.on('close', () => this.handleWsClose(ws));
45
+ });
46
+ httpServer.listen(this.port, this.host, () => {
47
+ console.log(`[router] Listening on ws://${this.host}:${this.port}/ws`);
48
+ });
49
+ // start the messaging client (blocking — e.g. Telegram poll loop)
50
+ await this.client.start();
51
+ }
52
+ // ---------- inbound message dispatch ----------
53
+ async handleInboundMessage(msg) {
54
+ // remember the chat for unsolicited messages (bridge replies, connect/disconnect)
55
+ if (this.defaultChatId === null) {
56
+ this.defaultChatId = msg.chatId;
57
+ }
58
+ // handle commands — ALL /commands stay in the router, never forwarded
59
+ if (msg.text.startsWith('/')) {
60
+ const parsed = this.parseCommand(msg.text);
61
+ console.log(`[router] Command detected: ${parsed ? `/${parsed.command} args="${parsed.args}"` : `(parse failed: "${msg.text}")`}`);
62
+ await this.handleCommand(msg.chatId, msg.text);
63
+ return;
64
+ }
65
+ // natural language switch detection
66
+ if (this.model && this.agents.size > 1 && /\bswitch\b/i.test(msg.text)) {
67
+ console.log(`[router] "switch" keyword detected in: "${msg.text}", asking Haiku for intent...`);
68
+ const intent = await this.detectSwitchIntent(msg.text);
69
+ if (intent) {
70
+ console.log(`[router] Haiku detected switch intent, query: "${intent.query}"`);
71
+ await this.client.sendMessage(msg.chatId, await this.switchAgent(intent.query));
72
+ return;
73
+ }
74
+ console.log(`[router] Haiku says not a switch request, forwarding to agent`);
75
+ }
76
+ // route to active agent
77
+ if (!this.activeAgent || !this.agents.has(this.activeAgent)) {
78
+ if (this.agents.size === 1) {
79
+ this.activeAgent = this.agents.keys().next().value ?? null;
80
+ console.log(`[router] Auto-selected only agent: ${this.activeAgent}`);
81
+ }
82
+ else {
83
+ console.log(`[router] No active agent, ${this.agents.size} agents connected`);
84
+ await this.client.sendMessage(msg.chatId, this.agents.size === 0
85
+ ? 'No agents connected. Start a Claude Code session with the bridge plugin.'
86
+ : `Multiple agents connected. Use /switch <name> to pick one.\n${this.listAgents()}`);
87
+ return;
88
+ }
89
+ }
90
+ const agent = this.agents.get(this.activeAgent);
91
+ if (!agent)
92
+ return;
93
+ console.log(`[router] Forwarding to agent: ${this.activeAgent}`);
94
+ agent.ws.send(JSON.stringify({
95
+ type: 'message',
96
+ text: msg.text,
97
+ chatId: msg.chatId,
98
+ senderName: msg.senderName,
99
+ }));
100
+ }
101
+ // ---------- LLM routing ----------
102
+ async llmMatchAgent(query, agentNames) {
103
+ if (!this.model || agentNames.length === 0)
104
+ return null;
105
+ const prompt = `Agents:\n${agentNames.map(n => `- ${n}`).join('\n')}\n\nUser wants to switch to: "${query}"`;
106
+ console.log(`[llm] Request: ${prompt}`);
107
+ try {
108
+ const { text: result } = await generateText({
109
+ model: this.model,
110
+ maxOutputTokens: 100,
111
+ system: [
112
+ 'You are a routing assistant. Given a list of agent names (formatted as repo/branch) and a user query, ',
113
+ 'pick the single best matching agent. Respond with ONLY the exact agent name, nothing else. ',
114
+ 'If no agent matches at all, respond with "NONE".',
115
+ ].join(''),
116
+ prompt,
117
+ });
118
+ const trimmed = result.trim();
119
+ console.log(`[llm] Response: "${trimmed}"`);
120
+ if (trimmed === 'NONE')
121
+ return 'NONE';
122
+ if (agentNames.includes(trimmed)) {
123
+ console.log(`[llm] Exact match in agent list`);
124
+ return trimmed;
125
+ }
126
+ const lower = trimmed.toLowerCase();
127
+ const found = agentNames.find(n => n.toLowerCase() === lower);
128
+ if (found) {
129
+ console.log(`[llm] Case-insensitive match: "${found}"`);
130
+ return found;
131
+ }
132
+ console.log(`[llm] Response "${trimmed}" does not match any agent: [${agentNames.join(', ')}]`);
133
+ return null;
134
+ }
135
+ catch (err) {
136
+ console.error('[llm] LLM routing failed, falling back to fuzzy match:', err);
137
+ return null;
138
+ }
139
+ }
140
+ async detectSwitchIntent(text) {
141
+ if (!this.model)
142
+ return null;
143
+ try {
144
+ const { object: intent } = await generateObject({
145
+ model: this.model,
146
+ maxOutputTokens: 200,
147
+ system: [
148
+ 'You help route messages. The user is chatting with coding agents identified by repo/branch names. ',
149
+ 'Determine if this message is asking to switch to a different agent/worktree/repo/branch. ',
150
+ 'If YES: set intent to "switch" and query to the part describing which agent. ',
151
+ 'If NO (the word "switch" is part of a coding instruction like "switch statement" or "switch branches in git"): ',
152
+ 'set intent to "message".',
153
+ ].join(''),
154
+ schema: z.object({
155
+ intent: z.enum(['switch', 'message']),
156
+ query: z.string().optional(),
157
+ }),
158
+ prompt: `Connected agents:\n${[...this.agents.keys()].map(n => `- ${n}`).join('\n')}\n\nUser message: "${text}"`,
159
+ });
160
+ console.log(`[llm-intent] Response: ${JSON.stringify(intent)}`);
161
+ if (intent.intent === 'switch' && intent.query) {
162
+ return { query: intent.query };
163
+ }
164
+ return null;
165
+ }
166
+ catch (err) {
167
+ console.error('[llm-intent] Intent detection failed, forwarding as message:', err);
168
+ return null;
169
+ }
170
+ }
171
+ // ---------- command handling ----------
172
+ listAgents() {
173
+ if (this.agents.size === 0)
174
+ return 'No agents connected.';
175
+ const lines = [];
176
+ for (const [name, agent] of this.agents) {
177
+ const marker = name === this.activeAgent ? ' (active)' : '';
178
+ const uptime = Math.round((Date.now() - agent.registeredAt.getTime()) / 60000);
179
+ lines.push(` ${name}${marker} — up ${uptime}m`);
180
+ }
181
+ return `Connected agents:\n${lines.join('\n')}`;
182
+ }
183
+ fuzzyMatchAgent(query) {
184
+ const queryTokens = query.toLowerCase().split(/[\s\/\-_]+/).filter(Boolean);
185
+ if (queryTokens.length === 0)
186
+ return [];
187
+ const results = [];
188
+ for (const agentName of this.agents.keys()) {
189
+ const nameTokens = agentName.toLowerCase().split(/[\s\/\-_]+/).filter(Boolean);
190
+ const nameLower = agentName.toLowerCase();
191
+ let score = 0;
192
+ if (nameLower === query.toLowerCase()) {
193
+ score = 1000;
194
+ }
195
+ else {
196
+ for (const qt of queryTokens) {
197
+ if (nameTokens.includes(qt)) {
198
+ score += 10;
199
+ }
200
+ else if (nameTokens.some(nt => nt.startsWith(qt))) {
201
+ score += 7;
202
+ }
203
+ else if (nameLower.includes(qt)) {
204
+ score += 4;
205
+ }
206
+ else if (nameTokens.some(nt => qt.startsWith(nt))) {
207
+ score += 2;
208
+ }
209
+ }
210
+ }
211
+ if (score > 0) {
212
+ results.push({ name: agentName, score });
213
+ }
214
+ }
215
+ results.sort((a, b) => b.score - a.score);
216
+ return results;
217
+ }
218
+ async switchAgent(query) {
219
+ console.log(`[switch] Query: "${query}"`);
220
+ console.log(`[switch] Connected agents: [${[...this.agents.keys()].join(', ')}]`);
221
+ if (this.agents.has(query)) {
222
+ this.activeAgent = query;
223
+ console.log(`[switch] Exact match → ${query}`);
224
+ return `Switched to ${query}`;
225
+ }
226
+ const agentNames = [...this.agents.keys()];
227
+ if (this.model) {
228
+ console.log(`[switch] No exact match, calling LLM...`);
229
+ const llmResult = await this.llmMatchAgent(query, agentNames);
230
+ if (llmResult && llmResult !== 'NONE') {
231
+ this.activeAgent = llmResult;
232
+ console.log(`[switch] Haiku matched → ${llmResult}`);
233
+ return `Switched to ${llmResult}`;
234
+ }
235
+ if (llmResult === 'NONE') {
236
+ console.log(`[switch] Haiku says no match`);
237
+ const available = agentNames.join('\n ') || 'none';
238
+ return `No agent matches "${query}". Connected:\n ${available}`;
239
+ }
240
+ console.log('[switch] Haiku unavailable, falling back to fuzzy match');
241
+ }
242
+ const matches = this.fuzzyMatchAgent(query);
243
+ console.log(`[switch] Fuzzy matches: ${JSON.stringify(matches.slice(0, 5))}`);
244
+ if (matches.length === 0) {
245
+ console.log(`[switch] No fuzzy matches`);
246
+ const available = agentNames.join('\n ') || 'none';
247
+ return `No agent matches "${query}". Connected:\n ${available}`;
248
+ }
249
+ if (matches.length === 1 || matches[0].score >= matches[1].score * 1.5) {
250
+ this.activeAgent = matches[0].name;
251
+ console.log(`[switch] Fuzzy matched → ${matches[0].name} (score: ${matches[0].score})`);
252
+ return `Switched to ${matches[0].name}`;
253
+ }
254
+ console.log(`[switch] Ambiguous, showing options`);
255
+ const options = matches.slice(0, 5)
256
+ .map((m, i) => ` ${i + 1}. ${m.name}`)
257
+ .join('\n');
258
+ return `Multiple matches for "${query}":\n${options}\n\nBe more specific, or use /switch with the full name.`;
259
+ }
260
+ statusMessage() {
261
+ const active = this.activeAgent && this.agents.has(this.activeAgent) ? this.activeAgent : 'none';
262
+ return `Active: ${active}\nTotal agents: ${this.agents.size}`;
263
+ }
264
+ parseCommand(text) {
265
+ const trimmed = text.trim();
266
+ if (!trimmed.startsWith('/'))
267
+ return null;
268
+ // strip @bot_name suffix (Telegram convention, harmless for other platforms)
269
+ const match = trimmed.match(/^\/(\w+)(?:@\S+)?\s*(.*)$/s);
270
+ if (!match)
271
+ return null;
272
+ return { command: match[1].toLowerCase(), args: match[2].trim() };
273
+ }
274
+ async handleCommand(chatId, text) {
275
+ const parsed = this.parseCommand(text);
276
+ if (!parsed)
277
+ return false;
278
+ switch (parsed.command) {
279
+ case 'list':
280
+ await this.client.sendMessage(chatId, this.listAgents());
281
+ return true;
282
+ case 'status':
283
+ await this.client.sendMessage(chatId, this.statusMessage());
284
+ return true;
285
+ case 'switch':
286
+ if (!parsed.args) {
287
+ await this.client.sendMessage(chatId, 'Usage: /switch <agent-name>');
288
+ }
289
+ else {
290
+ await this.client.sendMessage(chatId, await this.switchAgent(parsed.args));
291
+ }
292
+ return true;
293
+ case 'help':
294
+ case 'start':
295
+ await this.client.sendMessage(chatId, [
296
+ 'Commands:',
297
+ ' /list — show connected agents',
298
+ ' /switch <name> — route messages to an agent',
299
+ ' /status — show current routing',
300
+ ' /help — this message',
301
+ '',
302
+ 'Any other message goes to the active agent.',
303
+ ].join('\n'));
304
+ return true;
305
+ default:
306
+ await this.client.sendMessage(chatId, `Unknown command: /${parsed.command}\nType /help for available commands.`);
307
+ return true;
308
+ }
309
+ }
310
+ // ---------- HTTP ----------
311
+ handleHttpRequest(_req, res) {
312
+ if (_req.url === '/health') {
313
+ res.writeHead(200, { 'Content-Type': 'application/json' });
314
+ res.end(JSON.stringify({
315
+ agents: [...this.agents.keys()],
316
+ activeAgent: this.activeAgent,
317
+ paired: this.defaultChatId !== null,
318
+ }));
319
+ return;
320
+ }
321
+ res.writeHead(200, { 'Content-Type': 'text/plain' });
322
+ res.end('claude-mux router');
323
+ }
324
+ // ---------- WebSocket (bridges) ----------
325
+ handleWsMessage(ws, raw) {
326
+ try {
327
+ const msg = JSON.parse(raw);
328
+ if (msg.type === 'register') {
329
+ const name = String(msg.name);
330
+ if (this.agents.has(name)) {
331
+ const old = this.agents.get(name);
332
+ this.wsBySocket.delete(old.ws);
333
+ try {
334
+ old.ws.close();
335
+ }
336
+ catch { }
337
+ }
338
+ const agent = { name, ws, registeredAt: new Date() };
339
+ this.agents.set(name, agent);
340
+ this.wsBySocket.set(ws, agent);
341
+ console.log(`[router] Agent registered: ${name} (total: ${this.agents.size})`);
342
+ if (this.agents.size === 1)
343
+ this.activeAgent = name;
344
+ if (this.defaultChatId) {
345
+ this.client.sendMessage(this.defaultChatId, `Agent "${name}" connected.${this.activeAgent === name ? ' (active)' : ` Use /switch ${name} to activate.`}`);
346
+ }
347
+ ws.send(JSON.stringify({ type: 'registered', name }));
348
+ }
349
+ if (msg.type === 'reply' || msg.type === 'notify') {
350
+ if (!this.defaultChatId)
351
+ return;
352
+ const agentInfo = this.wsBySocket.get(ws);
353
+ const prefix = agentInfo ? `[${agentInfo.name}] ` : '';
354
+ this.client.sendMessage(this.defaultChatId, `${prefix}${String(msg.text)}`);
355
+ }
356
+ }
357
+ catch (err) {
358
+ console.error('[router] Bad message from bridge:', err);
359
+ }
360
+ }
361
+ handleWsClose(ws) {
362
+ const agent = this.wsBySocket.get(ws);
363
+ if (agent) {
364
+ this.agents.delete(agent.name);
365
+ this.wsBySocket.delete(ws);
366
+ console.log(`[router] Agent disconnected: ${agent.name} (total: ${this.agents.size})`);
367
+ if (this.activeAgent === agent.name) {
368
+ this.activeAgent = this.agents.size > 0 ? this.agents.keys().next().value ?? null : null;
369
+ }
370
+ if (this.defaultChatId) {
371
+ this.client.sendMessage(this.defaultChatId, `Agent "${agent.name}" disconnected.${this.activeAgent ? ` Active: ${this.activeAgent}` : ''}`);
372
+ }
373
+ }
374
+ }
375
+ }
@@ -0,0 +1,23 @@
1
+ /** A message received from the messaging platform */
2
+ export interface InboundMessage {
3
+ text: string;
4
+ chatId: string;
5
+ senderId: string;
6
+ senderName: string;
7
+ }
8
+ /** Callback the client calls when a message arrives */
9
+ export type OnMessageCallback = (msg: InboundMessage) => Promise<void>;
10
+ /**
11
+ * Interface that any messaging platform must implement.
12
+ * The router calls sendMessage(); the client calls onMessage() when a user sends something.
13
+ */
14
+ export interface MessagingClient {
15
+ /** Send text to a chat. The client handles platform-specific chunking/limits. */
16
+ sendMessage(chatId: string, text: string): Promise<void>;
17
+ /** Register the handler the router will use to receive messages */
18
+ onMessage(callback: OnMessageCallback): void;
19
+ /** Start the client (polling, webhook, etc). Called once by the router. */
20
+ start(): Promise<void>;
21
+ /** Optional cleanup */
22
+ stop?(): Promise<void>;
23
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * claude-mux router entrypoint
4
+ *
5
+ * Loads config from .env, creates a Telegram client and core router,
6
+ * and starts everything. Swap TelegramClient for another MessagingClient
7
+ * implementation to support Discord, Slack, WhatsApp, etc.
8
+ *
9
+ * LLM provider is configured via ROUTER_MODEL env var:
10
+ * anthropic:claude-haiku-4-5-20251001 (default if ANTHROPIC_API_KEY is set)
11
+ * openai:gpt-5.4-nano
12
+ * google:gemini-3.1-flash-lite-preview
13
+ */
14
+ export {};
package/dist/router.js ADDED
@@ -0,0 +1,69 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * claude-mux router entrypoint
4
+ *
5
+ * Loads config from .env, creates a Telegram client and core router,
6
+ * and starts everything. Swap TelegramClient for another MessagingClient
7
+ * implementation to support Discord, Slack, WhatsApp, etc.
8
+ *
9
+ * LLM provider is configured via ROUTER_MODEL env var:
10
+ * anthropic:claude-haiku-4-5-20251001 (default if ANTHROPIC_API_KEY is set)
11
+ * openai:gpt-5.4-nano
12
+ * google:gemini-3.1-flash-lite-preview
13
+ */
14
+ import { config } from 'dotenv';
15
+ config();
16
+ import { Router } from './core-router.js';
17
+ import { TelegramClient } from './clients/telegram.js';
18
+ const botToken = process.env.TELEGRAM_BOT_TOKEN;
19
+ if (!botToken) {
20
+ console.error('TELEGRAM_BOT_TOKEN is required');
21
+ process.exit(1);
22
+ }
23
+ // ---------- LLM model ----------
24
+ async function createModel() {
25
+ const modelSpec = process.env.ROUTER_MODEL;
26
+ if (modelSpec) {
27
+ const [provider, ...rest] = modelSpec.split(':');
28
+ const modelId = rest.join(':');
29
+ if (!modelId) {
30
+ console.error(`Invalid ROUTER_MODEL format: "${modelSpec}". Expected "provider:model-id".`);
31
+ process.exit(1);
32
+ }
33
+ switch (provider) {
34
+ case 'anthropic': {
35
+ const { createAnthropic } = await import('@ai-sdk/anthropic');
36
+ return createAnthropic()(modelId);
37
+ }
38
+ case 'openai': {
39
+ const { createOpenAI } = await import('@ai-sdk/openai');
40
+ return createOpenAI()(modelId);
41
+ }
42
+ case 'google': {
43
+ const { createGoogleGenerativeAI } = await import('@ai-sdk/google');
44
+ return createGoogleGenerativeAI()(modelId);
45
+ }
46
+ default:
47
+ console.error(`Unknown LLM provider: "${provider}". Supported: anthropic, openai, google.`);
48
+ process.exit(1);
49
+ }
50
+ }
51
+ // default: use Anthropic Haiku if API key is available
52
+ if (process.env.ANTHROPIC_API_KEY) {
53
+ const { createAnthropic } = await import('@ai-sdk/anthropic');
54
+ return createAnthropic()('claude-haiku-4-5-20251001');
55
+ }
56
+ return undefined;
57
+ }
58
+ // ---------- start ----------
59
+ const allowedUserIds = process.env.ALLOWED_USER_IDS
60
+ ?.split(',')
61
+ .map(id => parseInt(id.trim(), 10));
62
+ const client = new TelegramClient({ botToken, allowedUserIds });
63
+ const router = new Router({
64
+ client,
65
+ model: await createModel(),
66
+ port: parseInt(process.env.ROUTER_PORT ?? '9900', 10),
67
+ host: process.env.ROUTER_HOST ?? '127.0.0.1',
68
+ });
69
+ router.start();
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "claude-code-mux",
3
+ "version": "0.1.0",
4
+ "description": "One messaging client, many Claude Code agents — a multiplexer for routing messages to multiple Claude Code sessions",
5
+ "type": "module",
6
+ "bin": {
7
+ "claude-mux-router": "./dist/router.js",
8
+ "claude-mux-bridge": "./dist/bridge.js"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "demo.png"
13
+ ],
14
+ "scripts": {
15
+ "build": "tsc",
16
+ "prepublishOnly": "tsc",
17
+ "router": "tsx src/router.ts",
18
+ "typecheck": "tsc --noEmit"
19
+ },
20
+ "keywords": [
21
+ "claude",
22
+ "claude-code",
23
+ "mcp",
24
+ "telegram",
25
+ "multiplexer",
26
+ "agent"
27
+ ],
28
+ "license": "MIT",
29
+ "dependencies": {
30
+ "@ai-sdk/anthropic": "^3.0.63",
31
+ "@ai-sdk/google": "^3.0.53",
32
+ "@ai-sdk/openai": "^3.0.48",
33
+ "@modelcontextprotocol/sdk": "^1.12.1",
34
+ "ai": "^6.0.137",
35
+ "dotenv": "^16.4.0",
36
+ "ws": "^8.18.0",
37
+ "zod": "^3.24.0"
38
+ },
39
+ "devDependencies": {
40
+ "@types/ws": "^8.5.0",
41
+ "tsx": "^4.19.0",
42
+ "typescript": "^5.7.0"
43
+ }
44
+ }