@stage-labs/metro 0.1.0-beta.6 → 0.1.0-beta.7

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 CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  > **A live JSON stream of Telegram + Discord messages for your local Claude Code / Codex session.**
4
4
 
5
- Metro is a small daemon you launch from inside your agent. It connects to Discord and Telegram, emits each inbound as one JSON line on stdout (which Claude Code's `Monitor` consumes natively, and Codex picks up via an app-server WebSocket push), and exposes a tiny CLI — `metro reply`, `metro send`, `metro edit`, `metro react`, `metro download`, `metro fetch`, `metro notify` — for posting back. Cross-agent: any agent can ping any other via `metro send metro://claude/<topic>` and the daemon re-emits it on the stream.
5
+ Metro is a small daemon you launch from inside your agent. It connects to Discord and Telegram, emits each inbound as one JSON line on stdout (which Claude Code's `Monitor` consumes natively, and Codex picks up via an app-server WebSocket push), and exposes a tiny CLI — `metro reply`, `metro send`, `metro edit`, `metro react`, `metro download`, `metro fetch` — for posting back. Cross-agent: any agent can ping any other via `metro send metro://claude/<topic>` and the daemon re-emits it on the stream.
6
6
 
7
7
  ```
8
8
  [Claude Code session]
@@ -10,8 +10,10 @@ Metro is a small daemon you launch from inside your agent. It connects to Discor
10
10
  $ metro & # backgrounded
11
11
  $ Monitor( … metro's stdout … )
12
12
 
13
- >>> {"type":"inbound","station":"discord","line":"metro://discord/123…","messageId":"9876",
14
- "text":"@bot we got a 5xx spike from /v1/sync. Look?","mentionsBot":true,…}
13
+ >>> {"kind":"inbound","station":"discord","line":"metro://discord/123…","messageId":"9876",
14
+ "text":"@bot we got a 5xx spike from /v1/sync. Look?",
15
+ "payload":{"channelId":"123…","guildId":"456…","content":"<@…> we got a 5xx spike…",
16
+ "mentions":{"users":["<bot-id>"],"roles":[],"everyone":false},…}}
15
17
 
16
18
  [I'd run git log + read services/sync.ts, then…]
17
19
  Bash: metro reply metro://discord/123… 9876 "three deploys in the last 24h…"
@@ -46,7 +48,7 @@ Telegram poller ──┤
46
48
 
47
49
  ├─▶ metro daemon ───▶ stdout (JSON events; Claude Code's Monitor reads here)
48
50
  │ ───▶ codex-rc WebSocket (Codex turn/start; opt-in)
49
- │ ◀── IPC Unix socket (metro notify / metro send to agent lines)
51
+ │ ◀── IPC Unix socket (metro send to agent lines)
50
52
 
51
53
  agent CLI calls ──┴── REST → Discord / Telegram (metro reply / send / edit / react / download / fetch)
52
54
  ```
@@ -54,7 +56,7 @@ agent CLI calls ──┴── REST → Discord / Telegram (metro reply / sen
54
56
  - **Inversion of control.** The agent (Claude Code, Codex) launches `metro`, not the other way around. Metro never spawns an agent process.
55
57
  - **Single daemon per machine.** Lockfile at `$METRO_STATE_DIR/.tail-lock` enforces singleton.
56
58
  - **Codex push (opt-in).** Set `METRO_CODEX_RC=ws://127.0.0.1:8421` and metro pushes each event via JSON-RPC `turn/start` to the Codex app-server. Codex's TUI must be attached with `--remote` to the same URL.
57
- - **Cross-agent notification.** `metro send metro://claude/<topic>` or `metro notify metro://codex/<topic>` routes through the daemon's IPC socket; the daemon re-emits on its stdout (and pushes to codex-rc), so the peer agent sees it.
59
+ - **Cross-agent notification.** `metro send metro://claude/<topic>` (or `metro://codex/<topic>`) routes through the daemon's IPC socket; the daemon re-emits on its stdout (and pushes to codex-rc), so the peer agent sees it.
58
60
 
59
61
  ---
60
62
 
@@ -115,7 +117,6 @@ metro react <line> <message_id> <emoji> Set or clear ('') a reaction.
115
117
  metro download <line> <message_id> [--out=<dir>]
116
118
  Download image attachments to disk.
117
119
  metro fetch <line> [--limit=N] Recent-message lookback (Discord only).
118
- metro notify <line> <text> [--from=<line>] Emit a notification on the daemon's stream.
119
120
  metro history [--limit=N] [--line=…] [--station=…] [--kind=…] [--from=…] [--text=…] [--since=…]
120
121
  Universal message log (every inbound + outbound), newest first.
121
122
  metro update Upgrade in place.
package/dist/cache.js CHANGED
@@ -48,9 +48,16 @@ export function noteSeen(line, name) {
48
48
  export const listLines = () => Object.entries(read()).map(([line, entry]) => ({ line: line, entry }));
49
49
  /** Bot identity cache: `{discord: "<userId>", telegram: "<userId>"}`. Daemon writes after getMe(). */
50
50
  const botIdsFile = join(STATE_DIR, 'bot-ids.json');
51
+ const readBotIds = () => {
52
+ try {
53
+ return existsSync(botIdsFile) ? JSON.parse(readFileSync(botIdsFile, 'utf8')) : {};
54
+ }
55
+ catch {
56
+ return {};
57
+ }
58
+ };
51
59
  export function saveBotId(station, id) {
52
- const cur = existsSync(botIdsFile)
53
- ? JSON.parse(readFileSync(botIdsFile, 'utf8')) : {};
60
+ const cur = readBotIds();
54
61
  if (cur[station] === id)
55
62
  return;
56
63
  cur[station] = id;
@@ -63,12 +70,6 @@ export function saveBotId(station, id) {
63
70
  }
64
71
  /** Resolve the bot's URI for a station. Returns `metro://<station>/bot/<id>` or the placeholder. */
65
72
  export function botLine(station) {
66
- try {
67
- const ids = existsSync(botIdsFile)
68
- ? JSON.parse(readFileSync(botIdsFile, 'utf8')) : {};
69
- return ids[station] ? Line.bot(station, ids[station]) : `metro://${station}/bot`;
70
- }
71
- catch {
72
- return `metro://${station}/bot`;
73
- }
73
+ const id = readBotIds()[station];
74
+ return id ? Line.bot(station, id) : `metro://${station}/bot`;
74
75
  }
@@ -1,4 +1,4 @@
1
- /** CLI action handlers: send/reply/edit/react/download/fetch/notify + helpers. */
1
+ /** CLI action handlers: send/reply/edit/react/download/fetch + helpers. */
2
2
  import { mkdirSync } from 'node:fs';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
@@ -45,34 +45,29 @@ function richOpts(f) {
45
45
  opts.buttons = buttons;
46
46
  return opts;
47
47
  }
48
- /** Append an outbound action to history.jsonl; pass `to` = the original sender when replying/reacting. */
49
- function logOutbound(f, kind, line, text, platformMessageId, replyTo, opts, emoji, to) {
48
+ /** Append an outbound action to history.jsonl; `to` = the original sender when replying/reacting. */
49
+ function logOutbound(f, e) {
50
50
  const id = mintId();
51
- const station = Line.station(line) ?? '?';
52
51
  const fromOverride = flagOne(f, 'from');
53
- const from = fromOverride ? asLine(fromOverride) : agentSelf();
54
52
  appendHistory({
55
- id, ts: new Date().toISOString(), kind, station, line, from, to: to ?? line,
56
- text, platformMessageId, replyTo, emoji,
57
- attachments: [...(opts?.images ?? []), ...(opts?.documents ?? []), ...(opts?.voice ? [opts.voice] : [])],
53
+ id, ts: new Date().toISOString(), station: Line.station(e.line) ?? '?',
54
+ from: fromOverride ? asLine(fromOverride) : agentSelf(), to: e.to ?? e.line, ...e,
58
55
  });
59
56
  return id;
60
57
  }
61
- /** When replying/reacting/editing, the recipient is the original message's sender (if we have it). */
62
- const recipientFor = (idOrPlatform) => lookupEntry(idOrPlatform)?.from;
63
58
  export async function cmdSend(p, f) {
64
59
  need(p, 1, 'metro send <line> <text> [--image=<path>]… [--document=<path>]… [--voice=<path>] [--buttons=<json>]');
65
60
  loadMetroEnv();
66
61
  const text = await resolveText(p, 1), line = asLine(p[0]);
67
62
  if (Line.isAgent(line)) {
68
- const resp = await ipcCall({ op: 'notify', line, text });
63
+ const from = flagOne(f, 'from');
64
+ const resp = await ipcCall({ op: 'notify', line, from, text });
69
65
  if (!resp.ok)
70
66
  throw new Error(resp.error);
71
67
  return emit(f, `notified ${line}`, { ok: true, line, id: null, messageId: null });
72
68
  }
73
- const opts = richOpts(f);
74
- const messageId = await chatStationOf(line).send(line, text, opts);
75
- const id = logOutbound(f, 'outbound', line, text, messageId, undefined, opts);
69
+ const messageId = await chatStationOf(line).send(line, text, richOpts(f));
70
+ const id = logOutbound(f, { kind: 'outbound', line, text, messageId });
76
71
  emit(f, `sent ${id} (${messageId}) to ${line}`, { ok: true, line, id, messageId });
77
72
  }
78
73
  export async function cmdReply(p, f) {
@@ -80,9 +75,8 @@ export async function cmdReply(p, f) {
80
75
  loadMetroEnv();
81
76
  const [to, replyToArg] = p, text = await resolveText(p, 2), line = asLine(to);
82
77
  const replyTo = resolvePlatformId(replyToArg);
83
- const opts = richOpts(f);
84
- const messageId = await chatStationOf(line).send(line, text, { ...opts, replyTo });
85
- const id = logOutbound(f, 'outbound', line, text, messageId, replyToArg, opts, undefined, recipientFor(replyToArg));
78
+ const messageId = await chatStationOf(line).send(line, text, { ...richOpts(f), replyTo });
79
+ const id = logOutbound(f, { kind: 'outbound', line, text, messageId, replyTo: replyToArg, to: lookupEntry(replyToArg)?.from });
86
80
  emit(f, `replied ${id} (${messageId}) to ${line}#${replyTo}`, { ok: true, line, id, replyTo: replyToArg, messageId });
87
81
  }
88
82
  export async function cmdEdit(p, f) {
@@ -93,8 +87,7 @@ export async function cmdEdit(p, f) {
93
87
  const buttons = parseButtons(f);
94
88
  await chatStationOf(line).edit(line, platformId, text, buttons ? { buttons } : undefined);
95
89
  /** Carry forward the original recipient if we have a row for this message. */
96
- const original = lookupEntry(msgArg);
97
- const id = logOutbound(f, 'edit', line, text, platformId, msgArg, undefined, undefined, original?.to);
90
+ const id = logOutbound(f, { kind: 'edit', line, text, messageId: platformId, replyTo: msgArg, to: lookupEntry(msgArg)?.to });
98
91
  emit(f, `edited ${line}#${platformId} (${id})`, { ok: true, line, id, messageId: platformId });
99
92
  }
100
93
  export async function cmdReact(p, f) {
@@ -103,7 +96,7 @@ export async function cmdReact(p, f) {
103
96
  const [to, msgArg, emoji = ''] = p, line = asLine(to);
104
97
  const platformId = resolvePlatformId(msgArg);
105
98
  await chatStationOf(line).react(line, platformId, emoji);
106
- const id = logOutbound(f, 'react', line, undefined, platformId, undefined, undefined, emoji, recipientFor(msgArg));
99
+ const id = logOutbound(f, { kind: 'react', line, messageId: platformId, emoji, to: lookupEntry(msgArg)?.from });
107
100
  const human = emoji ? `reacted ${emoji} on ${line}#${platformId}` : `cleared reaction on ${line}#${platformId}`;
108
101
  emit(f, human, { ok: true, line, id, messageId: platformId, emoji });
109
102
  }
@@ -144,13 +137,3 @@ export async function cmdFetch(p, f) {
144
137
  for (const m of messages)
145
138
  process.stdout.write(`${m.timestamp} ${m.author}: ${m.text}\n`);
146
139
  }
147
- export async function cmdNotify(p, f) {
148
- need(p, 1, 'metro notify <line> <text> [--from=<line>]');
149
- loadMetroEnv();
150
- const text = await resolveText(p, 1), line = asLine(p[0]);
151
- const from = flagOne(f, 'from');
152
- const resp = await ipcCall({ op: 'notify', line, from, text });
153
- if (!resp.ok)
154
- throw new Error(resp.error);
155
- emit(f, `notified ${line}`, { ok: true, line });
156
- }
@@ -10,6 +10,7 @@ import { CONFIG_ENV_FILE, configuredPlatforms, loadMetroEnv, readDotenv, STATE_D
10
10
  import { emit, exitErr, isJson, writeJson } from './util.js';
11
11
  import { cmdSetupSkill, skillStatus } from './skill.js';
12
12
  const TOKEN_KEYS = { telegram: 'TELEGRAM_BOT_TOKEN', discord: 'DISCORD_BOT_TOKEN' };
13
+ const stationFor = (p) => p === 'telegram' ? new TelegramStation() : new DiscordStation();
13
14
  const maskToken = (t) => !t ? '' : t.length <= 8 ? '••••' : `${t.slice(0, 6)}…${t.slice(-2)}`;
14
15
  /** Apply token across CONFIG_ENV_FILE (always set/cleared) AND cwd/.env (only if it exists). */
15
16
  function applyToken(key, value) {
@@ -62,7 +63,7 @@ export async function cmdSetup(p, f) {
62
63
  if (!f['no-validate']) {
63
64
  process.env[TOKEN_KEYS[sub]] = trimmed;
64
65
  try {
65
- const me = await (sub === 'telegram' ? new TelegramStation() : new DiscordStation()).getMe();
66
+ const me = await stationFor(sub).getMe();
66
67
  identity = sub === 'telegram' ? `@${me.username}` : me.username;
67
68
  }
68
69
  catch (err) {
@@ -103,26 +104,26 @@ function tokenSource(key) {
103
104
  export async function cmdDoctor(_, f) {
104
105
  loadMetroEnv();
105
106
  const cfg = configuredPlatforms();
106
- const sources = [['telegram', 'TELEGRAM_BOT_TOKEN'], ['discord', 'DISCORD_BOT_TOKEN']]
107
- .filter(([p]) => cfg[p]).map(([p, k]) => `${p}←${tokenSource(k)}`).join(', ');
108
- const checks = [{
109
- name: 'tokens', ok: cfg.telegram || cfg.discord,
110
- detail: cfg.telegram || cfg.discord ? sources
111
- : 'no platform configured — run `metro setup telegram|discord <token>`',
112
- }];
107
+ const checks = [];
108
+ const sources = [];
113
109
  for (const p of ['telegram', 'discord']) {
114
110
  if (!cfg[p]) {
115
111
  checks.push({ name: p, ok: null, detail: 'not configured' });
116
112
  continue;
117
113
  }
114
+ sources.push(`${p}←${tokenSource(TOKEN_KEYS[p])}`);
118
115
  try {
119
- const me = await (p === 'telegram' ? new TelegramStation() : new DiscordStation()).getMe();
116
+ const me = await stationFor(p).getMe();
120
117
  checks.push({ name: p, ok: true, detail: `getMe → ${p === 'telegram' ? '@' : ''}${me.username}` });
121
118
  }
122
119
  catch (err) {
123
120
  checks.push({ name: p, ok: false, detail: errMsg(err) });
124
121
  }
125
122
  }
123
+ checks.unshift({
124
+ name: 'tokens', ok: cfg.telegram || cfg.discord,
125
+ detail: sources.length ? sources.join(', ') : 'no platform configured — run `metro setup telegram|discord <token>`',
126
+ });
126
127
  const lockFile = join(STATE_DIR, '.tail-lock');
127
128
  if (!existsSync(lockFile))
128
129
  checks.push({ name: 'dispatcher', ok: null, detail: 'not running' });
package/dist/cli/index.js CHANGED
@@ -7,7 +7,7 @@ import { fmtCapabilities, listStations } from '../stations/index.js';
7
7
  import { loadMetroEnv } from '../paths.js';
8
8
  import { readHistory } from '../history.js';
9
9
  import { cmdDoctor, cmdSetup, cmdUpdate } from './config.js';
10
- import { cmdDownload, cmdEdit, cmdFetch, cmdNotify, cmdReact, cmdReply, cmdSend, } from './actions.js';
10
+ import { cmdDownload, cmdEdit, cmdFetch, cmdReact, cmdReply, cmdSend, } from './actions.js';
11
11
  import { flagOne, isJson, parseArgs, writeJson, } from './util.js';
12
12
  const USAGE = `metro — Telegram + Discord stream for your Claude Code / Codex agent
13
13
 
@@ -29,7 +29,6 @@ Usage:
29
29
  metro download <line> <message_id> [--out=<dir>]
30
30
  Download image attachments to disk.
31
31
  metro fetch <line> [--limit=N] Recent-message lookback (Discord only).
32
- metro notify <line> <text> [--from=<line>] Emit a notification on the daemon's stream.
33
32
  metro history [--limit=N] [--line=…] [--station=…] [--kind=…] [--from=…] [--text=…] [--since=…]
34
33
  Read the universal message log (newest first).
35
34
  metro update Upgrade in place.
@@ -58,10 +57,8 @@ async function cmdLines(_, f) {
58
57
  .sort((a, b) => (b.lastSeenAt ?? '').localeCompare(a.lastSeenAt ?? ''));
59
58
  if (isJson(f))
60
59
  return writeJson({ lines: rows });
61
- if (!rows.length) {
62
- process.stdout.write('metro lines\n\n (none yet — start the dispatcher and send a message)\n\n');
63
- return;
64
- }
60
+ if (!rows.length)
61
+ return void process.stdout.write('metro lines\n\n (none yet — start the dispatcher and send a message)\n\n');
65
62
  const widest = Math.max(...rows.map(r => r.line.length));
66
63
  process.stdout.write('metro lines\n\n');
67
64
  for (const r of rows) {
@@ -128,7 +125,7 @@ const pad = (s, n) => (s.length > n ? `${s.slice(0, n - 1)}…` : s.padEnd(n));
128
125
  const COMMANDS = {
129
126
  setup: cmdSetup, doctor: cmdDoctor, stations: cmdStations, lines: cmdLines,
130
127
  send: cmdSend, reply: cmdReply, edit: cmdEdit, react: cmdReact,
131
- download: cmdDownload, fetch: cmdFetch, notify: cmdNotify,
128
+ download: cmdDownload, fetch: cmdFetch,
132
129
  history: cmdHistory, update: cmdUpdate,
133
130
  };
134
131
  async function main() {
@@ -7,7 +7,7 @@ import { fileURLToPath } from 'node:url';
7
7
  import pkg from '../package.json' with { type: 'json' };
8
8
  import { DiscordStation } from './stations/discord.js';
9
9
  import { TelegramStation } from './stations/telegram.js';
10
- import { asLine } from './stations/index.js';
10
+ import { asLine, Line } from './stations/index.js';
11
11
  import { CodexRC } from './codex-rc.js';
12
12
  import { startIpcServer, stopIpcServer } from './ipc.js';
13
13
  import { agentSelf, appendHistory, mintId } from './history.js';
@@ -34,37 +34,21 @@ const codexRc = process.env.METRO_CODEX_RC ? new CodexRC(process.env.METRO_CODEX
34
34
  codexRc?.start();
35
35
  const discord = new DiscordStation();
36
36
  const telegram = new TelegramStation();
37
- function emit(event) {
38
- const json = JSON.stringify(event);
37
+ function emit(entry) {
38
+ const json = JSON.stringify(entry);
39
39
  process.stdout.write(json + '\n');
40
40
  codexRc?.push(json);
41
- if ('lineName' in event)
42
- noteSeen(event.line, event.lineName);
43
- else
44
- noteSeen(event.line);
45
- if (event.type === 'inbound') {
46
- appendHistory({
47
- id: event.id, ts: event.ts, kind: 'inbound', station: event.station, line: event.line,
48
- from: event.from, fromName: event.fromName, to: agentSelf(), text: event.text,
49
- platformMessageId: event.messageId, attachments: event.attachmentNames,
50
- });
51
- }
52
- else if (event.type === 'notification') {
53
- appendHistory({
54
- id: event.id, ts: event.ts, kind: 'notification',
55
- station: event.line.replace(/^metro:\/\/([^/]+)\/.*$/, '$1'),
56
- line: event.line, from: event.from ?? agentSelf(),
57
- to: event.line, text: event.text,
58
- });
59
- }
41
+ noteSeen(entry.line, entry.lineName);
42
+ appendHistory(entry);
60
43
  }
61
- const onInbound = (m) => emit({ type: 'inbound', ...m, to: agentSelf() });
44
+ const onInbound = (m) => emit({ ...m, kind: 'inbound', to: agentSelf() });
62
45
  const ipc = startIpcServer(async (req) => {
63
46
  if (req.op === 'notify') {
47
+ const line = asLine(req.line);
64
48
  emit({
65
- type: 'notification', id: mintId(), ts: new Date().toISOString(),
66
- line: asLine(req.line),
67
- from: req.from ? asLine(req.from) : agentSelf(), text: req.text,
49
+ id: mintId(), ts: new Date().toISOString(), kind: 'notification',
50
+ station: Line.station(line) ?? '?', line,
51
+ from: req.from ? asLine(req.from) : agentSelf(), to: line, text: req.text,
68
52
  });
69
53
  return { ok: true };
70
54
  }
@@ -105,8 +89,7 @@ async function shutdown() {
105
89
  await telegram.stop().catch(() => { });
106
90
  process.exit(0);
107
91
  }
108
- process.stdin.on('end', shutdown);
109
- process.stdin.on('close', shutdown);
110
- process.on('SIGINT', shutdown);
111
- process.on('SIGTERM', shutdown);
92
+ process.stdin.on('end', shutdown).on('close', shutdown);
93
+ for (const sig of ['SIGINT', 'SIGTERM'])
94
+ process.on(sig, shutdown);
112
95
  await main();
package/dist/history.js CHANGED
@@ -60,15 +60,15 @@ function matches(e, f) {
60
60
  /** Find an entry by universal id OR platform message id. */
61
61
  export function lookupEntry(id) {
62
62
  const entries = readHistory({ limit: 5_000 });
63
- return entries.find(e => e.id === id || e.platformMessageId === id);
63
+ return entries.find(e => e.id === id || e.messageId === id);
64
64
  }
65
65
  /** Look up the platform messageId for a universal `msg_*` id; returns the input unchanged otherwise. */
66
66
  export function resolvePlatformId(id) {
67
67
  if (!id.startsWith('msg_'))
68
68
  return id;
69
69
  const hit = lookupEntry(id);
70
- if (hit?.platformMessageId)
71
- return hit.platformMessageId;
70
+ if (hit?.messageId)
71
+ return hit.messageId;
72
72
  throw new Error(`unknown universal id: ${id} (run \`metro history --limit=50\` to see recent ids)`);
73
73
  }
74
74
  /** Resolve the current agent's identity URI. Precedence: METRO_FROM > runtime env > generic. */
package/dist/ipc.js CHANGED
@@ -12,7 +12,8 @@ export function startIpcServer(handler) {
12
12
  }
13
13
  catch { /* ignore */ }
14
14
  }
15
- const server = createServer(socket => handleConnection(socket, handler));
15
+ /** allowHalfOpen: any `await` in the handler races Node's auto-end-on-client-FIN, dropping the response. */
16
+ const server = createServer({ allowHalfOpen: true }, s => handleConnection(s, handler));
16
17
  server.on('error', err => log.warn({ err: errMsg(err) }, 'ipc server error'));
17
18
  server.listen(SOCKET_PATH, () => log.debug({ path: SOCKET_PATH }, 'ipc socket listening'));
18
19
  return server;
@@ -97,7 +97,7 @@ export class DiscordStation {
97
97
  }
98
98
  async start() {
99
99
  const c = this.getClient();
100
- c.on(Events.MessageCreate, m => { void this.handleMessage(m, c); });
100
+ c.on(Events.MessageCreate, m => { void this.handleMessage(m); });
101
101
  c.on(Events.Error, err => log.error({ err: errMsg(err) }, 'discord error'));
102
102
  await c.login(process.env.DISCORD_BOT_TOKEN);
103
103
  await new Promise(r => c.once(Events.ClientReady, () => r()));
@@ -170,29 +170,31 @@ export class DiscordStation {
170
170
  messageId: m.id, author: m.author.username, text: m.content, timestamp: m.timestamp,
171
171
  }));
172
172
  }
173
- async handleMessage(m, c) {
173
+ async handleMessage(m) {
174
174
  if (m.author.bot)
175
175
  return;
176
- const attachmentNames = [];
177
- for (const a of m.attachments.values()) {
178
- if (a.contentType?.startsWith('image/'))
179
- attachmentNames.push('[image]');
180
- else
181
- attachmentNames.push(a.contentType?.startsWith('audio/') ? `[audio: ${a.name}]` : `[file: ${a.name}]`);
182
- }
183
- const text = m.content.trim();
184
- if (!text && !attachmentNames.length)
176
+ const tags = [...m.attachments.values()].map(a => a.contentType?.startsWith('image/') ? '[image]'
177
+ : a.contentType?.startsWith('audio/') ? `[audio: ${a.name}]` : `[file: ${a.name}]`);
178
+ const text = [m.content.trim(), ...tags].filter(Boolean).join(' ');
179
+ if (!text)
185
180
  return;
186
- log.info({ from: m.author.username, bot: c.user?.username, channel: m.channelId, text: text.slice(0, 80) }, 'discord: inbound');
181
+ log.info({ from: m.author.username, channel: m.channelId, text: text.slice(0, 80) }, 'discord: inbound');
187
182
  const lineName = m.channel && 'name' in m.channel
188
183
  ? m.channel.name ?? undefined : undefined;
184
+ const payload = m.toJSON();
185
+ if (m.reference?.messageId) {
186
+ try {
187
+ payload.referencedMessage = (await m.fetchReference()).toJSON();
188
+ }
189
+ catch (err) {
190
+ log.debug({ err: errMsg(err) }, 'discord: fetchReference failed');
191
+ }
192
+ }
189
193
  this.messageHandler({
190
194
  id: mintId(), ts: new Date(m.createdTimestamp).toISOString(),
191
195
  station: 'discord', line: Line.discord(m.channelId), lineName,
192
196
  from: Line.user('discord', m.author.id), fromName: m.author.username,
193
- messageId: m.id, text, attachmentNames,
194
- mentionsBot: !m.guildId || (c.user ? m.mentions.has(c.user.id) : false),
195
- meta: { inGuild: !!m.guildId },
197
+ messageId: m.id, text, payload,
196
198
  });
197
199
  }
198
200
  }
@@ -59,8 +59,6 @@ const CAPS = {
59
59
  export class TelegramStation {
60
60
  name = 'telegram';
61
61
  capabilities = CAPS;
62
- botUsername = null;
63
- botUserId = null;
64
62
  pollOffset = 0;
65
63
  pollAbort = null;
66
64
  messageHandler = () => { };
@@ -85,10 +83,7 @@ export class TelegramStation {
85
83
  }
86
84
  async stop() { this.pollAbort?.abort(); this.pollAbort = null; }
87
85
  async getMe() {
88
- const me = await tg('getMe', {});
89
- this.botUsername = me.username;
90
- this.botUserId = me.id;
91
- return me;
86
+ return tg('getMe', {});
92
87
  }
93
88
  async send(line, text, opts) {
94
89
  const { chatId, topicId } = targetOf(line);
@@ -196,15 +191,12 @@ export class TelegramStation {
196
191
  const m = u.message;
197
192
  if (!m?.chat?.id || typeof m.message_id !== 'number' || m.from?.is_bot)
198
193
  return;
199
- const attachmentNames = attachmentTags(m);
200
- const text = m.text ?? m.caption ?? '';
201
- if (!text && !attachmentNames.length)
194
+ const text = [m.text ?? m.caption, ...attachmentTags(m)].filter(Boolean).join(' ');
195
+ if (!text)
202
196
  return;
203
197
  const topicId = m.is_topic_message ? m.message_thread_id : undefined;
204
198
  const fromName = m.from?.username ? `@${m.from.username}` : m.from?.first_name;
205
- const fromUri = Line.user('telegram', m.from?.id ?? 'unknown');
206
- const bot = this.botUsername ? `@${this.botUsername}` : undefined;
207
- log.info({ from: fromName, bot, chat: m.chat.id, topic: topicId, text: text.slice(0, 80) }, 'telegram: inbound');
199
+ log.info({ from: fromName, chat: m.chat.id, topic: topicId, text: text.slice(0, 80) }, 'telegram: inbound');
208
200
  if (this.recent.size >= 50) {
209
201
  const first = this.recent.keys().next().value;
210
202
  if (first)
@@ -215,21 +207,7 @@ export class TelegramStation {
215
207
  id: mintId(), ts: new Date((m.date ?? Math.floor(Date.now() / 1000)) * 1000).toISOString(),
216
208
  station: 'telegram', line: Line.telegram(m.chat.id, topicId), messageId: String(m.message_id),
217
209
  lineName: topicId === undefined ? (m.chat.title ?? m.chat.first_name ?? undefined) : undefined,
218
- from: fromUri, fromName, text, attachmentNames, mentionsBot: this.detectMentionsBot(m),
219
- meta: { isPrivate: m.chat.type === 'private', inForum: !!m.chat.is_forum, isForumTopic: !!m.is_topic_message },
210
+ from: Line.user('telegram', m.from?.id ?? 'unknown'), fromName, text, payload: m,
220
211
  });
221
212
  }
222
- detectMentionsBot(m) {
223
- if (m.chat?.type === 'private')
224
- return true;
225
- const text = m.text ?? m.caption ?? '';
226
- for (const e of m.entities ?? m.caption_entities ?? []) {
227
- if (e.type === 'mention' && this.botUsername
228
- && text.substring(e.offset, e.offset + e.length).toLowerCase() === `@${this.botUsername.toLowerCase()}`)
229
- return true;
230
- if (e.type === 'text_mention' && e.user?.id === this.botUserId)
231
- return true;
232
- }
233
- return false;
234
- }
235
213
  }
package/docs/agents.md CHANGED
@@ -31,22 +31,31 @@ Run `metro doctor` if anything seems off.
31
31
 
32
32
  ## Event shape
33
33
 
34
- Every event carries `id` (`msg_…`), `ts`, `from` (a universal participant URI), `fromName` (display name), `line` (conversation = `to`), and `messageId` (the platform-side id).
34
+ Every event is a **history entry** — the same record that's appended to `history.jsonl`. Fields: `kind` (`inbound`/`notification`/`outbound`/`edit`/`react`), `id` (`msg_…`), `ts`, `station`, `line` (conversation), `lineName?`, `from` (participant URI), `fromName?`, `to`, `text`, `messageId?` (platform-side id; inbound/outbound only), `payload?` (raw platform message; inbound only).
35
35
 
36
36
  ```json
37
- {"type":"inbound","id":"msg_aB3xY7zP","ts":"2026-05-14T12:00:00Z","station":"telegram","line":"metro://telegram/-100…/247","from":"metro://telegram/user/12345","fromName":"@alice","messageId":"4567","text":"hello","attachmentNames":["[image]"],"mentionsBot":true,"meta":{"isPrivate":false,"inForum":true,"isForumTopic":true},"lineName":"infra"}
37
+ {"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/agent","messageId":"4567","text":"hello [image]","payload":{"message_id":4567,"chat":{"id":-100,"type":"supergroup","is_forum":true},"from":{"id":12345,"username":"alice"},"text":"hello","entities":[{"type":"mention","offset":0,"length":6}],"photo":[{"file_id":"…"}],"reply_to_message":{"message_id":4500,"text":"earlier","from":{"id":99,"username":"bob"}}}}
38
38
  ```
39
39
 
40
40
  ```json
41
- {"type":"notification","id":"msg_pQ4r5sT0","ts":"…","line":"metro://claude/deploys","from":"metro://codex/ci","text":"deploy succeeded"}
41
+ {"kind":"notification","id":"msg_pQ4r5sT0","ts":"…","station":"claude","line":"metro://claude/deploys","from":"metro://codex/ci","to":"metro://claude/deploys","text":"deploy succeeded"}
42
42
  ```
43
43
 
44
+ ### `payload` by station
45
+
46
+ `payload` is the platform's native message shape. Narrow on `event.station`:
47
+
48
+ - **`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` (also `toJSON()`-shaped) is added inline on replies (auto-fetched).
49
+ - **`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.
50
+
51
+ Use `payload` for anything the envelope doesn't surface — mentions, reply chains, embeds, stickers, entities.
52
+
44
53
  Both `from` and `to` are **participant URIs** (the conversation lives in `line`): `metro://<station>/user/<id>` for a person, `metro://claude/<topic>` / `metro://codex/<topic>` for an agent, `metro://<station>/<channelId>` as a fallback `to` when sending to a group with no single recipient.
45
54
 
46
55
  When **you** call `metro send`/`reply`/`edit`/`react`, metro auto-stamps `from` to your runtime — `metro://claude/agent` (from `$CLAUDECODE`) or `metro://codex/agent` (from `$METRO_CODEX_RC`/`$CODEX_HOME`). Override with `--from=<uri>` or `$METRO_FROM`. When replying/reacting, `to` is auto-set to the original sender (history lookup).
47
56
 
48
- - `type: "inbound"` — a human (or another bot) posted on a chat platform.
49
- - `type: "notification"` — another agent called `metro notify`/`metro send` against your agent line. This is how Codex pings Claude Code and vice versa.
57
+ - `kind: "inbound"` — a human (or another bot) posted on a chat platform.
58
+ - `kind: "notification"` — another agent called `metro send` against your agent line. This is how Codex pings Claude Code and vice versa.
50
59
 
51
60
  `text` may include `[image]` / `[voice]` / `[audio]` / `[file: <name>]` placeholders alongside the real text — non-image attachments are opaque markers, images can be materialized via `metro download`.
52
61
 
@@ -55,6 +64,15 @@ When **you** call `metro send`/`reply`/`edit`/`react`, metro auto-stamps `from`
55
64
  1. **Echo the event** to your visible output: `[<line>#<messageId>] <text>`. Both Monitor and Codex collapse tool output, so this echo is the only thing the user sees without expanding cards.
56
65
  2. **Decide and act** using the subcommands below.
57
66
 
67
+ ## Detecting "is this for me?"
68
+
69
+ Derive from `payload`. Bot id per station is in `$METRO_STATE_DIR/bot-ids.json` (`{discord:"<userId>", telegram:"<userId>"}`).
70
+
71
+ - **`discord`** — DM if `payload.guildId == null`; otherwise pinged if `payload.mentions.users.includes(<bot-id>)`.
72
+ - **`telegram`** — DM if `payload.chat.type === 'private'`; otherwise pinged if any entity in `payload.entities` (or `caption_entities`) is `{type:"mention"}` matching `@<bot-username>`, or `{type:"text_mention", user:{id:<bot-id>}}`.
73
+
74
+ Default: only reply on DM or ping; otherwise stay silent or `metro react` to ack.
75
+
58
76
  ## Subcommands
59
77
 
60
78
  | Action | Command |
@@ -65,7 +83,7 @@ When **you** call `metro send`/`reply`/`edit`/`react`, metro auto-stamps `from`
65
83
  | Reaction (empty emoji clears it) | `metro react <line> <messageId> <emoji>` |
66
84
  | Download `[image]` attachments | `metro download <line> <messageId> [--out=<dir>]` |
67
85
  | Recent-message lookback (Discord only) | `metro fetch <line> [--limit=20]` |
68
- | Cross-agent ping | `metro notify <line> <text> [--from=<line>]` |
86
+ | Cross-agent ping | `metro send <agent-line> <text> [--from=<line>]` |
69
87
 
70
88
  `reply` / `send` / `edit` accept multi-line text via stdin (heredoc).
71
89
 
@@ -150,8 +168,7 @@ Both agents can post to each other's "agent line" — a logical channel under `m
150
168
 
151
169
  ```bash
152
170
  metro send metro://claude/deploys "build green, ready to ship"
153
- # or equivalently:
154
- metro notify metro://claude/deploys "build green, ready to ship" --from=metro://codex/ci
171
+ metro send metro://claude/deploys "build green" --from=metro://codex/ci # override sender
155
172
  ```
156
173
 
157
174
  This requires the metro daemon to be running on the machine. Without a daemon, agent-line sends error with a clear message.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.6",
3
+ "version": "0.1.0-beta.7",
4
4
  "description": "Live JSON stream of Telegram + Discord messages for your local Claude Code / Codex session. The agent launches metro; metro emits inbounds on stdout and accepts replies via CLI subcommands.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: metro
3
- description: Run the metro Telegram/Discord bridge in this session — launch `metro` in the background, watch its stdout for inbound JSON events, and act on each. Use when the user asks to start/run/launch metro, when you see JSON lines on stdout shaped `{"type":"inbound","station":...,"line":"metro://...","messageId":...,"text":...}`, or when handling a chat reply/edit/react/send/download/fetch/notify.
3
+ description: Run the metro Telegram/Discord bridge in this session — launch `metro` in the background, watch its stdout for inbound JSON events, and act on each. Use when the user asks to start/run/launch metro, when you see JSON lines on stdout shaped `{"kind":"inbound","station":...,"line":"metro://...","messageId":...,"text":...}`, or when handling a chat reply/edit/react/send/download/fetch/notify.
4
4
  ---
5
5
 
6
6
  # Metro — running the Telegram & Discord bridge
@@ -40,22 +40,44 @@ If something seems off, run `metro doctor`. Common causes: missing tokens (`metr
40
40
 
41
41
  ## Event shape
42
42
 
43
- Every line on stdout is one JSON object. Each event carries:
43
+ Every line on stdout is one **history entry** the same record appended to `history.jsonl`. Fields:
44
+ - `kind` — `"inbound"`, `"notification"`, `"outbound"`, `"edit"`, or `"react"`
44
45
  - `id` (`msg_…`) — universal message ID minted by metro
45
46
  - `ts` — ISO timestamp
46
- - `from` — universal **participant URI** of the sender (a Line)
47
- - `fromName` — optional human-readable display name (`@alice`, `bonustrack_`)
48
- - `line` — the conversation URI (also the implicit `to`)
49
- - `messageId` — the platform-side id (Discord snowflake, Telegram int, )
47
+ - `station` — `"discord"`, `"telegram"`, `"claude"`, `"codex"`
48
+ - `line` — conversation URI; `lineName?` is the channel/topic display name
49
+ - `from` / `fromName?` sender participant URI + optional display name
50
+ - `to` — recipient participant URI (agent for inbound, line for notification, original sender for replies/reacts)
51
+ - `text` — universal display projection. Includes `[image]`/`[file: …]`/`[voice]`/`[audio]` tags inline.
52
+ - `messageId?` — platform-side id (Discord snowflake, Telegram int). Set on inbound/outbound.
53
+ - `payload?` — raw platform-native message object. Set on inbound only. Shape varies per `station`.
50
54
 
51
55
  ```json
52
- {"type":"inbound","id":"msg_aB3xY7zP","ts":"2026-05-14T12:00:00Z","station":"telegram","line":"metro://telegram/-100…/247","from":"metro://telegram/user/12345","fromName":"@alice","messageId":"4567","text":"hi","attachmentNames":["[image]"],"mentionsBot":true,"meta":{"isPrivate":false,"inForum":true,"isForumTopic":true},"lineName":"infra"}
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/agent","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"}}}}
53
57
  ```
54
58
 
55
59
  ```json
56
- {"type":"notification","id":"msg_pQ4r5sT0","ts":"…","line":"metro://claude/deploys","from":"metro://codex/ci","text":"deploy green"}
60
+ {"kind":"notification","id":"msg_pQ4r5sT0","ts":"…","station":"claude","line":"metro://claude/deploys","from":"metro://codex/ci","to":"metro://claude/deploys","text":"deploy green"}
57
61
  ```
58
62
 
63
+ ### `payload` by station
64
+
65
+ `payload` is the platform's native message shape. Narrow on `event.station`:
66
+
67
+ - **`discord`** — discord.js `Message.toJSON()`: camelCase fields (`channelId`, `guildId`, `content`, `author`, `mentions: { users[], roles[], everyone }`, `attachments[]`, `reference`, …). Collections come back as **arrays of IDs**. `referencedMessage` is added inline on replies (auto-fetched).
68
+ - **`telegram`** — raw Bot API `Message` (snake_case): `{ message_id, chat, from, text, caption, entities[], photo[], document, voice, audio, reply_to_message, … }`. `reply_to_message` is inline on replies.
69
+
70
+ Use `payload` for anything the envelope doesn't surface — mentions, reply chains, embeds, entities.
71
+
72
+ ## Detecting "is this for me?"
73
+
74
+ 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).
75
+
76
+ - **discord** — DM when `payload.guildId == null`; otherwise pinged when `payload.mentions.users.includes(<bot-id>)`.
77
+ - **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>}}`.
78
+
79
+ Default: only reply on DM or ping; otherwise stay silent or `metro react` to ack.
80
+
59
81
  Both `from` and `to` are **participant URIs** (the conversation context lives in `line`):
60
82
  - `metro://<station>/user/<id>` — a person on a chat platform
61
83
  - `metro://claude/<topic>` / `metro://codex/<topic>` — an agent
@@ -65,8 +87,8 @@ When **you** send via `metro send`/`reply`/`edit`/`react`, metro auto-stamps `fr
65
87
 
66
88
  The `id` is the **canonical handle** for that message across all stations — store it if you want to refer back to it later.
67
89
 
68
- - `type: "inbound"` — a human (or another bot) posted on a chat platform.
69
- - `type: "notification"` — another agent called `metro notify` / `metro send` against your agent line. This is how Codex pings Claude Code and vice versa.
90
+ - `kind: "inbound"` — a human (or another bot) posted on a chat platform.
91
+ - `kind: "notification"` — another agent called `metro send` against your agent line. This is how Codex pings Claude Code and vice versa.
70
92
 
71
93
  `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`.
72
94
 
@@ -89,7 +111,7 @@ All take positional args (no `--to=`/`--text=` flags). Append `--json` to any fo
89
111
  | Reaction (empty emoji clears) | `metro react <line> <messageId> <emoji>` |
90
112
  | Download `[image]` attachments → paths | `metro download <line> <messageId> [--out=<dir>]` |
91
113
  | Recent channel history (Discord only) | `metro fetch <line> [--limit=20]` |
92
- | Ping another agent (cross-agent line) | `metro send metro://claude/<topic> <text>` or `metro notify <line> <text> [--from=<line>]` |
114
+ | Ping another agent (cross-agent line) | `metro send metro://claude/<topic> <text> [--from=<line>]` |
93
115
 
94
116
  `reply` / `send` / `edit` accept multi-line text via stdin (heredoc).
95
117
 
@@ -153,11 +175,10 @@ Both agents can post to each other's "agent line":
153
175
 
154
176
  ```bash
155
177
  metro send metro://claude/deploys "build green, ready to ship"
156
- # or
157
- metro notify metro://codex/ci "build green" --from=metro://claude/deploys
178
+ metro send metro://codex/ci "build green" --from=metro://claude/deploys # override sender
158
179
  ```
159
180
 
160
- The daemon re-emits the post on its stdout stream (and pushes via codex-rc if configured), so the peer agent sees a `{"type":"notification",...}` event. Requires the metro daemon to be running on the machine — agent-line sends error with `metro daemon is not running` otherwise.
181
+ The daemon re-emits the post on its stdout stream (and pushes via codex-rc if configured), so the peer agent sees a `{"kind":"notification",...}` event. Requires the metro daemon to be running on the machine — agent-line sends error with `metro daemon is not running` otherwise.
161
182
 
162
183
  ## Discoverability
163
184