@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.
Files changed (41) hide show
  1. package/README.md +76 -189
  2. package/dist/broker/claims.js +144 -0
  3. package/dist/{broker.js → broker/history-stream.js} +44 -99
  4. package/dist/cli/config.js +115 -121
  5. package/dist/cli/index.js +20 -58
  6. package/dist/cli/tail.js +161 -113
  7. package/dist/cli/webhook.js +103 -3
  8. package/dist/{codex-rc.js → codex-rc/client.js} +12 -32
  9. package/dist/codex-rc/protocol.js +38 -0
  10. package/dist/dispatcher/server.js +130 -0
  11. package/dist/dispatcher.js +51 -82
  12. package/dist/history.js +43 -18
  13. package/dist/ipc.js +28 -10
  14. package/dist/lines.js +54 -0
  15. package/dist/local-identity.js +80 -0
  16. package/dist/paths.js +58 -12
  17. package/dist/trains/protocol.js +99 -0
  18. package/dist/trains/supervisor.js +210 -0
  19. package/dist/tunnel.js +39 -1
  20. package/docs/broker.md +88 -136
  21. package/docs/monitor.md +19 -2
  22. package/docs/uri-scheme.md +10 -7
  23. package/examples/README.md +32 -0
  24. package/examples/telegram.ts +121 -0
  25. package/package.json +6 -5
  26. package/skills/metro/SKILL.md +63 -215
  27. package/dist/cache.js +0 -69
  28. package/dist/cli/actions.js +0 -206
  29. package/dist/cli/skill.js +0 -62
  30. package/dist/monitor.js +0 -194
  31. package/dist/registry.js +0 -48
  32. package/dist/stations/claude.js +0 -45
  33. package/dist/stations/codex.js +0 -68
  34. package/dist/stations/discord.js +0 -216
  35. package/dist/stations/index.js +0 -129
  36. package/dist/stations/telegram-md.js +0 -34
  37. package/dist/stations/telegram-upload.js +0 -113
  38. package/dist/stations/telegram.js +0 -234
  39. package/dist/stations/webhook.js +0 -103
  40. package/dist/webhooks.js +0 -41
  41. package/docs/users.md +0 -226
package/dist/cli/tail.js CHANGED
@@ -1,128 +1,74 @@
1
- /** CLI subcommands: `metro tail` (claim-aware log subscriber) + `metro claim|release|claims`. */
2
- import { existsSync, watch } from 'node:fs';
3
- import { CLAIMS_FILE, HISTORY_FILE, claimLine, cursorKey, historySize, passesMode, readClaims, readCursor, readEntriesFrom, releaseLine, writeCursor, } from '../broker.js';
4
- import { userSelf } from '../history.js';
5
- import { asLine } from '../stations/index.js';
6
- import { loadMetroEnv } from '../paths.js';
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
- function resolveMode(f, self) {
9
- const strict = f.strict === true, unclaimed = f.unclaimed === true, all = f.all === true;
10
- if ([strict, unclaimed, all].filter(Boolean).length > 1) {
11
- throw exitErr('--strict, --unclaimed, --all are mutually exclusive', 1);
12
- }
13
- if (strict) {
14
- if (!self)
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 self = resolveSelf(f);
48
- const mode = resolveMode(f, self);
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
- const includeWebhooks = f['include-webhooks'] === true;
55
- /** Cursor key derives from the effective mode (not userSelf), so `--all` / `--unclaimed` */
56
- /** don't trample the personal `--as=<me>` cursor in a CLAUDECODE shell. */
57
- const key = cursorKey(mode, self, { includeWebhooks });
58
- const startOffset = resolveStartOffset(f, key);
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
- let offset = startOffset;
61
- const drain = () => {
62
- /** read claims once per drain so a burst of events shares a snapshot */
63
- const claims = readClaims();
64
- for (const { entry, offset: next } of readEntriesFrom(offset)) {
65
- offset = next;
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
- if (drain() && !follow)
85
- return;
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
- let watcher = null;
91
- const trigger = () => { if (drain())
92
- cleanup(); };
93
- const cleanup = () => {
94
- if (watcher) {
95
- try {
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
- if (!released) {
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('(no claims — every tail with matching filters receives every event)\n');
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 widest = Math.max(...entries.map(([l]) => l.length));
99
+ const w = Math.max(...entries.map(([l]) => l.length));
159
100
  process.stdout.write('metro claims\n\n');
160
- for (const [line, owner] of entries) {
161
- process.stdout.write(` ${line.padEnd(widest)} → ${owner}\n`);
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
+ }
@@ -1,7 +1,13 @@
1
- /** CLI subcommands: `metro webhook add|list|remove` + `metro tunnel setup`. */
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 { addEndpoint, listEndpoints, removeEndpoint, webhookPort } from '../webhooks.js';
4
- import { loadTunnelConfig, saveTunnelConfig } from '../tunnel.js';
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
- * 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';
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?.thread?.id;
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
- /* Codex 0.130+: status `{active}` means in-flight; anything else is idle. */
130
- const p = msg.params;
131
- if (p?.threadId !== this.threadId)
112
+ const s = isStatusActive(msg.params, this.threadId);
113
+ if (!s.match)
132
114
  break;
133
- const active = typeof p.status === 'object' && p.status !== null && 'active' in p.status;
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
- const input = [{ type: 'text', text: line, textElements: [] }];
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(JSON.stringify({ jsonrpc: '2.0', id, method, params }));
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
+ }