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

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
@@ -9,8 +9,8 @@ In your shell:
9
9
  ```bash
10
10
  npm install -g @stage-labs/metro@beta # or: bun add -g @stage-labs/metro@beta
11
11
 
12
- metro setup telegram <token> # https://t.me/BotFather
13
- metro setup discord <token> # https://discord.com/developers/applications
12
+ metro setup telegram <token> # https://t.me/BotFather
13
+ metro setup discord <token> # https://discord.com/developers/applications
14
14
 
15
15
  metro setup skill # writes SKILL.md so Claude Code + Codex auto-onboard
16
16
  metro doctor # verify
@@ -18,11 +18,33 @@ metro doctor # verify
18
18
 
19
19
  > **Discord setup:** toggle **Message Content Intent** in Developer Portal → Bot → Privileged Gateway Intents.
20
20
 
21
- Open Claude Code (`claude`) or Codex (`codex`), and tell it:
21
+ ### Run with Claude Code
22
22
 
23
- > Run `metro` in the background.
23
+ ```bash
24
+ claude
25
+ > Run metro in the background.
26
+ ```
27
+
28
+ Then DM your bot. The bundled skill auto-triggers — the agent launches metro via Bash + Monitor, watches stdout, and replies.
29
+
30
+ ### Run with Codex
31
+
32
+ Codex's `unified_exec` is poll-only ([#4751](https://github.com/openai/codex/issues/4751)) — there's no Monitor equivalent. Metro instead pushes each inbound into the agent's history via JSON-RPC. Two terminals plus a prompt — the TUI's `--remote` flag only accepts `ws://`, so daemon and TUI share one URL:
33
+
34
+ ```bash
35
+ # Terminal 1 — daemon (must be running first)
36
+ codex app-server --listen ws://127.0.0.1:8421
37
+
38
+ # Terminal 2 — TUI attached to the daemon
39
+ codex --remote ws://127.0.0.1:8421
40
+ > Run metro in the background.
41
+ ```
42
+
43
+ The agent launches `metro` (with `METRO_CODEX_RC=ws://127.0.0.1:8421` set) via its shell tool. Metro connects to the daemon and pushes each inbound as a `turn/start` on the active thread — the agent in terminal 2 reacts on its next turn. `codex remote-control` is stdio-only (no listener), so don't use it for this flow.
44
+
45
+ Bare `codex` (no `--remote`) can't work with metro — the agent has no daemon to push to. The TUI must be attached to a running app-server.
24
46
 
