@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
package/dist/cli/tail.js
CHANGED
|
@@ -1,128 +1,64 @@
|
|
|
1
|
-
/** CLI
|
|
2
|
-
import { existsSync
|
|
3
|
-
import { CLAIMS_FILE,
|
|
1
|
+
/** CLI: `metro tail / claim / release / claims`. HTTP monitor endpoints live in monitor-api.ts. */
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { CLAIMS_FILE, claimLine, readClaims, releaseLine } from '../broker/claims.js';
|
|
4
|
+
import { cursorKey, drainTail, followTail, historySize, readCursor, writeCursor, } from '../broker/history-stream.js';
|
|
4
5
|
import { userSelf } from '../history.js';
|
|
5
|
-
import { asLine } from '../
|
|
6
|
+
import { asLine } from '../lines.js';
|
|
6
7
|
import { loadMetroEnv } from '../paths.js';
|
|
8
|
+
import { pickMode } from './monitor-api.js';
|
|
7
9
|
import { emit, exitErr, flagOne, isJson, need, writeJson } from './util.js';
|
|
8
|
-
|
|
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
|
-
}
|
|
18
|
-
if (unclaimed)
|
|
19
|
-
return 'unclaimed';
|
|
20
|
-
if (all || !self)
|
|
21
|
-
return 'all';
|
|
22
|
-
return 'mine-or-unclaimed';
|
|
23
|
-
}
|
|
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
|
-
}
|
|
10
|
+
export { handleMonitorRequest } from './monitor-api.js';
|
|
45
11
|
export async function cmdTail(_, f) {
|
|
46
12
|
loadMetroEnv();
|
|
47
|
-
const
|
|
48
|
-
const
|
|
13
|
+
const raw = flagOne(f, 'as');
|
|
14
|
+
const auto = userSelf();
|
|
15
|
+
const self = raw !== undefined ? asLine(raw) : auto === 'metro://user' ? null : auto;
|
|
16
|
+
const mode = pickMode(f.strict === true, f.unclaimed === true, f.all === true, self, msg => { throw exitErr(`--${msg}`, 1); });
|
|
17
|
+
const excludeFromFlag = flagOne(f, 'exclude-from');
|
|
18
|
+
const tail = {
|
|
19
|
+
mode, self, chatFilter: flagOne(f, 'chat'), stationFilter: flagOne(f, 'station'),
|
|
20
|
+
includeWebhooks: f['include-webhooks'] === true,
|
|
21
|
+
excludeFrom: excludeFromFlag
|
|
22
|
+
? excludeFromFlag.split(',').map(s => s.trim()).filter(Boolean)
|
|
23
|
+
: undefined,
|
|
24
|
+
};
|
|
49
25
|
const follow = f.follow === true;
|
|
50
|
-
const chatFilter = flagOne(f, 'chat');
|
|
51
|
-
const stationFilter = flagOne(f, 'station');
|
|
52
26
|
const limit = Number(flagOne(f, 'limit')) || 0;
|
|
53
27
|
const json = isJson(f);
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
const
|
|
58
|
-
|
|
28
|
+
/** Cursor key derives from effective mode (not userSelf), so --all/--unclaimed don't trample --as. */
|
|
29
|
+
const key = cursorKey(mode, self, { includeWebhooks: tail.includeWebhooks });
|
|
30
|
+
const since = flagOne(f, 'since');
|
|
31
|
+
const sN = since !== undefined && since !== 'tail' ? Number(since) : NaN;
|
|
32
|
+
if (since !== undefined && since !== 'tail' && (!Number.isFinite(sN) || sN < 0)) {
|
|
33
|
+
throw exitErr(`--since must be a byte offset or 'tail' (got '${since}')`, 1);
|
|
34
|
+
}
|
|
35
|
+
let offset = since === 'tail' ? historySize() : Number.isFinite(sN) ? sN : key ? readCursor(key) : 0;
|
|
59
36
|
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;
|
|
37
|
+
const onEntry = (entry) => {
|
|
38
|
+
process.stdout.write((json ? JSON.stringify(entry) : fmtRow(entry)) + '\n');
|
|
39
|
+
if (key)
|
|
40
|
+
writeCursor(key, offset);
|
|
41
|
+
if (limit && ++emitted >= limit)
|
|
42
|
+
return true;
|
|
83
43
|
};
|
|
84
|
-
|
|
44
|
+
offset = drainTail(offset, tail, onEntry);
|
|
45
|
+
if ((limit && emitted >= limit) || !follow)
|
|
85
46
|
return;
|
|
86
|
-
if (!follow)
|
|
87
|
-
return;
|
|
88
|
-
/** fs.watch on macOS sometimes coalesces or drops events — poll every 500ms as a backstop. */
|
|
89
47
|
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);
|
|
48
|
+
const stop = followTail(offset, tail, e => { if (onEntry(e) === true)
|
|
49
|
+
finish(); }, 500);
|
|
50
|
+
const finish = () => { stop(); resolve(); };
|
|
51
|
+
process.on('SIGINT', finish);
|
|
52
|
+
process.on('SIGTERM', finish);
|
|
53
|
+
process.stdin.on('end', finish).on('close', finish);
|
|
117
54
|
});
|
|
118
55
|
}
|
|
119
56
|
function fmtRow(e) {
|
|
120
|
-
const
|
|
121
|
-
const body = e.text ?? (e.emoji ? `[react ${e.emoji}]` : '');
|
|
57
|
+
const body = e.text ?? '';
|
|
122
58
|
const text = body.length > 80 ? body.slice(0, 79) + '…' : body;
|
|
123
59
|
const who = (e.fromName ?? e.from).padEnd(28).slice(0, 28);
|
|
124
60
|
const where = e.line.padEnd(40).slice(0, 40);
|
|
125
|
-
return `${
|
|
61
|
+
return `${e.ts.slice(11, 19)} ${e.id.padEnd(12)} ${who} ${where} ${text}`;
|
|
126
62
|
}
|
|
127
63
|
export async function cmdClaim(p, f) {
|
|
128
64
|
loadMetroEnv();
|
|
@@ -138,11 +74,7 @@ export async function cmdRelease(p, f) {
|
|
|
138
74
|
need(p, 1, 'metro release <line>');
|
|
139
75
|
const line = asLine(p[0]);
|
|
140
76
|
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 });
|
|
77
|
+
emit(f, released ? `released ${line}` : `${line} was not claimed`, { ok: true, released, line, claims });
|
|
146
78
|
}
|
|
147
79
|
export async function cmdClaims(_, f) {
|
|
148
80
|
loadMetroEnv();
|
|
@@ -151,14 +83,13 @@ export async function cmdClaims(_, f) {
|
|
|
151
83
|
if (isJson(f))
|
|
152
84
|
return writeJson({ claims });
|
|
153
85
|
if (!entries.length) {
|
|
154
|
-
process.stdout.write('(no claims — every tail with matching filters receives every event)\n'
|
|
155
|
-
|
|
86
|
+
process.stdout.write('(no claims — every tail with matching filters receives every event)\n'
|
|
87
|
+
+ `file: ${CLAIMS_FILE}${existsSync(CLAIMS_FILE) ? '' : ' (not created yet)'}\n`);
|
|
156
88
|
return;
|
|
157
89
|
}
|
|
158
|
-
const
|
|
90
|
+
const w = Math.max(...entries.map(([l]) => l.length));
|
|
159
91
|
process.stdout.write('metro claims\n\n');
|
|
160
|
-
for (const [
|
|
161
|
-
process.stdout.write(` ${
|
|
162
|
-
}
|
|
92
|
+
for (const [l, o] of entries)
|
|
93
|
+
process.stdout.write(` ${l.padEnd(w)} → ${o}\n`);
|
|
163
94
|
process.stdout.write(`\n${entries.length} claim${entries.length === 1 ? '' : 's'} · ${CLAIMS_FILE}\n`);
|
|
164
95
|
}
|
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
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/** Dispatcher's plumbing: outbound event emission + train-envelope translation + HTTP receiver. */
|
|
2
|
+
import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
3
|
+
import { createServer, } from 'node:http';
|
|
4
|
+
import { Line } from '../lines.js';
|
|
5
|
+
import { errMsg, log } from '../log.js';
|
|
6
|
+
import { noteSeen } from '../paths.js';
|
|
7
|
+
import { appendHistory, formatDisplay, mintId, noteUserFromLine, userSelf, } from '../history.js';
|
|
8
|
+
import { handleMonitorRequest } from '../cli/tail.js';
|
|
9
|
+
import { findEndpoint, listEndpoints, webhookPort } from '../tunnel.js';
|
|
10
|
+
export function makeEmit(codexRc) {
|
|
11
|
+
return function emit(entry) {
|
|
12
|
+
/** `display` first so it survives Monitor's body truncation — the user must see it to echo it. */
|
|
13
|
+
const enriched = { display: formatDisplay(entry), ...entry };
|
|
14
|
+
const json = JSON.stringify(enriched);
|
|
15
|
+
process.stdout.write(json + '\n');
|
|
16
|
+
codexRc?.push(json);
|
|
17
|
+
noteSeen(entry.line, entry.lineName);
|
|
18
|
+
for (const l of [entry.line, entry.from, entry.to])
|
|
19
|
+
if (l)
|
|
20
|
+
noteUserFromLine(l);
|
|
21
|
+
appendHistory(enriched);
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
/** Translate the snake_case train wire envelope to a camelCase `HistoryEntry`. */
|
|
25
|
+
/** Trains can omit `id`/`station`/`to`; metro fills sensible defaults. */
|
|
26
|
+
export function trainEventToHistoryEntry(env, trainName) {
|
|
27
|
+
const line = env.line;
|
|
28
|
+
if (typeof line !== 'string') {
|
|
29
|
+
log.warn({ train: trainName }, 'train: dropped event without `line`');
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
const station = env.station ?? Line.station(line) ?? trainName;
|
|
33
|
+
const isPrivate = env.is_private === true;
|
|
34
|
+
/** Trains may still emit `emoji` for reactions — fold it into text so the new envelope stays minimal. */
|
|
35
|
+
const text = env.text ?? (env.emoji ? `[react ${env.emoji}]` : undefined);
|
|
36
|
+
return {
|
|
37
|
+
id: env.id ?? mintId(),
|
|
38
|
+
ts: env.ts ?? new Date().toISOString(),
|
|
39
|
+
station,
|
|
40
|
+
line: line,
|
|
41
|
+
lineName: env.line_name,
|
|
42
|
+
from: (env.from ?? `metro://${station}`),
|
|
43
|
+
fromName: env.from_name,
|
|
44
|
+
to: (env.to ?? (isPrivate ? userSelf() : line)),
|
|
45
|
+
text,
|
|
46
|
+
messageId: env.message_id,
|
|
47
|
+
replyTo: env.reply_to,
|
|
48
|
+
payload: env.payload,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
export async function startWebhookServer(emit) {
|
|
52
|
+
const port = webhookPort();
|
|
53
|
+
const server = createServer((req, res) => {
|
|
54
|
+
handleRequest(req, res, emit).catch(err => {
|
|
55
|
+
log.warn({ err: errMsg(err) }, 'webhook handler error');
|
|
56
|
+
if (!res.headersSent)
|
|
57
|
+
res.writeHead(500).end();
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
await new Promise((resolve, reject) => {
|
|
61
|
+
server.once('error', reject);
|
|
62
|
+
server.listen(port, '127.0.0.1', () => {
|
|
63
|
+
log.info({ port, endpoints: listEndpoints().length }, 'webhook + monitor ready');
|
|
64
|
+
resolve();
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
return server;
|
|
68
|
+
}
|
|
69
|
+
async function handleRequest(req, res, emit) {
|
|
70
|
+
if (handleMonitorRequest(req, res, emit))
|
|
71
|
+
return;
|
|
72
|
+
const m = req.url?.match(/^\/wh\/([A-Za-z0-9_-]+)/);
|
|
73
|
+
if (!m) {
|
|
74
|
+
res.writeHead(404).end();
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const endpointId = m[1];
|
|
78
|
+
const endpoint = findEndpoint(endpointId);
|
|
79
|
+
if (!endpoint) {
|
|
80
|
+
res.writeHead(404).end();
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (req.method === 'GET') {
|
|
84
|
+
res.writeHead(200).end(`metro webhook ${endpointId} ready\n`);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (req.method !== 'POST') {
|
|
88
|
+
res.writeHead(405).end();
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const chunks = [];
|
|
92
|
+
for await (const c of req)
|
|
93
|
+
chunks.push(c);
|
|
94
|
+
const raw = Buffer.concat(chunks);
|
|
95
|
+
const headers = Object.fromEntries(Object.entries(req.headers).map(([k, v]) => [k, Array.isArray(v) ? v.join(',') : v ?? '']));
|
|
96
|
+
if (endpoint.secret && !verifySig(endpoint.secret, raw, headers['x-hub-signature-256'])) {
|
|
97
|
+
log.warn({ endpoint: endpointId }, 'webhook signature mismatch — rejecting');
|
|
98
|
+
res.writeHead(401).end('signature mismatch');
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
let body = raw.toString('utf8');
|
|
102
|
+
try {
|
|
103
|
+
body = JSON.parse(body);
|
|
104
|
+
}
|
|
105
|
+
catch { /* keep as string */ }
|
|
106
|
+
const line = Line.webhook(endpointId);
|
|
107
|
+
emit({
|
|
108
|
+
id: mintId(), ts: new Date().toISOString(), station: 'webhook',
|
|
109
|
+
line, lineName: endpoint.label, from: line, to: line,
|
|
110
|
+
messageId: headers['x-github-delivery'] || headers['x-request-id'] || randomUUID(),
|
|
111
|
+
text: `${headers['x-github-event'] ?? headers['x-intercom-topic'] ?? 'event'} ${req.method} ${req.url}`,
|
|
112
|
+
payload: { headers, body },
|
|
113
|
+
});
|
|
114
|
+
res.writeHead(200).end('ok');
|
|
115
|
+
}
|
|
116
|
+
function verifySig(secret, raw, header) {
|
|
117
|
+
if (!header?.startsWith('sha256='))
|
|
118
|
+
return false;
|
|
119
|
+
const given = Buffer.from(header.slice(7), 'hex');
|
|
120
|
+
const want = createHmac('sha256', secret).update(raw).digest();
|
|
121
|
+
return given.length === want.length && timingSafeEqual(given, want);
|
|
122
|
+
}
|