@stage-labs/metro 0.1.0-beta.13 → 0.1.0-beta.15
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 +76 -189
- package/dist/broker/claims.js +144 -0
- package/dist/{broker.js → broker/history-stream.js} +46 -99
- package/dist/cli/config.js +115 -121
- package/dist/cli/index.js +51 -64
- package/dist/cli/messenger-api.js +214 -0
- package/dist/cli/messenger-transcribe.js +43 -0
- package/dist/cli/messenger-uploads.js +116 -0
- package/dist/cli/monitor-api.js +205 -0
- package/dist/cli/tail.js +49 -118
- package/dist/cli/webhook.js +103 -3
- package/dist/{codex-rc.js → codex-rc/client.js} +12 -32
- package/dist/codex-rc/protocol.js +38 -0
- package/dist/dispatcher/server.js +122 -0
- package/dist/dispatcher.js +52 -83
- package/dist/history.js +49 -27
- package/dist/ipc.js +28 -10
- package/dist/lines.js +54 -0
- package/dist/local-identity.js +80 -0
- package/dist/paths.js +58 -12
- package/dist/trains/protocol.js +99 -0
- package/dist/trains/supervisor.js +210 -0
- package/dist/tunnel.js +39 -1
- package/docs/broker.md +88 -136
- package/docs/monitor.md +88 -10
- package/docs/uri-scheme.md +10 -7
- package/examples/README.md +32 -0
- package/examples/telegram.ts +121 -0
- package/package.json +6 -5
- package/skills/metro/SKILL.md +67 -213
- package/dist/cache.js +0 -69
- package/dist/cli/actions.js +0 -206
- package/dist/cli/skill.js +0 -62
- package/dist/monitor.js +0 -194
- package/dist/registry.js +0 -48
- package/dist/stations/claude.js +0 -45
- package/dist/stations/codex.js +0 -68
- package/dist/stations/discord.js +0 -216
- package/dist/stations/index.js +0 -129
- package/dist/stations/telegram-md.js +0 -34
- package/dist/stations/telegram-upload.js +0 -113
- package/dist/stations/telegram.js +0 -234
- package/dist/stations/webhook.js +0 -103
- package/dist/webhooks.js +0 -41
- package/docs/users.md +0 -226
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/** Train stdin/stdout protocol: line buffering + JSON envelope + outbound call bookkeeping. */
|
|
2
|
+
import { readdirSync, statSync } from 'node:fs';
|
|
3
|
+
import { join, parse as parsePath } from 'node:path';
|
|
4
|
+
import { errMsg, log } from '../log.js';
|
|
5
|
+
export const CALL_TIMEOUT_MS = 60_000;
|
|
6
|
+
export const STDOUT_LINE_MAX = 4 * 1024 * 1024; // 4 MiB safeguard per line
|
|
7
|
+
/** Classify a single parsed stdout line from a train. */
|
|
8
|
+
export function parseTrainLine(name, line) {
|
|
9
|
+
let msg;
|
|
10
|
+
try {
|
|
11
|
+
msg = JSON.parse(line);
|
|
12
|
+
}
|
|
13
|
+
catch (err) {
|
|
14
|
+
log.warn({ name, err: errMsg(err), line: line.slice(0, 200) }, 'train: bad JSON');
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
if (msg.op === 'response') {
|
|
18
|
+
if (typeof msg.id !== 'string')
|
|
19
|
+
return { op: 'ignore' };
|
|
20
|
+
return { op: 'response', id: msg.id, result: msg.result, error: msg.error };
|
|
21
|
+
}
|
|
22
|
+
if (msg.op === 'log')
|
|
23
|
+
return { op: 'log', text: msg.text };
|
|
24
|
+
/** Anything without an `op` (or with `op:"event"`) is an inbound event. */
|
|
25
|
+
/** Defensive shape check: every event needs `line` (string). Log + drop if missing. */
|
|
26
|
+
if (typeof msg.line !== 'string') {
|
|
27
|
+
log.warn({ name, line: line.slice(0, 200) }, 'train: event missing `line` (string) — dropped');
|
|
28
|
+
return { op: 'ignore' };
|
|
29
|
+
}
|
|
30
|
+
return { op: 'event', event: msg };
|
|
31
|
+
}
|
|
32
|
+
/** Consume complete `\n`-terminated lines from a rolling buffer; invoke `onLine` for each. */
|
|
33
|
+
/** Returns the leftover (incomplete) buffer to keep accumulating. */
|
|
34
|
+
export function drainLines(name, buf, onLine) {
|
|
35
|
+
if (buf.length > STDOUT_LINE_MAX && !buf.includes('\n')) {
|
|
36
|
+
log.warn({ name, bytes: buf.length }, 'train: dropping oversized stdout line');
|
|
37
|
+
return '';
|
|
38
|
+
}
|
|
39
|
+
let nl;
|
|
40
|
+
while ((nl = buf.indexOf('\n')) !== -1) {
|
|
41
|
+
const line = buf.slice(0, nl).trim();
|
|
42
|
+
buf = buf.slice(nl + 1);
|
|
43
|
+
if (!line)
|
|
44
|
+
continue;
|
|
45
|
+
onLine(line);
|
|
46
|
+
}
|
|
47
|
+
return buf;
|
|
48
|
+
}
|
|
49
|
+
export function encodeCall(id, action, args) {
|
|
50
|
+
return JSON.stringify({ op: 'call', id, action, args }) + '\n';
|
|
51
|
+
}
|
|
52
|
+
function isTrainFile(name) {
|
|
53
|
+
return /\.(ts|js|mjs)$/.test(name) && !name.startsWith('_') && !name.startsWith('.');
|
|
54
|
+
}
|
|
55
|
+
/** Discover trains under `dir`: regular files with allowed extensions, no `_` / `.` prefix. */
|
|
56
|
+
export function listTrainFiles(dir) {
|
|
57
|
+
return readdirSync(dir).filter(isTrainFile)
|
|
58
|
+
.map(f => ({ name: parsePath(f).name, path: join(dir, f) }))
|
|
59
|
+
.filter(t => { try {
|
|
60
|
+
return statSync(t.path).isFile();
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return false;
|
|
64
|
+
} });
|
|
65
|
+
}
|
|
66
|
+
/* ──────────── outbound call bookkeeping (per-train pending map + timeout) ──────────── */
|
|
67
|
+
export function mintCallId(seq) {
|
|
68
|
+
return `req_${seq}_${Math.random().toString(36).slice(2, 8)}`;
|
|
69
|
+
}
|
|
70
|
+
/** Reject + clear all pending calls on a train (used on shutdown/exit). */
|
|
71
|
+
export function failAllPending(pending, reason) {
|
|
72
|
+
for (const p of pending.values()) {
|
|
73
|
+
clearTimeout(p.timer);
|
|
74
|
+
p.reject(new Error(reason));
|
|
75
|
+
}
|
|
76
|
+
pending.clear();
|
|
77
|
+
}
|
|
78
|
+
/** Dispatch `action` to a train's stdin and register a pending entry; returns the awaitable. */
|
|
79
|
+
export function sendCall(t, id, action, args) {
|
|
80
|
+
return new Promise((resolve, reject) => {
|
|
81
|
+
const timer = setTimeout(() => {
|
|
82
|
+
t.pending.delete(id);
|
|
83
|
+
reject(new Error(`train '${t.name}' call '${action}' timed out after ${CALL_TIMEOUT_MS}ms`));
|
|
84
|
+
}, CALL_TIMEOUT_MS);
|
|
85
|
+
t.pending.set(id, { resolve, reject, timer });
|
|
86
|
+
try {
|
|
87
|
+
const stdin = t.proc?.stdin;
|
|
88
|
+
if (!stdin || typeof stdin === 'number')
|
|
89
|
+
throw new Error('stdin not piped');
|
|
90
|
+
stdin.write(encodeCall(id, action, args));
|
|
91
|
+
stdin.flush();
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
clearTimeout(timer);
|
|
95
|
+
t.pending.delete(id);
|
|
96
|
+
reject(new Error(`train '${t.name}' stdin write failed: ${errMsg(err)}`));
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/** Train supervisor: spawn `~/.metro/trains/*.{ts,js,mjs}` under `bun run`, multiplex their */
|
|
2
|
+
/** stdout (events + call-responses), route outbound calls to their stdin. Pure transport. */
|
|
3
|
+
import { mkdirSync } from 'node:fs';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { errMsg, log } from '../log.js';
|
|
7
|
+
import { userSelf } from '../history.js';
|
|
8
|
+
import { drainLines, failAllPending, listTrainFiles, mintCallId, parseTrainLine, sendCall, } from './protocol.js';
|
|
9
|
+
const RESTART_BACKOFFS_MS = [1_000, 5_000, 30_000];
|
|
10
|
+
const MAX_CONSECUTIVE_FAILS = 5;
|
|
11
|
+
export const TRAINS_DIR = process.env.METRO_TRAINS_DIR ?? join(homedir(), '.metro', 'trains');
|
|
12
|
+
export class TrainSupervisor {
|
|
13
|
+
dir;
|
|
14
|
+
trains = new Map();
|
|
15
|
+
onEvent = null;
|
|
16
|
+
nextCallId = 1;
|
|
17
|
+
constructor(dir = TRAINS_DIR) {
|
|
18
|
+
this.dir = dir;
|
|
19
|
+
}
|
|
20
|
+
onTrainEvent(handler) { this.onEvent = handler; }
|
|
21
|
+
/** Discover trains under `dir` and spawn one subprocess per file. Creates the dir if missing. */
|
|
22
|
+
start() {
|
|
23
|
+
mkdirSync(this.dir, { recursive: true });
|
|
24
|
+
for (const t of listTrainFiles(this.dir))
|
|
25
|
+
this.startTrain(t.name, t.path);
|
|
26
|
+
log.info({ dir: this.dir, count: this.trains.size }, 'train supervisor: started');
|
|
27
|
+
}
|
|
28
|
+
/** Shut everything down (graceful: send SIGTERM, then SIGKILL after grace period). */
|
|
29
|
+
async stop() {
|
|
30
|
+
const tasks = [];
|
|
31
|
+
for (const t of this.trains.values()) {
|
|
32
|
+
t.stopped = true;
|
|
33
|
+
if (t.restartTimer) {
|
|
34
|
+
clearTimeout(t.restartTimer);
|
|
35
|
+
t.restartTimer = null;
|
|
36
|
+
}
|
|
37
|
+
failAllPending(t.pending, 'train shutting down');
|
|
38
|
+
const wait = killGracefully(t.proc);
|
|
39
|
+
if (wait)
|
|
40
|
+
tasks.push(wait);
|
|
41
|
+
}
|
|
42
|
+
await Promise.all(tasks);
|
|
43
|
+
}
|
|
44
|
+
list() {
|
|
45
|
+
return [...this.trains.values()].map(t => ({
|
|
46
|
+
name: t.name, path: t.path, running: !!(t.proc && t.proc.exitCode === null),
|
|
47
|
+
pid: t.proc?.pid ?? null, startedAt: t.startedAt, failCount: t.failCount,
|
|
48
|
+
}));
|
|
49
|
+
}
|
|
50
|
+
/** Kill + respawn a named train; resets fail counter so backoff starts fresh. */
|
|
51
|
+
async restart(name) {
|
|
52
|
+
const t = this.trains.get(name);
|
|
53
|
+
if (!t)
|
|
54
|
+
throw new Error(`no train named '${name}'`);
|
|
55
|
+
if (t.restartTimer) {
|
|
56
|
+
clearTimeout(t.restartTimer);
|
|
57
|
+
t.restartTimer = null;
|
|
58
|
+
}
|
|
59
|
+
failAllPending(t.pending, `train '${name}' restarting`);
|
|
60
|
+
t.stopped = true;
|
|
61
|
+
const wait = killGracefully(t.proc);
|
|
62
|
+
if (wait)
|
|
63
|
+
await wait;
|
|
64
|
+
t.stopped = false;
|
|
65
|
+
t.failCount = 0;
|
|
66
|
+
t.proc = null;
|
|
67
|
+
this.spawn(t);
|
|
68
|
+
}
|
|
69
|
+
/** Send a call to a named train and await the matching response. */
|
|
70
|
+
async call(name, action, args) {
|
|
71
|
+
const t = this.trains.get(name);
|
|
72
|
+
if (!t)
|
|
73
|
+
throw new Error(`no train named '${name}' (have: ${[...this.trains.keys()].join(', ') || '(none)'})`);
|
|
74
|
+
if (!t.proc || t.proc.exitCode !== null)
|
|
75
|
+
throw new Error(`train '${name}' is not running`);
|
|
76
|
+
return sendCall(t, mintCallId(this.nextCallId++), action, args);
|
|
77
|
+
}
|
|
78
|
+
startTrain(name, path) {
|
|
79
|
+
if (this.trains.has(name)) {
|
|
80
|
+
log.warn({ name }, 'train supervisor: duplicate name, skipping');
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const state = {
|
|
84
|
+
name, path, proc: null, pending: new Map(), buf: '', errBuf: '',
|
|
85
|
+
failCount: 0, restartTimer: null, startedAt: null, stopped: false,
|
|
86
|
+
};
|
|
87
|
+
this.trains.set(name, state);
|
|
88
|
+
this.spawn(state);
|
|
89
|
+
}
|
|
90
|
+
spawn(state) {
|
|
91
|
+
if (state.stopped)
|
|
92
|
+
return;
|
|
93
|
+
try {
|
|
94
|
+
const proc = Bun.spawn(['bun', 'run', state.path], {
|
|
95
|
+
stdin: 'pipe', stdout: 'pipe', stderr: 'pipe',
|
|
96
|
+
env: { ...process.env, METRO_TRAIN_NAME: state.name, METRO_SELF_URI: userSelf() },
|
|
97
|
+
});
|
|
98
|
+
state.proc = proc;
|
|
99
|
+
state.startedAt = new Date().toISOString();
|
|
100
|
+
state.buf = '';
|
|
101
|
+
state.errBuf = '';
|
|
102
|
+
log.info({ name: state.name, pid: proc.pid }, 'train: spawned');
|
|
103
|
+
void this.pumpStdout(state);
|
|
104
|
+
void this.pumpStderr(state);
|
|
105
|
+
void proc.exited.then(code => this.onExit(state, code ?? 0));
|
|
106
|
+
}
|
|
107
|
+
catch (err) {
|
|
108
|
+
log.warn({ name: state.name, err: errMsg(err) }, 'train: spawn failed');
|
|
109
|
+
this.scheduleRestart(state);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
pumpStdout(state) {
|
|
113
|
+
return pumpStream(state.proc?.stdout, state.name, 'stdout', chunk => {
|
|
114
|
+
state.buf += chunk;
|
|
115
|
+
state.buf = drainLines(state.name, state.buf, line => this.handleLine(state, line));
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
/** Stream train stderr line-by-line into the daemon's logger so users see crashes/warnings. */
|
|
119
|
+
pumpStderr(state) {
|
|
120
|
+
return pumpStream(state.proc?.stderr, state.name, 'stderr', chunk => {
|
|
121
|
+
state.errBuf += chunk;
|
|
122
|
+
let nl;
|
|
123
|
+
while ((nl = state.errBuf.indexOf('\n')) !== -1) {
|
|
124
|
+
const line = state.errBuf.slice(0, nl).trimEnd();
|
|
125
|
+
state.errBuf = state.errBuf.slice(nl + 1);
|
|
126
|
+
if (line)
|
|
127
|
+
log.warn({ train: state.name }, line);
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
handleLine(state, line) {
|
|
132
|
+
const msg = parseTrainLine(state.name, line);
|
|
133
|
+
if (!msg || msg.op === 'ignore')
|
|
134
|
+
return;
|
|
135
|
+
if (msg.op === 'response') {
|
|
136
|
+
const pending = state.pending.get(msg.id);
|
|
137
|
+
if (!pending) {
|
|
138
|
+
log.debug({ name: state.name, id: msg.id }, 'train: stale response');
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
state.pending.delete(msg.id);
|
|
142
|
+
clearTimeout(pending.timer);
|
|
143
|
+
pending.resolve({ result: msg.result, error: msg.error });
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (msg.op === 'log') {
|
|
147
|
+
log.info({ name: state.name, msg: msg.text }, 'train log');
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
this.onEvent?.(msg.event, state.name);
|
|
151
|
+
}
|
|
152
|
+
onExit(state, code) {
|
|
153
|
+
log.warn({ name: state.name, code }, 'train: exited');
|
|
154
|
+
state.proc = null;
|
|
155
|
+
state.startedAt = null;
|
|
156
|
+
failAllPending(state.pending, `train '${state.name}' exited (code=${code}) before responding`);
|
|
157
|
+
if (state.stopped)
|
|
158
|
+
return;
|
|
159
|
+
state.failCount++;
|
|
160
|
+
if (state.failCount >= MAX_CONSECUTIVE_FAILS) {
|
|
161
|
+
log.error({ name: state.name, fails: state.failCount }, 'train: too many consecutive failures, giving up (restart metro to retry)');
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
this.scheduleRestart(state);
|
|
165
|
+
}
|
|
166
|
+
scheduleRestart(state) {
|
|
167
|
+
if (state.stopped)
|
|
168
|
+
return;
|
|
169
|
+
const idx = Math.min(state.failCount, RESTART_BACKOFFS_MS.length - 1);
|
|
170
|
+
const delay = RESTART_BACKOFFS_MS[idx];
|
|
171
|
+
log.info({ name: state.name, delay, attempt: state.failCount }, 'train: restart scheduled');
|
|
172
|
+
state.restartTimer = setTimeout(() => {
|
|
173
|
+
state.restartTimer = null;
|
|
174
|
+
/** Any subprocess that survives 30s resets its consecutive-fail counter. */
|
|
175
|
+
this.spawn(state);
|
|
176
|
+
setTimeout(() => { if (state.proc && state.proc.exitCode === null)
|
|
177
|
+
state.failCount = 0; }, 30_000);
|
|
178
|
+
}, delay);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
function killGracefully(proc) {
|
|
182
|
+
if (!proc || proc.exitCode !== null)
|
|
183
|
+
return null;
|
|
184
|
+
try {
|
|
185
|
+
proc.kill('SIGTERM');
|
|
186
|
+
}
|
|
187
|
+
catch { /* ignore */ }
|
|
188
|
+
const grace = setTimeout(() => { try {
|
|
189
|
+
proc.kill('SIGKILL');
|
|
190
|
+
}
|
|
191
|
+
catch { /* ignore */ } }, 2_000);
|
|
192
|
+
return proc.exited.finally(() => clearTimeout(grace));
|
|
193
|
+
}
|
|
194
|
+
async function pumpStream(stream, name, kind, onChunk) {
|
|
195
|
+
if (!stream || typeof stream === 'number')
|
|
196
|
+
return;
|
|
197
|
+
const reader = stream.getReader();
|
|
198
|
+
const dec = new TextDecoder();
|
|
199
|
+
try {
|
|
200
|
+
while (true) {
|
|
201
|
+
const { value, done } = await reader.read();
|
|
202
|
+
if (done)
|
|
203
|
+
break;
|
|
204
|
+
onChunk(dec.decode(value, { stream: true }));
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
catch (err) {
|
|
208
|
+
log.debug({ name, err: errMsg(err) }, `train: ${kind} pump ended`);
|
|
209
|
+
}
|
|
210
|
+
}
|
package/dist/tunnel.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
/** Cloudflared tunnel manager
|
|
1
|
+
/** Cloudflared tunnel manager + webhook endpoint registry. Both persist tiny JSON files in STATE_DIR. */
|
|
2
2
|
import { spawn, spawnSync } from 'node:child_process';
|
|
3
|
+
import { randomBytes } from 'node:crypto';
|
|
3
4
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
4
5
|
import { join } from 'node:path';
|
|
5
6
|
import { STATE_DIR } from './paths.js';
|
|
@@ -62,3 +63,40 @@ export class Tunnel {
|
|
|
62
63
|
this.child = null;
|
|
63
64
|
}
|
|
64
65
|
}
|
|
66
|
+
/* ──────────── webhook endpoint registry (id, label, optional secret) ──────────── */
|
|
67
|
+
const WEBHOOKS_FILE = join(STATE_DIR, 'webhooks.json');
|
|
68
|
+
/** Local listener port — `127.0.0.1` only; expose publicly via Cloudflare tunnel. */
|
|
69
|
+
export const webhookPort = () => Number(process.env.METRO_WEBHOOK_PORT) || 8420;
|
|
70
|
+
function readWebhooks() {
|
|
71
|
+
if (!existsSync(WEBHOOKS_FILE))
|
|
72
|
+
return { endpoints: [] };
|
|
73
|
+
try {
|
|
74
|
+
return JSON.parse(readFileSync(WEBHOOKS_FILE, 'utf8'));
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return { endpoints: [] };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const writeWebhooks = (s) => writeFileSync(WEBHOOKS_FILE, JSON.stringify(s, null, 2));
|
|
81
|
+
export const listEndpoints = () => readWebhooks().endpoints;
|
|
82
|
+
export const findEndpoint = (id) => readWebhooks().endpoints.find(e => e.id === id);
|
|
83
|
+
export function addEndpoint(label, secret) {
|
|
84
|
+
const s = readWebhooks();
|
|
85
|
+
/** 16-char URL-safe id (~96 bits — collision-proof for any reasonable count). */
|
|
86
|
+
const ep = {
|
|
87
|
+
id: randomBytes(12).toString('base64url'), label, createdAt: new Date().toISOString(),
|
|
88
|
+
...(secret ? { secret } : {}),
|
|
89
|
+
};
|
|
90
|
+
s.endpoints.push(ep);
|
|
91
|
+
writeWebhooks(s);
|
|
92
|
+
return ep;
|
|
93
|
+
}
|
|
94
|
+
export function removeEndpoint(id) {
|
|
95
|
+
const s = readWebhooks();
|
|
96
|
+
const before = s.endpoints.length;
|
|
97
|
+
s.endpoints = s.endpoints.filter(e => e.id !== id);
|
|
98
|
+
if (s.endpoints.length === before)
|
|
99
|
+
return false;
|
|
100
|
+
writeWebhooks(s);
|
|
101
|
+
return true;
|
|
102
|
+
}
|