@stage-labs/metro 0.1.0-beta.5 → 0.1.0-beta.6

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 CHANGED
@@ -1,23 +1,24 @@
1
1
  # Metro
2
2
 
3
- > **Bridge Discord, Telegram, and GitHub to Claude Code + Codex.**
3
+ > **A live JSON stream of Telegram + Discord messages for your local Claude Code / Codex session.**
4
4
 
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 sessionClaude 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.
5
+ Metro is a small daemon you launch from inside your agent. It connects to Discord and Telegram, emits each inbound as one JSON line on stdout (which Claude Code's `Monitor` consumes natively, and Codex picks up via an app-server WebSocket push), and exposes a tiny CLI `metro reply`, `metro send`, `metro edit`, `metro react`, `metro download`, `metro fetch`, `metro notify` for posting back. Cross-agent: any agent can ping any other via `metro send metro://claude/<topic>` and the daemon re-emits it on the stream.
6
6
 
7
7
  ```
8
- [Discord #infra]
8
+ [Claude Code session]
9
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
10
+ $ metro & # backgrounded
11
+ $ Monitor( metro's stdout … )
13
12
 
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?
13
+ >>> {"type":"inbound","station":"discord","line":"metro://discord/123…","messageId":"9876",
14
+ "text":"@bot we got a 5xx spike from /v1/sync. Look?","mentionsBot":true,…}
17
15
 
18
- [ Stop ]
16
+ [I'd run git log + read services/sync.ts, then…]
17
+ Bash: metro reply metro://discord/123… 9876 "three deploys in the last 24h…"
19
18
  ```
20
19
 
20
+ The agent owns its own streaming, tool calls, and reply timing. Metro is the wire.
21
+
21
22
  ---
22
23
 
23
24
  ## Quickstart
@@ -28,141 +29,108 @@ npm install -g @stage-labs/metro@beta # or: bun add -g @stage-labs/metro@beta
28
29
  metro setup discord <token> # https://discord.com/developers/applications
29
30
  metro setup telegram <token> # https://t.me/BotFather
30
31
  metro doctor # verify
31
- metro # run the dispatcher
32
+ metro # run the daemon
32
33
  ```
33
34
 
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` |
35
+ Requires **Node ≥ 22 or Bun ≥ 1.3**. Metro doesn't launch Claude or Codex you do, and the agent launches metro. See [`docs/agents.md`](docs/agents.md).
51
36
 
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).
37
+ In **Discord**: DM the bot, or `@<bot>` in any channel. In **Telegram**: DM, or `@<bot>` in a forum supergroup. Every inbound becomes one JSON line on `metro`'s stdout.
55
38
 
56
39
  ---
57
40
 
58
- ## Lines
59
-
60
- Every conversational scope is identified by a **Line** — a URI in the form `metro://<station>/<path>`:
41
+ ## Architecture
61
42
 
62
43
  ```
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
44
+ Discord gateway ──┐
45
+ Telegram poller ──┤
46
+
47
+ ├─▶ metro daemon ───▶ stdout (JSON events; Claude Code's Monitor reads here)
48
+ │ ───▶ codex-rc WebSocket (Codex turn/start; opt-in)
49
+ │ ◀── IPC Unix socket (metro notify / metro send to agent lines)
50
+
51
+ agent CLI calls ──┴── REST → Discord / Telegram (metro reply / send / edit / react / download / fetch)
68
52
  ```
69
53
 
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).
54
+ - **Inversion of control.** The agent (Claude Code, Codex) launches `metro`, not the other way around. Metro never spawns an agent process.
55
+ - **Single daemon per machine.** Lockfile at `$METRO_STATE_DIR/.tail-lock` enforces singleton.
56
+ - **Codex push (opt-in).** Set `METRO_CODEX_RC=ws://127.0.0.1:8421` and metro pushes each event via JSON-RPC `turn/start` to the Codex app-server. Codex's TUI must be attached with `--remote` to the same URL.
57
+ - **Cross-agent notification.** `metro send metro://claude/<topic>` or `metro notify metro://codex/<topic>` routes through the daemon's IPC socket; the daemon re-emits on its stdout (and pushes to codex-rc), so the peer agent sees it.
71
58
 
72
59
  ---
73
60
 
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.
61
+ ## Stations
96
62
 
