@stage-labs/metro 0.1.0-beta.13 → 0.1.0-beta.15

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 (45) 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} +46 -99
  4. package/dist/cli/config.js +115 -121
  5. package/dist/cli/index.js +51 -64
  6. package/dist/cli/messenger-api.js +214 -0
  7. package/dist/cli/messenger-transcribe.js +43 -0
  8. package/dist/cli/messenger-uploads.js +116 -0
  9. package/dist/cli/monitor-api.js +205 -0
  10. package/dist/cli/tail.js +49 -118
  11. package/dist/cli/webhook.js +103 -3
  12. package/dist/{codex-rc.js → codex-rc/client.js} +12 -32
  13. package/dist/codex-rc/protocol.js +38 -0
  14. package/dist/dispatcher/server.js +122 -0
  15. package/dist/dispatcher.js +52 -83
  16. package/dist/history.js +49 -27
  17. package/dist/ipc.js +28 -10
  18. package/dist/lines.js +54 -0
  19. package/dist/local-identity.js +80 -0
  20. package/dist/paths.js +58 -12
  21. package/dist/trains/protocol.js +99 -0
  22. package/dist/trains/supervisor.js +210 -0
  23. package/dist/tunnel.js +39 -1
  24. package/docs/broker.md +88 -136
  25. package/docs/monitor.md +88 -10
  26. package/docs/uri-scheme.md +10 -7
  27. package/examples/README.md +32 -0
  28. package/examples/telegram.ts +121 -0
  29. package/package.json +6 -5
  30. package/skills/metro/SKILL.md +67 -213
  31. package/dist/cache.js +0 -69
  32. package/dist/cli/actions.js +0 -206
  33. package/dist/cli/skill.js +0 -62
  34. package/dist/monitor.js +0 -194
  35. package/dist/registry.js +0 -48
  36. package/dist/stations/claude.js +0 -45
  37. package/dist/stations/codex.js +0 -68
  38. package/dist/stations/discord.js +0 -216
  39. package/dist/stations/index.js +0 -129
  40. package/dist/stations/telegram-md.js +0 -34
  41. package/dist/stations/telegram-upload.js +0 -113
  42. package/dist/stations/telegram.js +0 -234
  43. package/dist/stations/webhook.js +0 -103
  44. package/dist/webhooks.js +0 -41
  45. package/docs/users.md +0 -226
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Reference train — Telegram (long-polling, no npm deps). Pattern is identical for any platform.
3
+ *
4
+ * Setup:
5
+ * cp <this-file> ~/.metro/trains/telegram.ts
6
+ * echo 'TELEGRAM_BOT_TOKEN=…' >> ~/.metro/.env
7
+ *
8
+ * Discord-flavoured port: swap the API base + `tg()` helper for `https://discord.com/api/v10`
9
+ * with `Authorization: Bot $TOKEN`, install `discord.js` for the gateway, and emit the same
10
+ * envelope shape. The stdin/stdout protocol is platform-independent. Discord adds `sticker_ids`
11
+ * support on `sendMessage` payloads — preserve those in `payload` so downstream tooling sees them.
12
+ */
13
+
14
+ const TOKEN = process.env.TELEGRAM_BOT_TOKEN;
15
+ if (!TOKEN) { process.stderr.write('TELEGRAM_BOT_TOKEN unset\n'); process.exit(2); }
16
+ const API = `https://api.telegram.org/bot${TOKEN}`;
17
+
18
+ const emit = (e: unknown): void => void process.stdout.write(JSON.stringify(e) + '\n');
19
+ const respond = (id: string, body: { result?: unknown; error?: string }): void =>
20
+ void process.stdout.write(JSON.stringify({ op: 'response', id, ...body }) + '\n');
21
+ const mintId = (): string => `msg_${Math.random().toString(36).slice(2, 10)}`;
22
+ /** Self URI for outbound emissions. Set by the supervisor; trains echo their own sends so mobile + tail see them. */
23
+ const SELF_URI = process.env.METRO_SELF_URI ?? '';
24
+ const emitOutbound = (line: string, messageId: string, text: string, replyTo?: string): void => emit({
25
+ kind: 'outbound', id: mintId(), ts: new Date().toISOString(),
26
+ station: 'telegram', line, from: SELF_URI, to: line, message_id: messageId, text, reply_to: replyTo,
27
+ });
28
+
29
+ async function tg<T>(method: string, body: unknown, timeoutMs = 30_000): Promise<T> {
30
+ const res = await fetch(`${API}/${method}`, {
31
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
32
+ body: JSON.stringify(body), signal: AbortSignal.timeout(timeoutMs),
33
+ });
34
+ const json = (await res.json()) as { ok: boolean; description?: string; result?: T };
35
+ if (!json.ok) throw new Error(`telegram ${method}: ${json.description ?? 'unknown'}`);
36
+ return json.result as T;
37
+ }
38
+
39
+ type TgMsg = {
40
+ message_id: number; date: number;
41
+ chat: { id: number; type: string; title?: string; first_name?: string };
42
+ from?: { id: number; username?: string; first_name?: string; is_bot?: boolean };
43
+ text?: string; caption?: string;
44
+ message_thread_id?: number; is_topic_message?: boolean;
45
+ photo?: unknown[]; document?: { file_name?: string };
46
+ };
47
+
48
+ function envelope(m: TgMsg): Record<string, unknown> {
49
+ const tags = [...(m.photo?.length ? ['[image]'] : []), ...(m.document ? [`[file: ${m.document.file_name ?? 'doc'}]`] : [])];
50
+ const text = [m.text ?? m.caption, ...tags].filter(Boolean).join(' ');
51
+ const topicId = m.is_topic_message ? m.message_thread_id : undefined;
52
+ const line = topicId !== undefined ? `metro://telegram/${m.chat.id}/${topicId}` : `metro://telegram/${m.chat.id}`;
53
+ return {
54
+ kind: 'inbound', id: mintId(), ts: new Date(m.date * 1000).toISOString(),
55
+ station: 'telegram', line,
56
+ line_name: topicId === undefined ? (m.chat.title ?? m.chat.first_name ?? undefined) : undefined,
57
+ from: `metro://telegram/user/${m.from?.id ?? 'unknown'}`,
58
+ from_name: m.from?.username ? `@${m.from.username}` : m.from?.first_name,
59
+ message_id: String(m.message_id), text, payload: m, is_private: m.chat.type === 'private',
60
+ };
61
+ }
62
+
63
+ const targetOf = (line: string): { chatId: number; topicId?: number } => {
64
+ const m = line.match(/^metro:\/\/telegram\/(-?\d+)(?:\/(\d+))?/);
65
+ if (!m) throw new Error(`bad telegram line: ${line}`);
66
+ return { chatId: Number(m[1]), topicId: m[2] ? Number(m[2]) : undefined };
67
+ };
68
+
69
+ type CallMsg = { op: 'call'; id: string; action: string; args: Record<string, unknown> };
70
+ async function handleCall({ id, action, args }: CallMsg): Promise<void> {
71
+ try {
72
+ if (action === 'send') {
73
+ const { line, text, replyTo } = args as { line: string; text: string; replyTo?: string };
74
+ const { chatId, topicId } = targetOf(line);
75
+ const body: Record<string, unknown> = { chat_id: chatId, text };
76
+ if (topicId !== undefined) body.message_thread_id = topicId;
77
+ if (replyTo) body.reply_parameters = { message_id: Number(replyTo) };
78
+ const sent = await tg<{ message_id: number }>('sendMessage', body);
79
+ const sentId = String(sent.message_id);
80
+ emitOutbound(line, sentId, text, replyTo);
81
+ respond(id, { result: { messageId: sentId } });
82
+ } else if (action === 'react') {
83
+ const { line, messageId, emoji } = args as { line: string; messageId: string; emoji: string };
84
+ await tg('setMessageReaction', {
85
+ chat_id: targetOf(line).chatId, message_id: Number(messageId),
86
+ reaction: emoji ? [{ type: 'emoji', emoji }] : [],
87
+ });
88
+ respond(id, { result: { ok: true } });
89
+ } else respond(id, { error: `unknown action '${action}' (have: send, react)` });
90
+ } catch (err) { respond(id, { error: (err as Error).message }); }
91
+ }
92
+
93
+ let buf = '';
94
+ process.stdin.setEncoding('utf8');
95
+ process.stdin.on('data', chunk => {
96
+ buf += chunk;
97
+ let nl;
98
+ while ((nl = buf.indexOf('\n')) !== -1) {
99
+ const line = buf.slice(0, nl).trim();
100
+ buf = buf.slice(nl + 1);
101
+ if (!line) continue;
102
+ try { const msg = JSON.parse(line); if (msg.op === 'call') void handleCall(msg); }
103
+ catch (err) { process.stderr.write(`bad stdin line: ${(err as Error).message}\n`); }
104
+ }
105
+ });
106
+
107
+ let offset = 0;
108
+ process.stderr.write('telegram train ready\n');
109
+ while (true) {
110
+ try {
111
+ const updates = await tg<{ update_id: number; message?: TgMsg }[]>('getUpdates',
112
+ { offset, timeout: 25, allowed_updates: ['message'] }, 60_000);
113
+ for (const u of updates) {
114
+ offset = u.update_id + 1;
115
+ if (u.message && !u.message.from?.is_bot) emit(envelope(u.message));
116
+ }
117
+ } catch (err) {
118
+ process.stderr.write(`telegram poll error: ${(err as Error).message}\n`);
119
+ await new Promise(r => setTimeout(r, 2_000));
120
+ }
121
+ }
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.13",
3
+ "version": "0.1.0-beta.15",
4
4
  "private": false,
