@stage-labs/metro 0.1.0-beta.4 → 0.1.0-beta.5
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 +221 -61
- package/dist/cli/index.js +236 -0
- package/dist/cli/lines.js +40 -0
- package/dist/cli/update.js +27 -0
- package/dist/dispatcher.js +203 -0
- package/dist/helpers/async-queue.js +41 -0
- package/dist/helpers/scope-cache.js +28 -26
- package/dist/helpers/streaming.js +29 -19
- package/dist/helpers/turn.js +53 -31
- package/dist/log.js +7 -2
- package/dist/{agents/claude.js → stations/claude/index.js} +75 -58
- package/dist/{agents/codex.js → stations/codex/index.js} +77 -58
- package/dist/stations/discord/index.js +160 -0
- package/dist/stations/github/index.js +135 -0
- package/dist/stations/line.js +54 -0
- package/dist/stations/listing.js +16 -0
- package/dist/stations/send.js +19 -0
- package/dist/stations/telegram/files.js +31 -0
- package/dist/stations/telegram/index.js +209 -0
- package/dist/stations/types.js +2 -0
- package/docs/agents.md +92 -0
- package/docs/uri-scheme.md +52 -0
- package/package.json +5 -3
- package/dist/agents/types.js +0 -2
- package/dist/channels/discord.js +0 -104
- package/dist/channels/telegram.js +0 -189
- package/dist/cli.js +0 -221
- package/dist/orchestrator.js +0 -208
- /package/dist/{helpers/telegram-format.js → stations/telegram/format.js} +0 -0
package/README.md
CHANGED
|
@@ -1,15 +1,24 @@
|
|
|
1
1
|
# Metro
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
> **Bridge Discord, Telegram, and GitHub to Claude Code + Codex.**
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Metro is a small daemon that turns any chat thread, forum topic, or GitHub issue into a live conversation with your local coding agents. Each thread is its own agent session — Claude and Codex run side-by-side, replies stream in real time with tool calls visible inline, and you can stop a turn with one click.
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
7
|
+
```
|
|
8
|
+
[Discord — #infra]
|
|
9
|
+
|
|
10
|
+
less @bot we got a 5xx spike from /v1/sync. Look?
|
|
11
|
+
sandboxbot > 🛠 Bash git log --since=24h --oneline
|
|
12
|
+
> 🛠 Read services/sync.ts
|
|
13
|
+
|
|
14
|
+
Three deploys in the last 24h. The 14:02 one swallows a
|
|
15
|
+
timeout into a 500 on line 47 instead of retrying. Want
|
|
16
|
+
me to open a PR?
|
|
17
|
+
|
|
18
|
+
[ ⏹ Stop ]
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
---
|
|
13
22
|
|
|
14
23
|
## Quickstart
|
|
15
24
|
|
|
@@ -18,100 +27,251 @@ npm install -g @stage-labs/metro@beta # or: bun add -g @stage-labs/metro@beta
|
|
|
18
27
|
|
|
19
28
|
metro setup discord <token> # https://discord.com/developers/applications
|
|
20
29
|
metro setup telegram <token> # https://t.me/BotFather
|
|
21
|
-
|
|
22
30
|
metro doctor # verify
|
|
23
|
-
metro # run the
|
|
31
|
+
metro # run the dispatcher
|
|
24
32
|
```
|
|
25
33
|
|
|
26
|
-
|
|
34
|
+
Requires **Node ≥ 22 or Bun ≥ 1.3** and at least one of [Claude Code](https://claude.com/claude-code) or [Codex](https://github.com/openai/codex) installed and logged in (run them once interactively first; metro inherits your auth).
|
|
35
|
+
|
|
36
|
+
In **Discord**: DM the bot, or `@<bot>` in any channel. In **Telegram**: DM, or `@<bot>` in a forum's General topic. In **GitHub**: see [Testing GitHub](#testing-github).
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## Stations
|
|
41
|
+
|
|
42
|
+
Everything in metro is a **station** with declared capabilities:
|
|
43
|
+
|
|
44
|
+
| Station | Kind | Modalities | Features | Config |
|
|
45
|
+
|------------|-------|---------------|-------------------------------------|-------------------------------------------------------------------|
|
|
46
|
+
| `claude` | agent | text + image | stream, tools, cancel, attachments | `claude` CLI on PATH, logged in |
|
|
47
|
+
| `codex` | agent | text + image | stream, tools, cancel, attachments | `codex` CLI on PATH, logged in |
|
|
48
|
+
| `discord` | chat | text + image | stream, edit, attachments | `DISCORD_BOT_TOKEN` + Message Content Intent |
|
|
49
|
+
| `telegram` | chat | text + image | stream, edit, attachments | `TELEGRAM_BOT_TOKEN` + Manage Topics admin (for forums) |
|
|
50
|
+
| `github` | chat | text | edit | `GITHUB_WEBHOOK_SECRET` + `GITHUB_BOT_USERNAME` + `GITHUB_TOKEN` |
|
|
51
|
+
|
|
52
|
+
Run `metro stations` to see the same matrix with live config status (`✓` configured, `✗` not).
|
|
53
|
+
|
|
54
|
+
Each chat platform behaves slightly differently — what's universal is captured by the capabilities; the rest is in [Conversations](#conversations).
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
## Lines
|
|
59
|
+
|
|
60
|
+
Every conversational scope is identified by a **Line** — a URI in the form `metro://<station>/<path>`:
|
|
27
61
|
|
|
28
62
|
```
|
|
29
|
-
|
|
30
|
-
|
|
63
|
+
metro://discord/1234567890123456789
|
|
64
|
+
metro://telegram/-1001234567890 # main chat / DM
|
|
65
|
+
metro://telegram/-1001234567890/42 # forum topic 42
|
|
66
|
+
metro://github/bonustrack/metro/issues/123 # GitHub issue
|
|
67
|
+
metro://github/bonustrack/metro/pull/456 # GitHub PR
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Lines map 1:1 to agent sessions in `scopes.json`. Anyone can post to a line via [`metro send`](#cli) — daemon optional. Full grammar in [`docs/uri-scheme.md`](docs/uri-scheme.md).
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## Conversations
|
|
75
|
+
|
|
76
|
+
### Discord
|
|
77
|
+
|
|
78
|
+
- **DM the bot** — every message is implicit; one line per DM.
|
|
79
|
+
- **`@<bot>` in any guild channel** — metro creates a thread from your message, allocates an agent session, and streams the reply. Follow-ups in the thread route automatically.
|
|
80
|
+
- **Tool calls** — render as `🛠 <tool>` headers plus two fenced code blocks (input → output). Outputs cap at 50 lines / 1500 chars with a `_(N more lines)_` note when truncated. Parallel tool calls are paired by id and don't collide.
|
|
81
|
+
- **Stop button** — every in-flight turn carries an `⏹ Stop` button that aborts the underlying subprocess (Claude via `SIGTERM`, Codex via `turn/interrupt`).
|
|
82
|
+
- **Catchup on restart** — Discord uses a per-line `lastSeenMessageId` watermark; metro REST-fetches anything newer when it comes back up.
|
|
83
|
+
|
|
84
|
+
### Telegram
|
|
85
|
+
|
|
86
|
+
- **DM the bot** — implicit; one line per chat.
|
|
87
|
+
- **`@<bot>` in a forum supergroup's General topic** — metro creates a new forum topic for the conversation and posts a deep link back in General so it's one tap away. Follow-ups in that topic route automatically.
|
|
88
|
+
- **Inside an existing custom topic** — routes to that topic's line on every message.
|
|
89
|
+
- **Markdown → Telegram HTML** — agent markdown (`**bold**`, `*italic*`, `` `code` ``, fences, `[link](url)`, blockquotes) is converted on the way out. Plain-text fallback if Telegram rejects the HTML.
|
|
90
|
+
|
|
91
|
+
Regular (non-forum) groups are skipped — without a per-thread boundary the routing model breaks down.
|
|
92
|
+
|
|
93
|
+
### GitHub
|
|
94
|
+
|
|
95
|
+
`@<bot-user>` in an **issue body, issue comment, PR body, or PR comment** allocates a per-issue agent session and the bot replies as a comment on the same issue/PR. Streaming is simulated via `PATCH /issues/comments` edits to the bot's own comment. Each issue/PR holds its own session across follow-ups.
|
|
96
|
+
|
|
97
|
+
Setup:
|
|
98
|
+
|
|
99
|
+
```
|
|
100
|
+
GITHUB_WEBHOOK_SECRET=<random> # any high-entropy string
|
|
101
|
+
GITHUB_BOT_USERNAME=<github user> # whose @-mentions trigger the bot
|
|
102
|
+
GITHUB_TOKEN=<PAT> # issues:write (+ pull_requests:write for PRs)
|
|
103
|
+
METRO_GITHUB_PORT=4321 # optional, default 4321
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
In your repo's *Settings → Webhooks*: payload URL `https://<your-public-url>/webhook`, content type `application/json`, the secret matches `GITHUB_WEBHOOK_SECRET`, events: **Issues** + **Issue comments**. For local development tunnel with [smee.io](https://smee.io), [cloudflared](https://github.com/cloudflare/cloudflared), or [ngrok](https://ngrok.com).
|
|
107
|
+
|
|
108
|
+
End-to-end recipe: [Testing GitHub](#testing-github).
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
## Agents
|
|
113
|
+
|
|
114
|
+
Both agents run side-by-side at boot. Each line defaults to **Claude** for the first turn; once you've used an agent in a line, it sticks. Switch per-message with a `with claude` / `with codex` suffix:
|
|
115
|
+
|
|
116
|
+
```
|
|
117
|
+
@bot draft a release note
|
|
118
|
+
→ uses Claude (default for a new line)
|
|
31
119
|
|
|
32
120
|
How would Codex have done this? with codex
|
|
33
|
-
→ routes this turn to Codex; stays Codex on
|
|
121
|
+
→ routes this turn to Codex; the line stays Codex on follow-ups
|
|
34
122
|
```
|
|
35
123
|
|
|
36
|
-
|
|
124
|
+
A line can hold one session per agent — independent histories — so switching back later resumes where that agent left off. If only one agent is installed, metro still starts and asks-for-the-missing-one error inline.
|
|
37
125
|
|
|
38
|
-
|
|
39
|
-
1. Metro creates a thread anchored on your message (named after the message).
|
|
40
|
-
2. Spins up an agent session for that thread.
|
|
41
|
-
3. Streams the agent's reply with each tool call as its own block: plain header `🛠 **Read**` followed by two fenced code blocks — input (`src/foo.ts`) above, output (file contents) below. Outputs are capped at 50 lines / 1500 chars per tool with a `_(N more lines)_` note if truncated. `Thinking…` shows as a transient status that vanishes once real content arrives.
|
|
126
|
+
---
|
|
42
127
|
|
|
43
|
-
|
|
128
|
+
## Cross-station relay
|
|
44
129
|
|
|
45
|
-
|
|
130
|
+
Agents can post to any line through the CLI:
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
metro send metro://telegram/-1001234567890/42 "patch deployed"
|
|
134
|
+
metro send metro://github/bonustrack/metro/issues/1 "all clear"
|
|
135
|
+
```
|
|
46
136
|
|
|
47
|
-
|
|
48
|
-
- **@-mention the bot in a forum supergroup's General topic** — metro auto-creates a new topic for the conversation (Discord-style "thread from message") and posts a deep link back in General so the new topic is one tap away. Subsequent messages in that topic route automatically.
|
|
49
|
-
- **Inside an existing custom topic** — routes to that topic's scope on every message.
|
|
137
|
+
`metro send` uses the same env tokens as the dispatcher and doesn't require the daemon — useful when an agent in one place needs to relay to another.
|
|
50
138
|
|
|
51
|
-
|
|
139
|
+
---
|
|
52
140
|
|
|
53
|
-
##
|
|
141
|
+
## Architecture
|
|
54
142
|
|
|
55
143
|
```
|
|
56
|
-
Discord gateway ──┐
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
144
|
+
Discord gateway ──┐
|
|
145
|
+
Telegram poller ──┤ ┌─▶ codex station (long-lived `codex app-server`, UDS JSON-RPC)
|
|
146
|
+
GitHub webhook ───┼─▶ metro dispatcher ──────┤
|
|
147
|
+
│ └─▶ claude station (per-turn `claude -p`, stream-json)
|
|
148
|
+
└─── line → agent-thread map (`scopes.json`)
|
|
61
149
|
```
|
|
62
150
|
|
|
63
|
-
|
|
64
|
-
- **Both agents side-by-side.** A scope can have up to one session per agent — independent histories. Routing is per-message: explicit `with claude` / `with codex` suffix, otherwise the scope's last-used agent, otherwise Claude.
|
|
65
|
-
- **Streaming.** Replies edit one message every ~1500 ms while deltas stream in (leading-edge first flush for fast initial feedback). Long replies split past ~1900 chars onto a follow-up message.
|
|
66
|
-
- **Tool-call visibility.** Each tool call is rendered as a plain `🛠 **<tool>**` header plus two fenced code blocks — input then output — paired by tool id so parallel calls don't collide. Both blocks are fully visible (no collapse). Outputs are capped at 50 lines / 1500 chars per tool.
|
|
67
|
-
- **Telegram formatting.** Agent markdown (`**bold**`, `*italic*`, `` `code` ``, fenced blocks, `[link](url)`, blockquotes) is converted to Telegram's HTML parse mode on the way out, so it renders as formatted text instead of literal characters.
|
|
68
|
-
- **No link previews.** Outgoing messages set `link_preview_options.is_disabled` on Telegram and the `SUPPRESS_EMBEDS` flag on Discord, so URLs in agent replies don't unfurl into giant auto-embeds.
|
|
69
|
-
- **Queueing.** Messages that arrive while a turn is running are buffered per-scope and answered together in the next reply.
|
|
70
|
-
- **Catchup-on-restart.** Discord uses a per-scope `lastSeenMessageId` watermark and REST-fetches anything newer when metro comes back up. Telegram leans on its own update-id queue (persisted offset in `telegram-offset.json`).
|
|
151
|
+
The codebase is built on a small protocol in [`src/stations/types.ts`](src/stations/types.ts):
|
|
71
152
|
|
|
72
|
-
|
|
153
|
+
- **`Station`** — name, capabilities, `start`/`stop` lifecycle.
|
|
154
|
+
- **`AgentStation`** — `createThread()`, `sendTurn(req)` returns an `AsyncIterable<TurnEvent>` of `delta` / `tool-start` / `tool-end`. Cancellation via `AbortSignal`.
|
|
155
|
+
- **`ChatStation<TMeta>`** — `onMessage`/`onStop` event hooks, `send`/`edit` for posting back. Typed meta carries platform extras (`inGuild`, `inForum`, `isPR`, …).
|
|
156
|
+
- **`Line`** — branded URI string. Each station owns its parse/format helpers in [`src/stations/line.ts`](src/stations/line.ts).
|
|
157
|
+
|
|
158
|
+
Adding a backend (Slack, Matrix, SMS, another LLM) = `class XStation implements ChatStation` + a `Line.x(...)` helper. The dispatcher picks it up polymorphically.
|
|
159
|
+
|
|
160
|
+
Behaviors worth knowing:
|
|
161
|
+
- **One daemon per machine.** Lockfile at `$METRO_STATE_DIR/.tail-lock` enforces singleton.
|
|
162
|
+
- **Streaming.** Replies edit one message every ~1500 ms while deltas arrive (leading-edge first flush so feedback feels instant). Long replies split past ~1900 chars onto follow-up messages.
|
|
163
|
+
- **No link previews.** Outgoing messages set `link_preview_options.is_disabled` on Telegram and `SUPPRESS_EMBEDS` on Discord so URLs don't unfurl.
|
|
164
|
+
- **Image attachments.** Discord and Telegram image uploads are forwarded as vision inputs (Anthropic `image/base64` for Claude; `image_url` data URI for Codex). 20 MB cap; non-images surface as `[file: name]` text.
|
|
165
|
+
- **In-flight queueing.** Messages arriving during a turn are buffered per-line and answered as one combined follow-up.
|
|
166
|
+
|
|
167
|
+
---
|
|
168
|
+
|
|
169
|
+
## Configuration
|
|
73
170
|
|
|
74
171
|
| Variable | Default | Description |
|
|
75
172
|
|---|---|---|
|
|
76
173
|
| `TELEGRAM_BOT_TOKEN`, `DISCORD_BOT_TOKEN` | — | Bot tokens. `metro setup` writes them here. |
|
|
174
|
+
| `GITHUB_WEBHOOK_SECRET`, `GITHUB_BOT_USERNAME`, `GITHUB_TOKEN` | — | Enables GitHub. Token needs `issues:write` (+ `pull_requests:write` for PR comments). Webhook listens on `METRO_GITHUB_PORT` (default `4321`). |
|
|
77
175
|
| `METRO_CONFIG_DIR` | `~/.config/metro` | Where the global `.env` lives. |
|
|
78
|
-
| `METRO_STATE_DIR` | `~/.cache/metro` | Lockfile,
|
|
176
|
+
| `METRO_STATE_DIR` | `~/.cache/metro` | Lockfile, line cache, codex socket, telegram offset, claude session set. |
|
|
79
177
|
| `METRO_LOG_LEVEL` | `info` | `trace` / `debug` / `info` / `warn` / `error` / `fatal`. |
|
|
80
178
|
|
|
81
|
-
|
|
179
|
+
Precedence: process env → `./.env` → `$METRO_CONFIG_DIR/.env`. Logs go to stderr.
|
|
180
|
+
|
|
181
|
+
---
|
|
182
|
+
|
|
183
|
+
## CLI
|
|
184
|
+
|
|
185
|
+
```
|
|
186
|
+
metro Run the dispatcher daemon.
|
|
187
|
+
metro setup [telegram|discord <token>] Save token, or show status.
|
|
188
|
+
metro setup clear [telegram|discord|all] Remove tokens.
|
|
189
|
+
metro doctor Health check.
|
|
190
|
+
metro stations List stations + capabilities.
|
|
191
|
+
metro lines List active conversations (sorted by recency, with names).
|
|
192
|
+
metro send <line> <text> Post to any metro:// line.
|
|
193
|
+
metro update Upgrade in place.
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
All commands accept `--json` for machine-readable output.
|
|
197
|
+
|
|
198
|
+
**State files** in `$METRO_STATE_DIR`:
|
|
199
|
+
- `scopes.json` — Line → agent-session map
|
|
200
|
+
- `AGENTS.md` — skill doc copied from the package on every dispatcher start; surfaced into each agent's per-turn context so it knows about `metro lines` / `metro send` / `metro stations`
|
|
201
|
+
- `.tail-lock` — dispatcher pid
|
|
202
|
+
- `codex-app-server.sock` — codex Unix socket
|
|
203
|
+
- `telegram-offset.json` — last processed update id
|
|
204
|
+
- `claude-sessions.json` — known Claude session uuids (so restarts use `--resume`)
|
|
205
|
+
|
|
206
|
+
---
|
|
82
207
|
|
|
83
|
-
## Develop
|
|
208
|
+
## Develop
|
|
84
209
|
|
|
85
210
|
```bash
|
|
86
211
|
git clone https://github.com/bonustrack/metro && cd metro
|
|
87
212
|
bun install && bun run build
|
|
88
213
|
bun link # makes `metro` resolve to this checkout
|
|
89
214
|
METRO_LOG_LEVEL=debug metro
|
|
215
|
+
|
|
216
|
+
bun run typecheck # ts
|
|
217
|
+
bun run lint # eslint
|
|
90
218
|
```
|
|
91
219
|
|
|
92
|
-
|
|
220
|
+
Source map:
|
|
221
|
+
|
|
222
|
+
- [`src/cli/`](src/cli/) — the `metro` binary entry ([`cli/index.ts`](src/cli/index.ts)) plus subcommand handlers (`lines.ts`, `update.ts`).
|
|
223
|
+
- [`src/dispatcher.ts`](src/dispatcher.ts) — the daemon: wires stations together, routes inbounds, installs `AGENTS.md` into state on each start.
|
|
224
|
+
- [`src/stations/`](src/stations/) — one folder per station (`claude/`, `codex/`, `discord/`, `github/`, `telegram/`) plus the shared `types.ts`, `line.ts`, `listing.ts`, `send.ts`.
|
|
225
|
+
- [`src/helpers/`](src/helpers/) — `streaming.ts`, `turn.ts`, `scope-cache.ts`, `async-queue.ts`.
|
|
226
|
+
- [`docs/uri-scheme.md`](docs/uri-scheme.md) specs the Line format; [`docs/agents.md`](docs/agents.md) is the in-context skill copied to `$METRO_STATE_DIR/AGENTS.md`.
|
|
227
|
+
|
|
228
|
+
CI runs typecheck + lint + build on every PR via [`.github/workflows/ci.yml`](.github/workflows/ci.yml).
|
|
229
|
+
|
|
230
|
+
---
|
|
231
|
+
|
|
232
|
+
## Testing GitHub
|
|
233
|
+
|
|
234
|
+
1. **Pick a GitHub user** the bot acts as (your own account works for testing) and generate a token. Two options:
|
|
235
|
+
- **Fine-grained PAT** at `github.com/settings/tokens?type=beta` — scoped to a test repo with **Issues: Read & write** (+ **Pull requests: Read & write** for PR comments). Works for *your* repos. For org repos, an org admin must enable fine-grained PATs in *Organization settings → Personal access tokens*.
|
|
236
|
+
- **Classic PAT** at `github.com/settings/tokens?type=classic` — pick the `repo` scope. Works for any repo you have access to, no org opt-in needed.
|
|
237
|
+
2. **Set env vars**:
|
|
238
|
+
```bash
|
|
239
|
+
export GITHUB_WEBHOOK_SECRET="$(openssl rand -hex 32)"
|
|
240
|
+
export GITHUB_BOT_USERNAME="metrobot" # see solo-testing note below
|
|
241
|
+
export GITHUB_TOKEN="ghp_..."
|
|
242
|
+
```
|
|
243
|
+
3. **Tunnel** your local webhook port to the internet:
|
|
244
|
+
```bash
|
|
245
|
+
npx smee-client --target http://localhost:4321/webhook --url <https://smee.io/your-channel>
|
|
246
|
+
```
|
|
247
|
+
4. **Add a webhook** on the test repo (*Settings → Webhooks*): payload URL = the smee channel, content type `application/json`, the same secret, events **Issues** + **Issue comments**.
|
|
248
|
+
5. **Run metro**: `METRO_LOG_LEVEL=debug metro` — you should see `github station: listening`.
|
|
249
|
+
6. **Open an issue** with body `@metrobot hello, what's this repo?` — the bot comments back (as the token's owner).
|
|
93
250
|
|
|
94
|
-
- `
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
251
|
+
**Solo testing note.** Metro filters out self-mentions (`sender === bot`) to prevent reply loops. If you set `GITHUB_BOT_USERNAME` to your *own* username and then try to `@<yourself>` on an issue, the filter drops it — you can't trigger the bot alone. Workaround: use a pseudo-name like `metrobot` (doesn't need to be a real GitHub user — it's just a string match). The bot still replies as the token owner, but the filter passes because `your-username ≠ metrobot`.
|
|
252
|
+
|
|
253
|
+
**Common gotchas:**
|
|
254
|
+
- Webhook 401 → secret mismatch between `.env` and the webhook config.
|
|
255
|
+
- Webhook 200 but no reply → token lacks `issues:write`, or `GITHUB_BOT_USERNAME` doesn't appear in the body verbatim (case-sensitive).
|
|
256
|
+
- Bot replies in a loop → the bot's own comment somehow includes `@<bot-username>`; rare with the default prompt, but check `AGENTS.md` if it happens.
|
|
257
|
+
|
|
258
|
+
---
|
|
259
|
+
|
|
260
|
+
## Caveats
|
|
261
|
+
|
|
262
|
+
- **No allowlist.** Anyone who can DM/`@`-mention your bot can spawn a session. Run against bots you own.
|
|
263
|
+
- **Per-agent histories are separate.** `with codex` mid-line starts a fresh Codex session; it doesn't see what Claude saw, and vice versa.
|
|
264
|
+
- **Telegram non-forum groups are skipped.** No thread boundary to scope on. DMs and forum topics work normally.
|
|
265
|
+
- **Telegram bot privacy is on by default**, which can block `@`-mentions in groups. Disable via [@BotFather](https://t.me/BotFather) → Bot Settings → Group Privacy, then kick + re-invite.
|
|
266
|
+
- **GitHub needs a public URL.** Webhooks are HMAC-verified, so a leaked URL only lets attackers spam rejected POSTs — but use a high-entropy `GITHUB_WEBHOOK_SECRET`.
|
|
267
|
+
- **Pre-1.0 line URIs aren't migrated.** Older `discord:ID` / `telegram:CHAT:TOPIC` keys in `scopes.json` are ignored after upgrade.
|
|
268
|
+
|
|
269
|
+
---
|
|
102
270
|
|
|
103
271
|
## Uninstall
|
|
104
272
|
|
|
105
273
|
```bash
|
|
106
274
|
metro setup clear
|
|
107
|
-
rm -rf ~/.cache/metro
|
|
275
|
+
rm -rf ~/.cache/metro
|
|
108
276
|
npm uninstall -g @stage-labs/metro
|
|
109
277
|
```
|
|
110
|
-
|
|
111
|
-
## Caveats
|
|
112
|
-
|
|
113
|
-
- **No allowlist.** Anyone who can DM/@-mention your bot can spawn an agent session. Run against bots you own.
|
|
114
|
-
- **Per-agent histories are separate.** Switching with `with codex` mid-scope spins up a fresh Codex session — it has no idea what you discussed with Claude in the same scope. Each agent only sees what was sent to it.
|
|
115
|
-
- **One agent missing is OK.** If only `claude` or only `codex` is installed/authenticated, metro still starts; messages asking for the missing one surface an error in chat.
|
|
116
|
-
- **Telegram non-forum groups are skipped.** Without a per-topic thread boundary the routing model breaks down. DMs and forum topics (incl. auto-created ones from General) work normally.
|
|
117
|
-
- **Telegram bot privacy.** Default Telegram bot privacy is *enabled*, which can block group messages even with @-mentions. Disable in [@BotFather](https://t.me/BotFather) → Bot Settings → Group Privacy → Turn off, then kick + re-invite the bot.
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import pkg from '../../package.json' with { type: 'json' };
|
|
5
|
+
import { cmdLines } from './lines.js';
|
|
6
|
+
import { cmdUpdate } from './update.js';
|
|
7
|
+
import { DiscordStation } from '../stations/discord/index.js';
|
|
8
|
+
import { TelegramStation } from '../stations/telegram/index.js';
|
|
9
|
+
import { fmtCapabilities, listStations } from '../stations/listing.js';
|
|
10
|
+
import { sendToLine } from '../stations/send.js';
|
|
11
|
+
import { errMsg } from '../log.js';
|
|
12
|
+
import { CONFIG_ENV_FILE, configuredPlatforms, loadMetroEnv, readDotenv, STATE_DIR, writeDotenv } from '../paths.js';
|
|
13
|
+
const USAGE = `metro — Telegram + Discord bridge for your Claude Code / Codex agent
|
|
14
|
+
|
|
15
|
+
Usage:
|
|
16
|
+
metro Run the dispatcher daemon.
|
|
17
|
+
metro setup [telegram|discord <token>] Save token, or show status with no args.
|
|
18
|
+
metro setup clear [telegram|discord|all] Remove tokens.
|
|
19
|
+
metro doctor Health check.
|
|
20
|
+
metro stations List stations + capabilities.
|
|
21
|
+
metro lines List active conversations (sorted by recency).
|
|
22
|
+
metro send <line> <text> Post a message to a metro:// line.
|
|
23
|
+
metro update Upgrade in place.
|
|
24
|
+
metro --version | --help
|
|
25
|
+
Exit codes: 0 success · 1 usage · 2 config · 3 upstream
|
|
26
|
+
`;
|
|
27
|
+
const exitErr = (msg, code) => Object.assign(new Error(msg), { code });
|
|
28
|
+
const isJson = (f) => f.json === true;
|
|
29
|
+
const emit = (f, human, structured) => void process.stdout.write(isJson(f) ? JSON.stringify(structured) + '\n' : human + '\n');
|
|
30
|
+
const maskToken = (t) => !t ? '' : t.length <= 8 ? '••••' : `${t.slice(0, 6)}…${t.slice(-2)}`;
|
|
31
|
+
const TOKEN_KEYS = { telegram: 'TELEGRAM_BOT_TOKEN', discord: 'DISCORD_BOT_TOKEN' };
|
|
32
|
+
function parseArgs(argv) {
|
|
33
|
+
const positional = [], flags = {};
|
|
34
|
+
for (let i = 0; i < argv.length; i++) {
|
|
35
|
+
const a = argv[i];
|
|
36
|
+
if (!a.startsWith('--')) {
|
|
37
|
+
positional.push(a);
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
const eq = a.indexOf('=');
|
|
41
|
+
if (eq !== -1) {
|
|
42
|
+
flags[a.slice(2, eq)] = a.slice(eq + 1);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
const key = a.slice(2), next = argv[i + 1];
|
|
46
|
+
if (next !== undefined && !next.startsWith('--')) {
|
|
47
|
+
flags[key] = next;
|
|
48
|
+
i++;
|
|
49
|
+
}
|
|
50
|
+
else
|
|
51
|
+
flags[key] = true;
|
|
52
|
+
}
|
|
53
|
+
return { positional, flags };
|
|
54
|
+
}
|
|
55
|
+
/** Apply a token across CONFIG_ENV_FILE (always set/cleared) AND cwd/.env (only if it exists). Returns paths touched. */
|
|
56
|
+
function applyTokenToAllEnvs(key, value) {
|
|
57
|
+
const out = [];
|
|
58
|
+
for (const path of [CONFIG_ENV_FILE, join(process.cwd(), '.env')]) {
|
|
59
|
+
if (path !== CONFIG_ENV_FILE && !existsSync(path))
|
|
60
|
+
continue;
|
|
61
|
+
const env = readDotenv(path);
|
|
62
|
+
if (value === null) {
|
|
63
|
+
if (!(key in env))
|
|
64
|
+
continue;
|
|
65
|
+
delete env[key];
|
|
66
|
+
}
|
|
67
|
+
else
|
|
68
|
+
env[key] = value;
|
|
69
|
+
writeDotenv(path, env);
|
|
70
|
+
out.push(path);
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
async function cmdSetup(positional, flags) {
|
|
75
|
+
const [sub, value] = positional;
|
|
76
|
+
if (!sub)
|
|
77
|
+
return cmdSetupStatus(flags);
|
|
78
|
+
if (sub === 'telegram' || sub === 'discord') {
|
|
79
|
+
if (!value)
|
|
80
|
+
throw new Error(`metro setup ${sub} <token> — token is required`);
|
|
81
|
+
const trimmed = value.trim();
|
|
82
|
+
let identity;
|
|
83
|
+
if (!flags['no-validate']) {
|
|
84
|
+
process.env[TOKEN_KEYS[sub]] = trimmed;
|
|
85
|
+
try {
|
|
86
|
+
identity = sub === 'telegram' ? `@${(await new TelegramStation().getMe()).username}` : (await new DiscordStation().getMe()).username;
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
delete process.env[TOKEN_KEYS[sub]];
|
|
90
|
+
throw exitErr(`token rejected by ${sub}: ${errMsg(err)} (use --no-validate to save anyway)`, 3);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const paths = applyTokenToAllEnvs(TOKEN_KEYS[sub], trimmed);
|
|
94
|
+
emit(flags, `saved ${TOKEN_KEYS[sub]}${identity ? ` (verified as ${identity})` : ''} to ${paths.join(', ')}\nrestart metro for the new token to take effect.`, { ok: true, saved: TOKEN_KEYS[sub], paths, verified_as: identity ?? null });
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (sub === 'clear') {
|
|
98
|
+
const target = value ?? 'all';
|
|
99
|
+
if (target !== 'all' && target !== 'telegram' && target !== 'discord')
|
|
100
|
+
throw new Error(`metro setup clear <telegram|discord|all> — got '${target}'`);
|
|
101
|
+
const keys = target === 'all' ? ['TELEGRAM_BOT_TOKEN', 'DISCORD_BOT_TOKEN'] : [TOKEN_KEYS[target]];
|
|
102
|
+
const paths = new Set();
|
|
103
|
+
for (const k of keys)
|
|
104
|
+
for (const p of applyTokenToAllEnvs(k, null))
|
|
105
|
+
paths.add(p);
|
|
106
|
+
const label = target === 'all' ? 'all metro tokens' : TOKEN_KEYS[target];
|
|
107
|
+
return void emit(flags, `cleared ${label} from ${[...paths].join(', ') || '(no files had it)'}\nrestart metro for changes to take effect.`, { ok: true, cleared: target, paths: [...paths] });
|
|
108
|
+
}
|
|
109
|
+
throw new Error(`unknown setup subcommand '${sub}' (try: telegram, discord, clear)`);
|
|
110
|
+
}
|
|
111
|
+
async function cmdSetupStatus(flags) {
|
|
112
|
+
loadMetroEnv();
|
|
113
|
+
const tg = process.env.TELEGRAM_BOT_TOKEN ?? '', dc = process.env.DISCORD_BOT_TOKEN ?? '';
|
|
114
|
+
if (isJson(flags))
|
|
115
|
+
return void process.stdout.write(JSON.stringify({ version: pkg.version, config_env_file: CONFIG_ENV_FILE,
|
|
116
|
+
tokens: { telegram: { set: !!tg, masked: maskToken(tg) }, discord: { set: !!dc, masked: maskToken(dc) } } }) + '\n');
|
|
117
|
+
const cfgState = existsSync(CONFIG_ENV_FILE) ? '' : ' (not yet written)';
|
|
118
|
+
process.stdout.write(`metro ${pkg.version}\n\nconfig: ${CONFIG_ENV_FILE}${cfgState}\n\n TELEGRAM_BOT_TOKEN ${tg ? `set (${maskToken(tg)})` : 'not set'}\n DISCORD_BOT_TOKEN ${dc ? `set (${maskToken(dc)})` : 'not set'}\n\n${!tg && !dc
|
|
119
|
+
? 'Get started:\n 1. metro setup telegram <token> # https://t.me/BotFather\n metro setup discord <token> # https://discord.com/developers/applications\n 2. metro doctor\n 3. metro\n'
|
|
120
|
+
: 'Run `metro` to start the dispatcher, or `metro doctor` to verify.\n'}`);
|
|
121
|
+
}
|
|
122
|
+
/** Find which file (or process env) supplied the currently-loaded token, so doctor can name the real source. */
|
|
123
|
+
function tokenSource(key) {
|
|
124
|
+
const val = process.env[key];
|
|
125
|
+
if (!val)
|
|
126
|
+
return '';
|
|
127
|
+
for (const path of [join(process.cwd(), '.env'), CONFIG_ENV_FILE])
|
|
128
|
+
if (existsSync(path) && readDotenv(path)[key] === val)
|
|
129
|
+
return path;
|
|
130
|
+
return 'process env';
|
|
131
|
+
}
|
|
132
|
+
async function cmdDoctor(flags) {
|
|
133
|
+
loadMetroEnv();
|
|
134
|
+
const cfg = configuredPlatforms();
|
|
135
|
+
const sources = [['telegram', 'TELEGRAM_BOT_TOKEN'], ['discord', 'DISCORD_BOT_TOKEN']].filter(([p]) => cfg[p]).map(([p, k]) => `${p}←${tokenSource(k)}`).join(', ');
|
|
136
|
+
const checks = [{ name: 'tokens', ok: cfg.telegram || cfg.discord,
|
|
137
|
+
detail: cfg.telegram || cfg.discord ? sources : 'no platform configured — run `metro setup telegram|discord <token>`',
|
|
138
|
+
}];
|
|
139
|
+
const getMeFns = { telegram: () => new TelegramStation().getMe(), discord: () => new DiscordStation().getMe() };
|
|
140
|
+
for (const p of ['telegram', 'discord']) {
|
|
141
|
+
if (!cfg[p]) {
|
|
142
|
+
checks.push({ name: p, ok: null, detail: 'not configured' });
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
try {
|
|
146
|
+
const me = await getMeFns[p]();
|
|
147
|
+
checks.push({ name: p, ok: true, detail: `getMe → ${p === 'telegram' ? '@' : ''}${me.username}` });
|
|
148
|
+
}
|
|
149
|
+
catch (err) {
|
|
150
|
+
checks.push({ name: p, ok: false, detail: errMsg(err) });
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
checks.push(dispatcherCheck());
|
|
154
|
+
if (isJson(flags))
|
|
155
|
+
process.stdout.write(JSON.stringify({ checks }) + '\n');
|
|
156
|
+
else {
|
|
157
|
+
process.stdout.write('metro doctor\n\n');
|
|
158
|
+
for (const c of checks)
|
|
159
|
+
process.stdout.write(` ${c.ok === true ? '✓' : c.ok === false ? '✗' : '–'} ${c.name.padEnd(15)} ${c.detail}\n`);
|
|
160
|
+
process.stdout.write('\n');
|
|
161
|
+
}
|
|
162
|
+
if (checks.some(c => c.ok === false))
|
|
163
|
+
throw exitErr('one or more checks failed', 3);
|
|
164
|
+
}
|
|
165
|
+
function dispatcherCheck() {
|
|
166
|
+
const lockFile = join(STATE_DIR, '.tail-lock');
|
|
167
|
+
if (!existsSync(lockFile))
|
|
168
|
+
return { name: 'dispatcher', ok: null, detail: 'not running' };
|
|
169
|
+
try {
|
|
170
|
+
const pid = Number(readFileSync(lockFile, 'utf8').trim());
|
|
171
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
172
|
+
throw new Error('invalid pid');
|
|
173
|
+
process.kill(pid, 0);
|
|
174
|
+
return { name: 'dispatcher', ok: true, detail: `running (pid ${pid})` };
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
return { name: 'dispatcher', ok: null, detail: 'stale lockfile (will auto-reclaim on next start)' };
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
async function cmdSend(positional, flags) {
|
|
181
|
+
const [to, ...rest] = positional;
|
|
182
|
+
if (!to || !rest.length)
|
|
183
|
+
throw exitErr('usage: metro send <line> <text>', 1);
|
|
184
|
+
loadMetroEnv();
|
|
185
|
+
const { line, messageId } = await sendToLine(to, rest.join(' '));
|
|
186
|
+
emit(flags, `sent ${messageId} to ${line}`, { ok: true, line, messageId });
|
|
187
|
+
}
|
|
188
|
+
async function cmdStations(flags) {
|
|
189
|
+
loadMetroEnv();
|
|
190
|
+
const rows = listStations();
|
|
191
|
+
if (isJson(flags))
|
|
192
|
+
return void process.stdout.write(JSON.stringify({ stations: rows }) + '\n');
|
|
193
|
+
process.stdout.write('metro stations\n\n');
|
|
194
|
+
for (const s of rows) {
|
|
195
|
+
const mark = s.configured === true ? '✓' : s.configured === false ? '✗' : '·';
|
|
196
|
+
process.stdout.write(` ${mark} ${s.name.padEnd(10)} ${s.kind.padEnd(6)} ${fmtCapabilities(s.capabilities)}\n ${s.detail}\n`);
|
|
197
|
+
}
|
|
198
|
+
process.stdout.write('\n');
|
|
199
|
+
}
|
|
200
|
+
const COMMANDS = {
|
|
201
|
+
setup: cmdSetup,
|
|
202
|
+
doctor: (_, f) => cmdDoctor(f),
|
|
203
|
+
stations: (_, f) => cmdStations(f),
|
|
204
|
+
lines: (_, f) => cmdLines(isJson(f)),
|
|
205
|
+
send: cmdSend,
|
|
206
|
+
update: (_, f) => cmdUpdate(isJson(f)),
|
|
207
|
+
};
|
|
208
|
+
async function main() {
|
|
209
|
+
const cmd = process.argv[2];
|
|
210
|
+
if (cmd === '--version' || cmd === '-v')
|
|
211
|
+
return void process.stdout.write(`${pkg.version}\n`);
|
|
212
|
+
if (cmd === '--help' || cmd === '-h')
|
|
213
|
+
return void process.stdout.write(USAGE);
|
|
214
|
+
if (!cmd) {
|
|
215
|
+
await import('../dispatcher.js');
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
const handler = COMMANDS[cmd];
|
|
219
|
+
if (!handler) {
|
|
220
|
+
process.stderr.write(`unknown command '${cmd}'\n\n${USAGE}`);
|
|
221
|
+
process.exit(1);
|
|
222
|
+
}
|
|
223
|
+
const { positional, flags } = parseArgs(process.argv.slice(3));
|
|
224
|
+
try {
|
|
225
|
+
await handler(positional, flags);
|
|
226
|
+
}
|
|
227
|
+
catch (err) {
|
|
228
|
+
const code = err.code;
|
|
229
|
+
if (isJson(flags))
|
|
230
|
+
process.stdout.write(JSON.stringify({ ok: false, error: errMsg(err), code: code ?? 1 }) + '\n');
|
|
231
|
+
else
|
|
232
|
+
process.stderr.write(`error: ${errMsg(err)}\n`);
|
|
233
|
+
process.exit(typeof code === 'number' ? code : 1);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
await main();
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** `metro lines` — list active lines from scopes.json, sorted by recency. */
|
|
2
|
+
import { listLines } from '../helpers/scope-cache.js';
|
|
3
|
+
import { loadMetroEnv } from '../paths.js';
|
|
4
|
+
export async function cmdLines(json) {
|
|
5
|
+
loadMetroEnv();
|
|
6
|
+
const rows = listLines()
|
|
7
|
+
/** Drop pre-URI legacy keys (e.g. `discord:ID`) — they're not routable and just clutter the listing. */
|
|
8
|
+
.filter(({ line }) => line.startsWith('metro://'))
|
|
9
|
+
.map(({ line, entry }) => ({
|
|
10
|
+
line, name: entry.name ?? null,
|
|
11
|
+
lastSeenAt: entry.lastSeenAt ?? null,
|
|
12
|
+
lastAgent: entry.lastAgent ?? null,
|
|
13
|
+
agents: Object.keys(entry.agents ?? {}),
|
|
14
|
+
}))
|
|
15
|
+
.sort((a, b) => (b.lastSeenAt ?? '').localeCompare(a.lastSeenAt ?? ''));
|
|
16
|
+
if (json)
|
|
17
|
+
return void process.stdout.write(JSON.stringify({ lines: rows }) + '\n');
|
|
18
|
+
if (!rows.length)
|
|
19
|
+
return void process.stdout.write('metro lines\n\n (none yet — start a conversation to populate)\n\n');
|
|
20
|
+
const widest = Math.max(...rows.map(r => r.line.length));
|
|
21
|
+
process.stdout.write('metro lines\n\n');
|
|
22
|
+
for (const r of rows) {
|
|
23
|
+
const when = r.lastSeenAt ? humanAgo(r.lastSeenAt) : '—';
|
|
24
|
+
const agent = r.lastAgent ?? r.agents.join('+') ?? '—';
|
|
25
|
+
const tag = r.name ? ` ${truncate(r.name, 40)}` : '';
|
|
26
|
+
process.stdout.write(` ${when.padEnd(10)} ${agent.padEnd(8)} ${r.line.padEnd(widest)}${tag}\n`);
|
|
27
|
+
}
|
|
28
|
+
process.stdout.write('\n');
|
|
29
|
+
}
|
|
30
|
+
const humanAgo = (iso) => {
|
|
31
|
+
const sec = Math.max(0, Math.floor((Date.now() - new Date(iso).getTime()) / 1000));
|
|
32
|
+
if (sec < 60)
|
|
33
|
+
return `${sec}s ago`;
|
|
34
|
+
if (sec < 3600)
|
|
35
|
+
return `${Math.floor(sec / 60)}m ago`;
|
|
36
|
+
if (sec < 86400)
|
|
37
|
+
return `${Math.floor(sec / 3600)}h ago`;
|
|
38
|
+
return `${Math.floor(sec / 86400)}d ago`;
|
|
39
|
+
};
|
|
40
|
+
const truncate = (s, n) => s.length <= n ? s : s.slice(0, n - 1) + '…';
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** `metro update` — npm registry lookup + in-place global install. */
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import pkg from '../../package.json' with { type: 'json' };
|
|
4
|
+
const emit = (json, human, structured) => {
|
|
5
|
+
process.stdout.write(json ? JSON.stringify(structured) + '\n' : human + '\n');
|
|
6
|
+
};
|
|
7
|
+
export async function cmdUpdate(json) {
|
|
8
|
+
const tag = pkg.version.includes('-') ? 'beta' : 'latest';
|
|
9
|
+
const res = await fetch('https://registry.npmjs.org/@stage-labs/metro', { signal: AbortSignal.timeout(15_000) });
|
|
10
|
+
if (!res.ok)
|
|
11
|
+
throw new Error(`npm registry: ${res.status}`);
|
|
12
|
+
const latest = (await res.json())['dist-tags']?.[tag];
|
|
13
|
+
if (!latest)
|
|
14
|
+
throw new Error(`no '${tag}' dist-tag for @stage-labs/metro`);
|
|
15
|
+
if (latest === pkg.version)
|
|
16
|
+
return emit(json, `already on ${pkg.version} (latest ${tag})`, { ok: true, current: pkg.version, latest, upgraded: false });
|
|
17
|
+
const argv1 = process.argv[1] ?? '', spec = `@stage-labs/metro@${tag}`;
|
|
18
|
+
const argv = argv1.includes('/.bun/') || argv1.includes('\\bun\\') ? ['bun', 'add', '-g', spec]
|
|
19
|
+
: argv1.includes('/pnpm/') || argv1.includes('\\pnpm\\') ? ['pnpm', 'add', '-g', spec]
|
|
20
|
+
: ['npm', 'install', '-g', spec];
|
|
21
|
+
emit(json, `metro ${pkg.version} → ${latest}\n$ ${argv.join(' ')}`, { ok: true, current: pkg.version, latest, command: argv.join(' ') });
|
|
22
|
+
await new Promise((resolve, reject) => {
|
|
23
|
+
const child = spawn(argv[0], argv.slice(1), { stdio: json ? 'ignore' : 'inherit' });
|
|
24
|
+
child.on('exit', code => code === 0 ? resolve() : reject(new Error(`${argv[0]} exited ${code}`)));
|
|
25
|
+
child.on('error', reject);
|
|
26
|
+
});
|
|
27
|
+
}
|