@stage-labs/metro 0.1.0-beta.2 → 0.1.0-beta.3

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.
@@ -1,274 +0,0 @@
1
- // JSON-RPC over WebSocket client for the Codex app-server. By default
2
- // connects over the Unix domain socket exposed by `codex remote-control`
3
- // (see paths.DEFAULT_CODEX_SOCKET). On every metro inbound it calls
4
- // `turn/start`, which lands the JSON line in the agent's history as a
5
- // user message and wakes the agent — the Codex equivalent of Claude
6
- // Code's `Monitor`.
7
- //
8
- // Wire format: standard JSON-RPC 2.0 over WebSocket. Methods we use:
9
- // - initialize → handshake, declares clientInfo.
10
- // - thread/list → discover existing threads on connect.
11
- // - thread/started → notification; track new threads as they appear.
12
- // - turn/started → notification; mark a turn in flight.
13
- // - turn/completed → notification; drain queued inbounds.
14
- // - turn/start → push a user message and wake the agent.
15
- //
16
- // If no thread exists yet, inbounds queue in memory; they fire as soon
17
- // as a thread is started or learned. If the connection drops, reconnect
18
- // with linear backoff and replay the queue. Connection failures don't
19
- // break metro — stdout emit always runs first, so Claude Code Monitor
20
- // users keep working regardless.
21
- import { createConnection } from 'node:net';
22
- import { WebSocket } from 'ws';
23
- import { errMsg, log } from '../log.js';
24
- const RECONNECT_DELAY_MS = 2_000;
25
- const MAX_QUEUE = 100;
26
- // Backstop: if `turn/completed` never arrives (the daemon doesn't broadcast
27
- // it to all clients, or we miss it for any reason), unstick after this long.
28
- // Generous enough that any normal turn finishes well within it.
29
- const TURN_TIMEOUT_MS = 120_000;
30
- /**
31
- * Accept any of these forms for METRO_CODEX_RC:
32
- * ws://host:port/ TCP WebSocket.
33
- * wss://host/ TCP WebSocket over TLS.
34
- * unix:///abs/path UDS WebSocket (the default for `codex remote-control`).
35
- * /abs/path shorthand for unix:///abs/path.
36
- */
37
- export function parseCodexUrl(input) {
38
- if (input.startsWith('ws://') || input.startsWith('wss://')) {
39
- return { kind: 'tcp', url: input };
40
- }
41
- if (input.startsWith('unix://')) {
42
- return { kind: 'unix', path: input.replace(/^unix:\/+/, '/') };
43
- }
44
- if (input.startsWith('/')) {
45
- return { kind: 'unix', path: input };
46
- }
47
- throw new Error(`unsupported METRO_CODEX_RC: ${input} (expected ws://…, wss://…, unix://…, or absolute path)`);
48
- }
49
- function openSocket(endpoint) {
50
- if (endpoint.kind === 'tcp')
51
- return new WebSocket(endpoint.url);
52
- // UDS WebSocket: ws library upgrades any duplex stream, so we feed it a
53
- // unix-socket Net connection via `createConnection`. The `ws://localhost/`
54
- // URL is a placeholder — only the framing matters at this point; the
55
- // bytes flow over the UDS we just opened.
56
- return new WebSocket('ws://localhost/', {
57
- createConnection: () => createConnection({ path: endpoint.path }),
58
- });
59
- }
60
- export class CodexRC {
61
- clientVersion;
62
- ws = null;
63
- nextId = 1;
64
- pending = new Map();
65
- threadId = null;
66
- queue = [];
67
- connected = false;
68
- connecting = false;
69
- turnInFlight = false;
70
- turnTimeout = null;
71
- closed = false;
72
- endpoint;
73
- displayUrl;
74
- constructor(url, clientVersion) {
75
- this.clientVersion = clientVersion;
76
- this.endpoint = parseCodexUrl(url);
77
- this.displayUrl = url;
78
- }
79
- start() {
80
- void this.connect();
81
- }
82
- stop() {
83
- this.closed = true;
84
- this.ws?.close();
85
- this.ws = null;
86
- }
87
- /**
88
- * Push an inbound JSON line to the Codex agent. If not yet connected or
89
- * no thread is active, queues until ready (FIFO, capped).
90
- */
91
- push(line) {
92
- if (this.queue.length >= MAX_QUEUE) {
93
- log.warn({ url: this.displayUrl }, 'codex-rc queue full, dropping oldest inbound');
94
- this.queue.shift();
95
- }
96
- this.queue.push(line);
97
- void this.drainQueue();
98
- }
99
- async connect() {
100
- if (this.closed || this.connected || this.connecting)
101
- return;
102
- this.connecting = true;
103
- try {
104
- const ws = openSocket(this.endpoint);
105
- this.ws = ws;
106
- // Register the persistent error handler BEFORE awaiting open — without
107
- // it, an error fired during the upgrade (UDS missing, daemon down,
108
- // bad URL) is "unhandled" and crashes the process under Bun. The
109
- // once('error') below covers the connect-time rejection; this on()
110
- // covers later errors and the rare double-emit.
111
- ws.on('error', err => log.warn({ err: errMsg(err) }, 'codex-rc websocket error'));
112
- await new Promise((resolve, reject) => {
113
- ws.once('open', resolve);
114
- ws.once('error', err => reject(err));
115
- });
116
- ws.on('message', data => this.onMessage(data));
117
- ws.on('close', () => this.onClose());
118
- await this.call('initialize', {
119
- clientInfo: { name: 'metro', version: this.clientVersion, title: null },
120
- });
121
- try {
122
- const list = await this.call('thread/list', {});
123
- const active = (list.data ?? []).find(t => t.status !== 'archived');
124
- if (active)
125
- this.threadId = active.id;
126
- }
127
- catch (err) {
128
- log.warn({ err: errMsg(err) }, 'codex-rc thread/list failed (non-fatal)');
129
- }
130
- this.connected = true;
131
- log.info({ url: this.displayUrl, thread: this.threadId ?? '(none yet)' }, 'codex-rc connected');
132
- void this.drainQueue();
133
- }
134
- catch (err) {
135
- log.warn({ err: errMsg(err), url: this.displayUrl }, 'codex-rc connect failed; retrying');
136
- this.scheduleReconnect();
137
- }
138
- finally {
139
- this.connecting = false;
140
- }
141
- }
142
- onMessage(raw) {
143
- let msg;
144
- try {
145
- msg = JSON.parse(raw.toString());
146
- }
147
- catch (err) {
148
- log.warn({ err: errMsg(err) }, 'codex-rc malformed message');
149
- return;
150
- }
151
- log.debug({ id: msg.id, method: msg.method, hasError: !!msg.error }, 'codex-rc ← message');
152
- if (msg.id !== undefined && this.pending.has(msg.id)) {
153
- const p = this.pending.get(msg.id);
154
- this.pending.delete(msg.id);
155
- if (msg.error)
156
- p.reject(new Error(msg.error.message ?? 'rpc error'));
157
- else
158
- p.resolve(msg.result);
159
- return;
160
- }
161
- if (msg.method === 'thread/started') {
162
- const params = msg.params;
163
- if (params?.thread?.id) {
164
- this.threadId = params.thread.id;
165
- log.info({ thread: this.threadId }, 'codex-rc thread started');
166
- void this.drainQueue();
167
- }
168
- }
169
- else if (msg.method === 'thread/status/changed') {
170
- // Codex 0.130 doesn't emit turn/started or turn/completed to a non-
171
- // owner connection (the TUI gets them; metro doesn't). Instead the
172
- // daemon broadcasts thread/status/changed transitions: status is
173
- // either a string (`"idle"` / `"notLoaded"` / `"systemError"`) or an
174
- // object (`{"active": {...}}`) per the v2 protocol enum. Treat
175
- // anything other than `active` as ready-for-next-turn.
176
- const params = msg.params;
177
- if (params?.threadId === this.threadId) {
178
- const isActive = typeof params.status === 'object' && params.status !== null && 'active' in params.status;
179
- log.debug({ thread: this.threadId, isActive, status: params.status }, 'codex-rc thread status changed');
180
- if (isActive) {
181
- this.turnInFlight = true;
182
- }
183
- else {
184
- this.clearTurnTimeout();
185
- this.turnInFlight = false;
186
- void this.drainQueue();
187
- }
188
- }
189
- }
190
- else if (msg.method === 'turn/completed') {
191
- // Backstop in case some codex version does emit turn lifecycle
192
- // notifications on the metro connection.
193
- log.debug({ thread: this.threadId, queue: this.queue.length }, 'codex-rc turn/completed; draining');
194
- this.clearTurnTimeout();
195
- this.turnInFlight = false;
196
- void this.drainQueue();
197
- }
198
- else if (msg.method === 'turn/started') {
199
- log.debug({ thread: this.threadId }, 'codex-rc turn/started');
200
- this.turnInFlight = true;
201
- }
202
- else if (msg.method === 'thread/closed' || msg.method === 'thread/archived') {
203
- log.warn({ method: msg.method, params: msg.params }, 'codex-rc thread closed/archived; clearing thread reference');
204
- this.threadId = null;
205
- }
206
- }
207
- onClose() {
208
- if (this.closed)
209
- return;
210
- this.connected = false;
211
- this.ws = null;
212
- for (const p of this.pending.values())
213
- p.reject(new Error('websocket closed'));
214
- this.pending.clear();
215
- this.scheduleReconnect();
216
- }
217
- scheduleReconnect() {
218
- if (this.closed)
219
- return;
220
- this.connected = false;
221
- setTimeout(() => void this.connect(), RECONNECT_DELAY_MS);
222
- }
223
- async drainQueue() {
224
- if (!this.connected || !this.threadId || this.turnInFlight)
225
- return;
226
- if (this.queue.length === 0)
227
- return;
228
- const line = this.queue[0];
229
- this.turnInFlight = true;
230
- this.armTurnTimeout();
231
- log.debug({ thread: this.threadId, queue: this.queue.length }, 'codex-rc → turn/start');
232
- try {
233
- await this.call('turn/start', {
234
- threadId: this.threadId,
235
- input: [{ type: 'text', text: line, textElements: [] }],
236
- });
237
- this.queue.shift();
238
- }
239
- catch (err) {
240
- log.warn({ err: errMsg(err) }, 'codex-rc turn/start failed; will retry');
241
- this.clearTurnTimeout();
242
- this.turnInFlight = false;
243
- setTimeout(() => void this.drainQueue(), 1_000);
244
- }
245
- }
246
- // If `turn/completed` never arrives (the daemon doesn't broadcast it to
247
- // metro's connection, or we miss it for any reason), unstick after the
248
- // backstop so subsequent inbounds don't queue forever.
249
- armTurnTimeout() {
250
- this.clearTurnTimeout();
251
- this.turnTimeout = setTimeout(() => {
252
- log.warn({ thread: this.threadId, queue: this.queue.length }, `codex-rc turn/completed not received within ${TURN_TIMEOUT_MS}ms; force-clearing single-flight gate`);
253
- this.turnInFlight = false;
254
- this.turnTimeout = null;
255
- void this.drainQueue();
256
- }, TURN_TIMEOUT_MS);
257
- }
258
- clearTurnTimeout() {
259
- if (this.turnTimeout) {
260
- clearTimeout(this.turnTimeout);
261
- this.turnTimeout = null;
262
- }
263
- }
264
- call(method, params) {
265
- if (!this.ws)
266
- return Promise.reject(new Error('not connected'));
267
- const id = this.nextId++;
268
- const ws = this.ws;
269
- return new Promise((resolve, reject) => {
270
- this.pending.set(id, { resolve: resolve, reject });
271
- ws.send(JSON.stringify({ jsonrpc: '2.0', id, method, params }));
272
- });
273
- }
274
- }
package/dist/tail.js DELETED
@@ -1,161 +0,0 @@
1
- // Standalone inbound stream. Polls Telegram + connects to Discord, prints
2
- // one JSON line per inbound message on stdout. Designed to be launched by
3
- // an agent and observed via Bash+Monitor (Claude Code) or unified_exec
4
- // polling (Codex).
5
- //
6
- // On every inbound: fires a 👀 reaction and starts a typing indicator that
7
- // refreshes until the agent replies (signaled by `metro reply` touching
8
- // .typing-stop/<key>) or the 60s safety cap is hit.
9
- import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
10
- import { join } from 'node:path';
11
- import pkg from '../package.json' with { type: 'json' };
12
- import * as discord from './channels/discord.js';
13
- import * as telegram from './channels/telegram.js';
14
- import { tg } from './channels/telegram.js';
15
- import { CodexRC } from './lib/codex-rc.js';
16
- import { configuredPlatforms, loadMetroEnv, STATE_DIR, requireConfiguredPlatform } from './paths.js';
17
- import { errMsg, log } from './log.js';
18
- loadMetroEnv();
19
- const platforms = configuredPlatforms();
20
- requireConfiguredPlatform(platforms);
21
- // Telegram allows only one getUpdates poller per bot token. If another
22
- // `metro` instance is already running, exit cleanly instead of fighting
23
- // (409 spam). Stale lockfiles (PID dead) are reclaimed.
24
- const LOCK_FILE = join(STATE_DIR, '.tail-lock');
25
- function processIsAlive(pid) {
26
- try {
27
- process.kill(pid, 0);
28
- return true;
29
- }
30
- catch {
31
- return false;
32
- }
33
- }
34
- if (existsSync(LOCK_FILE)) {
35
- const pid = Number(readFileSync(LOCK_FILE, 'utf8').trim());
36
- if (Number.isInteger(pid) && pid > 0 && processIsAlive(pid)) {
37
- log.info({ pid }, 'another `metro` instance is already polling; exiting');
38
- process.exit(0);
39
- }
40
- try {
41
- unlinkSync(LOCK_FILE);
42
- }
43
- catch { }
44
- }
45
- writeFileSync(LOCK_FILE, String(process.pid));
46
- function releaseLock() {
47
- try {
48
- if (existsSync(LOCK_FILE) && readFileSync(LOCK_FILE, 'utf8').trim() === String(process.pid)) {
49
- unlinkSync(LOCK_FILE);
50
- }
51
- }
52
- catch { }
53
- }
54
- process.on('exit', releaseLock);
55
- const TYPING_DIR = join(STATE_DIR, '.typing-stop');
56
- const TYPING_REFRESH_MS = 4_000;
57
- const TYPING_MAX_MS = 60_000;
58
- mkdirSync(TYPING_DIR, { recursive: true });
59
- // Codex push channel. Set METRO_CODEX_RC to the codex app-server URL
60
- // (typically `ws://127.0.0.1:8421` matching `codex app-server --listen
61
- // ws://127.0.0.1:8421`) to inject each inbound into the agent's history
62
- // via JSON-RPC `turn/start`. Codex's TUI `--remote` flag only accepts
63
- // ws://, so the daemon, the TUI, and metro must all share the same URL.
64
- // Unset → metro behaves exactly as before; stdout emit always runs first
65
- // so Claude Code Monitor users are unaffected.
66
- const codexRC = process.env.METRO_CODEX_RC ? new CodexRC(process.env.METRO_CODEX_RC, pkg.version) : null;
67
- codexRC?.start();
68
- const emit = (line) => {
69
- const json = JSON.stringify(line);
70
- process.stdout.write(`${json}\n`);
71
- codexRC?.push(json);
72
- };
73
- const typingActive = new Map();
74
- const typingKey = (platform, chat) => `${platform}_${chat}`;
75
- function fireTyping(platform, chat) {
76
- if (platform === 'telegram') {
77
- void tg('sendChatAction', { chat_id: chat, action: 'typing' }).catch(err => log.warn({ err: errMsg(err) }, 'telegram typing failed'));
78
- }
79
- else {
80
- void discord.sendTyping(chat).catch(err => log.warn({ err: errMsg(err) }, 'discord typing failed'));
81
- }
82
- }
83
- function startTyping(platform, chat) {
84
- const k = typingKey(platform, chat);
85
- typingActive.set(k, { platform, chat, started: Date.now() });
86
- // Clear any stale stop signal so the new typing actually fires.
87
- const stopFile = join(TYPING_DIR, k);
88
- if (existsSync(stopFile)) {
89
- try {
90
- unlinkSync(stopFile);
91
- }
92
- catch { }
93
- }
94
- fireTyping(platform, chat);
95
- }
96
- setInterval(() => {
97
- const now = Date.now();
98
- for (const [k, e] of typingActive) {
99
- const stopFile = join(TYPING_DIR, k);
100
- if (existsSync(stopFile)) {
101
- try {
102
- unlinkSync(stopFile);
103
- }
104
- catch { }
105
- typingActive.delete(k);
106
- continue;
107
- }
108
- if (now - e.started > TYPING_MAX_MS) {
109
- typingActive.delete(k);
110
- continue;
111
- }
112
- fireTyping(e.platform, e.chat);
113
- }
114
- }, TYPING_REFRESH_MS);
115
- if (platforms.telegram) {
116
- const me = await telegram.getMe();
117
- log.info({ bot: `@${me.username}` }, 'telegram ready');
118
- telegram.onInbound(m => {
119
- void tg('setMessageReaction', {
120
- chat_id: m.chat_id,
121
- message_id: m.message_id,
122
- reaction: [{ type: 'emoji', emoji: '👀' }],
123
- }).catch(err => log.warn({ err: errMsg(err) }, 'telegram auto-react failed'));
124
- const chat = String(m.chat_id);
125
- startTyping('telegram', chat);
126
- emit({ platform: 'telegram', to: `telegram:${chat}/${m.message_id}`, text: m.text });
127
- });
128
- void telegram.startPolling();
129
- }
130
- if (platforms.discord) {
131
- await discord.startGateway();
132
- const me = await discord.getMe();
133
- log.info({ bot: me.username }, 'discord ready');
134
- discord.onInbound(m => {
135
- void discord
136
- .setReaction(m.channel_id, m.message_id, '👀')
137
- .catch(err => log.warn({ err: errMsg(err) }, 'discord auto-react failed'));
138
- startTyping('discord', m.channel_id);
139
- emit({ platform: 'discord', to: `discord:${m.channel_id}/${m.message_id}`, text: m.text });
140
- });
141
- }
142
- // `process.on('exit', releaseLock)` above runs whenever process.exit is
143
- // called. We also `await discord.shutdownGateway()` here so the bot flips
144
- // offline immediately on SIGTERM / SIGINT instead of waiting ~45s for the
145
- // gateway's missed-heartbeat timeout. SIGKILL bypasses this (nothing we can
146
- // do); the lockfile auto-reclaims on the next start either way.
147
- let shuttingDown = false;
148
- async function shutdown() {
149
- if (shuttingDown)
150
- return;
151
- shuttingDown = true;
152
- codexRC?.stop();
153
- if (platforms.discord) {
154
- await discord.shutdownGateway().catch(err => log.warn({ err: errMsg(err) }, 'discord shutdown failed'));
155
- }
156
- process.exit(0);
157
- }
158
- process.stdin.on('end', shutdown);
159
- process.stdin.on('close', shutdown);
160
- process.on('SIGINT', shutdown);
161
- process.on('SIGTERM', shutdown);
@@ -1,122 +0,0 @@
1
- ---
2
- name: metro
3
- description: Handle Telegram/Discord messages from `metro` for this agent session. Use when the user asks to start/run/launch metro, when you see JSON lines on stdout or as user input shaped `{"platform":..., "to":..., "text":...}`, or when handling chat reply/react/edit/send/download/fetch.
4
- ---
5
-
6
- # Metro — handling the Telegram & Discord bridge
7
-
8
- Metro is a CLI bridge between this agent session and Telegram/Discord. Each inbound message arrives as a JSON line; you act on it via `metro <subcommand>`. The launch mechanics differ between Claude Code (you launch metro via shell) and Codex (the user launches metro outside the agent and the daemon pushes turns to you).
9
-
10
- ## Starting the bridge
11
-
12
- When the user asks to run/start/launch metro, you launch it as a backgrounded shell command. The exact invocation depends on the runtime:
13
-
14
- ### Claude Code
15
-
16
- ```
17
- Bash(command: "metro", run_in_background: true)
18
- ```
19
-
20
- Then attach `Monitor` to its stdout. Each stdout line is one inbound JSON event you act on directly.
21
-
22
- ### Codex
23
-
24
- ```
25
- shell(command: "METRO_CODEX_RC=ws://127.0.0.1:8421 metro", run_in_background: true)
26
- ```
27
-
28
- Don't watch its stdout — Codex has no Monitor equivalent. Instead, metro pushes each inbound into your thread via JSON-RPC `turn/start`, so events arrive as user input on your next turn. The user must have a daemon and the TUI running for this to work — refer them to:
29
-
30
- ```
31
- codex app-server --listen ws://127.0.0.1:8421 # daemon (terminal 1)
32
- codex --remote ws://127.0.0.1:8421 # TUI (this session — terminal 2)
33
- ```
34
-
35
- If `metro` exits immediately or the daemon isn't on 8421, ask the user. (`codex remote-control` is stdio-only and doesn't work for this flow.)
36
-
37
- ### Diagnostics
38
-
39
- If something seems off, run `metro doctor`. Common causes: missing tokens (`metro setup telegram <token>` / `metro setup discord <token>`), Discord Message Content Intent not toggled, stale lockfile. On Codex, also: app-server not listening on the expected URL, or the TUI not attached via `--remote`.
40
-
41
- ## Inbound shape
42
-
43
- Each `metro` line on stdout:
44
-
45
- ```json
46
- {"platform":"telegram"|"discord","to":"<platform>:<chat>/<message_id>","text":"…"}
47
- ```
48
-
49
- `text` may include placeholders for non-text content: `[image]`, `[voice]`, `[audio]`, `[file: <name>]`. Voice/audio are opaque markers — you can't download them.
50
-
51
- Discord guild messages preserve the user's raw mention markup, including the bot's own `<@bot_id>` (the gate that made the message visible). Treat the bot's own mention as metadata; other users' mentions (`<@some_other_id>`) can be addressee context. Reply normally — the message is addressed to you regardless of where the mention sits.
52
-
53
- ## Required flow on every inbound
54
-
55
- 1. **Echo to the visible reply.** Write `[<to>] <text>` on its own line in your visible output. Both Claude Code's Monitor and Codex dim/collapse tool output, so this echo is the only way the user sees what arrived without expanding cards.
56
- 2. **Decide and act.** Pick the matching subcommand below.
57
-
58
- > 👀 is already on the message — `metro` auto-reacts server-side on every inbound and clears the reaction when you reply. Don't call `metro react --emoji=👀` yourself; you'd just flicker it on/off and waste a tool call.
59
-
60
- ## Subcommands
61
-
62
- `reply` / `react` / `edit` / `download` take `--to=<platform>:<chat>/<message_id>` copied verbatim from the inbound `to` field. `send` and `fetch` take a channel-only `--to=<platform>:<chat>` (no message id). Append `--json` to any of them for a single JSON result line you can parse.
63
-
64
- | Action | Command |
65
- |---|---|
66
- | Quote-reply (threads under original; clears 👀) | `metro reply --to=<to> --text=<reply>` |
67
- | Quick ack reaction | `metro react --to=<to> --emoji=👍` |
68
- | Edit your previous bot message | `metro edit --to=<to> --text=<new text>` |
69
- | Send a proactive message (no reply context) | `metro send --to=<platform>:<chat_id> --text=<msg>` |
70
- | Download `[image]` attachments → file paths | `metro download --to=<to>` |
71
- | Fetch recent channel history (Discord only) | `metro fetch --to=discord:<channel_id> --limit=20` |
72
-
73
- `reply` / `edit` / `send` accept multi-line `--text` via stdin (heredoc).
74
-
75
- ## When to use `send` vs `reply`
76
-
77
- - **`reply`** — responding to a specific inbound message. Threads under it. This is the default when handling a `metro` inbound line.
78
- - **`send`** — initiating without a triggering message: a long task you kicked off finished, a scheduled job fired, a follow-up the user asked you to deliver later. The chat/channel id you target must be one the bot can reach (existing DM, joined guild channel).
79
-
80
- ## Address format
81
-
82
- - `telegram:<chat_id>/<message_id>` — copied straight from inbound `to`
83
- - `discord:<channel_id>/<message_id>` — same
84
- - `discord:<channel_id>` — channel-only, used for `metro fetch`
85
-
86
- ## Image attachments
87
-
88
- When `text` contains `[image]`:
89
-
90
- 1. Run `metro download --to=<to>` — writes images to disk and prints absolute paths (one per line).
91
- 2. `Read` each path with the Read tool — the image enters your context as a vision input.
92
- 3. Reply normally with `metro reply`.
93
-
94
- ## Opaque attachment markers
95
-
96
- `[voice]`, `[audio: <name>]`, and `[file: <name>]` are opaque — `metro download` only handles images. Acknowledge in text (e.g., "got your voice note — could you type it out?") or, if your runtime accepts audio/file input directly, ask the user to resend as a regular file.
97
-
98
- ## Exit codes
99
-
100
- - `0` success
101
- - `1` usage error (bad flags, unknown subcommand)
102
- - `2` configuration error (no tokens; tell the user to run `metro setup`)
103
- - `3` upstream error (rate limit, auth, network) — wait a few seconds and retry once before surfacing to the user
104
-
105
- If anything's misbehaving, run `metro doctor` to see which check fails.
106
-
107
- ## --json output
108
-
109
- Every action command supports `--json` for stable, parseable output:
110
-
111
- ```bash
112
- metro reply --to=… --text=… --json
113
- # {"ok":true,"platform":"discord","to":"discord:123/456","sent_message_id":"…"}
114
-
115
- metro fetch --to=discord:1234 --limit=10 --json
116
- # [{"message_id":"…","author":"…","text":"…","timestamp":"…"}, …]
117
-
118
- metro download --to=… --json
119
- # {"images":[{"path":"/abs/…png","mime":"image/png"}]}
120
- ```
121
-
122
- Use `--json` when you need to chain calls or capture the new message_id for a later edit.