@stage-labs/metro 0.1.0-beta.13 → 0.1.0-beta.14
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} +44 -99
- package/dist/cli/config.js +115 -121
- package/dist/cli/index.js +20 -58
- package/dist/cli/tail.js +161 -113
- 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 +130 -0
- package/dist/dispatcher.js +51 -82
- package/dist/history.js +43 -18
- 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 +19 -2
- 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 +63 -215
- 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
package/dist/cli/tail.js
CHANGED
|
@@ -1,128 +1,74 @@
|
|
|
1
|
-
/** CLI
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
1
|
+
/** CLI tail/claim/release/claims + read-only /api/state + /api/tail HTTP. Share drain/watch primitives. */
|
|
2
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
4
|
+
import pkg from '../../package.json' with { type: 'json' };
|
|
5
|
+
import { CLAIMS_FILE, claimLine, readClaims, releaseLine } from '../broker/claims.js';
|
|
6
|
+
import { cursorKey, drainTail, followTail, historySize, readCursor, writeCursor, } from '../broker/history-stream.js';
|
|
7
|
+
import { readHistory, userSelf } from '../history.js';
|
|
8
|
+
import { asLine } from '../lines.js';
|
|
9
|
+
import { errMsg, log } from '../log.js';
|
|
10
|
+
import { loadMetroEnv, readBotIds } from '../paths.js';
|
|
7
11
|
import { emit, exitErr, flagOne, isJson, need, writeJson } from './util.js';
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
if (strict)
|
|
14
|
-
|
|
15
|
-
throw exitErr('--strict requires --as <user-uri>', 1);
|
|
16
|
-
return 'mine-only';
|
|
17
|
-
}
|
|
12
|
+
/* ──────────── CLI: metro tail / claim / release / claims ──────────── */
|
|
13
|
+
/** Pick mode from 3 booleans + optional self. Conflict/strict-no-self routed through `onErr`. */
|
|
14
|
+
function pickMode(strict, unclaimed, all, self, onErr) {
|
|
15
|
+
if ([strict, unclaimed, all].filter(Boolean).length > 1)
|
|
16
|
+
return onErr('strict/unclaimed/all are mutually exclusive');
|
|
17
|
+
if (strict)
|
|
18
|
+
return self ? 'mine-only' : onErr('strict requires --as <user-uri>');
|
|
18
19
|
if (unclaimed)
|
|
19
20
|
return 'unclaimed';
|
|
20
21
|
if (all || !self)
|
|
21
22
|
return 'all';
|
|
22
23
|
return 'mine-or-unclaimed';
|
|
23
24
|
}
|
|
24
|
-
/** Generic fallback returned by `userSelf()` when there's no Claude/Codex env — treat as "no identity". */
|
|
25
|
-
const GENERIC_USER = 'metro://user';
|
|
26
|
-
function resolveSelf(f) {
|
|
27
|
-
const raw = flagOne(f, 'as');
|
|
28
|
-
if (raw !== undefined)
|
|
29
|
-
return asLine(raw);
|
|
30
|
-
const auto = userSelf();
|
|
31
|
-
return auto === GENERIC_USER ? null : auto;
|
|
32
|
-
}
|
|
33
|
-
function resolveStartOffset(f, key) {
|
|
34
|
-
const since = flagOne(f, 'since');
|
|
35
|
-
if (since === 'tail')
|
|
36
|
-
return historySize();
|
|
37
|
-
if (since !== undefined) {
|
|
38
|
-
const n = Number(since);
|
|
39
|
-
if (!Number.isFinite(n) || n < 0)
|
|
40
|
-
throw exitErr(`--since must be a byte offset or 'tail' (got '${since}')`, 1);
|
|
41
|
-
return n;
|
|
42
|
-
}
|
|
43
|
-
return key ? readCursor(key) : 0;
|
|
44
|
-
}
|
|
45
25
|
export async function cmdTail(_, f) {
|
|
46
26
|
loadMetroEnv();
|
|
47
|
-
const
|
|
48
|
-
const
|
|
27
|
+
const raw = flagOne(f, 'as');
|
|
28
|
+
const auto = userSelf();
|
|
29
|
+
const self = raw !== undefined ? asLine(raw) : auto === 'metro://user' ? null : auto;
|
|
30
|
+
const mode = pickMode(f.strict === true, f.unclaimed === true, f.all === true, self, msg => { throw exitErr(`--${msg}`, 1); });
|
|
31
|
+
const tail = {
|
|
32
|
+
mode, self, chatFilter: flagOne(f, 'chat'), stationFilter: flagOne(f, 'station'),
|
|
33
|
+
includeWebhooks: f['include-webhooks'] === true,
|
|
34
|
+
};
|
|
49
35
|
const follow = f.follow === true;
|
|
50
|
-
const chatFilter = flagOne(f, 'chat');
|
|
51
|
-
const stationFilter = flagOne(f, 'station');
|
|
52
36
|
const limit = Number(flagOne(f, 'limit')) || 0;
|
|
53
37
|
const json = isJson(f);
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
const
|
|
58
|
-
|
|
38
|
+
/** Cursor key derives from effective mode (not userSelf), so --all/--unclaimed don't trample --as. */
|
|
39
|
+
const key = cursorKey(mode, self, { includeWebhooks: tail.includeWebhooks });
|
|
40
|
+
const since = flagOne(f, 'since');
|
|
41
|
+
const sN = since !== undefined && since !== 'tail' ? Number(since) : NaN;
|
|
42
|
+
if (since !== undefined && since !== 'tail' && (!Number.isFinite(sN) || sN < 0)) {
|
|
43
|
+
throw exitErr(`--since must be a byte offset or 'tail' (got '${since}')`, 1);
|
|
44
|
+
}
|
|
45
|
+
let offset = since === 'tail' ? historySize() : Number.isFinite(sN) ? sN : key ? readCursor(key) : 0;
|
|
59
46
|
let emitted = 0;
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
if (chatFilter && entry.line !== chatFilter)
|
|
67
|
-
continue;
|
|
68
|
-
if (stationFilter && entry.station !== stationFilter)
|
|
69
|
-
continue;
|
|
70
|
-
if (!passesMode(entry, mode, self, claims, { includeWebhooks }))
|
|
71
|
-
continue;
|
|
72
|
-
if (json)
|
|
73
|
-
process.stdout.write(JSON.stringify(entry) + '\n');
|
|
74
|
-
else
|
|
75
|
-
process.stdout.write(fmtRow(entry) + '\n');
|
|
76
|
-
if (key)
|
|
77
|
-
writeCursor(key, offset);
|
|
78
|
-
emitted++;
|
|
79
|
-
if (limit && emitted >= limit)
|
|
80
|
-
return true;
|
|
81
|
-
}
|
|
82
|
-
return false;
|
|
47
|
+
const onEntry = (entry) => {
|
|
48
|
+
process.stdout.write((json ? JSON.stringify(entry) : fmtRow(entry)) + '\n');
|
|
49
|
+
if (key)
|
|
50
|
+
writeCursor(key, offset);
|
|
51
|
+
if (limit && ++emitted >= limit)
|
|
52
|
+
return true;
|
|
83
53
|
};
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
if (!follow)
|
|
54
|
+
offset = drainTail(offset, tail, onEntry);
|
|
55
|
+
if ((limit && emitted >= limit) || !follow)
|
|
87
56
|
return;
|
|
88
|
-
/** fs.watch on macOS sometimes coalesces or drops events — poll every 500ms as a backstop. */
|
|
89
57
|
await new Promise(resolve => {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
watcher.close();
|
|
97
|
-
}
|
|
98
|
-
catch { /* ignore */ }
|
|
99
|
-
watcher = null;
|
|
100
|
-
}
|
|
101
|
-
clearInterval(poll);
|
|
102
|
-
resolve();
|
|
103
|
-
};
|
|
104
|
-
const poll = setInterval(trigger, 500);
|
|
105
|
-
const startWatcher = () => {
|
|
106
|
-
if (!existsSync(HISTORY_FILE))
|
|
107
|
-
return;
|
|
108
|
-
try {
|
|
109
|
-
watcher = watch(HISTORY_FILE, () => trigger());
|
|
110
|
-
}
|
|
111
|
-
catch { /* ignore — poll will catch */ }
|
|
112
|
-
};
|
|
113
|
-
startWatcher();
|
|
114
|
-
process.on('SIGINT', cleanup);
|
|
115
|
-
process.on('SIGTERM', cleanup);
|
|
116
|
-
process.stdin.on('end', cleanup).on('close', cleanup);
|
|
58
|
+
const stop = followTail(offset, tail, e => { if (onEntry(e) === true)
|
|
59
|
+
finish(); }, 500);
|
|
60
|
+
const finish = () => { stop(); resolve(); };
|
|
61
|
+
process.on('SIGINT', finish);
|
|
62
|
+
process.on('SIGTERM', finish);
|
|
63
|
+
process.stdin.on('end', finish).on('close', finish);
|
|
117
64
|
});
|
|
118
65
|
}
|
|
119
66
|
function fmtRow(e) {
|
|
120
|
-
const ts = e.ts.slice(11, 19);
|
|
121
67
|
const body = e.text ?? (e.emoji ? `[react ${e.emoji}]` : '');
|
|
122
68
|
const text = body.length > 80 ? body.slice(0, 79) + '…' : body;
|
|
123
69
|
const who = (e.fromName ?? e.from).padEnd(28).slice(0, 28);
|
|
124
70
|
const where = e.line.padEnd(40).slice(0, 40);
|
|
125
|
-
return `${ts} ${e.id.padEnd(12)} ${e.kind.padEnd(8)} ${who} ${where} ${text}`;
|
|
71
|
+
return `${e.ts.slice(11, 19)} ${e.id.padEnd(12)} ${e.kind.padEnd(8)} ${who} ${where} ${text}`;
|
|
126
72
|
}
|
|
127
73
|
export async function cmdClaim(p, f) {
|
|
128
74
|
loadMetroEnv();
|
|
@@ -138,11 +84,7 @@ export async function cmdRelease(p, f) {
|
|
|
138
84
|
need(p, 1, 'metro release <line>');
|
|
139
85
|
const line = asLine(p[0]);
|
|
140
86
|
const { released, claims } = releaseLine(line);
|
|
141
|
-
|
|
142
|
-
emit(f, `${line} was not claimed`, { ok: true, released: false, line, claims });
|
|
143
|
-
return;
|
|
144
|
-
}
|
|
145
|
-
emit(f, `released ${line}`, { ok: true, released: true, line, claims });
|
|
87
|
+
emit(f, released ? `released ${line}` : `${line} was not claimed`, { ok: true, released, line, claims });
|
|
146
88
|
}
|
|
147
89
|
export async function cmdClaims(_, f) {
|
|
148
90
|
loadMetroEnv();
|
|
@@ -151,14 +93,120 @@ export async function cmdClaims(_, f) {
|
|
|
151
93
|
if (isJson(f))
|
|
152
94
|
return writeJson({ claims });
|
|
153
95
|
if (!entries.length) {
|
|
154
|
-
process.stdout.write(
|
|
155
|
-
process.stdout.write(`file: ${CLAIMS_FILE}${existsSync(CLAIMS_FILE) ? '' : ' (not created yet)'}\n`);
|
|
96
|
+
process.stdout.write(`(no claims — every tail with matching filters receives every event)\nfile: ${CLAIMS_FILE}${existsSync(CLAIMS_FILE) ? '' : ' (not created yet)'}\n`);
|
|
156
97
|
return;
|
|
157
98
|
}
|
|
158
|
-
const
|
|
99
|
+
const w = Math.max(...entries.map(([l]) => l.length));
|
|
159
100
|
process.stdout.write('metro claims\n\n');
|
|
160
|
-
for (const [
|
|
161
|
-
process.stdout.write(` ${
|
|
162
|
-
}
|
|
101
|
+
for (const [l, o] of entries)
|
|
102
|
+
process.stdout.write(` ${l.padEnd(w)} → ${o}\n`);
|
|
163
103
|
process.stdout.write(`\n${entries.length} claim${entries.length === 1 ? '' : 's'} · ${CLAIMS_FILE}\n`);
|
|
164
104
|
}
|
|
105
|
+
/* ──────────── HTTP: /api/state + /api/tail (mounted on webhook server) ──────────── */
|
|
106
|
+
/** Monitor endpoints answer only on dedicated hostnames so webhook tunnel can't double-serve them. */
|
|
107
|
+
const MONITOR_HOSTS = new Set((process.env.METRO_MONITOR_HOSTS ?? 'monitor.metro.box,localhost,127.0.0.1').toLowerCase().split(',').map(s => s.trim()).filter(Boolean));
|
|
108
|
+
const JSON_CT = { 'content-type': 'application/json' };
|
|
109
|
+
function jsonRes(res, status, body) {
|
|
110
|
+
res.writeHead(status, JSON_CT);
|
|
111
|
+
res.end(JSON.stringify(body));
|
|
112
|
+
}
|
|
113
|
+
function authorized(req) {
|
|
114
|
+
const token = process.env.METRO_MONITOR_TOKEN;
|
|
115
|
+
if (!token)
|
|
116
|
+
return { status: 503, msg: 'monitor endpoints not configured (METRO_MONITOR_TOKEN unset)' };
|
|
117
|
+
const value = [].concat(req.headers['authorization'] ?? [])[0];
|
|
118
|
+
if (!value?.startsWith('Bearer '))
|
|
119
|
+
return { status: 401, msg: 'unauthorized' };
|
|
120
|
+
const given = Buffer.from(value.slice(7)), want = Buffer.from(token);
|
|
121
|
+
if (given.length !== want.length || !timingSafeEqual(given, want))
|
|
122
|
+
return { status: 401, msg: 'unauthorized' };
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
export function handleMonitorRequest(req, res) {
|
|
126
|
+
const url = req.url ?? '';
|
|
127
|
+
if (!url.startsWith('/api/'))
|
|
128
|
+
return false;
|
|
129
|
+
const host = req.headers[':authority'] ?? req.headers.host;
|
|
130
|
+
if (host && !MONITOR_HOSTS.has(host.split(':')[0].toLowerCase()))
|
|
131
|
+
return false;
|
|
132
|
+
if (req.method !== 'GET') {
|
|
133
|
+
jsonRes(res, 405, { error: 'method not allowed' });
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
const auth = authorized(req);
|
|
137
|
+
if (auth) {
|
|
138
|
+
jsonRes(res, auth.status, { error: auth.msg });
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
const [path, qs = ''] = url.split('?', 2);
|
|
142
|
+
const q = new URLSearchParams(qs);
|
|
143
|
+
if (path === '/api/state') {
|
|
144
|
+
handleState(res, q);
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
if (path === '/api/tail') {
|
|
148
|
+
handleTail(req, res, q).catch(err => {
|
|
149
|
+
log.warn({ err: errMsg(err) }, 'monitor: tail handler error');
|
|
150
|
+
try {
|
|
151
|
+
if (!res.headersSent)
|
|
152
|
+
res.writeHead(500).end();
|
|
153
|
+
else
|
|
154
|
+
res.end();
|
|
155
|
+
}
|
|
156
|
+
catch { /* ignore */ }
|
|
157
|
+
});
|
|
158
|
+
return true;
|
|
159
|
+
}
|
|
160
|
+
jsonRes(res, 404, { error: 'not found' });
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
function nonNegInt(raw) {
|
|
164
|
+
const n = raw == null ? NaN : Number(raw);
|
|
165
|
+
return Number.isInteger(n) && n >= 0 ? n : null;
|
|
166
|
+
}
|
|
167
|
+
function handleState(res, q) {
|
|
168
|
+
const before = nonNegInt(q.get('before'));
|
|
169
|
+
const limit = Math.min(nonNegInt(q.get('limit')) ?? 100, 500);
|
|
170
|
+
if (before !== null)
|
|
171
|
+
return jsonRes(res, 200, { recent_history: readHistory({ limit, skip: before }) });
|
|
172
|
+
const recent = readHistory({ limit }), claims = readClaims();
|
|
173
|
+
const lines = new Set([...recent.map(e => e.line), ...Object.keys(claims)]);
|
|
174
|
+
jsonRes(res, 200, {
|
|
175
|
+
claims, lines: [...lines], recent_history: recent, bot_ids: readBotIds(), version: pkg.version,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
async function handleTail(req, res, q) {
|
|
179
|
+
const asParam = q.get('as');
|
|
180
|
+
const self = asParam ? asLine(asParam) : null;
|
|
181
|
+
const isOn = (k) => q.get(k) === 'true' || q.get('mode') === k;
|
|
182
|
+
const mode = pickMode(isOn('strict'), isOn('unclaimed'), isOn('all'), self, () => 'all');
|
|
183
|
+
const opts = {
|
|
184
|
+
mode, self, chatFilter: q.get('chat') ?? undefined,
|
|
185
|
+
stationFilter: q.get('station') ?? undefined,
|
|
186
|
+
includeWebhooks: q.get('include_webhooks') === 'true',
|
|
187
|
+
};
|
|
188
|
+
res.writeHead(200, {
|
|
189
|
+
'content-type': 'text/event-stream', 'cache-control': 'no-cache, no-transform',
|
|
190
|
+
'connection': 'keep-alive', 'access-control-allow-origin': '*',
|
|
191
|
+
/** Cloudflare/proxies buffer SSE without this hint. */
|
|
192
|
+
'x-accel-buffering': 'no',
|
|
193
|
+
});
|
|
194
|
+
/** `since=tail` (default) starts at EOF; `since=0` replays the full file; numeric = byte offset. */
|
|
195
|
+
const since = q.get('since');
|
|
196
|
+
const sinceN = since && since !== 'tail' ? Number(since) : NaN;
|
|
197
|
+
let offset = Number.isFinite(sinceN) && sinceN >= 0 ? sinceN : historySize();
|
|
198
|
+
/** 4 KiB padding so Cloudflare's HTTP/2 buffer flushes (else holds 30+ s on free tier). */
|
|
199
|
+
res.write(`: metro monitor tail (mode=${opts.mode}${self ? `, as=${self}` : ''})\n: ${'-'.repeat(4096)}\n\n`);
|
|
200
|
+
const sse = (e) => {
|
|
201
|
+
res.write(`id: ${e.id}\nevent: history\ndata: ${JSON.stringify(e)}\n\n`);
|
|
202
|
+
};
|
|
203
|
+
offset = drainTail(offset, opts, sse);
|
|
204
|
+
const stop = followTail(offset, opts, sse, 1_000);
|
|
205
|
+
const keepalive = setInterval(() => res.write(': keepalive\n\n'), 25_000);
|
|
206
|
+
const cleanup = () => { stop(); clearInterval(keepalive); try {
|
|
207
|
+
res.end();
|
|
208
|
+
}
|
|
209
|
+
catch { /* ignore */ } };
|
|
210
|
+
req.on('close', cleanup);
|
|
211
|
+
req.on('error', cleanup);
|
|
212
|
+
}
|
package/dist/cli/webhook.js
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
|
-
/** CLI subcommands: `metro
|
|
1
|
+
/** CLI subcommands: `metro call`, `metro trains list`, `metro webhook ...`, `metro tunnel ...`. */
|
|
2
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
2
6
|
import { spawnSync } from 'node:child_process';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
7
|
+
import { ipcCall } from '../ipc.js';
|
|
8
|
+
import { loadMetroEnv } from '../paths.js';
|
|
9
|
+
import { TRAINS_DIR } from '../trains/supervisor.js';
|
|
10
|
+
import { addEndpoint, listEndpoints, loadTunnelConfig, removeEndpoint, saveTunnelConfig, webhookPort, } from '../tunnel.js';
|
|
5
11
|
import { emit, exitErr, flagOne, isJson, need, writeJson } from './util.js';
|
|
6
12
|
function urlFor(endpointId) {
|
|
7
13
|
const t = loadTunnelConfig();
|
|
@@ -79,3 +85,97 @@ function run(cmd, args, opts = {}) {
|
|
|
79
85
|
if (r.status !== 0 && !opts.allowFail)
|
|
80
86
|
throw exitErr(`${cmd} ${args.join(' ')} exited ${r.status}`, 2);
|
|
81
87
|
}
|
|
88
|
+
/* ──────────── metro call <train> <action> [args] + metro trains list ──────────── */
|
|
89
|
+
async function readArgs(raw) {
|
|
90
|
+
if (raw === undefined)
|
|
91
|
+
return {};
|
|
92
|
+
if (raw === '-') {
|
|
93
|
+
const chunks = [];
|
|
94
|
+
for await (const c of process.stdin)
|
|
95
|
+
chunks.push(c);
|
|
96
|
+
const s = Buffer.concat(chunks).toString('utf8').trim();
|
|
97
|
+
return s ? JSON.parse(s) : {};
|
|
98
|
+
}
|
|
99
|
+
if (raw.startsWith('@'))
|
|
100
|
+
return JSON.parse(readFileSync(raw.slice(1), 'utf8'));
|
|
101
|
+
/** Bare string allowed (handed to the train as-is). */
|
|
102
|
+
try {
|
|
103
|
+
return JSON.parse(raw);
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return raw;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
export async function cmdCall(p, f) {
|
|
110
|
+
need(p, 2, 'metro call <train> <action> [args-json | @file | -]');
|
|
111
|
+
loadMetroEnv();
|
|
112
|
+
const [train, action, rawArgs] = p;
|
|
113
|
+
const resp = await ipcCall({ op: 'forward-call', train, action, args: await readArgs(rawArgs) });
|
|
114
|
+
if (!resp.ok)
|
|
115
|
+
throw new Error(resp.error);
|
|
116
|
+
if (!('response' in resp))
|
|
117
|
+
throw new Error('daemon returned malformed forward-call response');
|
|
118
|
+
if (resp.response.error)
|
|
119
|
+
throw new Error(`train '${train}': ${resp.response.error}`);
|
|
120
|
+
if (isJson(f))
|
|
121
|
+
writeJson(resp.response.result ?? null);
|
|
122
|
+
else
|
|
123
|
+
process.stdout.write(JSON.stringify(resp.response.result ?? null) + '\n');
|
|
124
|
+
}
|
|
125
|
+
export async function cmdTrains(p, f) {
|
|
126
|
+
const sub = p[0] ?? 'list';
|
|
127
|
+
if (sub === 'restart')
|
|
128
|
+
return cmdTrainsRestart(p.slice(1), f);
|
|
129
|
+
if (sub === 'new')
|
|
130
|
+
return cmdTrainsNew(p.slice(1), f);
|
|
131
|
+
if (sub !== 'list')
|
|
132
|
+
throw new Error(`metro trains <list|restart|new> (got '${sub}')`);
|
|
133
|
+
loadMetroEnv();
|
|
134
|
+
const resp = await ipcCall({ op: 'trains-list' });
|
|
135
|
+
if (!resp.ok)
|
|
136
|
+
throw new Error(resp.error);
|
|
137
|
+
if (!('trains' in resp))
|
|
138
|
+
throw new Error('daemon returned malformed trains-list response');
|
|
139
|
+
if (isJson(f))
|
|
140
|
+
return writeJson({ trains: resp.trains });
|
|
141
|
+
if (!resp.trains.length) {
|
|
142
|
+
process.stdout.write('metro trains\n\n (no trains in ~/.metro/trains/)\n');
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
process.stdout.write('metro trains\n\n');
|
|
146
|
+
for (const t of resp.trains) {
|
|
147
|
+
const mark = t.running ? '●' : '○';
|
|
148
|
+
const pid = t.pid ? ` pid ${t.pid}` : '';
|
|
149
|
+
const started = t.startedAt ? ` since ${t.startedAt.slice(11, 19)}` : '';
|
|
150
|
+
const fails = t.failCount ? ` · ${t.failCount} fail${t.failCount === 1 ? '' : 's'}` : '';
|
|
151
|
+
process.stdout.write(` ${mark} ${t.name.padEnd(16)}${pid}${started}${fails}\n ${t.path}\n`);
|
|
152
|
+
}
|
|
153
|
+
process.stdout.write('\n');
|
|
154
|
+
}
|
|
155
|
+
async function cmdTrainsRestart(p, f) {
|
|
156
|
+
need(p, 1, 'metro trains restart <name>');
|
|
157
|
+
loadMetroEnv();
|
|
158
|
+
const resp = await ipcCall({ op: 'train-restart', name: p[0] });
|
|
159
|
+
if (!resp.ok)
|
|
160
|
+
throw new Error(resp.error);
|
|
161
|
+
emit(f, `restarted train '${p[0]}'`, { ok: true, name: p[0] });
|
|
162
|
+
}
|
|
163
|
+
/** dist/cli/webhook.js → <package-root>/examples/telegram.ts */
|
|
164
|
+
const bundledExample = () => join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'examples', 'telegram.ts');
|
|
165
|
+
async function cmdTrainsNew(p, f) {
|
|
166
|
+
need(p, 1, 'metro trains new <name>');
|
|
167
|
+
const name = p[0];
|
|
168
|
+
if (!/^[A-Za-z0-9_-]+$/.test(name))
|
|
169
|
+
throw exitErr(`bad train name '${name}' (use [A-Za-z0-9_-])`, 1);
|
|
170
|
+
const src = bundledExample();
|
|
171
|
+
if (!existsSync(src))
|
|
172
|
+
throw exitErr(`bundled example missing at ${src} (broken install?)`, 2);
|
|
173
|
+
mkdirSync(TRAINS_DIR, { recursive: true });
|
|
174
|
+
const dest = join(TRAINS_DIR, `${name}.ts`);
|
|
175
|
+
if (existsSync(dest))
|
|
176
|
+
throw exitErr(`train already exists: ${dest}`, 1);
|
|
177
|
+
copyFileSync(src, dest);
|
|
178
|
+
const metroPkg = join(homedir(), '.metro', 'package.json');
|
|
179
|
+
const pkgHint = existsSync(metroPkg) ? '' : '\n (run `cd ~/.metro && bun init` first if your train needs deps)';
|
|
180
|
+
emit(f, `created ${dest}${pkgHint}\n next: edit the file, then \`metro trains restart ${name}\``, { ok: true, path: dest, pkgInitialized: existsSync(metroPkg) });
|
|
181
|
+
}
|
|
@@ -1,30 +1,10 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
import { createConnection } from 'node:net';
|
|
5
|
-
import { WebSocket } from 'ws';
|
|
6
|
-
import { errMsg, log } from './log.js';
|
|
1
|
+
/** JSON-RPC/WS client for codex app-server: each event → `turn/start`. */
|
|
2
|
+
import { errMsg, log } from '../log.js';
|
|
3
|
+
import { buildTurnInput, encodeRpc, extractThreadStartedId, isStatusActive, openSocket, parseUrl, } from './protocol.js';
|
|
7
4
|
const RECONNECT_DELAY_MS = 2_000;
|
|
8
5
|
const MAX_QUEUE = 100;
|
|
9
6
|
/** Backstop in case `turn/completed` never arrives — unstick the single-flight gate. */
|
|
10
7
|
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
8
|
export class CodexRC {
|
|
29
9
|
url;
|
|
30
10
|
clientVersion;
|
|
@@ -115,9 +95,12 @@ export class CodexRC {
|
|
|
115
95
|
p.resolve(msg.result);
|
|
116
96
|
return;
|
|
117
97
|
}
|
|
98
|
+
this.handleNotification(msg);
|
|
99
|
+
}
|
|
100
|
+
handleNotification(msg) {
|
|
118
101
|
switch (msg.method) {
|
|
119
102
|
case 'thread/started': {
|
|
120
|
-
const id = msg.params
|
|
103
|
+
const id = extractThreadStartedId(msg.params);
|
|
121
104
|
if (id) {
|
|
122
105
|
this.setThreadId(id);
|
|
123
106
|
log.info({ thread: id }, 'codex-rc thread started');
|
|
@@ -126,12 +109,10 @@ export class CodexRC {
|
|
|
126
109
|
break;
|
|
127
110
|
}
|
|
128
111
|
case 'thread/status/changed': {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
if (p?.threadId !== this.threadId)
|
|
112
|
+
const s = isStatusActive(msg.params, this.threadId);
|
|
113
|
+
if (!s.match)
|
|
132
114
|
break;
|
|
133
|
-
|
|
134
|
-
if (active)
|
|
115
|
+
if (s.active)
|
|
135
116
|
this.turnInFlight = true;
|
|
136
117
|
else {
|
|
137
118
|
this.clearTurnTimeout();
|
|
@@ -203,8 +184,7 @@ export class CodexRC {
|
|
|
203
184
|
this.turnInFlight = true;
|
|
204
185
|
this.armTurnTimeout();
|
|
205
186
|
try {
|
|
206
|
-
|
|
207
|
-
await this.call('turn/start', { threadId: this.threadId, input });
|
|
187
|
+
await this.call('turn/start', { threadId: this.threadId, input: buildTurnInput(line) });
|
|
208
188
|
this.queue.shift();
|
|
209
189
|
}
|
|
210
190
|
catch (err) {
|
|
@@ -239,7 +219,7 @@ export class CodexRC {
|
|
|
239
219
|
const ws = this.ws;
|
|
240
220
|
return new Promise((resolve, reject) => {
|
|
241
221
|
this.pending.set(id, { resolve: resolve, reject });
|
|
242
|
-
ws.send(
|
|
222
|
+
ws.send(encodeRpc(id, method, params));
|
|
243
223
|
});
|
|
244
224
|
}
|
|
245
225
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** Codex JSON-RPC over WS: endpoint parsing, socket open, message encode/decode. */
|
|
2
|
+
import { createConnection } from 'node:net';
|
|
3
|
+
import { WebSocket } from 'ws';
|
|
4
|
+
/** Accept ws://, wss://, unix:///abs/path, or /abs/path (shorthand for unix). */
|
|
5
|
+
export function parseUrl(input) {
|
|
6
|
+
if (input.startsWith('ws://') || input.startsWith('wss://'))
|
|
7
|
+
return { kind: 'tcp', url: input };
|
|
8
|
+
if (input.startsWith('unix://'))
|
|
9
|
+
return { kind: 'unix', path: input.replace(/^unix:\/+/, '/') };
|
|
10
|
+
if (input.startsWith('/'))
|
|
11
|
+
return { kind: 'unix', path: input };
|
|
12
|
+
throw new Error(`unsupported METRO_CODEX_RC: ${input} (expected ws://, wss://, unix://, or abs path)`);
|
|
13
|
+
}
|
|
14
|
+
export function openSocket(endpoint) {
|
|
15
|
+
if (endpoint.kind === 'tcp')
|
|
16
|
+
return new WebSocket(endpoint.url);
|
|
17
|
+
return new WebSocket('ws://localhost/', {
|
|
18
|
+
createConnection: () => createConnection({ path: endpoint.path }),
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
export function encodeRpc(id, method, params) {
|
|
22
|
+
return JSON.stringify({ jsonrpc: '2.0', id, method, params });
|
|
23
|
+
}
|
|
24
|
+
/** Extract the active-thread state from a `thread/status/changed` params payload. */
|
|
25
|
+
/** Codex 0.130+: `status: {active}` ⇒ in-flight; any other shape ⇒ idle. */
|
|
26
|
+
export function isStatusActive(params, expectedThreadId) {
|
|
27
|
+
const p = params;
|
|
28
|
+
if (p?.threadId !== expectedThreadId)
|
|
29
|
+
return { match: false, active: false };
|
|
30
|
+
const active = typeof p.status === 'object' && p.status !== null && 'active' in p.status;
|
|
31
|
+
return { match: true, active };
|
|
32
|
+
}
|
|
33
|
+
export function extractThreadStartedId(params) {
|
|
34
|
+
return params?.thread?.id;
|
|
35
|
+
}
|
|
36
|
+
export function buildTurnInput(line) {
|
|
37
|
+
return [{ type: 'text', text: line, textElements: [] }];
|
|
38
|
+
}
|