@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.
- package/README.md +76 -189
- package/dist/broker/claims.js +144 -0
- package/dist/{broker.js → broker/history-stream.js} +46 -99
- package/dist/cli/config.js +115 -121
- package/dist/cli/index.js +51 -64
- package/dist/cli/messenger-api.js +214 -0
- package/dist/cli/messenger-transcribe.js +43 -0
- package/dist/cli/messenger-uploads.js +116 -0
- package/dist/cli/monitor-api.js +205 -0
- package/dist/cli/tail.js +49 -118
- 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 +122 -0
- package/dist/dispatcher.js +52 -83
- package/dist/history.js +49 -27
- 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 +88 -10
- 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 +67 -213
- 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
|
@@ -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.
|
|
3
|
+
"version": "0.1.0-beta.15",
|
|
4
4
|
"private": false,
|
|
5
|
-
"description": "
|
|
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",
|
package/skills/metro/SKILL.md
CHANGED
|
@@ -1,256 +1,110 @@
|
|
|
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 `{"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 —
|
|
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.
|
|
17
|
+
4. Two builtin event sources stay in core: **webhooks** (HTTP receiver) and **messenger** (in-daemon chat).
|
|
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
|
+
## Envelope
|
|
27
28
|
|
|
28
|
-
|
|
29
|
+
Every event on stdout is a single JSON line:
|
|
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
|
+
{"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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
49
|
+
## Writing a new train
|
|
110
50
|
|
|
111
|
-
|
|
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
|
-
|
|
56
|
+
Trains are throwaway — if the user asks for new functionality, rewrite the train rather than adding glue in core.
|
|
114
57
|
|
|
115
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
70
|
+
Trains should set `is_private: true` for DMs. For groups, narrow on `payload`:
|
|
213
71
|
|
|
214
|
-
|
|
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
|
-
|
|
77
|
+
## CLI cheat sheet
|
|
217
78
|
|
|
218
|
-
```
|
|
219
|
-
#
|
|
220
|
-
metro
|
|
221
|
-
metro
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
240
|
-
metro reply <line> <messageId> "ack" --json
|
|
241
|
-
# {"ok":true,"line":"metro://discord/...","replyTo":"...","messageId":"..."}
|
|
96
|
+
## Messenger (builtin source)
|
|
242
97
|
|
|
243
|
-
|
|
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
|
|
247
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
108
|
+
## Crashes
|
|
253
109
|
|
|
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.
|
|
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
|
-
}
|