@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 +28 -5
- package/dist/cli.js +15 -3
- package/dist/lib/codex-rc.js +274 -0
- package/dist/log.js +7 -1
- package/dist/paths.js +8 -0
- package/dist/tail.js +17 -1
- package/package.json +4 -2
- package/skills/metro/SKILL.md +30 -7
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>
|
|
13
|
-
metro setup discord
|
|
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
|
-
|
|
21
|
+
### Run with Claude Code
|
|
22
22
|
|
|
23
|
-
|
|
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
|
-
|
|
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
|
|
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>
|
|
259
|
-
' metro setup discord
|
|
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) =>
|
|
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
|
-
|
|
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.
|
|
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"
|
package/skills/metro/SKILL.md
CHANGED
|
@@ -1,19 +1,42 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: metro
|
|
3
|
-
description:
|
|
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 —
|
|
6
|
+
# Metro — handling the Telegram & Discord bridge
|
|
7
7
|
|
|
8
|
-
Metro is a CLI bridge between this agent session and Telegram/Discord.
|
|
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
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
|