@stage-labs/metro 0.1.0-beta.0 → 0.1.0-beta.10

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.
@@ -0,0 +1,125 @@
1
+ # Metro URI scheme
2
+
3
+ Universal identifier for every conversational scope and notification sink in metro. Lines pass around as opaque strings; only the owning station parses its own paths.
4
+
5
+ ## Grammar
6
+
7
+ ```
8
+ line = "metro://" station "/" path
9
+ station = lowercase identifier (claude | codex | discord | telegram | webhook | …)
10
+ path = station-specific, "/"-separated segments
11
+ ```
12
+
13
+ The URI parses cleanly with the WHATWG `URL` parser: `new URL(line)` gives `protocol="metro:"`, `host=<station>`, `pathname="/<path>"`.
14
+
15
+ ## Registered stations
16
+
17
+ | Station | Kind | Pattern | Example |
18
+ |------------|---------|----------------------------------------------|------------------------------------------------------------------------|
19
+ | `discord` | chat | `metro://discord/<channel-id>` | `metro://discord/1234567890123456789` |
20
+ | `telegram` | chat | `metro://telegram/<chat-id>[/<topic-id>]` | `metro://telegram/-1001234567890/42` |
21
+ | `claude` | agent | `metro://claude/<agent-id>/<session-id>` | `metro://claude/9bfc7af0-…/50b00d11-…` |
22
+ | `codex` | agent | `metro://codex/<agent-id>/<session-id>` | `metro://codex/8119ecb1-…/01997d4b-…` |
23
+ | `webhook` | service | `metro://webhook/<endpoint-id>` | `metro://webhook/fwaCgTKJuLAjS2K0` |
24
+
25
+ Agent lines mirror the `<root>/<sub>` structure of `metro://telegram/<chat-id>/<topic-id>`: `<agent-id>` (the user's stable account id — same across devices) plays the role of `<chat-id>`, and `<session-id>` (one conversation) plays the role of `<topic-id>`. Both segments are derived per station (see [participants](#participants) below).
26
+
27
+ ## Participants
28
+
29
+ Every chat station also exposes participant URIs — used as `from` on inbound/outbound events and history rows.
30
+
31
+ | Kind | Pattern | Example |
32
+ |--------|----------------------------------|---------------------------------------------------------------|
33
+ | user | `metro://<station>/user/<id>` | `metro://discord/user/87654321` |
34
+ | claude | `metro://claude/user/<orgId>` | `metro://claude/user/9bfc7af0-2117-44c5-baf2-d22ba382d065` |
35
+ | codex | `metro://codex/user/<accountId>` | `metro://codex/user/8119ecb1-b05e-48db-aa80-434584439df9` |
36
+ | webhook | `metro://webhook/<endpointId>` | `metro://webhook/fwaCgTKJuLAjS2K0` (line + `from` are the same — no HTTP-side user identity) |
37
+
38
+ `from` and `to` on history entries are always participant URIs. Discord/Telegram inbounds set `from` to the user URI; the daemon sets `to` to the agent identity:
39
+
40
+ - **Claude Code** (`$CLAUDECODE` set) — `metro://claude/user/<orgId>`. `<orgId>` is the stable Anthropic-account UUID, resolved by shelling out to `claude auth status --json`.
41
+ - **Codex** (`$METRO_CODEX_RC` or `$CODEX_HOME` set) — `metro://codex/user/<accountId>`. `<accountId>` is the ChatGPT-account UUID, read from `$CODEX_HOME/auth.json` (default `~/.codex/auth.json`) at the `tokens.account_id` field. Requires `auth_mode=chatgpt`; API-key-only Codex sessions have no account id and metro will error.
42
+ - **Neither** — `to` is the generic `metro://agent`.
43
+
44
+ Same account on any machine yields the same URI. Switching accounts via `claude auth login` / `codex login` flips the URI within ~5 s for the long-lived daemon (5 s TTL cache); one-shot CLI invocations re-resolve every run. On outbound, `from` = the same agent identity; `to` = the original sender for replies/reacts (looked up from history), or the channel `line` for fresh group sends. A `fromName` field carries the display name (`@alice`, `bonustrack_`).
45
+
46
+ Override with `--from=<uri>` on any write command, or set `$METRO_FROM` to pin a custom identity for the whole session.
47
+
48
+ Chat lines identify a Discord channel / Telegram chat (with optional forum topic). Agent lines now identify a *specific session* of a specific agent (`<agent-id>/<session-id>`) — posting to one re-emits the message on the daemon's stdout stream and (if configured) pushes it to the Codex app-server. They have no inherent "messages"; only events.
49
+
50
+ ### Session derivation per station
51
+
52
+ | Station | `<agent-id>` | `<session-id>` |
53
+ |----------|----------------------------|-----------------------------------------------------------------|
54
+ | `claude` | `orgId` from `claude auth status --json` | `$CLAUDE_CODE_SESSION_ID` (set by Claude Code; stable across `--resume`)|
55
+ | `codex` | `tokens.account_id` from `$CODEX_HOME/auth.json` | codex-rc thread id from the JSON-RPC handshake (`thread/loaded/list` → `thread/start`) |
56
+
57
+ Override either segment with `METRO_AGENT_ID` / `METRO_AGENT_SESSION_ID` env vars.
58
+
59
+ ### Agent registry
60
+
61
+ The daemon persists every `(station, agent-id, session)` tuple it sees to `$METRO_STATE_DIR/agent-registry.json`. `metro stations` prints the count of seen agents and sessions per station. Run it to discover what's reachable rather than guessing topic names.
62
+
63
+ ## Webhook station
64
+
65
+ Receive-only HTTP endpoint for third-party services (GitHub, Intercom, Fireflies, …). Each registered endpoint is one `metro://webhook/<endpoint-id>` line.
66
+
67
+ - **Register:** `metro webhook add <label> [--secret=<shared-secret>]` mints a 16-char endpoint id (96 bits of entropy, persisted to `$METRO_STATE_DIR/webhooks.json`) and prints the receiving URL. `metro webhook list` / `remove <id>` for the obvious.
68
+ - **Listener:** the dispatcher binds `127.0.0.1:8420` (override with `METRO_WEBHOOK_PORT`) when ≥1 endpoint is registered. Routes `POST /wh/<endpoint-id>` to an inbound event with `payload: { headers, body }` — `body` is parsed JSON when the request `Content-Type` is JSON, raw string otherwise. `GET /wh/<endpoint-id>` returns 200 (for provider ping checks).
69
+ - **Envelope:** `messageId` falls back to `X-GitHub-Delivery` / `X-Request-ID` / a generated UUID for idempotency tracking. `text` is synthesized from `X-GitHub-Event` / `X-Intercom-Topic` plus method + path for at-a-glance routing; agents narrow on `payload.body` for full event details.
70
+ - **HMAC verification:** if `--secret` was set on `metro webhook add`, requests must include a matching `X-Hub-Signature-256: sha256=<hex>` (GitHub/Intercom format) — mismatches are rejected with 401 before reaching the stream.
71
+ - **Public reachability:** provided by a Cloudflare named tunnel — see [Tunneling](#tunneling) below. Without one, the listener stays loopback-only (useful for `curl` testing).
72
+
73
+ ## Tunneling
74
+
75
+ Webhook providers need a public URL. Metro integrates with **Cloudflare named tunnels** (free, stable, account-scoped):
76
+
77
+ ```bash
78
+ cloudflared tunnel login # one-time OAuth (browser)
79
+ metro tunnel setup metro webhook.yourdomain.com # creates the tunnel + DNS route
80
+ metro # daemon spawns `cloudflared tunnel run`
81
+ ```
82
+
83
+ After setup, `metro webhook list` prints `https://webhook.yourdomain.com/wh/<id>` for each endpoint. The URL is stable across restarts (bound to the tunnel UUID in `~/.cloudflared/<uuid>.json`, not the cloudflared process). Tunnel config persists at `$METRO_STATE_DIR/tunnel.json`. Without setup, endpoints fall back to `http://127.0.0.1:8420/wh/<id>` (local-only, useful for curl testing).
84
+
85
+ ## Message addressing
86
+
87
+ Messages on chat lines are referenced by **line + message id** (two args), not as part of the URI. So:
88
+
89
+ ```bash
90
+ metro reply metro://discord/123… 4567 "ack"
91
+ metro edit metro://discord/123… 9876 "fixed typo"
92
+ metro react metro://telegram/-100…/42 4567 👍
93
+ ```
94
+
95
+ ## Properties
96
+
97
+ - **Stable**: a Line is valid for the lifetime of the scope.
98
+ - **Self-describing**: the station name is encoded; the dispatcher routes by station prefix.
99
+ - **Persistable**: safe as a JSON key on disk (used by `lines.json`).
100
+ - **Branded**: TypeScript type `Line` prevents mixing with arbitrary strings.
101
+
102
+ ## API
103
+
104
+ ```ts
105
+ import { Line } from './stations/index.js'; // value namespace + type
106
+
107
+ const l: Line = Line.discord('1234567890'); // typed Line
108
+ Line.parse(l); // { station: 'discord', path: ['1234567890'] } | null
109
+ Line.station(l); // 'discord'
110
+ Line.claude(orgId, sessionId); // metro://claude/<orgId>/<sessionId>
111
+ Line.codex(accountId, threadId); // metro://codex/<accountId>/<threadId>
112
+ Line.parseClaude(l); // { agentId, sessionId } | null
113
+ Line.parseCodex(l); // { agentId, sessionId } | null
114
+ Line.webhook(endpointId); // metro://webhook/<endpointId>
115
+ Line.parseWebhook(l); // string | null (the endpoint id)
116
+ Line.user(station, id); // metro://<station>/user/<id>
117
+ Line.bot(station, id); // metro://<station>/bot/<id>
118
+ Line.isAgent(l); // true for any metro://{claude,codex}/...
119
+ ```
120
+
121
+ ## Adding a new station
122
+
123
+ 1. Pick a lowercase station name (`slack`, `matrix`, …).
124
+ 2. Add a `Line.<station>(...)` formatter and a parser that returns your typed payload.
125
+ 3. Document the path grammar in the table above.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.0",
4
- "description": "Chat with your Claude Code or Codex agent over Telegram and Discord. Ultra-lightweight: ~700 lines of TypeScript, one stdio MCP, no hosted infra.",
3
+ "version": "0.1.0-beta.10",
4
+ "description": "Live JSON stream of Telegram + Discord messages for your local Claude Code / Codex session. The agent launches metro; metro emits inbounds on stdout and accepts replies via CLI subcommands.",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -14,10 +14,12 @@
14
14
  "node": ">=22"
15
15
  },
16
16
  "bin": {
17
- "metro": "./dist/cli.js"
17
+ "metro": "./dist/cli/index.js"
18
18
  },
19
19
  "files": [
20
20
  "dist",
21
+ "docs",
22
+ "skills",
21
23
  "README.md",
22
24
  "LICENSE"
23
25
  ],
@@ -32,13 +34,14 @@
32
34
  "typecheck": "tsc --noEmit"
33
35
  },
