@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 +7 -6
- package/dist/cache.js +11 -10
- package/dist/cli/actions.js +13 -30
- package/dist/cli/config.js +10 -9
- package/dist/cli/index.js +4 -7
- package/dist/dispatcher.js +13 -30
- package/dist/history.js +3 -3
- package/dist/ipc.js +2 -1
- package/dist/stations/discord.js +17 -15
- package/dist/stations/telegram.js +5 -27
- package/docs/agents.md +25 -8
- package/package.json +1 -1
- package/skills/metro/SKILL.md +35 -14
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
|
|
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
|
-
>>> {"
|
|
14
|
-
"text":"@bot we got a 5xx spike from /v1/sync. Look?",
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
67
|
-
|
|
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
|
}
|
package/dist/cli/actions.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/** CLI action handlers: send/reply/edit/react/download/fetch
|
|
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;
|
|
49
|
-
function logOutbound(f,
|
|
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(),
|
|
56
|
-
|
|
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
|
|
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
|
|
74
|
-
const
|
|
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
|
|
84
|
-
const
|
|
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
|
|
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,
|
|
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
|
-
}
|
package/dist/cli/config.js
CHANGED
|
@@ -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
|
|
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
|
|
107
|
-
|
|
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
|
|
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,
|
|
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,
|
|
128
|
+
download: cmdDownload, fetch: cmdFetch,
|
|
132
129
|
history: cmdHistory, update: cmdUpdate,
|
|
133
130
|
};
|
|
134
131
|
async function main() {
|
package/dist/dispatcher.js
CHANGED
|
@@ -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(
|
|
38
|
-
const json = JSON.stringify(
|
|
37
|
+
function emit(entry) {
|
|
38
|
+
const json = JSON.stringify(entry);
|
|
39
39
|
process.stdout.write(json + '\n');
|
|
40
40
|
codexRc?.push(json);
|
|
41
|
-
|
|
42
|
-
|
|
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({
|
|
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
|
-
|
|
66
|
-
|
|
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
|
-
|
|
110
|
-
process.on(
|
|
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.
|
|
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?.
|
|
71
|
-
return hit.
|
|
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
|
-
|
|
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;
|
package/dist/stations/discord.js
CHANGED
|
@@ -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
|
|
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
|
|
173
|
+
async handleMessage(m) {
|
|
174
174
|
if (m.author.bot)
|
|
175
175
|
return;
|
|
176
|
-
const
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
|
200
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
{"
|
|
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
|
-
{"
|
|
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
|
-
- `
|
|
49
|
-
- `
|
|
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
|
|
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
|
-
#
|
|
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.
|
|
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": {
|
package/skills/metro/SKILL.md
CHANGED
|
@@ -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 `{"
|
|
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
|
|
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
|
-
- `
|
|
47
|
-
- `
|
|
48
|
-
- `
|
|
49
|
-
- `
|
|
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
|
-
{"
|
|
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
|
-
{"
|
|
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
|
-
- `
|
|
69
|
-
- `
|
|
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
|
|
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
|
-
#
|
|
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 `{"
|
|
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
|
|