97
- Setup:
63
+ Each endpoint is a **station** with declared capabilities:
98
64
 
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
- ```
65
+ | Station | Kind | Modalities | Features | Config |
66
+ |------------|-------|---------------|-------------------------------------------------------|-------------------------------------------------------------|
67
+ | `discord` | chat | text + image | reply, send, edit, react, download, fetch | `DISCORD_BOT_TOKEN` + Message Content Intent |
68
+ | `telegram` | chat | text + image | reply, send, edit, react, download | `TELEGRAM_BOT_TOKEN` |
69
+ | `claude` | agent | text | notify | watches metro stdout via Claude Code's `Monitor` |
70
+ | `codex` | agent | text | notify | set `METRO_CODEX_RC=ws://…` to push |
105
71
 
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).
72
+ Run `metro stations` to see live config status (`✓` configured, `✗` not, `·` informational).
107
73
 
108
- End-to-end recipe: [Testing GitHub](#testing-github).
74
+ Behaviors worth knowing:
75
+ - **No streaming / no edit machinery in metro.** The agent runs the show; metro is one-shot REST.
76
+ - **No link previews.** Outgoing messages set `link_preview_options.is_disabled` on Telegram and `SUPPRESS_EMBEDS` on Discord.
77
+ - **Image attachments inbound** — `[image]` placeholders surface inline in `text`; the agent calls `metro download` to materialize them. 20 MB cap.
78
+ - **Rich content outbound.** `metro send` / `reply` accept `--image=<path>` (repeatable: albums of up to 10), `--document=<path>` (repeatable), `--voice=<path>` (single voice message — Telegram renders the voice bubble), and `--buttons='[[{"text":"…","url":"…"}]]'` for inline URL-button keyboards. `metro edit` accepts `--buttons` (pass `'[]'` to clear). 20 MB / file. URL buttons only for now — no callback/interactive components.
79
+ - **Telegram non-forum groups are skipped.** No thread boundary to scope on.
109
80
 
110
81
  ---
111
82
 
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:
83
+ ## Lines
115
84
 
116
- ```
117
- @bot draft a release note
118
- → uses Claude (default for a new line)
85
+ Every conversational scope is identified by a **Line** — a URI in the form `metro://<station>/<path>`:
119
86
 
120
- How would Codex have done this? with codex
121
- → routes this turn to Codex; the line stays Codex on follow-ups
122
87
  ```
123
-
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.
125
-
126
- ---
127
-
128
- ## Cross-station relay
129
-
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"
88
+ metro://discord/1234567890123456789
89
+ metro://telegram/-1001234567890 # main chat / DM
90
+ metro://telegram/-1001234567890/42 # forum topic 42
91
+ metro://claude/deploys # agent notification sink
92
+ metro://codex/ci
135
93
  ```
136
94
 
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.
95
+ Anyone can post to a line via [`metro send`](#cli) daemon required only for agent lines. Full grammar in [`docs/uri-scheme.md`](docs/uri-scheme.md).
138
96
 
139
97
  ---
140
98
 
141
- ## Architecture
99
+ ## CLI
142
100
 
143
101
  ```
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`)
102
+ metro Run the daemon (emits JSON events on stdout).
103
+ metro setup [telegram|discord <token>] Save token, or show status.
104
+ metro setup clear [telegram|discord|all] Remove tokens.
105
+ metro doctor Health check.
106
+ metro stations List stations + capabilities.
107
+ metro lines List recently-seen conversations.
108
+ metro send <line> <text> [--image=…]… [--document=…]… [--voice=…] [--buttons=…]
109
+ Post a fresh message; --image/--document repeat for albums.
110
+ metro reply <line> <message_id> <text> [--image|--document|--voice|--buttons]
111
+ Threaded reply (same flags as send).
112
+ metro edit <line> <message_id> <text> [--buttons=<json>]
113
+ Edit a previously-sent message (text + URL-button keyboard).
114
+ metro react <line> <message_id> <emoji> Set or clear ('') a reaction.
115
+ metro download <line> <message_id> [--out=<dir>]
116
+ Download image attachments to disk.
117
+ metro fetch <line> [--limit=N] Recent-message lookback (Discord only).
118
+ metro notify <line> <text> [--from=<line>] Emit a notification on the daemon's stream.
119
+ metro history [--limit=N] [--line=…] [--station=…] [--kind=…] [--from=…] [--text=…] [--since=…]
120
+ Universal message log (every inbound + outbound), newest first.
121
+ metro update Upgrade in place.
149
122
  ```
150
123
 
151
- The codebase is built on a small protocol in [`src/stations/types.ts`](src/stations/types.ts):
152
-
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).
124
+ All commands accept `--json`. `reply` / `send` / `edit` read multi-line `<text>` from stdin if no positional is given.
157
125
 
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.
126
+ **State files** in `$METRO_STATE_DIR` (default `~/.cache/metro`):
127
+ - `AGENTS.md` — agent skill copied from the package on every start (so the path is stable across upgrades)
128
+ - `history.jsonl` — universal message log (one JSON object per line; append-only). Read with `metro history`. Each entry carries `from` and `to` as universal participant URIs (`metro://<station>/user/<id>`, `metro://claude/<topic>`, `metro://codex/<topic>`) plus a `fromName` display field. The dispatcher auto-detects the consuming agent for `to` on inbound (`$CLAUDECODE` → `metro://claude/agent`; `$METRO_CODEX_RC`/`$CODEX_HOME` → `metro://codex/agent`).
129
+ - `bot-ids.json` — `{discord: "<botUserId>", telegram: "<botUserId>"}` written by the daemon on startup (cached for the few historical lookups that still need a bot identity).
130
+ - `lines.json` line last-seen / name cache (read by `metro lines`)
131
+ - `.tail-lock` dispatcher pid
132
+ - `metro.sock` daemon IPC socket
133
+ - `telegram-offset.json` last processed update id
166
134
 
167
135
  ---
168
136
 
@@ -171,40 +139,15 @@ Behaviors worth knowing:
171
139
  | Variable | Default | Description |
172
140
  |---|---|---|
173
141
  | `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`). |
