@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.
Files changed (41) hide show
  1. package/README.md +76 -189
  2. package/dist/broker/claims.js +144 -0
  3. package/dist/{broker.js → broker/history-stream.js} +44 -99
  4. package/dist/cli/config.js +115 -121
  5. package/dist/cli/index.js +20 -58
  6. package/dist/cli/tail.js +161 -113
  7. package/dist/cli/webhook.js +103 -3
  8. package/dist/{codex-rc.js → codex-rc/client.js} +12 -32
  9. package/dist/codex-rc/protocol.js +38 -0
  10. package/dist/dispatcher/server.js +130 -0
  11. package/dist/dispatcher.js +51 -82
  12. package/dist/history.js +43 -18
  13. package/dist/ipc.js +28 -10
  14. package/dist/lines.js +54 -0
  15. package/dist/local-identity.js +80 -0
  16. package/dist/paths.js +58 -12
  17. package/dist/trains/protocol.js +99 -0
  18. package/dist/trains/supervisor.js +210 -0
  19. package/dist/tunnel.js +39 -1
  20. package/docs/broker.md +88 -136
  21. package/docs/monitor.md +19 -2
  22. package/docs/uri-scheme.md +10 -7
  23. package/examples/README.md +32 -0
  24. package/examples/telegram.ts +121 -0
  25. package/package.json +6 -5
  26. package/skills/metro/SKILL.md +63 -215
  27. package/dist/cache.js +0 -69
  28. package/dist/cli/actions.js +0 -206
  29. package/dist/cli/skill.js +0 -62
  30. package/dist/monitor.js +0 -194
  31. package/dist/registry.js +0 -48
  32. package/dist/stations/claude.js +0 -45
  33. package/dist/stations/codex.js +0 -68
  34. package/dist/stations/discord.js +0 -216
  35. package/dist/stations/index.js +0 -129
  36. package/dist/stations/telegram-md.js +0 -34
  37. package/dist/stations/telegram-upload.js +0 -113
  38. package/dist/stations/telegram.js +0 -234
  39. package/dist/stations/webhook.js +0 -103
  40. package/dist/webhooks.js +0 -41
  41. package/docs/users.md +0 -226
@@ -1,256 +1,104 @@
1
1
  ---
2
2
  name: metro
3
- description: Run the metro Telegram/Discord/webhook relay 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.
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 — running the Telegram / Discord / webhook relay
6
+ # Metro — event-interception wire
7
7
 
8
- Metro is a CLI that relays between this 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>`.
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
- ## Starting metro
12
+ ## What metro does
11
13
 
12
- When the user asks to run/start/launch metro:
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
- ### Claude Code
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
- Then attach `Monitor` to its stdout. Each line is one JSON event. Stderr is pino logs don't act on it.
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
- ### Codex
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
- 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
+ **Inbound (train metro stdout)** one JSON line per event:
29
30
 
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
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
- 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.
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
- ### Diagnostics
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
- 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.
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
- ```json
60
- {"kind":"inbound","id":"msg_pQ4r5sT0","ts":"…","station":"claude","line":"metro://claude/9bfc7af0-…/50b00d11-…","from":"metro://codex/user/8119ecb1-…","to":"metro://claude/9bfc7af0-…/50b00d11-…","text":"deploy green"}
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
- ### `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 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
- 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.
49
+ ## Writing a new train
112
50
 
113
- ## Subcommands
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
- All take positional args (no `--to=`/`--text=` flags). Append `--json` to any for a parseable single-line result.
56
+ Trains are throwaway if the user asks for new functionality, rewrite the train rather than adding glue in core.
116
57
 
117
- | Action | Command |
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
- `reply` / `send` / `edit` accept multi-line text via stdin (heredoc).
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
- Limits / quirks:
151
- - 20 MB per file (both platforms).
152
- - 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.
153
- - Telegram drops `--buttons` when multiple attachments are sent (the Bot API doesn't allow `reply_markup` on media groups).
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
- The daemon re-emits the post on its stdout stream (and pushes via codex-rc if configured), so the peer sees an `{"kind":"inbound",...}` event. Requires the metro daemon to be running on the machine — Claude / Codex line sends error with `metro daemon is not running` otherwise.
197
-
198
- ## Discoverability
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
- - `metro lines` list recently-seen conversations (sorted by recency).
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
- Every action you take is logged automatically — `metro send`/`reply`/`edit`/`react` append outbound entries, daemon-side inbounds append on arrival. Stored at `$METRO_STATE_DIR/history.jsonl`.
77
+ Trains should set `is_private: true` for DMs. For groups, narrow on `payload`:
213
78
 
214
- ## Universal message IDs
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
- The `id` from `metro history` or an event JSON works **anywhere a `<message_id>` argument is expected**:
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
- Use universal IDs when chaining commands or referring back to a specific message across stations.
225
-
226
- ## Exit codes
227
-
228
- - `0` success
229
- - `1` usage error (bad args, unknown subcommand)
230
- - `2` configuration error (no tokens tell the user to run `metro setup`)
231
- - `3` upstream error (rate limit, auth, network) — retry once after a few seconds before surfacing
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
- Use `--json` when you need to chain calls or capture the new `messageId` for a later edit.
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
- ## Don'ts
102
+ ## Crashes
253
103
 
254
- - Spawning a second metro daemon there's one per machine (lockfile-enforced).
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
- }
@@ -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
- }