34
36
  "dependencies": {
35
- "@modelcontextprotocol/sdk": "^1.29.0",
36
37
  "discord.js": "^14.14.0",
37
38
  "pino": "^9.5.0",
38
- "zod": "^3.23.0"
39
+ "pino-pretty": "^13.1.3",
40
+ "ws": "^8.20.0"
39
41
  },
40
42
  "devDependencies": {
41
43
  "@types/node": "^22.10.0",
44
+ "@types/ws": "^8.18.1",
42
45
  "eslint": "^10.3.0",
43
46
  "typescript": "^5",
44
47
  "typescript-eslint": "^8.59.2"
@@ -0,0 +1,257 @@
1
+ ---
2
+ name: metro
3
+ description: Run the metro Telegram/Discord/webhook bridge in this session — launch `metro` in the background, watch its stdout for inbound JSON events, and act on each. Use when the user asks to start/run/launch metro, when you see JSON lines on stdout shaped `{"kind":"inbound","station":...,"line":"metro://...","messageId":...,"text":...}`, or when handling a chat/webhook reply/edit/react/send/download/fetch/notify.
4
+ ---
5
+
6
+ # Metro — running the Telegram / Discord / webhook bridge
7
+
8
+ Metro is a CLI bridge between this agent session and external sources: Telegram, Discord, and HTTP webhooks from third parties (GitHub, Intercom, Fireflies, …). You launch `metro` once when the user asks, then act on each inbound JSON line via `metro <subcommand>`.
9
+
10
+ ## Starting the bridge
11
+
12
+ When the user asks to run/start/launch metro:
13
+
14
+ ### Claude Code
15
+
16
+ ```
17
+ Bash(command: "metro", run_in_background: true)
18
+ ```
19
+
20
+ Then attach `Monitor` to its stdout. Each line is one JSON event. Stderr is pino logs — don't act on it.
21
+
22
+ ### Codex
23
+
24
+ ```
25
+ shell(command: "METRO_CODEX_RC=ws://127.0.0.1:8421 metro", run_in_background: true)
26
+ ```
27
+
28
+ Codex has no Monitor equivalent. Instead, metro pushes each event into your thread via JSON-RPC `turn/start`, so events arrive as user input on your next turn. The user must have a daemon + TUI running on the **same WebSocket URL**:
29
+
30
+ ```
31
+ codex app-server --listen ws://127.0.0.1:8421 # daemon (terminal 1)
32
+ codex --remote ws://127.0.0.1:8421 # TUI (terminal 2) — type "hi" once to create a live thread
33
+ ```
34
+
35
+ Then metro starts third. If metro exits immediately or you see `thread not found` retries on its stderr, the TUI didn't create a thread yet — tell the user to type something in the TUI.
36
+
37
+ ### Diagnostics
38
+
39
+ If something seems off, run `metro doctor`. Common causes: missing tokens (`metro setup telegram <token>` / `metro setup discord <token>`), Discord Message Content Intent not toggled, stale lockfile, or (Codex) no live thread on the daemon.
40
+
41
+ ## Event shape
42
+
43
+ Every line on stdout is one **history entry** — the same record appended to `history.jsonl`. Fields:
44
+ - `kind` — `"inbound"`, `"notification"`, `"outbound"`, `"edit"`, or `"react"`
45
+ - `id` (`msg_…`) — universal message ID minted by metro
46
+ - `ts` — ISO timestamp
47
+ - `station` — `"discord"`, `"telegram"`, `"claude"`, `"codex"`, `"webhook"`
48
+ - `line` — conversation URI; `lineName?` is the channel/topic display name (for webhooks: the label you gave it)
49
+ - `from` / `fromName?` — sender participant URI + optional display name
50
+ - `to` — recipient participant URI (agent for inbound, line for notification, original sender for replies/reacts)
51
+ - `text` — universal display projection. Includes `[image]`/`[file: …]`/`[voice]`/`[audio]` tags inline.
52
+ - `messageId?` — platform-side id (Discord snowflake, Telegram int). Set on inbound/outbound.
53
+ - `payload?` — raw platform-native message object. Set on inbound only. Shape varies per `station`.
54
+
55
+ ```json
56
+ {"kind":"inbound","id":"msg_aB3xY7zP","ts":"2026-05-14T12:00:00Z","station":"telegram","line":"metro://telegram/-100…/247","lineName":"infra","from":"metro://telegram/user/12345","fromName":"@alice","to":"metro://claude/user/9bfc7af0-…","messageId":"4567","text":"hi [image]","payload":{"message_id":4567,"chat":{"id":-100,"type":"supergroup","is_forum":true},"from":{"id":12345,"username":"alice"},"text":"hi","photo":[{"file_id":"…"}],"reply_to_message":{"message_id":4500,"text":"earlier","from":{"id":99,"username":"bob"}}}}
57
+ ```
58
+
59
+ ```json
60
+ {"kind":"notification","id":"msg_pQ4r5sT0","ts":"…","station":"claude","line":"metro://claude/9bfc7af0-…/50b00d11-…","from":"metro://codex/user/8119ecb1-…","to":"metro://claude/9bfc7af0-…/50b00d11-…","text":"deploy green"}
61
+ ```
62
+
63
+ ### `payload` by station
64
+
65
+ `payload` is the platform's native message shape. Narrow on `event.station`:
66
+
67
+ - **`discord`** — discord.js `Message.toJSON()`: camelCase fields (`channelId`, `guildId`, `content`, `author`, `mentions: { users[], roles[], everyone }`, `attachments[]`, `reference`, …). Collections come back as **arrays of IDs**. `referencedMessage` is added inline on replies (auto-fetched).
68
+ - **`telegram`** — raw Bot API `Message` (snake_case): `{ message_id, chat, from, text, caption, entities[], photo[], document, voice, audio, reply_to_message, … }`. `reply_to_message` is inline on replies.
69
+ - **`webhook`** — `{ headers, body }`. The provider lives in `headers['x-github-event']`, `headers['x-intercom-topic']`, etc. Full event payload is `body` (parsed JSON when possible). `text` is a short summary; always narrow on `body` for real routing.
70
+
71
+ Use `payload` for anything the envelope doesn't surface — mentions, reply chains, embeds, entities.
72
+
73
+ ## Detecting "is this for me?"
74
+
75
+ Derive from `payload`. Bot id per station is cached in `$METRO_STATE_DIR/bot-ids.json` (`{discord:"<userId>", telegram:"<userId>"}`, written by the daemon on start).
76
+
77
+ - **discord** — DM when `payload.guildId == null`; otherwise pinged when `payload.mentions.users.includes(<bot-id>)`.
78
+ - **telegram** — DM when `payload.chat.type === 'private'`; otherwise pinged when any entity in `payload.entities` (or `caption_entities`) is `{type:"mention"}` matching `@<bot-username>` or `{type:"text_mention", user:{id:<bot-id>}}`.
79
+ - **webhook** — every POST is "for you" by design (it's an endpoint you registered). Route on `payload.headers['x-github-event']` / `x-intercom-topic` etc. to decide which provider event you're handling.
80
+
81
+ Default for chat: only reply on DM or ping; otherwise stay silent or `metro react` to ack. Webhooks have no "ack" mechanism — just consume the event.
82
+
83
+ Both `from` and `to` are **participant URIs** (the conversation context lives in `line`):
84
+ - `metro://<station>/user/<id>` — a person on a chat platform
85
+ - `metro://claude/user/<orgId>` — a Claude Code agent (orgId = stable Anthropic-account UUID, same across devices for the same account)
86
+ - `metro://codex/user/<accountId>` — a Codex agent (accountId = stable ChatGPT-account UUID, same across devices)
87
+ - `metro://webhook/<endpoint-id>` — a webhook endpoint (line + `from` are the same — no HTTP-side user identity)
88
+ - `metro://<station>/<channelId>` — a channel (used as `to` for fresh sends to a group, where no single recipient)
89
+
90
+ When **you** send via `metro send`/`reply`/`edit`/`react`, metro auto-stamps `from = metro://claude/user/<orgId>` (when `$CLAUDECODE` is set; resolved from `claude auth status --json`) or `metro://codex/user/<accountId>` (from `$METRO_CODEX_RC` / `$CODEX_HOME`; resolved from `$CODEX_HOME/auth.json`). Switching accounts via `claude auth login` / `codex login` flips the id on the next event (within ~5 s for the daemon). Override with `--from=<uri>` or `$METRO_FROM`. When replying/reacting, `to` is automatically the original sender (looked up via the universal id).
91
+
92
+ The `id` is the **canonical handle** for that message across all stations — store it if you want to refer back to it later.
93
+
94
+ - `kind: "inbound"` — a human (or another bot) posted on a chat platform.
95
+ - `kind: "notification"` — another agent called `metro send` against your agent line. This is how Codex pings Claude Code and vice versa.
96
+
97
+ `text` may contain `[image]`, `[voice]`, `[audio]`, or `[file: <name>]` placeholders alongside the real text — non-image attachments are opaque markers; images can be materialized via `metro download`.
98
+
99
+ ## Required flow on every event
100
+
101
+ 1. **Echo `event.display` verbatim as your first chat output.** Every event ships a pre-rendered chat-bubble in `event.display` — bold header (icon + station + sender) and a markdown blockquote body. Render this string as-is, before any commentary or tool calls. Monitor's notification chip is a CLI-only UI and won't surface visibly in VSCode/Cursor, so this echo is the only cross-surface signal the user has. Example:
102
+
103
+ ```
104
+ **📩 telegram · @bonustrack**
105
+ > Hey
106
+ ```
107
+
108
+ Don't compose your own bubble — the format is centralized in metro's dispatcher; just paste the string.
109
+
110
+ 2. **Decide and act** using the subcommands below.
111
+
112
+ No server-side auto-reaction — don't expect 👀 to be on the user's message; add one yourself with `metro react` if you want to ack quickly.
113
+
114
+ ## Subcommands
115
+
116
+ All take positional args (no `--to=`/`--text=` flags). Append `--json` to any for a parseable single-line result.
117
+
118
+ | Action | Command |
119
+ |---|---|
120
+ | Quote-reply (threads under original) | `metro reply <line> <messageId> <text>` |
121
+ | Send a fresh message (no reply context) | `metro send <line> <text>` |
122
+ | Edit a message you previously sent | `metro edit <line> <messageId> <text>` |
123
+ | Reaction (empty emoji clears) | `metro react <line> <messageId> <emoji>` |
124
+ | Download `[image]` attachments → paths | `metro download <line> <messageId> [--out=<dir>]` |
125
+ | Recent channel history (Discord only) | `metro fetch <line> [--limit=20]` |
126
+ | Ping another agent (cross-agent line) | `metro send metro://claude/<agent-id>/<session-id> <text> [--from=<line>]` |
127
+ | Register webhook endpoint | `metro webhook add <label> [--secret=<hmac-secret>]` |
128
+ | List / remove webhook endpoints | `metro webhook list` · `metro webhook remove <id>` |
129
+ | Configure Cloudflare named tunnel | `metro tunnel setup <tunnel-name> <hostname>` |
130
+
131
+ `reply` / `send` / `edit` accept multi-line text via stdin (heredoc).
132
+
133
+ ### Rich content flags
134
+
135
+ `send` and `reply` accept these extra flags; `edit` accepts `--buttons` only.
136
+
137
+ - `--image=<path>` — upload a local image. **Repeatable** for albums: `--image=a.png --image=b.png`. Comma-separated also works: `--image='a.png,b.png'`. Up to 10 / message. Text becomes the caption (on the first image for albums).
138
+ - `--document=<path>` — upload any local file (PDF, log, csv, …). Same repeat/comma syntax.
139
+ - `--voice=<path>` — single voice message (`.ogg` Opus or `.mp3`). On Telegram renders as a voice bubble via `sendVoice`; on Discord uploaded as an audio attachment.
140
+ - `--buttons='[[{"text":"…","url":"https://…"}]]'` — attach an inline URL-button keyboard. 2D array: outer = rows, inner = buttons on that row.
141
+
142
+ ```bash
143
+ metro send <line> "screenshot" --image=/tmp/build.png
144
+ metro send <line> "before/after" --image=/tmp/before.png --image=/tmp/after.png
145
+ metro reply <line> <id> "log + transcript" --document=/tmp/run.log --document=/tmp/transcript.txt
146
+ metro send <line> "have a listen" --voice=/tmp/note.ogg
147
+ metro send <line> "approve?" --buttons='[[{"text":"Open PR","url":"https://github.com/x/y/pull/1"}]]'
148
+ metro edit <line> <id> "still working…" --buttons='[]' # clears buttons
149
+ ```
150
+
151
+ Limits / quirks:
152
+ - 20 MB per file (both platforms).
153
+ - Telegram albums are single-type (all photos OR all documents in one album). Mixing kinds in one send still works — metro splits into two album messages and returns the first id.
154
+ - Telegram drops `--buttons` when multiple attachments are sent (the bot API doesn't allow `reply_markup` on media groups).
155
+ - URL buttons only (no callback / interactive components yet).
156
+
157
+ ## When to use `reply` vs `send`
158
+
159
+ - **`reply`** — responding to a specific inbound message. Threads under it. Default for handling an `inbound` event.
160
+ - **`send`** — initiating without a triggering message: a long task finished, a follow-up the user asked you to deliver later, or posting to an agent line (`metro://claude/...`, `metro://codex/...`) to notify a peer.
161
+
162
+ ## Line URI scheme
163
+
164
+ `metro://<station>/<path>` — see [docs/uri-scheme.md](https://github.com/bonustrack/metro/blob/main/docs/uri-scheme.md) for the full grammar.
165
+
166
+ | Station | Pattern | Example |
167
+ |------------|-------------------------------------------|--------------------------------------|
168
+ | `discord` | `metro://discord/<channel-id>` | `metro://discord/1234567890` |
169
+ | `telegram` | `metro://telegram/<chat-id>[/<topic-id>]` | `metro://telegram/-1001234567890/42` |
170
+ | `claude` | `metro://claude/<agent-id>/<session-id>` | `metro://claude/9bfc7af0-…/50b00d11-…` |
171
+ | `codex` | `metro://codex/<agent-id>/<session-id>` | `metro://codex/8119ecb1-…/01997d4b-…` |
172
+ | `webhook` | `metro://webhook/<endpoint-id>` | `metro://webhook/fwaCgTKJuLAjS2K0` |
173
+
174
+ The `messageId` is **not** part of the URI — it's a separate positional arg for `reply` / `edit` / `react` / `download`.
175
+
176
+ ## Image attachments
177
+
178
+ When an event's `text` contains `[image]`:
179
+
180
+ 1. `metro download <line> <messageId>` — writes images to disk and prints absolute paths.
181
+ 2. `Read` each path with your Read tool — the image enters your context as a vision input.
182
+ 3. Reply normally with `metro reply`.
183
+
184
+ ## Opaque attachment markers
185
+
186
+ `[voice]`, `[audio]`, and `[file: <name>]` are opaque — `metro download` only handles images. Acknowledge in text or ask the user to resend as a regular file.
187
+
188
+ ## Cross-agent notification
189
+
190
+ Both agents can post to each other's "agent line":
191
+
192
+ ```bash
193
+ metro send metro://claude/9bfc7af0-…/50b00d11-… "build green, ready to ship"
194
+ metro send metro://codex/8119ecb1-…/01997d4b-… "build green" --from=metro://claude/user/9bfc7af0-… # override sender
195
+ ```
196
+
197
+ The daemon re-emits the post on its stdout stream (and pushes via codex-rc if configured), so the peer agent sees a `{"kind":"notification",...}` event. Requires the metro daemon to be running on the machine — agent-line sends error with `metro daemon is not running` otherwise.
198
+
199
+ ## Discoverability
200
+
201
+ - `metro lines` — list recently-seen conversations (sorted by recency).
202
+ - `metro stations` — list stations + capability matrix.
203
+ - `metro history` — universal message log (every inbound + outbound + notification across all stations). Newest first. Filters:
204
+ - `--limit=N` (default 50)
205
+ - `--line=<metro://…>` — only this conversation
206
+ - `--station=<discord|telegram|claude|codex|webhook>`
207
+ - `--kind=<inbound|outbound|edit|react|notification>`
208
+ - `--from=<sender>`
209
+ - `--text=<substring>`
210
+ - `--since=<iso>` — e.g. `--since=2026-05-14T00:00:00Z`
211
+ - `--json` — machine-parseable
212
+
213
+ Every action you take is logged automatically — `metro send`/`reply`/`edit`/`react` append outbound entries, daemon-side inbounds + notifications append on arrival. Stored at `$METRO_STATE_DIR/history.jsonl`.
214
+
215
+ ## Universal message IDs
216
+
217
+ The `id` from `metro history` or an event JSON works **anywhere a `<message_id>` argument is expected**:
218
+
219
+ ```bash
220
+ # Either form works for reply/edit/react/download:
221
+ metro reply <line> 4567 "ack" # platform messageId (Telegram int)
222
+ metro reply <line> msg_aB3xY7zP "ack" # universal — resolves via history
223
+ ```
224
+
225
+ Use universal IDs when chaining commands or referring back to a specific message across stations.
226
+
227
+ ## Exit codes
228
+
229
+ - `0` success
230
+ - `1` usage error (bad args, unknown subcommand)
231
+ - `2` configuration error (no tokens — tell the user to run `metro setup`)
232
+ - `3` upstream error (rate limit, auth, network) — retry once after a few seconds before surfacing
233
+
234
+ `metro doctor` diagnoses tokens, gateways, dispatcher liveness, and codex-rc target.
235
+
236
+ ## --json output
237
+
238
+ Every command supports `--json` for stable parseable output:
239
+
240
+ ```bash
241
+ metro reply <line> <messageId> "ack" --json
242
+ # {"ok":true,"line":"metro://discord/...","replyTo":"...","messageId":"..."}
243
+
244
+ metro fetch metro://discord/1234 --limit=10 --json
245
+ # {"ok":true,"line":"...","messages":[{"messageId":"...","author":"...","text":"...","timestamp":"..."},...]}
246
+
247
+ metro download <line> <messageId> --json
248
+ # {"ok":true,"line":"...","files":[{"path":"/abs/...png","mediaType":"image/png"}]}
249
+ ```
250
+
251
+ Use `--json` when you need to chain calls or capture the new `messageId` for a later edit.
252
+
253
+ ## Don'ts
254
+
255
+ - ❌ Spawning a second metro daemon — there's one per machine (lockfile-enforced).
256
+ - ❌ Posting to a line that isn't in `metro lines` unless the user gave it to you explicitly.
257
+ - ❌ Narrating the tool ("I'll now use metro reply to…"). The tool call is already visible to the user.
@@ -1,117 +0,0 @@
1
- import { Client, Events, GatewayIntentBits, Partials } from 'discord.js';
2
- import { errMsg, log } from '../log.js';
3
- let client = null;
4
- function getClient() {
5
- if (client)
6
- return client;
7
- if (!process.env.DISCORD_BOT_TOKEN)
8
- throw new Error('DISCORD_BOT_TOKEN is not set');
9
- client = new Client({
10
- intents: [
11
- GatewayIntentBits.DirectMessages,
12
- GatewayIntentBits.Guilds,
13
- GatewayIntentBits.GuildMessages,
14
- GatewayIntentBits.MessageContent,
15
- ],
16
- // DM channels arrive partial; without this messageCreate never fires.
17
- partials: [Partials.Channel],
18
- });
19
- return client;
20
- }
21
- async function getTextChannel(channelId) {
22
- const channel = await getClient().channels.fetch(channelId);
23
- if (!channel?.isTextBased() || !('messages' in channel)) {
24
- throw new Error(`discord: channel ${channelId} is not text-capable`);
25
- }
26
- return channel;
27
- }
28
- async function fetchMessage(channelId, messageId) {
29
- return (await getTextChannel(channelId)).messages.fetch(messageId);
30
- }
31
- let onInboundHandler = () => { };
32
- export function onInbound(handler) {
33
- onInboundHandler = handler;
34
- }
35
- export async function startGateway() {
36
- const c = getClient();
37
- c.on(Events.MessageCreate, m => {
38
- if (m.author.bot)
39
- return;
40
- // Guild messages: only forward when the bot is mentioned. DMs always pass.
41
- if (m.guildId && c.user && !m.mentions.has(c.user.id))
42
- return;
43
- const tags = [...m.attachments.values()]
44
- .map(a => {
45
- if (a.contentType?.startsWith('image/'))
46
- return '[image]';
47
- if (a.contentType?.startsWith('audio/'))
48
- return `[audio: ${a.name}]`;
49
- return `[file: ${a.name}]`;
50
- })
51
- .join(' ');
52
- const text = [m.content, tags].filter(Boolean).join(' ').trim();
53
- if (!text)
54
- return;
55
- onInboundHandler({ channel_id: m.channelId, message_id: m.id, text });
56
- });
57
- c.on(Events.Error, err => log.error({ err: errMsg(err) }, 'discord error'));
58
- await c.login(process.env.DISCORD_BOT_TOKEN);
59
- await new Promise(r => c.once(Events.ClientReady, () => r()));
60
- }
61
- export async function getMe() {
62
- const c = getClient();
63
- if (!c.user)
64
- throw new Error('discord: gateway not ready');
65
- return { username: c.user.username };
66
- }
67
- export async function replyToMessage(channelId, messageId, text) {
68
- await (await fetchMessage(channelId, messageId)).reply(text);
69
- }
70
- export async function editMessage(channelId, messageId, text) {
71
- await (await fetchMessage(channelId, messageId)).edit(text);
72
- }
73
- export async function sendTyping(channelId) {
74
- const channel = await getClient().channels.fetch(channelId);
75
- if (!channel?.isTextBased() || !('sendTyping' in channel))
76
- return;
77
- await channel.sendTyping();
78
- }
79
- export async function setReaction(channelId, messageId, emoji) {
80
- const target = await fetchMessage(channelId, messageId);
81
- if (emoji) {
82
- await target.react(emoji);
83
- return;
84
- }
85
- // Clear only the bot's own reactions (matches Telegram's clear semantics).
86
- const me = getClient().user;
87
- if (!me)
88
- return;
89
- for (const r of target.reactions.cache.values()) {
90
- if (r.users.cache.has(me.id))
91
- await r.users.remove(me.id);
92
- }
93
- }
94
- export async function fetchAttachments(channelId, messageId) {
95
- const target = await fetchMessage(channelId, messageId);
96
- const out = [];
97
- for (const a of target.attachments.values()) {
98
- if (!a.contentType?.startsWith('image/'))
99
- continue;
100
- const res = await fetch(a.url);
101
- if (!res.ok)
102
- throw new Error(`discord: download ${a.url}: ${res.status}`);
103
- out.push({ data: Buffer.from(await res.arrayBuffer()).toString('base64'), mime: a.contentType });
104
- }
105
- return out;
106
- }
107
- export async function fetchRecentMessages(channelId, limit) {
108
- const channel = await getTextChannel(channelId);
109
- const msgs = await channel.messages.fetch({ limit: Math.min(Math.max(limit, 1), 100) });
110
- // Discord returns newest-first; reverse for chronological.
111
- return [...msgs.values()].reverse().map(m => ({
112
- message_id: m.id,
113
- author: m.author.username,
114
- text: m.content,
115
- timestamp: m.createdAt.toISOString(),
116
- }));
117
- }