142
+ | `METRO_CODEX_RC` | — | Codex app-server URL (`ws://…`, `wss://…`, `unix:///…`). When set, the daemon pushes each event via JSON-RPC `turn/start`. |
175
143
  | `METRO_CONFIG_DIR` | `~/.config/metro` | Where the global `.env` lives. |
176
- | `METRO_STATE_DIR` | `~/.cache/metro` | Lockfile, line cache, codex socket, telegram offset, claude session set. |
144
+ | `METRO_STATE_DIR` | `~/.cache/metro` | Lockfile, line cache, IPC socket, telegram offset. |
177
145
  | `METRO_LOG_LEVEL` | `info` | `trace` / `debug` / `info` / `warn` / `error` / `fatal`. |
178
146
 
179
147
  Precedence: process env → `./.env` → `$METRO_CONFIG_DIR/.env`. Logs go to stderr.
180
148
 
181
149
  ---
182
150
 
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
- ---
207
-
208
151
  ## Develop
209
152
 
210
153
  ```bash
@@ -219,52 +162,24 @@ bun run lint # eslint
219
162
 
220
163
  Source map:
221
164
 
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`.
165
+ - [`src/cli/`](src/cli/) — `metro` binary entry ([`index.ts`](src/cli/index.ts)) + admin commands ([`config.ts`](src/cli/config.ts): setup/doctor/update) + shared CLI primitives ([`util.ts`](src/cli/util.ts)).
166
+ - [`src/dispatcher.ts`](src/dispatcher.ts) — the daemon: starts each chat station, emits events on stdout, listens on the IPC socket, optionally pushes to codex-rc.
167
+ - [`src/stations/`](src/stations/) — Line URI scheme + ChatStation interface + listing ([`index.ts`](src/stations/index.ts)), the two chat-station impls (`discord.ts`, `telegram.ts`), plus the Telegram markdown→HTML helper ([`telegram-md.ts`](src/stations/telegram-md.ts)). Agent stations have no impl — they're notification sinks expressed only as URI prefixes.
168
+ - [`src/codex-rc.ts`](src/codex-rc.ts) — Codex app-server WebSocket push client.
169
+ - [`src/ipc.ts`](src/ipc.ts) Unix-socket IPC between the daemon and one-shot CLI commands.
170
+ - [`src/cache.ts`](src/cache.ts) — in-memory line cache with debounced flush to `lines.json`.
171
+ - [`docs/uri-scheme.md`](docs/uri-scheme.md) specs the Line format; [`docs/agents.md`](docs/agents.md) is the in-context skill for agents.
227
172
 
