@stage-labs/metro 0.1.0-beta.4 → 0.1.0-beta.6

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.
@@ -0,0 +1,236 @@
1
+ /**
2
+ * JSON-RPC/WS client for codex app-server: each event → `turn/start` (Codex's Monitor equivalent).
3
+ */
4
+ import { createConnection } from 'node:net';
5
+ import { WebSocket } from 'ws';
6
+ import { errMsg, log } from './log.js';
7
+ const RECONNECT_DELAY_MS = 2_000;
8
+ const MAX_QUEUE = 100;
9
+ /** Backstop in case `turn/completed` never arrives — unstick the single-flight gate. */
10
+ const TURN_TIMEOUT_MS = 120_000;
11
+ /** Accept ws://, wss://, unix:///abs/path, or /abs/path (shorthand for unix). */
12
+ function parseUrl(input) {
13
+ if (input.startsWith('ws://') || input.startsWith('wss://'))
14
+ return { kind: 'tcp', url: input };
15
+ if (input.startsWith('unix://'))
16
+ return { kind: 'unix', path: input.replace(/^unix:\/+/, '/') };
17
+ if (input.startsWith('/'))
18
+ return { kind: 'unix', path: input };
19
+ throw new Error(`unsupported METRO_CODEX_RC: ${input} (expected ws://, wss://, unix://, or abs path)`);
20
+ }
21
+ function openSocket(endpoint) {
22
+ if (endpoint.kind === 'tcp')
23
+ return new WebSocket(endpoint.url);
24
+ return new WebSocket('ws://localhost/', {
25
+ createConnection: () => createConnection({ path: endpoint.path }),
26
+ });
27
+ }
28
+ export class CodexRC {
29
+ url;
30
+ clientVersion;
31
+ ws = null;
32
+ nextId = 1;
33
+ pending = new Map();
34
+ threadId = null;
35
+ queue = [];
36
+ connected = false;
37
+ connecting = false;
38
+ turnInFlight = false;
39
+ turnTimeout = null;
40
+ closed = false;
41
+ endpoint;
42
+ constructor(url, clientVersion) {
43
+ this.url = url;
44
+ this.clientVersion = clientVersion;
45
+ this.endpoint = parseUrl(url);
46
+ }
47
+ start() { void this.connect(); }
48
+ stop() {
49
+ this.closed = true;
50
+ this.clearTurnTimeout();
51
+ this.ws?.close();
52
+ this.ws = null;
53
+ }
54
+ push(line) {
55
+ if (this.queue.length >= MAX_QUEUE) {
56
+ log.warn({ url: this.url }, 'codex-rc queue full, dropping oldest');
57
+ this.queue.shift();
58
+ }
59
+ this.queue.push(line);
60
+ void this.drainQueue();
61
+ }
62
+ async connect() {
63
+ if (this.closed || this.connected || this.connecting)
64
+ return;
65
+ this.connecting = true;
66
+ try {
67
+ const ws = openSocket(this.endpoint);
68
+ this.ws = ws;
69
+ ws.on('error', err => log.warn({ err: errMsg(err) }, 'codex-rc websocket error'));
70
+ await new Promise((resolve, reject) => {
71
+ ws.once('open', () => resolve());
72
+ ws.once('error', err => reject(err));
73
+ });
74
+ ws.on('message', data => this.onMessage(data));
75
+ ws.on('close', () => this.onClose());
76
+ const clientInfo = { name: 'metro', version: this.clientVersion, title: null };
77
+ await this.call('initialize', { clientInfo });
78
+ this.threadId = await this.pickOrCreateThread();
79
+ this.connected = true;
80
+ log.info({ url: this.url, thread: this.threadId ?? '(none yet)' }, 'codex-rc connected');
81
+ void this.drainQueue();
82
+ }
83
+ catch (err) {
84
+ log.warn({ err: errMsg(err), url: this.url }, 'codex-rc connect failed; retrying');
85
+ this.scheduleReconnect();
86
+ }
87
+ finally {
88
+ this.connecting = false;
89
+ }
90
+ }
91
+ onMessage(raw) {
92
+ let msg;
93
+ try {
94
+ msg = JSON.parse(raw.toString());
95
+ }
96
+ catch (err) {
97
+ log.warn({ err: errMsg(err) }, 'codex-rc malformed message');
98
+ return;
99
+ }
100
+ if (msg.id !== undefined && this.pending.has(msg.id)) {
101
+ const p = this.pending.get(msg.id);
102
+ this.pending.delete(msg.id);
103
+ if (msg.error)
104
+ p.reject(new Error(msg.error.message ?? 'rpc error'));
105
+ else
106
+ p.resolve(msg.result);
107
+ return;
108
+ }
109
+ switch (msg.method) {
110
+ case 'thread/started': {
111
+ const id = msg.params?.thread?.id;
112
+ if (id) {
113
+ this.threadId = id;
114
+ log.info({ thread: id }, 'codex-rc thread started');
115
+ void this.drainQueue();
116
+ }
117
+ break;
118
+ }
119
+ case 'thread/status/changed': {
120
+ /* Codex 0.130+: status `{active}` means in-flight; anything else is idle. */
121
+ const p = msg.params;
122
+ if (p?.threadId !== this.threadId)
123
+ break;
124
+ const active = typeof p.status === 'object' && p.status !== null && 'active' in p.status;
125
+ if (active)
126
+ this.turnInFlight = true;
127
+ else {
128
+ this.clearTurnTimeout();
129
+ this.turnInFlight = false;
130
+ void this.drainQueue();
131
+ }
132
+ break;
133
+ }
134
+ case 'turn/completed':
135
+ this.clearTurnTimeout();
136
+ this.turnInFlight = false;
137
+ void this.drainQueue();
138
+ break;
139
+ case 'turn/started':
140
+ this.turnInFlight = true;
141
+ break;
142
+ case 'thread/closed':
143
+ case 'thread/archived':
144
+ log.warn({ method: msg.method }, 'codex-rc thread closed/archived');
145
+ this.threadId = null;
146
+ break;
147
+ }
148
+ }
149
+ onClose() {
150
+ if (this.closed)
151
+ return;
152
+ this.connected = false;
153
+ this.ws = null;
154
+ for (const p of this.pending.values())
155
+ p.reject(new Error('websocket closed'));
156
+ this.pending.clear();
157
+ this.scheduleReconnect();
158
+ }
159
+ scheduleReconnect() {
160
+ if (this.closed)
161
+ return;
162
+ this.connected = false;
163
+ setTimeout(() => void this.connect(), RECONNECT_DELAY_MS);
164
+ }
165
+ /** Prefer the most-recently-loaded thread (the TUI's if attached); else create our own. */
166
+ async pickOrCreateThread() {
167
+ try {
168
+ const loaded = await this.call('thread/loaded/list', {});
169
+ const existing = loaded.data?.[loaded.data.length - 1];
170
+ if (existing)
171
+ return existing;
172
+ }
173
+ catch (err) {
174
+ log.warn({ err: errMsg(err) }, 'codex-rc thread/loaded/list failed');
175
+ }
176
+ try {
177
+ const created = await this.call('thread/start', {});
178
+ return created.thread?.id ?? null;
179
+ }
180
+ catch (err) {
181
+ log.warn({ err: errMsg(err) }, 'codex-rc thread/start failed');
182
+ return null;
183
+ }
184
+ }
185
+ async drainQueue() {
186
+ if (!this.connected || this.turnInFlight || !this.queue.length)
187
+ return;
188
+ if (!this.threadId) {
189
+ this.threadId = await this.pickOrCreateThread();
190
+ if (!this.threadId)
191
+ return;
192
+ }
193
+ const line = this.queue[0];
194
+ this.turnInFlight = true;
195
+ this.armTurnTimeout();
196
+ try {
197
+ const input = [{ type: 'text', text: line, textElements: [] }];
198
+ await this.call('turn/start', { threadId: this.threadId, input });
199
+ this.queue.shift();
200
+ }
201
+ catch (err) {
202
+ const dead = errMsg(err).includes('thread not found');
203
+ log.warn({ err: errMsg(err) }, dead ? 'codex-rc thread gone; will create a new one' : 'codex-rc turn/start failed; will retry');
204
+ this.clearTurnTimeout();
205
+ this.turnInFlight = false;
206
+ if (dead)
207
+ this.threadId = null;
208
+ setTimeout(() => void this.drainQueue(), 1_000);
209
+ }
210
+ }
211
+ armTurnTimeout() {
212
+ this.clearTurnTimeout();
213
+ this.turnTimeout = setTimeout(() => {
214
+ log.warn({ thread: this.threadId, queue: this.queue.length }, `codex-rc turn/completed not received within ${TURN_TIMEOUT_MS}ms; force-clearing gate`);
215
+ this.turnInFlight = false;
216
+ this.turnTimeout = null;
217
+ void this.drainQueue();
218
+ }, TURN_TIMEOUT_MS);
219
+ }
220
+ clearTurnTimeout() {
221
+ if (this.turnTimeout) {
222
+ clearTimeout(this.turnTimeout);
223
+ this.turnTimeout = null;
224
+ }
225
+ }
226
+ call(method, params) {
227
+ if (!this.ws)
228
+ return Promise.reject(new Error('not connected'));
229
+ const id = this.nextId++;
230
+ const ws = this.ws;
231
+ return new Promise((resolve, reject) => {
232
+ this.pending.set(id, { resolve: resolve, reject });
233
+ ws.send(JSON.stringify({ jsonrpc: '2.0', id, method, params }));
234
+ });
235
+ }
236
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Daemon: chat inbound → JSON on stdout; optional codex-rc push; cross-agent `notify` over Unix socket.
3
+ */
4
+ import { copyFileSync } from 'node:fs';
5
+ import { dirname, join } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import pkg from '../package.json' with { type: 'json' };
8
+ import { DiscordStation } from './stations/discord.js';
9
+ import { TelegramStation } from './stations/telegram.js';
10
+ import { asLine } from './stations/index.js';
11
+ import { CodexRC } from './codex-rc.js';
12
+ import { startIpcServer, stopIpcServer } from './ipc.js';
13
+ import { agentSelf, appendHistory, mintId } from './history.js';
14
+ import { noteSeen, saveBotId } from './cache.js';
15
+ import { errMsg, log } from './log.js';
16
+ import { acquireLock, configuredPlatforms, loadMetroEnv, STATE_DIR, requireConfiguredPlatform } from './paths.js';
17
+ loadMetroEnv();
18
+ const platforms = configuredPlatforms();
19
+ requireConfiguredPlatform(platforms);
20
+ acquireLock(join(STATE_DIR, '.tail-lock'));
21
+ const AGENTS_MD = join(STATE_DIR, 'AGENTS.md');
22
+ try {
23
+ copyFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'docs', 'agents.md'), AGENTS_MD);
24
+ }
25
+ catch (err) {
26
+ log.warn({ err: errMsg(err), path: AGENTS_MD }, 'failed to install agent skill');
27
+ }
28
+ /** Suppress EPIPE so the daemon survives the agent (Monitor reader) restarting / dying. */
29
+ process.stdout.on('error', err => {
30
+ if (err.code !== 'EPIPE')
31
+ log.warn({ err: errMsg(err) }, 'stdout error');
32
+ });
33
+ const codexRc = process.env.METRO_CODEX_RC ? new CodexRC(process.env.METRO_CODEX_RC, pkg.version) : null;
34
+ codexRc?.start();
35
+ const discord = new DiscordStation();
36
+ const telegram = new TelegramStation();
37
+ function emit(event) {
38
+ const json = JSON.stringify(event);
39
+ process.stdout.write(json + '\n');
40
+ codexRc?.push(json);
41
+ if ('lineName' in event)
42
+ noteSeen(event.line, event.lineName);
43
+ else
44
+ noteSeen(event.line);
45
+ if (event.type === 'inbound') {
46
+ appendHistory({
47
+ id: event.id, ts: event.ts, kind: 'inbound', station: event.station, line: event.line,
48
+ from: event.from, fromName: event.fromName, to: agentSelf(), text: event.text,
49
+ platformMessageId: event.messageId, attachments: event.attachmentNames,
50
+ });
51
+ }
52
+ else if (event.type === 'notification') {
53
+ appendHistory({
54
+ id: event.id, ts: event.ts, kind: 'notification',
55
+ station: event.line.replace(/^metro:\/\/([^/]+)\/.*$/, '$1'),
56
+ line: event.line, from: event.from ?? agentSelf(),
57
+ to: event.line, text: event.text,
58
+ });
59
+ }
60
+ }
61
+ const onInbound = (m) => emit({ type: 'inbound', ...m, to: agentSelf() });
62
+ const ipc = startIpcServer(async (req) => {
63
+ if (req.op === 'notify') {
64
+ emit({
65
+ type: 'notification', id: mintId(), ts: new Date().toISOString(),
66
+ line: asLine(req.line),
67
+ from: req.from ? asLine(req.from) : agentSelf(), text: req.text,
68
+ });
69
+ return { ok: true };
70
+ }
71
+ if (req.op === 'download') {
72
+ const line = asLine(req.line);
73
+ const station = req.line.startsWith('metro://telegram/') ? telegram : discord;
74
+ const files = await station.download(line, req.messageId, req.outDir);
75
+ return { ok: true, files };
76
+ }
77
+ return { ok: false, error: 'unknown op' };
78
+ });
79
+ async function main() {
80
+ if (platforms.discord) {
81
+ discord.onMessage(onInbound);
82
+ const [, me] = await Promise.all([discord.start(), discord.getMe()]);
83
+ saveBotId('discord', me.id);
84
+ log.info({ bot: me.username }, 'discord ready');
85
+ }
86
+ if (platforms.telegram) {
87
+ telegram.onMessage(onInbound);
88
+ const [me] = await Promise.all([telegram.getMe(), telegram.start()]);
89
+ saveBotId('telegram', String(me.id));
90
+ log.info({ bot: `@${me.username}` }, 'telegram ready');
91
+ }
92
+ log.info({ codexRc: !!codexRc }, 'dispatcher ready');
93
+ }
94
+ let shuttingDown = false;
95
+ async function shutdown() {
96
+ if (shuttingDown)
97
+ return;
98
+ shuttingDown = true;
99
+ log.info('dispatcher shutting down');
100
+ codexRc?.stop();
101
+ await stopIpcServer(ipc).catch(() => { });
102
+ if (platforms.discord)
103
+ await discord.stop().catch(() => { });
104
+ if (platforms.telegram)
105
+ await telegram.stop().catch(() => { });
106
+ process.exit(0);
107
+ }
108
+ process.stdin.on('end', shutdown);
109
+ process.stdin.on('close', shutdown);
110
+ process.on('SIGINT', shutdown);
111
+ process.on('SIGTERM', shutdown);
112
+ await main();
@@ -0,0 +1,84 @@
1
+ /** Append-only JSONL history of every message that flows through metro (inbound + outbound). */
2
+ import { randomBytes } from 'node:crypto';
3
+ import { appendFileSync, existsSync, readFileSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { errMsg, log } from './log.js';
6
+ import { STATE_DIR } from './paths.js';
7
+ const FILE = join(STATE_DIR, 'history.jsonl');
8
+ /** Mint a universal metro message ID. Short, prefixed, URL-safe. */
9
+ export const mintId = () => `msg_${randomBytes(6).toString('base64url')}`;
10
+ /** Append one entry as a JSON line. POSIX-atomic for sub-PIPE_BUF writes (we're well under). */
11
+ export function appendHistory(entry) {
12
+ try {
13
+ appendFileSync(FILE, JSON.stringify(entry) + '\n');
14
+ }
15
+ catch (err) {
16
+ log.warn({ err: errMsg(err), path: FILE }, 'history append failed');
17
+ }
18
+ }
19
+ /** Read JSONL, parse, filter (most-recent-first), apply `limit`. Empty array if file is missing. */
20
+ export function readHistory(filter = {}) {
21
+ if (!existsSync(FILE))
22
+ return [];
23
+ const lines = readFileSync(FILE, 'utf8').split('\n');
24
+ const out = [];
25
+ /** Walk backwards so `limit` clamps without scanning the whole file body twice. */
26
+ for (let i = lines.length - 1; i >= 0; i--) {
27
+ const raw = lines[i].trim();
28
+ if (!raw)
29
+ continue;
30
+ let e;
31
+ try {
32
+ e = JSON.parse(raw);
33
+ }
34
+ catch {
35
+ continue;
36
+ }
37
+ if (!matches(e, filter))
38
+ continue;
39
+ out.push(e);
40
+ if (filter.limit && out.length >= filter.limit)
41
+ break;
42
+ }
43
+ return out;
44
+ }
45
+ function matches(e, f) {
46
+ if (f.line && e.line !== f.line)
47
+ return false;
48
+ if (f.station && e.station !== f.station)
49
+ return false;
50
+ if (f.kind && e.kind !== f.kind)
51
+ return false;
52
+ if (f.from && e.from !== f.from)
53
+ return false;
54
+ if (f.textContains && !(e.text ?? '').toLowerCase().includes(f.textContains.toLowerCase()))
55
+ return false;
56
+ if (f.since && new Date(e.ts) < f.since)
57
+ return false;
58
+ return true;
59
+ }
60
+ /** Find an entry by universal id OR platform message id. */
61
+ export function lookupEntry(id) {
62
+ const entries = readHistory({ limit: 5_000 });
63
+ return entries.find(e => e.id === id || e.platformMessageId === id);
64
+ }
65
+ /** Look up the platform messageId for a universal `msg_*` id; returns the input unchanged otherwise. */
66
+ export function resolvePlatformId(id) {
67
+ if (!id.startsWith('msg_'))
68
+ return id;
69
+ const hit = lookupEntry(id);
70
+ if (hit?.platformMessageId)
71
+ return hit.platformMessageId;
72
+ throw new Error(`unknown universal id: ${id} (run \`metro history --limit=50\` to see recent ids)`);
73
+ }
74
+ /** Resolve the current agent's identity URI. Precedence: METRO_FROM > runtime env > generic. */
75
+ export function agentSelf() {
76
+ const explicit = process.env.METRO_FROM;
77
+ if (explicit)
78
+ return explicit;
79
+ if (process.env.CLAUDECODE)
80
+ return 'metro://claude/agent';
81
+ if (process.env.METRO_CODEX_RC || process.env.CODEX_HOME)
82
+ return 'metro://codex/agent';
83
+ return 'metro://agent';
84
+ }
package/dist/ipc.js ADDED
@@ -0,0 +1,71 @@
1
+ /** Unix-socket IPC: `notify` re-emits on daemon stdout; `download` resolves Telegram attachments. */
2
+ import { createConnection, createServer } from 'node:net';
3
+ import { existsSync, unlinkSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { errMsg, log } from './log.js';
6
+ import { STATE_DIR } from './paths.js';
7
+ const SOCKET_PATH = join(STATE_DIR, 'metro.sock');
8
+ export function startIpcServer(handler) {
9
+ if (existsSync(SOCKET_PATH)) {
10
+ try {
11
+ unlinkSync(SOCKET_PATH);
12
+ }
13
+ catch { /* ignore */ }
14
+ }
15
+ const server = createServer(socket => handleConnection(socket, handler));
16
+ server.on('error', err => log.warn({ err: errMsg(err) }, 'ipc server error'));
17
+ server.listen(SOCKET_PATH, () => log.debug({ path: SOCKET_PATH }, 'ipc socket listening'));
18
+ return server;
19
+ }
20
+ export async function stopIpcServer(server) {
21
+ await new Promise(resolve => server.close(() => resolve()));
22
+ try {
23
+ if (existsSync(SOCKET_PATH))
24
+ unlinkSync(SOCKET_PATH);
25
+ }
26
+ catch { /* ignore */ }
27
+ }
28
+ async function handleConnection(socket, handler) {
29
+ let buf = '';
30
+ socket.setEncoding('utf8');
31
+ socket.on('data', chunk => { buf += chunk; });
32
+ socket.on('end', async () => {
33
+ let resp;
34
+ try {
35
+ const req = JSON.parse(buf.trim());
36
+ resp = await handler(req);
37
+ }
38
+ catch (err) {
39
+ resp = { ok: false, error: errMsg(err) };
40
+ }
41
+ socket.end(JSON.stringify(resp) + '\n');
42
+ });
43
+ socket.on('error', err => log.debug({ err: errMsg(err) }, 'ipc connection error'));
44
+ }
45
+ /** CLI-side: send one request, get one response. Throws if the daemon isn't running. */
46
+ export function ipcCall(req, timeoutMs = 30_000) {
47
+ return new Promise((resolve, reject) => {
48
+ if (!existsSync(SOCKET_PATH)) {
49
+ reject(new Error('metro daemon is not running (start it with `metro`)'));
50
+ return;
51
+ }
52
+ const socket = createConnection({ path: SOCKET_PATH });
53
+ let buf = '';
54
+ const timer = setTimeout(() => {
55
+ socket.destroy();
56
+ reject(new Error(`ipc timeout after ${timeoutMs}ms`));
57
+ }, timeoutMs);
58
+ socket.on('connect', () => { socket.end(JSON.stringify(req)); });
59
+ socket.on('data', chunk => { buf += chunk.toString('utf8'); });
60
+ socket.on('end', () => {
61
+ clearTimeout(timer);
62
+ try {
63
+ resolve(JSON.parse(buf.trim()));
64
+ }
65
+ catch (err) {
66
+ reject(new Error(`ipc bad response: ${errMsg(err)}`));
67
+ }
68
+ });
69
+ socket.on('error', err => { clearTimeout(timer); reject(err); });
70
+ });
71
+ }
package/dist/log.js CHANGED
@@ -1,6 +1,11 @@
1
- /** Pino → stderr. Stdout reserved for command output / --json. */
1
+ /** Pino → stderr. TTY pino-pretty (colorized, hostname/pid stripped); else JSON. */
2
2
  import pino from 'pino';
3
- export const log = pino({ name: 'metro', level: process.env.METRO_LOG_LEVEL || 'info' }, pino.destination(2));
3
+ import pinoPretty from 'pino-pretty';
4
+ const stream = process.stderr.isTTY
5
+ ? pinoPretty({ colorize: true, translateTime: 'HH:MM:ss', ignore: 'pid,hostname,name', destination: 2 })
6
+ : pino.destination(2);
7
+ /** `base: { name }` strips pino's default pid+hostname from JSON output too (not just from the TTY pretty stream). */
8
+ export const log = pino({ base: { name: 'metro' }, level: process.env.METRO_LOG_LEVEL || 'info' }, stream);
4
9
  export const errMsg = (err) => {
5
10
  if (err instanceof Error)
6
11
  return err.message;