@stage-labs/metro 0.1.0-beta.0 → 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 +42 -57
- package/dist/channels/discord.js +70 -39
- package/dist/channels/telegram.js +7 -3
- package/dist/cli.js +569 -9
- package/dist/lib/address.js +21 -0
- package/dist/lib/codex-rc.js +274 -0
- package/dist/lib/dotenv.js +31 -0
- package/dist/log.js +10 -3
- package/dist/paths.js +45 -0
- package/dist/tail.js +45 -15
- package/package.json +5 -4
- package/skills/metro/SKILL.md +122 -0
- package/dist/config.js +0 -33
- package/dist/server.js +0 -158
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Tiny .env reader/writer. Used by `metro setup` (read/write the global
|
|
2
|
+
// config file) and by paths.ts (load env vars into process.env at startup).
|
|
3
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { dirname } from 'node:path';
|
|
5
|
+
const LINE_RE = /^\s*([A-Za-z_]\w*)\s*=\s*(.*?)\s*$/;
|
|
6
|
+
const QUOTED_RE = /^(['"])(.*)\1$/;
|
|
7
|
+
export function readDotenv(path) {
|
|
8
|
+
if (!existsSync(path))
|
|
9
|
+
return {};
|
|
10
|
+
const out = {};
|
|
11
|
+
for (const line of readFileSync(path, 'utf8').split('\n')) {
|
|
12
|
+
const m = line.match(LINE_RE);
|
|
13
|
+
if (m)
|
|
14
|
+
out[m[1]] = m[2].replace(QUOTED_RE, '$2');
|
|
15
|
+
}
|
|
16
|
+
return out;
|
|
17
|
+
}
|
|
18
|
+
export function writeDotenv(path, env) {
|
|
19
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
20
|
+
writeFileSync(path, Object.entries(env).map(([k, v]) => `${k}=${v}`).join('\n') + '\n');
|
|
21
|
+
chmodSync(path, 0o600);
|
|
22
|
+
}
|
|
23
|
+
// Load .env at `path` into process.env, but only for keys that aren't already
|
|
24
|
+
// set — first definer wins, so callers control precedence by the order they
|
|
25
|
+
// invoke this.
|
|
26
|
+
export function loadDotenvIntoProcess(path) {
|
|
27
|
+
for (const [k, v] of Object.entries(readDotenv(path))) {
|
|
28
|
+
if (process.env[k] === undefined)
|
|
29
|
+
process.env[k] = v;
|
|
30
|
+
}
|
|
31
|
+
}
|
package/dist/log.js
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
|
-
// Pino → stderr. Stdout is reserved for
|
|
2
|
-
//
|
|
1
|
+
// Pino → stderr. Stdout is reserved for command output (`metro`'s inbound
|
|
2
|
+
// JSON lines, subcommand results, --json) — any stray write there breaks
|
|
3
|
+
// parsing. Override level with METRO_LOG_LEVEL.
|
|
3
4
|
import pino from 'pino';
|
|
4
5
|
export const log = pino({ name: 'metro', level: process.env.METRO_LOG_LEVEL || 'info' }, pino.destination(2));
|
|
5
|
-
export const errMsg = (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
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { mkdirSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { loadDotenvIntoProcess } from './lib/dotenv.js';
|
|
5
|
+
import { log } from './log.js';
|
|
6
|
+
// Lockfile, typing-stop signals, attachment cache. Override with METRO_STATE_DIR.
|
|
7
|
+
export const STATE_DIR = process.env.METRO_STATE_DIR ?? join(homedir(), '.cache', 'metro');
|
|
8
|
+
mkdirSync(STATE_DIR, { recursive: true });
|
|
9
|
+
// Where `metro setup` writes the global .env. Override with METRO_CONFIG_DIR
|
|
10
|
+
// or the standard $XDG_CONFIG_HOME.
|
|
11
|
+
const CONFIG_DIR = process.env.METRO_CONFIG_DIR ??
|
|
12
|
+
join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'metro');
|
|
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';
|
|
22
|
+
export function skillDir(runtime, scope) {
|
|
23
|
+
const base = scope === 'user' ? homedir() : process.cwd();
|
|
24
|
+
const root = runtime === 'claude-code' ? '.claude' : '.agents';
|
|
25
|
+
return join(base, root, 'skills', 'metro');
|
|
26
|
+
}
|
|
27
|
+
// Precedence: process.env (already set) > cwd/.env > <CONFIG_DIR>/.env.
|
|
28
|
+
// loadDotenvIntoProcess only fills vars that aren't already populated, so the
|
|
29
|
+
// first call that defines a key wins.
|
|
30
|
+
export function loadMetroEnv() {
|
|
31
|
+
loadDotenvIntoProcess(join(process.cwd(), '.env'));
|
|
32
|
+
loadDotenvIntoProcess(CONFIG_ENV_FILE);
|
|
33
|
+
}
|
|
34
|
+
export function configuredPlatforms() {
|
|
35
|
+
return {
|
|
36
|
+
telegram: !!process.env.TELEGRAM_BOT_TOKEN,
|
|
37
|
+
discord: !!process.env.DISCORD_BOT_TOKEN,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
export function requireConfiguredPlatform(p) {
|
|
41
|
+
if (p.telegram || p.discord)
|
|
42
|
+
return;
|
|
43
|
+
log.fatal('no platforms configured — run `metro setup telegram <token>` or `metro setup discord <token>`');
|
|
44
|
+
process.exit(2);
|
|
45
|
+
}
|
package/dist/tail.js
CHANGED
|
@@ -4,21 +4,23 @@
|
|
|
4
4
|
// polling (Codex).
|
|
5
5
|
//
|
|
6
6
|
// On every inbound: fires a 👀 reaction and starts a typing indicator that
|
|
7
|
-
// refreshes until the agent replies (signaled by
|
|
7
|
+
// refreshes until the agent replies (signaled by `metro reply` touching
|
|
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';
|
|
14
|
-
import {
|
|
15
|
+
import { CodexRC } from './lib/codex-rc.js';
|
|
16
|
+
import { configuredPlatforms, loadMetroEnv, STATE_DIR, requireConfiguredPlatform } from './paths.js';
|
|
15
17
|
import { errMsg, log } from './log.js';
|
|
16
18
|
loadMetroEnv();
|
|
17
19
|
const platforms = configuredPlatforms();
|
|
18
20
|
requireConfiguredPlatform(platforms);
|
|
19
21
|
// Telegram allows only one getUpdates poller per bot token. If another
|
|
20
|
-
//
|
|
21
|
-
// Stale lockfiles (PID dead) are reclaimed.
|
|
22
|
+
// `metro` instance is already running, exit cleanly instead of fighting
|
|
23
|
+
// (409 spam). Stale lockfiles (PID dead) are reclaimed.
|
|
22
24
|
const LOCK_FILE = join(STATE_DIR, '.tail-lock');
|
|
23
25
|
function processIsAlive(pid) {
|
|
24
26
|
try {
|
|
@@ -32,7 +34,7 @@ function processIsAlive(pid) {
|
|
|
32
34
|
if (existsSync(LOCK_FILE)) {
|
|
33
35
|
const pid = Number(readFileSync(LOCK_FILE, 'utf8').trim());
|
|
34
36
|
if (Number.isInteger(pid) && pid > 0 && processIsAlive(pid)) {
|
|
35
|
-
log.info({ pid }, 'another
|
|
37
|
+
log.info({ pid }, 'another `metro` instance is already polling; exiting');
|
|
36
38
|
process.exit(0);
|
|
37
39
|
}
|
|
38
40
|
try {
|
|
@@ -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
|
-
|
|
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) {
|
|
@@ -106,8 +121,9 @@ if (platforms.telegram) {
|
|
|
106
121
|
message_id: m.message_id,
|
|
107
122
|
reaction: [{ type: 'emoji', emoji: '👀' }],
|
|
108
123
|
}).catch(err => log.warn({ err: errMsg(err) }, 'telegram auto-react failed'));
|
|
109
|
-
|
|
110
|
-
|
|
124
|
+
const chat = String(m.chat_id);
|
|
125
|
+
startTyping('telegram', chat);
|
|
126
|
+
emit({ platform: 'telegram', to: `telegram:${chat}/${m.message_id}`, text: m.text });
|
|
111
127
|
});
|
|
112
128
|
void telegram.startPolling();
|
|
113
129
|
}
|
|
@@ -120,12 +136,26 @@ if (platforms.discord) {
|
|
|
120
136
|
.setReaction(m.channel_id, m.message_id, '👀')
|
|
121
137
|
.catch(err => log.warn({ err: errMsg(err) }, 'discord auto-react failed'));
|
|
122
138
|
startTyping('discord', m.channel_id);
|
|
123
|
-
emit({ platform: 'discord',
|
|
139
|
+
emit({ platform: 'discord', to: `discord:${m.channel_id}/${m.message_id}`, text: m.text });
|
|
124
140
|
});
|
|
125
141
|
}
|
|
126
|
-
// process.on('exit', releaseLock) above runs whenever process.exit is
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stage-labs/metro",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
4
|
-
"description": "Chat with your Claude Code or Codex agent over Telegram and Discord. Ultra-lightweight: ~
|
|
3
|
+
"version": "0.1.0-beta.2",
|
|
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": {
|
|
7
7
|
"type": "git",
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
},
|
|
19
19
|
"files": [
|
|
20
20
|
"dist",
|
|
21
|
+
"skills",
|
|
21
22
|
"README.md",
|
|
22
23
|
"LICENSE"
|
|
23
24
|
],
|
|
@@ -32,13 +33,13 @@
|
|
|
32
33
|
"typecheck": "tsc --noEmit"
|
|
33
34
|
},
|
|
34
35
|
"dependencies": {
|
|
35
|
-
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
36
36
|
"discord.js": "^14.14.0",
|
|
37
37
|
"pino": "^9.5.0",
|
|
38
|
-
"
|
|
38
|
+
"ws": "^8.20.0"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"@types/node": "^22.10.0",
|
|
42
|
+
"@types/ws": "^8.18.1",
|
|
42
43
|
"eslint": "^10.3.0",
|
|
43
44
|
"typescript": "^5",
|
|
44
45
|
"typescript-eslint": "^8.59.2"
|
|
@@ -0,0 +1,122 @@
|
|
|
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.
|
package/dist/config.js
DELETED
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
2
|
-
import { homedir } from 'node:os';
|
|
3
|
-
import { join } from 'node:path';
|
|
4
|
-
import { log } from './log.js';
|
|
5
|
-
// Lockfiles, typing-stop signals, and the attachment cache live here.
|
|
6
|
-
// Override with METRO_STATE_DIR.
|
|
7
|
-
export const STATE_DIR = process.env.METRO_STATE_DIR ?? join(homedir(), '.cache', 'metro');
|
|
8
|
-
mkdirSync(STATE_DIR, { recursive: true });
|
|
9
|
-
// Optional .env in cwd — convenience for local development. In production,
|
|
10
|
-
// env vars come from the MCP server's `env` block.
|
|
11
|
-
export function loadMetroEnv() {
|
|
12
|
-
const envFile = join(process.cwd(), '.env');
|
|
13
|
-
if (!existsSync(envFile))
|
|
14
|
-
return;
|
|
15
|
-
for (const line of readFileSync(envFile, 'utf8').split('\n')) {
|
|
16
|
-
const m = line.match(/^\s*([A-Za-z_]\w*)\s*=\s*(.*?)\s*$/);
|
|
17
|
-
if (m && process.env[m[1]] === undefined) {
|
|
18
|
-
process.env[m[1]] = m[2].replace(/^(['"])(.*)\1$/, '$2');
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
export function configuredPlatforms() {
|
|
23
|
-
return {
|
|
24
|
-
telegram: !!process.env.TELEGRAM_BOT_TOKEN,
|
|
25
|
-
discord: !!process.env.DISCORD_BOT_TOKEN,
|
|
26
|
-
};
|
|
27
|
-
}
|
|
28
|
-
export function requireConfiguredPlatform(p) {
|
|
29
|
-
if (p.telegram || p.discord)
|
|
30
|
-
return;
|
|
31
|
-
log.fatal('set TELEGRAM_BOT_TOKEN and/or DISCORD_BOT_TOKEN — pass via the MCP server `env` block, or in ./.env for local dev');
|
|
32
|
-
process.exit(1);
|
|
33
|
-
}
|