228
173
  CI runs typecheck + lint + build on every PR via [`.github/workflows/ci.yml`](.github/workflows/ci.yml).
229
174
 
230
175
  ---
231
176
 
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).
250
-
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
177
  ## Caveats
261
178
 
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.
179
+ - **No allowlist.** Anyone who can DM/`@`-mention your bot can produce events. Run against bots you own.
265
180
  - **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.
181
+ - **Telegram non-forum groups are skipped.** No thread boundary to scope on. DMs and forum topics work normally.
182
+ - **Telegram fetch isn't supported** (bot API doesn't expose history); `metro fetch` returns `[]` on Telegram lines.
268
183
 
269
184
  ---
270
185
 
package/dist/cache.js ADDED
@@ -0,0 +1,74 @@
1
+ /** Per-machine caches: seen lines (lines.json) + bot ids (bot-ids.json). */
2
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { errMsg, log } from './log.js';
5
+ import { STATE_DIR } from './paths.js';
6
+ import { Line } from './stations/index.js';
7
+ const cacheFile = join(STATE_DIR, 'lines.json');
8
+ const FLUSH_DELAY_MS = 5_000;
9
+ let cache = null;
10
+ let dirty = false;
11
+ let flushTimer = null;
12
+ function read() {
13
+ if (cache)
14
+ return cache;
15
+ if (!existsSync(cacheFile))
16
+ return cache = {};
17
+ try {
18
+ cache = JSON.parse(readFileSync(cacheFile, 'utf8'));
19
+ }
20
+ catch (err) {
21
+ log.warn({ err: errMsg(err), path: cacheFile }, 'lines cache read failed; treating as empty');
22
+ cache = {};
23
+ }
24
+ return cache;
25
+ }
26
+ function flush() {
27
+ if (!dirty || !cache)
28
+ return;
29
+ try {
30
+ writeFileSync(cacheFile, JSON.stringify(cache, null, 2));
31
+ dirty = false;
32
+ }
33
+ catch (err) {
34
+ log.warn({ err: errMsg(err), path: cacheFile }, 'lines cache write failed');
35
+ }
36
+ }
37
+ process.on('exit', flush);
38
+ export function noteSeen(line, name) {
39
+ const c = read();
40
+ const entry = c[line] ??= { createdAt: new Date().toISOString() };
41
+ entry.lastSeenAt = new Date().toISOString();
42
+ if (name && entry.name !== name)
43
+ entry.name = name;
44
+ dirty = true;
45
+ if (!flushTimer)
46
+ flushTimer = setTimeout(() => { flushTimer = null; flush(); }, FLUSH_DELAY_MS);
47
+ }
48
+ export const listLines = () => Object.entries(read()).map(([line, entry]) => ({ line: line, entry }));
49
+ /** Bot identity cache: `{discord: "<userId>", telegram: "<userId>"}`. Daemon writes after getMe(). */
50
+ const botIdsFile = join(STATE_DIR, 'bot-ids.json');
51
+ export function saveBotId(station, id) {
52
+ const cur = existsSync(botIdsFile)
53
+ ? JSON.parse(readFileSync(botIdsFile, 'utf8')) : {};
54
+ if (cur[station] === id)
55
+ return;
56
+ cur[station] = id;
57
+ try {
58
+ writeFileSync(botIdsFile, JSON.stringify(cur, null, 2));
59
+ }
60
+ catch (err) {
61
+ log.warn({ err: errMsg(err) }, 'bot-ids cache write failed');
62
+ }
63
+ }
64
+ /** Resolve the bot's URI for a station. Returns `metro://<station>/bot/<id>` or the placeholder. */
65
+ export function botLine(station) {
66
+ try {
67
+ const ids = existsSync(botIdsFile)
68
+ ? JSON.parse(readFileSync(botIdsFile, 'utf8')) : {};
69
+ return ids[station] ? Line.bot(station, ids[station]) : `metro://${station}/bot`;
70
+ }
71
+ catch {
72
+ return `metro://${station}/bot`;
73
+ }
74
+ }
@@ -0,0 +1,156 @@
1
+ /** CLI action handlers: send/reply/edit/react/download/fetch/notify + helpers. */
2
+ import { mkdirSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { errMsg } from '../log.js';
6
+ import { DiscordStation } from '../stations/discord.js';
7
+ import { TelegramStation } from '../stations/telegram.js';
8
+ import { ipcCall } from '../ipc.js';
9
+ import { agentSelf, appendHistory, lookupEntry, mintId, resolvePlatformId, } from '../history.js';
10
+ import { asLine, Line } from '../stations/index.js';
11
+ import { loadMetroEnv } from '../paths.js';
12
+ import { emit, flagList, flagOne, isJson, need, resolveText, writeJson, } from './util.js';
13
+ export function chatStationOf(line) {
14
+ const s = Line.station(line);
15
+ if (s === 'discord')
16
+ return new DiscordStation();
17
+ if (s === 'telegram')
18
+ return new TelegramStation();
19
+ throw new Error(`no chat station for line "${line}" (try metro://{discord|telegram}/...)`);
20
+ }
21
+ function parseButtons(f) {
22
+ const raw = flagOne(f, 'buttons');
23
+ if (raw === undefined)
24
+ return undefined;
25
+ try {
26
+ return JSON.parse(raw);
27
+ }
28
+ catch (err) {
29
+ throw new Error(`--buttons must be JSON like '[[{"text":"…","url":"…"}]]': ${errMsg(err)}`);
30
+ }
31
+ }
32
+ function richOpts(f) {
33
+ const opts = {};
34
+ const images = flagList(f, 'image');
35
+ if (images.length)
36
+ opts.images = images;
37
+ const documents = flagList(f, 'document');
38
+ if (documents.length)
39
+ opts.documents = documents;
40
+ const voice = flagOne(f, 'voice');
41
+ if (voice)
42
+ opts.voice = voice;
43
+ const buttons = parseButtons(f);
44
+ if (buttons)
45
+ opts.buttons = buttons;
46
+ return opts;
47
+ }
48
+ /** Append an outbound action to history.jsonl; pass `to` = the original sender when replying/reacting. */
49
+ function logOutbound(f, kind, line, text, platformMessageId, replyTo, opts, emoji, to) {
50
+ const id = mintId();
51
+ const station = Line.station(line) ?? '?';
52
+ const fromOverride = flagOne(f, 'from');
53
+ const from = fromOverride ? asLine(fromOverride) : agentSelf();
54
+ appendHistory({
55
+ id, ts: new Date().toISOString(), kind, station, line, from, to: to ?? line,
56
+ text, platformMessageId, replyTo, emoji,
57
+ attachments: [...(opts?.images ?? []), ...(opts?.documents ?? []), ...(opts?.voice ? [opts.voice] : [])],
58
+ });
59
+ return id;
60
+ }
61
+ /** When replying/reacting/editing, the recipient is the original message's sender (if we have it). */
62
+ const recipientFor = (idOrPlatform) => lookupEntry(idOrPlatform)?.from;
63
+ export async function cmdSend(p, f) {
64
+ need(p, 1, 'metro send <line> <text> [--image=<path>]… [--document=<path>]… [--voice=<path>] [--buttons=<json>]');
65
+ loadMetroEnv();
66
+ const text = await resolveText(p, 1), line = asLine(p[0]);
67
+ if (Line.isAgent(line)) {
68
+ const resp = await ipcCall({ op: 'notify', line, text });
69
+ if (!resp.ok)
70
+ throw new Error(resp.error);
71
+ return emit(f, `notified ${line}`, { ok: true, line, id: null, messageId: null });
72
+ }
73
+ const opts = richOpts(f);
74
+ const messageId = await chatStationOf(line).send(line, text, opts);
75
+ const id = logOutbound(f, 'outbound', line, text, messageId, undefined, opts);
76
+ emit(f, `sent ${id} (${messageId}) to ${line}`, { ok: true, line, id, messageId });
77
+ }
78
+ export async function cmdReply(p, f) {
79
+ need(p, 2, 'metro reply <line> <message_id> <text> [--image=… --document=… --voice=… --buttons=…]');
80
+ loadMetroEnv();
81
+ const [to, replyToArg] = p, text = await resolveText(p, 2), line = asLine(to);
82
+ const replyTo = resolvePlatformId(replyToArg);
83
+ const opts = richOpts(f);
84
+ const messageId = await chatStationOf(line).send(line, text, { ...opts, replyTo });
85
+ const id = logOutbound(f, 'outbound', line, text, messageId, replyToArg, opts, undefined, recipientFor(replyToArg));
86
+ emit(f, `replied ${id} (${messageId}) to ${line}#${replyTo}`, { ok: true, line, id, replyTo: replyToArg, messageId });
87
+ }
88
+ export async function cmdEdit(p, f) {
89
+ need(p, 2, 'metro edit <line> <message_id> <text> [--buttons=<json>]');
90
+ loadMetroEnv();
91
+ const [to, msgArg] = p, text = await resolveText(p, 2), line = asLine(to);
92
+ const platformId = resolvePlatformId(msgArg);
93
+ const buttons = parseButtons(f);
94
+ await chatStationOf(line).edit(line, platformId, text, buttons ? { buttons } : undefined);
95
+ /** Carry forward the original recipient if we have a row for this message. */
96
+ const original = lookupEntry(msgArg);
97
+ const id = logOutbound(f, 'edit', line, text, platformId, msgArg, undefined, undefined, original?.to);
98
+ emit(f, `edited ${line}#${platformId} (${id})`, { ok: true, line, id, messageId: platformId });
99
+ }
100
+ export async function cmdReact(p, f) {
101
+ need(p, 2, 'metro react <line> <message_id> <emoji> (empty emoji clears)');
102
+ loadMetroEnv();
103
+ const [to, msgArg, emoji = ''] = p, line = asLine(to);
104
+ const platformId = resolvePlatformId(msgArg);
105
+ await chatStationOf(line).react(line, platformId, emoji);
106
+ const id = logOutbound(f, 'react', line, undefined, platformId, undefined, undefined, emoji, recipientFor(msgArg));
107
+ const human = emoji ? `reacted ${emoji} on ${line}#${platformId}` : `cleared reaction on ${line}#${platformId}`;
108
+ emit(f, human, { ok: true, line, id, messageId: platformId, emoji });
109
+ }
110
+ export async function cmdDownload(p, f) {
111
+ need(p, 2, 'metro download <line> <message_id> [--out=<dir>]');
112
+ loadMetroEnv();
113
+ const [to, msgArg] = p, line = asLine(to);
114
+ const messageId = resolvePlatformId(msgArg);
115
+ const outDir = typeof f.out === 'string' ? f.out : join(tmpdir(), 'metro-downloads');
116
+ mkdirSync(outDir, { recursive: true });
117
+ /** Telegram has no get-message-by-id REST endpoint — daemon holds the in-memory snapshot. */
118
+ let files;
119
+ if (Line.station(line) === 'telegram') {
120
+ const resp = await ipcCall({ op: 'download', line, messageId, outDir });
121
+ if (!resp.ok)
122
+ throw new Error(resp.error);
123
+ files = 'files' in resp ? resp.files : [];
124
+ }
125
+ else {
126
+ files = await chatStationOf(line).download(line, messageId, outDir);
127
+ }
128
+ if (isJson(f))
129
+ return writeJson({ ok: true, line, files });
130
+ if (!files.length)
131
+ process.stdout.write(`(no image attachments on ${line}#${messageId})\n`);
132
+ for (const file of files)
133
+ process.stdout.write(file.path + '\n');
134
+ }
135
+ export async function cmdFetch(p, f) {
136
+ need(p, 1, 'metro fetch <line> [--limit=N]');
137
+ loadMetroEnv();
138
+ const line = asLine(p[0]);
139
+ const messages = await chatStationOf(line).fetch(line, Number(flagOne(f, 'limit')) || 20);
140
+ if (isJson(f))
141
+ return writeJson({ ok: true, line, messages });
142
+ if (!messages.length)
143
+ process.stdout.write(`(no messages on ${line})\n`);
144
+ for (const m of messages)
145
+ process.stdout.write(`${m.timestamp} ${m.author}: ${m.text}\n`);
146
+ }
147
+ export async function cmdNotify(p, f) {
148
+ need(p, 1, 'metro notify <line> <text> [--from=<line>]');
149
+ loadMetroEnv();
150
+ const text = await resolveText(p, 1), line = asLine(p[0]);
151
+ const from = flagOne(f, 'from');
152
+ const resp = await ipcCall({ op: 'notify', line, from, text });
153
+ if (!resp.ok)
154
+ throw new Error(resp.error);
155
+ emit(f, `notified ${line}`, { ok: true, line });
156
+ }