25
- DM your bot. The agent picks up the next inbound and replies — the bundled skill handles launching, stdout watching, reactions, and replies.
47
+ `METRO_CODEX_RC` accepts `ws://host:port` (required for use with the codex TUI) or `unix:///abs/path` (headless only — the daemon supports UDS but the TUI doesn't).
26
48
 
27
49
  ## Config
28
50
 
@@ -32,6 +54,7 @@ DM your bot. The agent picks up the next inbound and replies — the bundled ski
32
54
  | `METRO_CONFIG_DIR` | `~/.config/metro` | Where the global `.env` lives. |
33
55
  | `METRO_STATE_DIR` | `~/.cache/metro` | Lockfile, attachment cache, default download dir. |
34
56
  | `METRO_LOG_LEVEL` | `info` | `trace` / `debug` / `info` / `warn` / `error` / `fatal`. |
57
+ | `METRO_CODEX_RC` | — | Codex app-server URL (e.g. `ws://127.0.0.1:8421`). When set, metro pushes each inbound into the agent's history via JSON-RPC `turn/start` — the Codex equivalent of Claude Code's Monitor. Accepts `ws://host:port` (required for use with the codex TUI) or `unix:///abs/path` (headless only). See [Codex setup](#codex-setup). |
35
58
 
36
59
  Token precedence: process env → `./.env` → `$METRO_CONFIG_DIR/.env`. Logs to stderr.
37
60
 
package/dist/cli.js CHANGED
@@ -30,7 +30,7 @@ Usage:
30
30
  setup verbs:
31
31
  metro setup Status: tokens, skills, what's next.
32
32
  metro setup telegram <token> Save TELEGRAM_BOT_TOKEN (validated via getMe; --no-validate skips).
33
- metro setup discord <token> Save DISCORD_BOT_TOKEN (validated via getMe; --no-validate skips).
33
+ metro setup discord <token> Save DISCORD_BOT_TOKEN (validated via getMe; --no-validate skips).
34
34
  metro setup clear [telegram|discord|all] Remove tokens.
35
35
  metro setup skill [--project] [--clear] Install (or remove) the agent skill.
36
36
 
@@ -52,6 +52,18 @@ Exit codes:
52
52
  1 usage error (bad flags, unknown subcommand)
53
53
  2 configuration error (no tokens — run \`metro setup\`)
54
54
  3 upstream error (rate limit, auth, network — retry once, then surface)
55
+
56
+ Codex push (opt-in):
57
+ Set METRO_CODEX_RC to the codex app-server URL — metro will push each
58
+ inbound into the agent's history via JSON-RPC \`turn/start\`, the Codex
59
+ equivalent of Claude Code's Monitor.
60
+
61
+ Three terminals share the same URL (codex 0.130's TUI --remote only
62
+ accepts ws://):
63
+
64
+ codex app-server --listen ws://127.0.0.1:8421 # daemon
65
+ METRO_CODEX_RC=ws://127.0.0.1:8421 metro # bridge
66
+ codex --remote ws://127.0.0.1:8421 # TUI
55
67
  `;
56
68
  function exitErr(message, code) {
57
69
  return Object.assign(new Error(message), { code });
@@ -255,8 +267,8 @@ async function cmdSetupStatus(flags) {
255
267
  ` codex ${fmtSkill(skills.codex)}\n\n`);
256
268
  if (!tg && !dc) {
257
269
  process.stdout.write('Get started:\n' +
258
- ' 1. metro setup telegram <token> # https://t.me/BotFather\n' +
259
- ' metro setup discord <token> # https://discord.com/developers/applications\n' +
270
+ ' 1. metro setup telegram <token> # https://t.me/BotFather\n' +
271
+ ' metro setup discord <token> # https://discord.com/developers/applications\n' +
260
272
  ' 2. metro setup skill # auto-onboard your agent (writes to both runtimes)\n' +
261
273
  ' 3. metro doctor # verify everything works\n' +
262
274
  ' 4. metro # start the inbound stream\n');
@@ -0,0 +1,274 @@
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/log.js CHANGED
@@ -3,4 +3,10 @@
3
3
  // parsing. Override level with METRO_LOG_LEVEL.
4
4
  import pino from 'pino';
5
5
  export const log = pino({ name: 'metro', level: process.env.METRO_LOG_LEVEL || 'info' }, pino.destination(2));
6
- export const errMsg = (err) => (err instanceof Error ? err.message : String(err));
6
+ export const errMsg = (err) => {
7
+ if (err instanceof Error)
8
+ return err.message;
9
+ if (err && typeof err === 'object' && 'message' in err)
10
+ return String(err.message);
11
+ return String(err);
12
+ };
package/dist/paths.js CHANGED
@@ -11,6 +11,14 @@ mkdirSync(STATE_DIR, { recursive: true });
11
11
  const CONFIG_DIR = process.env.METRO_CONFIG_DIR ??
12
12
  join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'metro');
13
13
  export const CONFIG_ENV_FILE = join(CONFIG_DIR, '.env');
14
+ // Default codex app-server WebSocket. The TUI's `--remote <ADDR>` flag
15
+ // only accepts ws:// (no UDS), so this is the canonical URL we recommend
16
+ // users align on across the daemon, the TUI, and metro:
17
+ // codex app-server --listen ws://127.0.0.1:8421
18
+ // codex --remote ws://127.0.0.1:8421
19
+ // METRO_CODEX_RC=ws://127.0.0.1:8421 metro
20
+ // Override via METRO_CODEX_RC if a different port/host is needed.
21
+ export const DEFAULT_CODEX_RC_URL = 'ws://127.0.0.1:8421';
14
22
  export function skillDir(runtime, scope) {
15
23
  const base = scope === 'user' ? homedir() : process.cwd();
16
24
  const root = runtime === 'claude-code' ? '.claude' : '.agents';
package/dist/tail.js CHANGED
@@ -8,9 +8,11 @@
8
8
  // .typing-stop/<key>) or the 60s safety cap is hit.
9
9
  import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
10
10
  import { join } from 'node:path';
11
+ import pkg from '../package.json' with { type: 'json' };
11
12
  import * as discord from './channels/discord.js';
12
13
  import * as telegram from './channels/telegram.js';
13
14
  import { tg } from './channels/telegram.js';
15
+ import { CodexRC } from './lib/codex-rc.js';
14
16
  import { configuredPlatforms, loadMetroEnv, STATE_DIR, requireConfiguredPlatform } from './paths.js';
15
17
  import { errMsg, log } from './log.js';
16
18
  loadMetroEnv();
@@ -54,7 +56,20 @@ const TYPING_DIR = join(STATE_DIR, '.typing-stop');
54
56
  const TYPING_REFRESH_MS = 4_000;
55
57
  const TYPING_MAX_MS = 60_000;
56
58
  mkdirSync(TYPING_DIR, { recursive: true });
57
- const emit = (line) => process.stdout.write(`${JSON.stringify(line)}\n`);
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
+ };
58
73
  const typingActive = new Map();
59
74
  const typingKey = (platform, chat) => `${platform}_${chat}`;
60
75
  function fireTyping(platform, chat) {
@@ -134,6 +149,7 @@ async function shutdown() {
134
149
  if (shuttingDown)
135
150
  return;
136
151
  shuttingDown = true;
152
+ codexRC?.stop();
137
153
  if (platforms.discord) {
138
154
  await discord.shutdownGateway().catch(err => log.warn({ err: errMsg(err) }, 'discord shutdown failed'));
139
155
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.1",
3
+ "version": "0.1.0-beta.2",
4
4
  "description": "Chat with your Claude Code or Codex agent over Telegram and Discord. Ultra-lightweight: ~1.2K lines of TypeScript, pure CLI, no hosted infra.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -34,10 +34,12 @@
34
34
  },
35
35
  "dependencies": {
36
36
  "discord.js": "^14.14.0",
37
- "pino": "^9.5.0"
37
+ "pino": "^9.5.0",
38
+ "ws": "^8.20.0"
38
39
  },
39
40
  "devDependencies": {
40
41
  "@types/node": "^22.10.0",
42
+ "@types/ws": "^8.18.1",
41
43
  "eslint": "^10.3.0",
42
44
  "typescript": "^5",
43
45
  "typescript-eslint": "^8.59.2"
@@ -1,19 +1,42 @@
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 lines, and act on each. Use when the user asks to start/run/launch metro, when you see JSON lines on stdout shaped `{"platform":..., "to":..., "text":...}`, or when handling a chat reply/react/edit/send/download/fetch.
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
4
  ---
5
5
 
6
- # Metro — running the Telegram & Discord bridge
6
+ # Metro — handling the Telegram & Discord bridge
7
7
 
8
- Metro is a CLI bridge between this agent session and Telegram/Discord. You launch `metro` once when the user asks, then act on each inbound JSON line via `metro <subcommand>`.
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
9
 
10
10
  ## Starting the bridge
11
11
 
12
- When the user asks to run/start/launch metro (or "start the bridge"):
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
13
 
14
- 1. Launch `metro` as a backgrounded Bash command (Claude Code: `run_in_background: true`; Codex: equivalent background spawn). Don't block on it.
15
- 2. Attach a stdout watcher: `Monitor` on Claude Code, `unified_exec` polling on Codex. Each stdout line is one inbound JSON. Stderr is logs — don't act on it.
16
- 3. If `metro` exits immediately or no inbounds arrive within a minute of a known DM, run `metro doctor` to diagnose. Common causes: missing tokens (tell the user to run `metro setup telegram <token>` and/or `metro setup discord <token>` in their shell), Discord Message Content Intent not toggled, or a stale lockfile from a previous session.
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`.
17
40
 
18
41
  ## Inbound shape
19
42