5
- "description": "Live JSON stream of Telegram + Discord messages for your local Claude Code / Codex session. The user launches metro; metro emits inbounds on stdout and accepts replies via CLI subcommands.",
5
+ "description": "Event-interception wire. Supervises train subprocesses in ~/.metro/trains/, multiplexes their JSON event stream onto stdout, and routes outbound action calls back via stdin. Per-platform code is written by the agent (or user) as train scripts metro core is pure transport.",
6
6
  "license": "MIT",
7
7
  "repository": {
8
8
  "type": "git",
@@ -20,6 +20,7 @@
20
20
  "files": [
21
21
  "dist",
22
22
  "docs",
23
+ "examples",
23
24
  "skills",
24
25
  "README.md",
25
26
  "LICENSE"
@@ -30,18 +31,18 @@
30
31
  "scripts": {
31
32
  "build": "tsc",
32
33
  "prepublishOnly": "tsc",
33
- "lint": "eslint src/",
34
- "lint:fix": "eslint src/ --fix",
34
+ "lint": "eslint src/ examples/",
35
+ "lint:fix": "eslint src/ examples/ --fix",
35
36
  "typecheck": "tsc --noEmit",
36
37
  "test": "METRO_STATE_DIR=\"$(mktemp -d /tmp/metro-test.XXXXXX)\" bun test test/"
37
38
  },
38
39
  "dependencies": {
39
- "discord.js": "^14.14.0",
40
40
  "pino": "^9.5.0",
41
41
  "pino-pretty": "^13.1.3",
42
42
  "ws": "^8.20.0"
43
43
  },
44
44
  "devDependencies": {
45
+ "@types/bun": "^1.2.0",
45
46
  "@types/node": "^22.10.0",
46
47
  "@types/ws": "^8.18.1",
47
48
  "eslint": "^10.3.0",
@@ -1,256 +1,110 @@
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 `{"station":"","line":"metro://…","from":"…","to":"…","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.
17
+ 4. Two builtin event sources stay in core: **webhooks** (HTTP receiver) and **messenger** (in-daemon chat).
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
+ ## Envelope
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
+ Every event on stdout is a single JSON line:
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
+ {"id":"msg_…","ts":"2026-05-17T18:00:00Z","station":"discord","line":"metro://discord/123","line_name":"infra","from":"metro://discord/user/456","from_name":"alice","to":"metro://claude/user/9bfc…","text":"hi","message_id":"789","reply_to":"…","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; the dispatcher translates to camelCase for `history.jsonl`. Trains supply `line`, `from`, `to`, `text`, plus `payload` (the platform's native message). Metro mints `id` + `display` if missing.
36
36
 
37
- ### Diagnostics
37
+ **No `kind` field.** Direction is derived: `Line.isLocal(from)` → outbound (📤), else inbound (📩). Reactions and edits are train-specific — encode them in `text` (e.g. `[react 👀]`) plus whatever you need in `payload`.
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.
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.
108
48
 
109
- 2. **Decide and act** using the subcommands below.
49
+ ## Writing a new train
110
50
 
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.
51
+ 1. Start from `node_modules/@stage-labs/metro/examples/telegram.ts`.
52
+ 2. Copy → `~/.metro/trains/<name>.ts` and edit. Keep the inbound envelope 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 (or just `metro trains restart <name>`) to pick up the new train.
112
55
 
113
- ## Subcommands
56
+ Trains are throwaway — if the user asks for new functionality, rewrite the train rather than adding glue in core.
114
57
 
115
- All take positional args (no `--to=`/`--text=` flags). Append `--json` to any for a parseable single-line result.
58
+ ## First-run setup (once per machine)
116
59
 
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>` |
129
-
130
- `reply` / `send` / `edit` accept multi-line text via stdin (heredoc).
131
-
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
60
  ```
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
61
+ mkdir -p ~/.metro && cd ~/.metro && bun init -y
62
+ cp node_modules/@stage-labs/metro/examples/telegram.ts ~/.metro/trains/
63
+ echo 'TELEGRAM_BOT_TOKEN=…' >> ~/.metro/.env
64
+ metro setup skill # optional installs this SKILL.md into ~/.claude / ~/.codex
65
+ metro
194
66
  ```
195
67
 
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
199
-
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
68
+ ## Detecting "is this for me?"
211
69
 
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`.
70
+ Trains should set `is_private: true` for DMs. For groups, narrow on `payload`:
213
71
 
214
- ## Universal message IDs
72
+ - **discord** DM when `payload.guildId == null`; otherwise look at `payload.mentions.users`.
73
+ - **telegram** — DM when `payload.chat.type === 'private'`; otherwise look at `payload.entities` mentions.
74
+ - **webhook** — every POST is for you by design. Route on `payload.headers['x-github-event']` / `x-intercom-topic`.
75
+ - **messenger** — every event on the messenger line is between the user and the agent. No filtering needed.
215
76
 
216
- The `id` from `metro history` or an event JSON works **anywhere a `<message_id>` argument is expected**:
77
+ ## CLI cheat sheet
217
78
 
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
79
+ ```
80
+ metro # start the daemon
81
+ metro trains [list] # list trains + state
82
+ metro trains new <name> # scaffold ~/.metro/trains/<name>.ts
83
+ metro trains restart <name> # kill + respawn a train (resets backoff)
84
+ metro call <train> <action> <args> # forward an action call
85
+ metro tail [--as=<user-uri>] [--follow] # subscribe to the event log
86
+ metro history [--limit=N] [--line=…] [--from=…] [--text=…] [--since=…]
87
+ metro webhook add <label> # register an HTTP receive endpoint
88
+ metro tunnel setup <name> <hostname> # configure a Cloudflare named tunnel
89
+ metro doctor # health check
222
90
  ```
223
91
 
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
92
+ ## Webhooks (builtin source)
236
93
 
237
- Every command supports `--json` for stable parseable output:
94
+ 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 on `metro://webhook/<id>`.
238
95
 
239
- ```bash
240
- metro reply <line> <messageId> "ack" --json
241
- # {"ok":true,"line":"metro://discord/...","replyTo":"...","messageId":"..."}
96
+ ## Messenger (builtin source)
242
97
 
243
- metro fetch metro://discord/1234 --limit=10 --json
244
- # {"ok":true,"line":"...","messages":[{"messageId":"...","author":"...","text":"...","timestamp":"..."},...]}
98
+ Direct chat between the agent and the device. Five endpoints — all under the same bearer-token guard as `/api/state`:
245
99
 
246
- metro download <line> <messageId> --json
247
- # {"ok":true,"line":"...","files":[{"path":"/abs/...png","mediaType":"image/png"}]}
248
- ```
100
+ - `POST /api/messenger/send {text?, attachments?, as?}` — emit an envelope on `metro://messenger/owner`. Either text or attachments required.
101
+ - `POST /api/messenger/react {messageId, emoji, as?}` — emit a slim `payload.reactTo` envelope. If the sender already has an active reaction with this emoji on this target, emits `{removed: true}` instead — reactions are toggleable.
102
+ - `POST /api/messenger/upload` — raw binary upload (≤ 25 MiB, body = file bytes, `Content-Type` + optional `X-Filename` headers). Returns `{id, url, kind, mime, size, name?}`; files land in `$METRO_STATE_DIR/messenger-uploads/`.
103
+ - `GET /api/messenger/files/<name>` — stream a stored upload. Accepts `Authorization: Bearer …` header *or* `?token=…` query (for `<img>` / `<audio>` tags). Content-Type derived from extension.
104
+ - `POST /api/messenger/register {pushToken}` — store an Expo push token so agent replies push to the device.
249
105
 
250
- Use `--json` when you need to chain calls or capture the new `messageId` for a later edit.
106
+ The mobile + web companion apps use these for chat with the agent. The envelope is the standard slim shape — no `kind` / `emoji` fields, direction derived from `Line.isLocal(from)`.
251
107
 
252
- ## Don'ts
108
+ ## Crashes
253
109
 
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.
110
+ If a train crashes, metro restarts it with backoff (1s 5s → 30s, then gives up after 5 consecutive failures). Use `metro trains` 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
- }