@stage-labs/metro 0.1.0-beta.13 → 0.1.0-beta.14
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 +76 -189
- package/dist/broker/claims.js +144 -0
- package/dist/{broker.js → broker/history-stream.js} +44 -99
- package/dist/cli/config.js +115 -121
- package/dist/cli/index.js +20 -58
- package/dist/cli/tail.js +161 -113
- package/dist/cli/webhook.js +103 -3
- package/dist/{codex-rc.js → codex-rc/client.js} +12 -32
- package/dist/codex-rc/protocol.js +38 -0
- package/dist/dispatcher/server.js +130 -0
- package/dist/dispatcher.js +51 -82
- package/dist/history.js +43 -18
- package/dist/ipc.js +28 -10
- package/dist/lines.js +54 -0
- package/dist/local-identity.js +80 -0
- package/dist/paths.js +58 -12
- package/dist/trains/protocol.js +99 -0
- package/dist/trains/supervisor.js +210 -0
- package/dist/tunnel.js +39 -1
- package/docs/broker.md +88 -136
- package/docs/monitor.md +19 -2
- package/docs/uri-scheme.md +10 -7
- package/examples/README.md +32 -0
- package/examples/telegram.ts +121 -0
- package/package.json +6 -5
- package/skills/metro/SKILL.md +63 -215
- package/dist/cache.js +0 -69
- package/dist/cli/actions.js +0 -206
- package/dist/cli/skill.js +0 -62
- package/dist/monitor.js +0 -194
- package/dist/registry.js +0 -48
- package/dist/stations/claude.js +0 -45
- package/dist/stations/codex.js +0 -68
- package/dist/stations/discord.js +0 -216
- package/dist/stations/index.js +0 -129
- package/dist/stations/telegram-md.js +0 -34
- package/dist/stations/telegram-upload.js +0 -113
- package/dist/stations/telegram.js +0 -234
- package/dist/stations/webhook.js +0 -103
- package/dist/webhooks.js +0 -41
- package/docs/users.md +0 -226
package/skills/metro/SKILL.md
CHANGED
|
@@ -1,256 +1,104 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: metro
|
|
3
|
-
description: Run the metro
|
|
3
|
+
description: Run the metro train-supervisor 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://...","message_id":...,"text":...}`, or when handling a chat/webhook reply/edit/react/send via `metro call`.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
# Metro —
|
|
6
|
+
# Metro — event-interception wire
|
|
7
7
|
|
|
8
|
-
Metro is
|
|
8
|
+
Metro is the wire between this session and any number of platforms. Platform code lives in
|
|
9
|
+
**trains** under `~/.metro/trains/` — single TS files that you (the agent) write, edit, or
|
|
10
|
+
replace on demand.
|
|
9
11
|
|
|
10
|
-
##
|
|
12
|
+
## What metro does
|
|
11
13
|
|
|
12
|
-
|
|
14
|
+
1. Spawns each file in `~/.metro/trains/*.{ts,js,mjs}` as a long-running Bun subprocess.
|
|
15
|
+
2. Multiplexes their stdout (JSON lines) into one unified event stream on metro's stdout.
|
|
16
|
+
3. Routes `metro call <train> <action> <args>` requests back to the matching train's stdin and prints the response.
|
|
17
|
+
4. Two builtin event sources stay in core: **webhooks** (HTTP receiver) and cross-user **notify** IPC.
|
|
13
18
|
|
|
14
|
-
|
|
19
|
+
## Starting metro
|
|
15
20
|
|
|
16
|
-
|
|
17
|
-
Bash(command: "metro", run_in_background: true)
|
|
18
|
-
```
|
|
21
|
+
**Claude Code:** `Bash(command: "metro", run_in_background: true)`, then attach `Monitor` to its stdout.
|
|
19
22
|
|
|
20
|
-
|
|
23
|
+
**Codex:** `shell(command: "METRO_CODEX_RC=ws://127.0.0.1:8421 metro", run_in_background: true)` — metro pushes each event via JSON-RPC `turn/start`. The user must run a Codex daemon + TUI on the same WebSocket URL (`codex app-server --listen ws://127.0.0.1:8421` + `codex --remote ws://127.0.0.1:8421`, type "hi" once to seed a thread).
|
|
21
24
|
|
|
22
|
-
|
|
25
|
+
`metro doctor` reports trains found, deps installed, dispatcher running, codex-rc, skill install.
|
|
23
26
|
|
|
24
|
-
|
|
25
|
-
shell(command: "METRO_CODEX_RC=ws://127.0.0.1:8421 metro", run_in_background: true)
|
|
26
|
-
```
|
|
27
|
+
## Train protocol
|
|
27
28
|
|
|
28
|
-
|
|
29
|
+
**Inbound (train → metro stdout)** — one JSON line per event:
|
|
29
30
|
|
|
30
|
-
```
|
|
31
|
-
|
|
32
|
-
codex --remote ws://127.0.0.1:8421 # TUI (terminal 2) — type "hi" once to create a live thread
|
|
31
|
+
```json
|
|
32
|
+
{"kind":"inbound","station":"discord","line":"metro://discord/123","from":"metro://discord/user/456","from_name":"alice","message_id":"789","text":"hi","is_private":false,"ts":"2026-05-17T18:00:00Z","payload":{...}}
|
|
33
33
|
```
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
Wire fields are `snake_case` on the train protocol: `from_name`, `message_id`, `line_name`, `is_private`, `reply_to`. The dispatcher translates these to camelCase for `history.jsonl` and the broker. Trains supply `line`, `from`, `from_name`, `text`, `is_private`. Metro mints `id` + `display` if missing and appends to `history.jsonl`. `payload` is the platform's native message shape — use it for mentions, replies, embeds.
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
**Canonical `kind` enum**: `inbound | outbound | edit | react`. The dispatcher normalizes legacy aliases (`message` → `inbound`, `reaction` → `react`), but new trains should emit the canonical values directly. Anything else is passed through unchanged.
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
**Outbound (`metro call <train> <action> <args>`)**:
|
|
40
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"`, `"outbound"`, `"edit"`, or `"react"`. Inbound `react` events fire when a human adds an emoji reaction in Discord/Telegram — `emoji` is set, `text` is omitted, `messageId` is the message that got reacted to.
|
|
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 (local user for DMs, conversation `line` for groups, 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
41
|
```
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
{"
|
|
42
|
+
metro call discord send '{"line":"metro://discord/123","text":"hi","replyTo":"789"}'
|
|
43
|
+
metro call telegram react '{"line":"metro://telegram/-100/1","messageId":"42","emoji":"👀"}'
|
|
44
|
+
metro call discord edit '{"line":"metro://discord/123","messageId":"999","text":"new"}'
|
|
61
45
|
```
|
|
62
46
|
|
|
63
|
-
|
|
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 user (orgId = stable Anthropic-account UUID, same across devices for the same account)
|
|
86
|
-
- `metro://codex/user/<accountId>` — a Codex user (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 message arrived. Source can be a human on Discord/Telegram, a webhook POST, or another Claude / Codex user posting to your line via `metro send` (cross-process).
|
|
95
|
-
|
|
96
|
-
`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`.
|
|
97
|
-
|
|
98
|
-
## Required flow on every event
|
|
99
|
-
|
|
100
|
-
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:
|
|
101
|
-
|
|
102
|
-
```
|
|
103
|
-
**📩 telegram · @bonustrack**
|
|
104
|
-
> Hey
|
|
105
|
-
```
|
|
106
|
-
|
|
107
|
-
Don't compose your own bubble — the format is centralized in metro's dispatcher; just paste the string.
|
|
108
|
-
|
|
109
|
-
2. **Decide and act** using the subcommands below.
|
|
47
|
+
`[args]` can be JSON, `@path/to/args.json`, `-` (stdin), or a bare string. Action names are whatever the train exposes — metro core knows nothing about them. The shipped example train (`telegram.ts`) exposes `send` and `react`; trains you write can expose anything.
|
|
110
48
|
|
|
111
|
-
|
|
49
|
+
## Writing a new train
|
|
112
50
|
|
|
113
|
-
|
|
51
|
+
1. Start from `node_modules/@stage-labs/metro/examples/telegram.ts` (the only shipped example — pattern is platform-independent).
|
|
52
|
+
2. Copy → `~/.metro/trains/<name>.ts` and edit. Keep the inbound shape and the `op:"call"` → `op:"response"` protocol.
|
|
53
|
+
3. Deps (if needed): `cd ~/.metro && bun add <pkg>`. Credentials: `echo 'FOO_TOKEN=…' >> ~/.metro/.env`.
|
|
54
|
+
4. Restart the metro daemon to pick up the new train.
|
|
114
55
|
|
|
115
|
-
|
|
56
|
+
Trains are throwaway — if the user asks for new functionality, rewrite the train rather than adding glue in core.
|
|
116
57
|
|
|
117
|
-
|
|
118
|
-
|---|---|
|
|
119
|
-
| Quote-reply (threads under original) | `metro reply <line> <messageId> <text>` |
|
|
120
|
-
| Send a fresh message (no reply context) | `metro send <line> <text>` |
|
|
121
|
-
| Edit a message you previously sent | `metro edit <line> <messageId> <text>` |
|
|
122
|
-
| Reaction (empty emoji clears) | `metro react <line> <messageId> <emoji>` |
|
|
123
|
-
| Download `[image]` attachments → paths | `metro download <line> <messageId> [--out=<dir>]` |
|
|
124
|
-
| Recent channel history (Discord only) | `metro fetch <line> [--limit=20]` |
|
|
125
|
-
| Ping another user (cross-user line) | `metro send metro://claude/<user-id>/<session-id> <text> [--from=<line>]` |
|
|
126
|
-
| Register webhook endpoint | `metro webhook add <label> [--secret=<hmac-secret>]` |
|
|
127
|
-
| List / remove webhook endpoints | `metro webhook list` · `metro webhook remove <id>` |
|
|
128
|
-
| Configure Cloudflare named tunnel | `metro tunnel setup <tunnel-name> <hostname>` |
|
|
58
|
+
## First-run setup (once per machine)
|
|
129
59
|
|
|
130
|
-
|
|
60
|
+
Telegram (no npm deps — uses native fetch + long polling):
|
|
131
61
|
|
|
132
|
-
### Rich content flags
|
|
133
|
-
|
|
134
|
-
`send` and `reply` accept these extra flags; `edit` accepts `--buttons` only.
|
|
135
|
-
|
|
136
|
-
- `--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).
|
|
137
|
-
- `--document=<path>` — upload any local file (PDF, log, csv, …). Same repeat/comma syntax.
|
|
138
|
-
- `--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.
|
|
139
|
-
- `--buttons='[[{"text":"…","url":"https://…"}]]'` — attach an inline URL-button keyboard. 2D array: outer = rows, inner = buttons on that row.
|
|
140
|
-
|
|
141
|
-
```bash
|
|
142
|
-
metro send <line> "screenshot" --image=/tmp/build.png
|
|
143
|
-
metro send <line> "before/after" --image=/tmp/before.png --image=/tmp/after.png
|
|
144
|
-
metro reply <line> <id> "log + transcript" --document=/tmp/run.log --document=/tmp/transcript.txt
|
|
145
|
-
metro send <line> "have a listen" --voice=/tmp/note.ogg
|
|
146
|
-
metro send <line> "approve?" --buttons='[[{"text":"Open PR","url":"https://github.com/x/y/pull/1"}]]'
|
|
147
|
-
metro edit <line> <id> "still working…" --buttons='[]' # clears buttons
|
|
148
62
|
```
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
- URL buttons only (no callback / interactive components yet).
|
|
155
|
-
|
|
156
|
-
## When to use `reply` vs `send`
|
|
157
|
-
|
|
158
|
-
- **`reply`** — responding to a specific inbound message. Threads under it. Default for handling an `inbound` event.
|
|
159
|
-
- **`send`** — initiating without a triggering message: a long task finished, a follow-up the user asked you to deliver later, or posting to a Claude / Codex line (`metro://claude/...`, `metro://codex/...`) to notify a peer.
|
|
160
|
-
|
|
161
|
-
## Line URI scheme
|
|
162
|
-
|
|
163
|
-
`metro://<station>/<path>` — see [docs/uri-scheme.md](https://github.com/bonustrack/metro/blob/main/docs/uri-scheme.md) for the full grammar.
|
|
164
|
-
|
|
165
|
-
| Station | Pattern | Example |
|
|
166
|
-
|------------|-------------------------------------------|--------------------------------------|
|
|
167
|
-
| `discord` | `metro://discord/<channel-id>` | `metro://discord/1234567890` |
|
|
168
|
-
| `telegram` | `metro://telegram/<chat-id>[/<topic-id>]` | `metro://telegram/-1001234567890/42` |
|
|
169
|
-
| `claude` | `metro://claude/<user-id>/<session-id>` | `metro://claude/9bfc7af0-…/50b00d11-…` |
|
|
170
|
-
| `codex` | `metro://codex/<user-id>/<session-id>` | `metro://codex/8119ecb1-…/01997d4b-…` |
|
|
171
|
-
| `webhook` | `metro://webhook/<endpoint-id>` | `metro://webhook/fwaCgTKJuLAjS2K0` |
|
|
172
|
-
|
|
173
|
-
The `messageId` is **not** part of the URI — it's a separate positional arg for `reply` / `edit` / `react` / `download`.
|
|
174
|
-
|
|
175
|
-
## Image attachments
|
|
176
|
-
|
|
177
|
-
When an event's `text` contains `[image]`:
|
|
178
|
-
|
|
179
|
-
1. `metro download <line> <messageId>` — writes images to disk and prints absolute paths.
|
|
180
|
-
2. `Read` each path with your Read tool — the image enters your context as a vision input.
|
|
181
|
-
3. Reply normally with `metro reply`.
|
|
182
|
-
|
|
183
|
-
## Opaque attachment markers
|
|
184
|
-
|
|
185
|
-
`[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.
|
|
186
|
-
|
|
187
|
-
## Cross-user notification
|
|
188
|
-
|
|
189
|
-
Both Claude Code and Codex can post to each other's line:
|
|
190
|
-
|
|
191
|
-
```bash
|
|
192
|
-
metro send metro://claude/9bfc7af0-…/50b00d11-… "build green, ready to ship"
|
|
193
|
-
metro send metro://codex/8119ecb1-…/01997d4b-… "build green" --from=metro://claude/user/9bfc7af0-… # override sender
|
|
63
|
+
mkdir -p ~/.metro && cd ~/.metro && bun init -y
|
|
64
|
+
cp node_modules/@stage-labs/metro/examples/telegram.ts ~/.metro/trains/
|
|
65
|
+
echo 'TELEGRAM_BOT_TOKEN=…' >> ~/.metro/.env
|
|
66
|
+
metro setup skill # optional — installs this SKILL.md into ~/.claude / ~/.codex
|
|
67
|
+
metro
|
|
194
68
|
```
|
|
195
69
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
70
|
+
Discord port: copy `telegram.ts` to `~/.metro/trains/discord.ts`, swap the API
|
|
71
|
+
base for `https://discord.com/api/v10` with `Authorization: Bot $TOKEN`,
|
|
72
|
+
`bun add discord.js` for the gateway, and keep the envelope + call/response
|
|
73
|
+
protocol unchanged.
|
|
199
74
|
|
|
200
|
-
|
|
201
|
-
- `metro stations` — list stations + capability matrix.
|
|
202
|
-
- `metro history` — universal message log (every inbound + outbound + edit + react across all stations). Newest first. Filters:
|
|
203
|
-
- `--limit=N` (default 50)
|
|
204
|
-
- `--line=<metro://…>` — only this conversation
|
|
205
|
-
- `--station=<discord|telegram|claude|codex|webhook>`
|
|
206
|
-
- `--kind=<inbound|outbound|edit|react>`
|
|
207
|
-
- `--from=<sender>`
|
|
208
|
-
- `--text=<substring>`
|
|
209
|
-
- `--since=<iso>` — e.g. `--since=2026-05-14T00:00:00Z`
|
|
210
|
-
- `--json` — machine-parseable
|
|
75
|
+
## Detecting "is this for me?"
|
|
211
76
|
|
|
212
|
-
|
|
77
|
+
Trains should set `is_private: true` for DMs. For groups, narrow on `payload`:
|
|
213
78
|
|
|
214
|
-
|
|
79
|
+
- **discord** — DM when `payload.guildId == null`; otherwise look at `payload.mentions.users`.
|
|
80
|
+
- **telegram** — DM when `payload.chat.type === 'private'`; otherwise look at `payload.entities` mentions.
|
|
81
|
+
- **webhook** — every POST is for you by design. Route on `payload.headers['x-github-event']` / `x-intercom-topic`.
|
|
215
82
|
|
|
216
|
-
|
|
83
|
+
## CLI cheat sheet
|
|
217
84
|
|
|
218
|
-
```bash
|
|
219
|
-
# Either form works for reply/edit/react/download:
|
|
220
|
-
metro reply <line> 4567 "ack" # platform messageId (Telegram int)
|
|
221
|
-
metro reply <line> msg_aB3xY7zP "ack" # universal — resolves via history
|
|
222
85
|
```
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
`metro doctor` diagnoses tokens, gateways, dispatcher liveness, and codex-rc target.
|
|
234
|
-
|
|
235
|
-
## --json output
|
|
236
|
-
|
|
237
|
-
Every command supports `--json` for stable parseable output:
|
|
238
|
-
|
|
239
|
-
```bash
|
|
240
|
-
metro reply <line> <messageId> "ack" --json
|
|
241
|
-
# {"ok":true,"line":"metro://discord/...","replyTo":"...","messageId":"..."}
|
|
242
|
-
|
|
243
|
-
metro fetch metro://discord/1234 --limit=10 --json
|
|
244
|
-
# {"ok":true,"line":"...","messages":[{"messageId":"...","author":"...","text":"...","timestamp":"..."},...]}
|
|
245
|
-
|
|
246
|
-
metro download <line> <messageId> --json
|
|
247
|
-
# {"ok":true,"line":"...","files":[{"path":"/abs/...png","mediaType":"image/png"}]}
|
|
86
|
+
metro # start the daemon
|
|
87
|
+
metro trains list # list trains + state
|
|
88
|
+
metro trains new <name> # scaffold ~/.metro/trains/<name>.ts from the example
|
|
89
|
+
metro trains restart <name> # kill + respawn a train (resets backoff)
|
|
90
|
+
metro call <train> <action> <args> # forward an action call
|
|
91
|
+
metro tail --as=<user-uri> [--follow] # subscribe to the event log
|
|
92
|
+
metro history --limit=50 # recent history (newest first)
|
|
93
|
+
metro webhook add <label> # register an HTTP receive endpoint
|
|
94
|
+
metro tunnel setup <name> <hostname> # configure a Cloudflare named tunnel
|
|
95
|
+
metro doctor # health check (trains, deps, tunnel, webhooks, env vars)
|
|
248
96
|
```
|
|
249
97
|
|
|
250
|
-
|
|
98
|
+
## Webhooks (builtin source)
|
|
99
|
+
|
|
100
|
+
Webhooks stay in core because they're shared HTTP infra (one Cloudflare tunnel routes many endpoints). `metro webhook add <label>` issues an endpoint id; the full URL is `https://<tunnel-host>/wh/<id>` (or `http://127.0.0.1:8420/wh/<id>` locally). Events arrive with `kind:"inbound", station:"webhook"`.
|
|
251
101
|
|
|
252
|
-
##
|
|
102
|
+
## Crashes
|
|
253
103
|
|
|
254
|
-
|
|
255
|
-
- ❌ Posting to a line that isn't in `metro lines` unless the user gave it to you explicitly.
|
|
256
|
-
- ❌ Narrating the tool ("I'll now use metro reply to…"). The tool call is already visible to the user.
|
|
104
|
+
If a train crashes, metro restarts it with backoff (1s → 5s → 30s, then gives up after 5 consecutive failures). Use `metro trains list` to check state.
|
package/dist/cache.js
DELETED
|
@@ -1,69 +0,0 @@
|
|
|
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
|
-
const cacheFile = join(STATE_DIR, 'lines.json');
|
|
7
|
-
const FLUSH_DELAY_MS = 5_000;
|
|
8
|
-
let cache = null;
|
|
9
|
-
let dirty = false;
|
|
10
|
-
let flushTimer = null;
|
|
11
|
-
function read() {
|
|
12
|
-
if (cache)
|
|
13
|
-
return cache;
|
|
14
|
-
if (!existsSync(cacheFile))
|
|
15
|
-
return cache = {};
|
|
16
|
-
try {
|
|
17
|
-
cache = JSON.parse(readFileSync(cacheFile, 'utf8'));
|
|
18
|
-
}
|
|
19
|
-
catch (err) {
|
|
20
|
-
log.warn({ err: errMsg(err), path: cacheFile }, 'lines cache read failed; treating as empty');
|
|
21
|
-
cache = {};
|
|
22
|
-
}
|
|
23
|
-
return cache;
|
|
24
|
-
}
|
|
25
|
-
function flush() {
|
|
26
|
-
if (!dirty || !cache)
|
|
27
|
-
return;
|
|
28
|
-
try {
|
|
29
|
-
writeFileSync(cacheFile, JSON.stringify(cache, null, 2));
|
|
30
|
-
dirty = false;
|
|
31
|
-
}
|
|
32
|
-
catch (err) {
|
|
33
|
-
log.warn({ err: errMsg(err), path: cacheFile }, 'lines cache write failed');
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
process.on('exit', flush);
|
|
37
|
-
export function noteSeen(line, name) {
|
|
38
|
-
const c = read();
|
|
39
|
-
const entry = c[line] ??= { createdAt: new Date().toISOString() };
|
|
40
|
-
entry.lastSeenAt = new Date().toISOString();
|
|
41
|
-
if (name && entry.name !== name)
|
|
42
|
-
entry.name = name;
|
|
43
|
-
dirty = true;
|
|
44
|
-
if (!flushTimer)
|
|
45
|
-
flushTimer = setTimeout(() => { flushTimer = null; flush(); }, FLUSH_DELAY_MS);
|
|
46
|
-
}
|
|
47
|
-
export const listLines = () => Object.entries(read()).map(([line, entry]) => ({ line: line, entry }));
|
|
48
|
-
/** Bot identity cache: `{discord: "<userId>", telegram: "<userId>"}`. Daemon writes after getMe(). */
|
|
49
|
-
const botIdsFile = join(STATE_DIR, 'bot-ids.json');
|
|
50
|
-
export const readBotIds = () => {
|
|
51
|
-
try {
|
|
52
|
-
return existsSync(botIdsFile) ? JSON.parse(readFileSync(botIdsFile, 'utf8')) : {};
|
|
53
|
-
}
|
|
54
|
-
catch {
|
|
55
|
-
return {};
|
|
56
|
-
}
|
|
57
|
-
};
|
|
58
|
-
export function saveBotId(station, id) {
|
|
59
|
-
const cur = readBotIds();
|
|
60
|
-
if (cur[station] === id)
|
|
61
|
-
return;
|
|
62
|
-
cur[station] = id;
|
|
63
|
-
try {
|
|
64
|
-
writeFileSync(botIdsFile, JSON.stringify(cur, null, 2));
|
|
65
|
-
}
|
|
66
|
-
catch (err) {
|
|
67
|
-
log.warn({ err: errMsg(err) }, 'bot-ids cache write failed');
|
|
68
|
-
}
|
|
69
|
-
}
|
package/dist/cli/actions.js
DELETED
|
@@ -1,206 +0,0 @@
|
|
|
1
|
-
/** CLI action handlers: send/reply/edit/react/download/fetch + 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 { userSelf, appendHistory, lookupEntry, mintId, readHistory, resolvePlatformId, } from '../history.js';
|
|
10
|
-
import { asLine, Line } from '../stations/index.js';
|
|
11
|
-
import { loadMetroEnv } from '../paths.js';
|
|
12
|
-
import { tryAutoClaim } from '../broker.js';
|
|
13
|
-
import { emit, flagList, flagOne, isJson, need, resolveText, writeJson, } from './util.js';
|
|
14
|
-
export function chatStationOf(line) {
|
|
15
|
-
const s = Line.station(line);
|
|
16
|
-
if (s === 'discord')
|
|
17
|
-
return new DiscordStation();
|
|
18
|
-
if (s === 'telegram')
|
|
19
|
-
return new TelegramStation();
|
|
20
|
-
throw new Error(`no chat station for line "${line}" (try metro://{discord|telegram}/...)`);
|
|
21
|
-
}
|
|
22
|
-
function parseButtons(f) {
|
|
23
|
-
const raw = flagOne(f, 'buttons');
|
|
24
|
-
if (raw === undefined)
|
|
25
|
-
return undefined;
|
|
26
|
-
try {
|
|
27
|
-
return JSON.parse(raw);
|
|
28
|
-
}
|
|
29
|
-
catch (err) {
|
|
30
|
-
throw new Error(`--buttons must be JSON like '[[{"text":"…","url":"…"}]]': ${errMsg(err)}`);
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
function richOpts(f) {
|
|
34
|
-
const opts = {};
|
|
35
|
-
const images = flagList(f, 'image');
|
|
36
|
-
if (images.length)
|
|
37
|
-
opts.images = images;
|
|
38
|
-
const documents = flagList(f, 'document');
|
|
39
|
-
if (documents.length)
|
|
40
|
-
opts.documents = documents;
|
|
41
|
-
const voice = flagOne(f, 'voice');
|
|
42
|
-
if (voice)
|
|
43
|
-
opts.voice = voice;
|
|
44
|
-
const buttons = parseButtons(f);
|
|
45
|
-
if (buttons)
|
|
46
|
-
opts.buttons = buttons;
|
|
47
|
-
return opts;
|
|
48
|
-
}
|
|
49
|
-
/** Mirror the original entry's destination: group → `line`; DM → the other-party user URI. */
|
|
50
|
-
function destinationFor(orig, line) {
|
|
51
|
-
if (!orig || !orig.to || orig.to === orig.line)
|
|
52
|
-
return line;
|
|
53
|
-
return orig.from;
|
|
54
|
-
}
|
|
55
|
-
/** Classify a chat line as DM/group/unknown for the auto-claim group-skip rule. */
|
|
56
|
-
/** Telegram: chat-id sign is authoritative (id < 0 ⇒ group). Discord: peek payload.guildId on */
|
|
57
|
-
/** the most-recent inbound (null ⇒ DM, set ⇒ group, none seen ⇒ unknown). Claude/Codex ⇒ dm. */
|
|
58
|
-
export function classifyLine(line) {
|
|
59
|
-
const station = Line.station(line);
|
|
60
|
-
if (station === 'telegram') {
|
|
61
|
-
const parsed = Line.parseTelegram(line);
|
|
62
|
-
if (!parsed)
|
|
63
|
-
return 'unknown';
|
|
64
|
-
return parsed.chatId < 0 ? 'group' : 'dm';
|
|
65
|
-
}
|
|
66
|
-
if (station === 'claude' || station === 'codex')
|
|
67
|
-
return 'dm';
|
|
68
|
-
if (station === 'webhook')
|
|
69
|
-
return 'group';
|
|
70
|
-
if (station === 'discord') {
|
|
71
|
-
/** Look at the most recent inbound on this line; the daemon stored the raw message in `payload`. */
|
|
72
|
-
const recent = readHistory({ line, kind: 'inbound', limit: 1 })[0];
|
|
73
|
-
if (!recent)
|
|
74
|
-
return 'unknown';
|
|
75
|
-
const payload = recent.payload;
|
|
76
|
-
if (!payload || !('guildId' in payload)) {
|
|
77
|
-
/** Older entries may not have a guildId — fall back to the `to` field: DMs route to a user URI. */
|
|
78
|
-
if (recent.to && recent.to !== recent.line)
|
|
79
|
-
return 'dm';
|
|
80
|
-
return 'unknown';
|
|
81
|
-
}
|
|
82
|
-
return payload.guildId == null ? 'dm' : 'group';
|
|
83
|
-
}
|
|
84
|
-
return 'unknown';
|
|
85
|
-
}
|
|
86
|
-
/** Append an outbound action to history.jsonl; `to` mirrors the destination per `destinationFor`. */
|
|
87
|
-
function logOutbound(f, e) {
|
|
88
|
-
const id = mintId();
|
|
89
|
-
const fromOverride = flagOne(f, 'from');
|
|
90
|
-
const from = fromOverride ? asLine(fromOverride) : userSelf();
|
|
91
|
-
appendHistory({
|
|
92
|
-
id, ts: new Date().toISOString(), station: Line.station(e.line) ?? '?',
|
|
93
|
-
from, to: e.to ?? e.line, ...e,
|
|
94
|
-
});
|
|
95
|
-
maybeAutoClaim(f, e.line, from);
|
|
96
|
-
return id;
|
|
97
|
-
}
|
|
98
|
-
/** Auto-claim on outbound — skips when `--no-claim` / `METRO_NO_AUTO_CLAIM=1`, when the line is */
|
|
99
|
-
/** a group/webhook, or when already owned by someone else. `--claim` forces a group-line claim. */
|
|
100
|
-
function maybeAutoClaim(f, line, owner) {
|
|
101
|
-
if (f['no-claim'] === true)
|
|
102
|
-
return;
|
|
103
|
-
if (process.env.METRO_NO_AUTO_CLAIM === '1')
|
|
104
|
-
return;
|
|
105
|
-
const force = f['claim'] === true;
|
|
106
|
-
const lineKind = classifyLine(line);
|
|
107
|
-
const result = tryAutoClaim(line, owner, { lineKind, force });
|
|
108
|
-
if (result.status === 'skipped') {
|
|
109
|
-
process.stderr.write(`auto-claim skipped: line owned by ${result.existing}\n`);
|
|
110
|
-
}
|
|
111
|
-
else if (result.status === 'group') {
|
|
112
|
-
process.stderr.write(`auto-claim skipped: ${line} is a group/public line; pass --claim to take it explicitly\n`);
|
|
113
|
-
}
|
|
114
|
-
else if (result.status === 'webhook') {
|
|
115
|
-
process.stderr.write(`auto-claim skipped: ${line} is a webhook line (broadcast stream)\n`);
|
|
116
|
-
}
|
|
117
|
-
else if (result.status === 'error') {
|
|
118
|
-
process.stderr.write(`auto-claim failed: ${result.error}\n`);
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
export async function cmdSend(p, f) {
|
|
122
|
-
need(p, 1, 'metro send <line> <text> [--image=<path>]… [--document=<path>]… [--voice=<path>] [--buttons=<json>]');
|
|
123
|
-
loadMetroEnv();
|
|
124
|
-
const text = await resolveText(p, 1), line = asLine(p[0]);
|
|
125
|
-
if (Line.isLocal(line)) {
|
|
126
|
-
const fromFlag = flagOne(f, 'from');
|
|
127
|
-
const resp = await ipcCall({ op: 'notify', line, from: fromFlag, text });
|
|
128
|
-
if (!resp.ok)
|
|
129
|
-
throw new Error(resp.error);
|
|
130
|
-
/** cross-user notify still counts as the sender taking the line: auto-claim if unclaimed */
|
|
131
|
-
maybeAutoClaim(f, line, fromFlag ? asLine(fromFlag) : userSelf());
|
|
132
|
-
return emit(f, `notified ${line}`, { ok: true, line, id: null, messageId: null });
|
|
133
|
-
}
|
|
134
|
-
const messageId = await chatStationOf(line).send(line, text, richOpts(f));
|
|
135
|
-
/** Inherit destination from the most recent inbound on this line so DM sends address the user. */
|
|
136
|
-
const to = destinationFor(readHistory({ line, kind: 'inbound', limit: 1 })[0], line);
|
|
137
|
-
const id = logOutbound(f, { kind: 'outbound', line, text, messageId, to });
|
|
138
|
-
emit(f, `sent ${id} (${messageId}) to ${line}`, { ok: true, line, id, messageId });
|
|
139
|
-
}
|
|
140
|
-
export async function cmdReply(p, f) {
|
|
141
|
-
need(p, 2, 'metro reply <line> <message_id> <text> [--image=… --document=… --voice=… --buttons=…]');
|
|
142
|
-
loadMetroEnv();
|
|
143
|
-
const [to, replyToArg] = p, text = await resolveText(p, 2), line = asLine(to);
|
|
144
|
-
const replyTo = resolvePlatformId(replyToArg);
|
|
145
|
-
const messageId = await chatStationOf(line).send(line, text, { ...richOpts(f), replyTo });
|
|
146
|
-
const id = logOutbound(f, { kind: 'outbound', line, text, messageId, replyTo: replyToArg, to: destinationFor(lookupEntry(replyToArg), line) });
|
|
147
|
-
emit(f, `replied ${id} (${messageId}) to ${line}#${replyTo}`, { ok: true, line, id, replyTo: replyToArg, messageId });
|
|
148
|
-
}
|
|
149
|
-
export async function cmdEdit(p, f) {
|
|
150
|
-
need(p, 2, 'metro edit <line> <message_id> <text> [--buttons=<json>]');
|
|
151
|
-
loadMetroEnv();
|
|
152
|
-
const [to, msgArg] = p, text = await resolveText(p, 2), line = asLine(to);
|
|
153
|
-
const platformId = resolvePlatformId(msgArg);
|
|
154
|
-
const buttons = parseButtons(f);
|
|
155
|
-
await chatStationOf(line).edit(line, platformId, text, buttons ? { buttons } : undefined);
|
|
156
|
-
/** Carry forward the original recipient if we have a row for this message. */
|
|
157
|
-
const id = logOutbound(f, { kind: 'edit', line, text, messageId: platformId, replyTo: msgArg, to: lookupEntry(msgArg)?.to });
|
|
158
|
-
emit(f, `edited ${line}#${platformId} (${id})`, { ok: true, line, id, messageId: platformId });
|
|
159
|
-
}
|
|
160
|
-
export async function cmdReact(p, f) {
|
|
161
|
-
need(p, 2, 'metro react <line> <message_id> <emoji> (empty emoji clears)');
|
|
162
|
-
loadMetroEnv();
|
|
163
|
-
const [to, msgArg, emoji = ''] = p, line = asLine(to);
|
|
164
|
-
const platformId = resolvePlatformId(msgArg);
|
|
165
|
-
await chatStationOf(line).react(line, platformId, emoji);
|
|
166
|
-
const id = logOutbound(f, { kind: 'react', line, messageId: platformId, emoji, to: destinationFor(lookupEntry(msgArg), line) });
|
|
167
|
-
const human = emoji ? `reacted ${emoji} on ${line}#${platformId}` : `cleared reaction on ${line}#${platformId}`;
|
|
168
|
-
emit(f, human, { ok: true, line, id, messageId: platformId, emoji });
|
|
169
|
-
}
|
|
170
|
-
export async function cmdDownload(p, f) {
|
|
171
|
-
need(p, 2, 'metro download <line> <message_id> [--out=<dir>]');
|
|
172
|
-
loadMetroEnv();
|
|
173
|
-
const [to, msgArg] = p, line = asLine(to);
|
|
174
|
-
const messageId = resolvePlatformId(msgArg);
|
|
175
|
-
const outDir = typeof f.out === 'string' ? f.out : join(tmpdir(), 'metro-downloads');
|
|
176
|
-
mkdirSync(outDir, { recursive: true });
|
|
177
|
-
/** Telegram has no get-message-by-id REST endpoint — daemon holds the in-memory snapshot. */
|
|
178
|
-
let files;
|
|
179
|
-
if (Line.station(line) === 'telegram') {
|
|
180
|
-
const resp = await ipcCall({ op: 'download', line, messageId, outDir });
|
|
181
|
-
if (!resp.ok)
|
|
182
|
-
throw new Error(resp.error);
|
|
183
|
-
files = 'files' in resp ? resp.files : [];
|
|
184
|
-
}
|
|
185
|
-
else {
|
|
186
|
-
files = await chatStationOf(line).download(line, messageId, outDir);
|
|
187
|
-
}
|
|
188
|
-
if (isJson(f))
|
|
189
|
-
return writeJson({ ok: true, line, files });
|
|
190
|
-
if (!files.length)
|
|
191
|
-
process.stdout.write(`(no image attachments on ${line}#${messageId})\n`);
|
|
192
|
-
for (const file of files)
|
|
193
|
-
process.stdout.write(file.path + '\n');
|
|
194
|
-
}
|
|
195
|
-
export async function cmdFetch(p, f) {
|
|
196
|
-
need(p, 1, 'metro fetch <line> [--limit=N]');
|
|
197
|
-
loadMetroEnv();
|
|
198
|
-
const line = asLine(p[0]);
|
|
199
|
-
const messages = await chatStationOf(line).fetch(line, Number(flagOne(f, 'limit')) || 20);
|
|
200
|
-
if (isJson(f))
|
|
201
|
-
return writeJson({ ok: true, line, messages });
|
|
202
|
-
if (!messages.length)
|
|
203
|
-
process.stdout.write(`(no messages on ${line})\n`);
|
|
204
|
-
for (const m of messages)
|
|
205
|
-
process.stdout.write(`${m.timestamp} ${m.author}: ${m.text}\n`);
|
|
206
|
-
}
|