@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
|
@@ -0,0 +1,130 @@
|
|
|
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
|
+
/** Forgive trains that use platform-flavored kinds (`message`, `reaction`) — map to the canonical enum. */
|
|
27
|
+
function normalizeKind(k) {
|
|
28
|
+
if (k === 'message' || k === undefined || k === null)
|
|
29
|
+
return 'inbound';
|
|
30
|
+
if (k === 'reaction')
|
|
31
|
+
return 'react';
|
|
32
|
+
return k;
|
|
33
|
+
}
|
|
34
|
+
export function trainEventToHistoryEntry(env, trainName) {
|
|
35
|
+
const line = env.line;
|
|
36
|
+
if (typeof line !== 'string') {
|
|
37
|
+
log.warn({ train: trainName }, 'train: dropped event without `line`');
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
const station = env.station ?? Line.station(line) ?? trainName;
|
|
41
|
+
const isPrivate = env.is_private === true;
|
|
42
|
+
return {
|
|
43
|
+
id: env.id ?? mintId(),
|
|
44
|
+
ts: env.ts ?? new Date().toISOString(),
|
|
45
|
+
kind: normalizeKind(env.kind),
|
|
46
|
+
station,
|
|
47
|
+
line: line,
|
|
48
|
+
lineName: env.line_name,
|
|
49
|
+
from: (env.from ?? `metro://${station}`),
|
|
50
|
+
fromName: env.from_name,
|
|
51
|
+
to: (env.to ?? (isPrivate ? userSelf() : line)),
|
|
52
|
+
text: env.text,
|
|
53
|
+
emoji: env.emoji,
|
|
54
|
+
messageId: env.message_id,
|
|
55
|
+
replyTo: env.reply_to,
|
|
56
|
+
payload: env.payload,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
export async function startWebhookServer(emit) {
|
|
60
|
+
const port = webhookPort();
|
|
61
|
+
const server = createServer((req, res) => {
|
|
62
|
+
handleRequest(req, res, emit).catch(err => {
|
|
63
|
+
log.warn({ err: errMsg(err) }, 'webhook handler error');
|
|
64
|
+
if (!res.headersSent)
|
|
65
|
+
res.writeHead(500).end();
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
await new Promise((resolve, reject) => {
|
|
69
|
+
server.once('error', reject);
|
|
70
|
+
server.listen(port, '127.0.0.1', () => {
|
|
71
|
+
log.info({ port, endpoints: listEndpoints().length }, 'webhook + monitor ready');
|
|
72
|
+
resolve();
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
return server;
|
|
76
|
+
}
|
|
77
|
+
async function handleRequest(req, res, emit) {
|
|
78
|
+
if (handleMonitorRequest(req, res))
|
|
79
|
+
return;
|
|
80
|
+
const m = req.url?.match(/^\/wh\/([A-Za-z0-9_-]+)/);
|
|
81
|
+
if (!m) {
|
|
82
|
+
res.writeHead(404).end();
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const endpointId = m[1];
|
|
86
|
+
const endpoint = findEndpoint(endpointId);
|
|
87
|
+
if (!endpoint) {
|
|
88
|
+
res.writeHead(404).end();
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (req.method === 'GET') {
|
|
92
|
+
res.writeHead(200).end(`metro webhook ${endpointId} ready\n`);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (req.method !== 'POST') {
|
|
96
|
+
res.writeHead(405).end();
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const chunks = [];
|
|
100
|
+
for await (const c of req)
|
|
101
|
+
chunks.push(c);
|
|
102
|
+
const raw = Buffer.concat(chunks);
|
|
103
|
+
const headers = Object.fromEntries(Object.entries(req.headers).map(([k, v]) => [k, Array.isArray(v) ? v.join(',') : v ?? '']));
|
|
104
|
+
if (endpoint.secret && !verifySig(endpoint.secret, raw, headers['x-hub-signature-256'])) {
|
|
105
|
+
log.warn({ endpoint: endpointId }, 'webhook signature mismatch — rejecting');
|
|
106
|
+
res.writeHead(401).end('signature mismatch');
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
let body = raw.toString('utf8');
|
|
110
|
+
try {
|
|
111
|
+
body = JSON.parse(body);
|
|
112
|
+
}
|
|
113
|
+
catch { /* keep as string */ }
|
|
114
|
+
const line = Line.webhook(endpointId);
|
|
115
|
+
emit({
|
|
116
|
+
id: mintId(), ts: new Date().toISOString(), kind: 'inbound', station: 'webhook',
|
|
117
|
+
line, lineName: endpoint.label, from: line, to: line,
|
|
118
|
+
messageId: headers['x-github-delivery'] || headers['x-request-id'] || randomUUID(),
|
|
119
|
+
text: `${headers['x-github-event'] ?? headers['x-intercom-topic'] ?? 'event'} ${req.method} ${req.url}`,
|
|
120
|
+
payload: { headers, body },
|
|
121
|
+
});
|
|
122
|
+
res.writeHead(200).end('ok');
|
|
123
|
+
}
|
|
124
|
+
function verifySig(secret, raw, header) {
|
|
125
|
+
if (!header?.startsWith('sha256='))
|
|
126
|
+
return false;
|
|
127
|
+
const given = Buffer.from(header.slice(7), 'hex');
|
|
128
|
+
const want = createHmac('sha256', secret).update(raw).digest();
|
|
129
|
+
return given.length === want.length && timingSafeEqual(given, want);
|
|
130
|
+
}
|
package/dist/dispatcher.js
CHANGED
|
@@ -1,43 +1,26 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
3
|
-
*/
|
|
4
|
-
import {
|
|
5
|
-
import { dirname, join } from 'node:path';
|
|
6
|
-
import { fileURLToPath } from 'node:url';
|
|
1
|
+
/** Daemon: supervises ~/.metro/trains/*, multiplexes their stdout to one JSON event stream, */
|
|
2
|
+
/** routes outbound `forward-call` IPC back to trains' stdin. Two builtin event sources stay */
|
|
3
|
+
/** in core: the HTTP webhook receiver + cross-user `notify` IPC. */
|
|
4
|
+
import { join } from 'node:path';
|
|
7
5
|
import pkg from '../package.json' with { type: 'json' };
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import { WebhookStation } from './stations/webhook.js';
|
|
11
|
-
import { asLine, Line } from './stations/index.js';
|
|
12
|
-
import { CodexRC } from './codex-rc.js';
|
|
6
|
+
import { Line } from './lines.js';
|
|
7
|
+
import { CodexRC } from './codex-rc/client.js';
|
|
13
8
|
import { startIpcServer, stopIpcServer } from './ipc.js';
|
|
14
|
-
import {
|
|
15
|
-
import { noteSeen, saveBotId } from './cache.js';
|
|
9
|
+
import { mintId, noteUserFromLine, selfLine, userSelf } from './history.js';
|
|
16
10
|
import { errMsg, log } from './log.js';
|
|
17
|
-
import { acquireLock,
|
|
18
|
-
import { setCodexSessionId } from './
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
11
|
+
import { acquireLock, loadMetroEnv, STATE_DIR } from './paths.js';
|
|
12
|
+
import { setCodexSessionId } from './local-identity.js';
|
|
13
|
+
import { loadTunnelConfig, Tunnel, webhookPort } from './tunnel.js';
|
|
14
|
+
import { TrainSupervisor, TRAINS_DIR } from './trains/supervisor.js';
|
|
15
|
+
import { makeEmit, startWebhookServer, trainEventToHistoryEntry } from './dispatcher/server.js';
|
|
22
16
|
loadMetroEnv();
|
|
23
|
-
const platforms = configuredPlatforms();
|
|
24
|
-
const endpoints = listEndpoints();
|
|
25
|
-
requireConfiguredPlatform(platforms, endpoints.length > 0);
|
|
26
17
|
acquireLock(join(STATE_DIR, '.tail-lock'));
|
|
27
|
-
// Fail fast if launched from Claude Code without a logged-in account.
|
|
28
18
|
const self = userSelf();
|
|
29
19
|
log.info({ self, line: selfLine() }, 'user identity');
|
|
30
20
|
const seedSelf = () => { const l = selfLine(); if (l)
|
|
31
21
|
noteUserFromLine(l); };
|
|
32
22
|
seedSelf();
|
|
33
|
-
|
|
34
|
-
try {
|
|
35
|
-
copyFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'docs', 'users.md'), USERS_MD);
|
|
36
|
-
}
|
|
37
|
-
catch (err) {
|
|
38
|
-
log.warn({ err: errMsg(err), path: USERS_MD }, 'failed to install user skill');
|
|
39
|
-
}
|
|
40
|
-
/** Suppress EPIPE so the daemon survives the user (Monitor reader) restarting / dying. */
|
|
23
|
+
/** Suppress EPIPE so the daemon survives the reader (Monitor) restarting. */
|
|
41
24
|
process.stdout.on('error', err => {
|
|
42
25
|
if (err.code !== 'EPIPE')
|
|
43
26
|
log.warn({ err: errMsg(err) }, 'stdout error');
|
|
@@ -45,66 +28,54 @@ process.stdout.on('error', err => {
|
|
|
45
28
|
const codexRc = process.env.METRO_CODEX_RC ? new CodexRC(process.env.METRO_CODEX_RC, pkg.version) : null;
|
|
46
29
|
codexRc?.onThread(id => { setCodexSessionId(id); seedSelf(); });
|
|
47
30
|
codexRc?.start();
|
|
48
|
-
const
|
|
49
|
-
const
|
|
50
|
-
|
|
31
|
+
const supervisor = new TrainSupervisor();
|
|
32
|
+
const emit = makeEmit(codexRc);
|
|
33
|
+
supervisor.onTrainEvent((env, train) => {
|
|
34
|
+
const entry = trainEventToHistoryEntry(env, train);
|
|
35
|
+
if (entry)
|
|
36
|
+
emit(entry);
|
|
37
|
+
});
|
|
38
|
+
let webhookServer = null;
|
|
51
39
|
const tunnelCfg = loadTunnelConfig();
|
|
52
40
|
const tunnel = tunnelCfg ? new Tunnel(tunnelCfg, webhookPort()) : null;
|
|
53
|
-
function emit(entry) {
|
|
54
|
-
/** `display` first so it survives Monitor's ~500-char body truncation — the user must see it to echo it. */
|
|
55
|
-
const enriched = { display: formatDisplay(entry), ...entry };
|
|
56
|
-
const json = JSON.stringify(enriched);
|
|
57
|
-
process.stdout.write(json + '\n');
|
|
58
|
-
codexRc?.push(json);
|
|
59
|
-
noteSeen(entry.line, entry.lineName);
|
|
60
|
-
for (const l of [entry.line, entry.from, entry.to])
|
|
61
|
-
if (l)
|
|
62
|
-
noteUserFromLine(l);
|
|
63
|
-
appendHistory(enriched);
|
|
64
|
-
}
|
|
65
|
-
const destinationFor = (m) => m.isPrivate ? userSelf() : m.line;
|
|
66
|
-
const onInbound = (m) => emit({ ...m, kind: 'inbound', to: destinationFor(m) });
|
|
67
|
-
const onReaction = (r) => emit({ ...r, kind: 'react', to: destinationFor(r) });
|
|
68
41
|
const ipc = startIpcServer(async (req) => {
|
|
69
42
|
if (req.op === 'notify') {
|
|
70
|
-
const line =
|
|
43
|
+
const line = req.line;
|
|
71
44
|
emit({
|
|
72
45
|
id: mintId(), ts: new Date().toISOString(), kind: 'inbound',
|
|
73
46
|
station: Line.station(line) ?? '?', line,
|
|
74
|
-
from:
|
|
47
|
+
from: (req.from ?? userSelf()), to: line, text: req.text,
|
|
75
48
|
});
|
|
76
49
|
return { ok: true };
|
|
77
50
|
}
|
|
78
|
-
if (req.op === '
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
51
|
+
if (req.op === 'forward-call') {
|
|
52
|
+
try {
|
|
53
|
+
const r = await supervisor.call(req.train, req.action, req.args);
|
|
54
|
+
return { ok: true, response: r };
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
return { ok: false, error: errMsg(err) };
|
|
58
|
+
}
|
|
83
59
|
}
|
|
84
|
-
|
|
85
|
-
}
|
|
86
|
-
async function main() {
|
|
87
|
-
if (platforms.discord) {
|
|
88
|
-
discord.onMessage(onInbound);
|
|
89
|
-
discord.onReaction(onReaction);
|
|
90
|
-
const [, me] = await Promise.all([discord.start(), discord.getMe()]);
|
|
91
|
-
saveBotId('discord', me.id);
|
|
92
|
-
log.info({ bot: me.username }, 'discord ready');
|
|
93
|
-
}
|
|
94
|
-
if (platforms.telegram) {
|
|
95
|
-
telegram.onMessage(onInbound);
|
|
96
|
-
telegram.onReaction(onReaction);
|
|
97
|
-
const [me] = await Promise.all([telegram.getMe(), telegram.start()]);
|
|
98
|
-
saveBotId('telegram', String(me.id));
|
|
99
|
-
log.info({ bot: `@${me.username}` }, 'telegram ready');
|
|
60
|
+
if (req.op === 'trains-list') {
|
|
61
|
+
return { ok: true, trains: supervisor.list() };
|
|
100
62
|
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
63
|
+
if (req.op === 'train-restart') {
|
|
64
|
+
try {
|
|
65
|
+
await supervisor.restart(req.name);
|
|
66
|
+
return { ok: true };
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
return { ok: false, error: errMsg(err) };
|
|
70
|
+
}
|
|
106
71
|
}
|
|
107
|
-
|
|
72
|
+
return { ok: false, error: `unknown op: ${req.op ?? '(none)'}` };
|
|
73
|
+
});
|
|
74
|
+
async function main() {
|
|
75
|
+
supervisor.start();
|
|
76
|
+
webhookServer = await startWebhookServer(emit);
|
|
77
|
+
tunnel?.start();
|
|
78
|
+
log.info({ codexRc: !!codexRc, tunnel: !!tunnel, trainsDir: TRAINS_DIR }, 'dispatcher ready');
|
|
108
79
|
}
|
|
109
80
|
let shuttingDown = false;
|
|
110
81
|
async function shutdown() {
|
|
@@ -115,11 +86,9 @@ async function shutdown() {
|
|
|
115
86
|
codexRc?.stop();
|
|
116
87
|
tunnel?.stop();
|
|
117
88
|
await stopIpcServer(ipc).catch(() => { });
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
if (platforms.telegram)
|
|
122
|
-
await telegram.stop().catch(() => { });
|
|
89
|
+
if (webhookServer)
|
|
90
|
+
await new Promise(r => webhookServer.close(() => r()));
|
|
91
|
+
await supervisor.stop();
|
|
123
92
|
process.exit(0);
|
|
124
93
|
}
|
|
125
94
|
process.stdin.on('end', shutdown).on('close', shutdown);
|
package/dist/history.js
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
/** Append-only JSONL history of every message that flows through metro (inbound + outbound). */
|
|
2
2
|
import { randomBytes } from 'node:crypto';
|
|
3
|
-
import { appendFileSync, existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { appendFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { errMsg, log } from './log.js';
|
|
6
6
|
import { STATE_DIR } from './paths.js';
|
|
7
|
-
import { Line } from './
|
|
8
|
-
import { claudeUserId, claudeSessionId } from './
|
|
9
|
-
import { codexUserId, codexSessionId } from './stations/codex.js';
|
|
7
|
+
import { Line } from './lines.js';
|
|
8
|
+
import { claudeUserId, claudeSessionId, codexUserId, codexSessionId } from './local-identity.js';
|
|
10
9
|
/** Pre-render a chat-bubble line — the user echoes `event.display` verbatim instead of composing markdown itself. */
|
|
11
10
|
export function formatDisplay(e) {
|
|
12
11
|
const headerFor = (icon, parts) => `**${icon} ${parts.filter(Boolean).join(' · ')}**`;
|
|
@@ -82,20 +81,6 @@ function matches(e, f) {
|
|
|
82
81
|
return false;
|
|
83
82
|
return true;
|
|
84
83
|
}
|
|
85
|
-
/** Find an entry by universal id OR platform message id. */
|
|
86
|
-
export function lookupEntry(id) {
|
|
87
|
-
const entries = readHistory({ limit: 5_000 });
|
|
88
|
-
return entries.find(e => e.id === id || e.messageId === id);
|
|
89
|
-
}
|
|
90
|
-
/** Look up the platform messageId for a universal `msg_*` id; returns the input unchanged otherwise. */
|
|
91
|
-
export function resolvePlatformId(id) {
|
|
92
|
-
if (!id.startsWith('msg_'))
|
|
93
|
-
return id;
|
|
94
|
-
const hit = lookupEntry(id);
|
|
95
|
-
if (hit?.messageId)
|
|
96
|
-
return hit.messageId;
|
|
97
|
-
throw new Error(`unknown universal id: ${id} (run \`metro history --limit=50\` to see recent ids)`);
|
|
98
|
-
}
|
|
99
84
|
/** The current user's **participant** URI for `from`/`to`. Precedence: METRO_FROM > runtime env > generic. */
|
|
100
85
|
export function userSelf() {
|
|
101
86
|
const explicit = process.env.METRO_FROM;
|
|
@@ -119,3 +104,43 @@ export function selfLine() {
|
|
|
119
104
|
}
|
|
120
105
|
return null;
|
|
121
106
|
}
|
|
107
|
+
/* ──────────── user-registry: append-only (station, userId, sessions[]) tuples ──────────── */
|
|
108
|
+
const REGISTRY_FILE = join(STATE_DIR, 'user-registry.json');
|
|
109
|
+
function readRegistry() {
|
|
110
|
+
if (!existsSync(REGISTRY_FILE))
|
|
111
|
+
return {};
|
|
112
|
+
try {
|
|
113
|
+
return JSON.parse(readFileSync(REGISTRY_FILE, 'utf8'));
|
|
114
|
+
}
|
|
115
|
+
catch (err) {
|
|
116
|
+
log.warn({ err: errMsg(err) }, 'user-registry: malformed, resetting');
|
|
117
|
+
return {};
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
function record(station, userId, sessionId) {
|
|
121
|
+
const reg = readRegistry();
|
|
122
|
+
const rows = (reg[station] ??= []);
|
|
123
|
+
let row = rows.find(r => r.userId === userId);
|
|
124
|
+
if (!row) {
|
|
125
|
+
row = { userId, sessions: [], lastSeen: '' };
|
|
126
|
+
rows.push(row);
|
|
127
|
+
}
|
|
128
|
+
if (sessionId && !row.sessions.includes(sessionId))
|
|
129
|
+
row.sessions.push(sessionId);
|
|
130
|
+
row.lastSeen = new Date().toISOString();
|
|
131
|
+
try {
|
|
132
|
+
writeFileSync(REGISTRY_FILE, JSON.stringify(reg, null, 2));
|
|
133
|
+
}
|
|
134
|
+
catch (err) {
|
|
135
|
+
log.warn({ err: errMsg(err) }, 'user-registry: write failed');
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
/** Scan a line URI for `(station, userId, sessionId)` and record it. No-op on non-user or participant URIs. */
|
|
139
|
+
export function noteUserFromLine(line) {
|
|
140
|
+
const station = Line.station(line);
|
|
141
|
+
if (station !== 'claude' && station !== 'codex')
|
|
142
|
+
return;
|
|
143
|
+
const p = station === 'claude' ? Line.parseClaude(line) : Line.parseCodex(line);
|
|
144
|
+
if (p)
|
|
145
|
+
record(station, p.userId, p.sessionId);
|
|
146
|
+
}
|
package/dist/ipc.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
/** Unix-socket IPC: `notify` re-emits
|
|
1
|
+
/** Unix-socket IPC: `notify` re-emits a cross-user message, `forward-call` reaches a train's */
|
|
2
|
+
/** stdin and awaits its response, `trains-list` snapshots supervisor state. */
|
|
2
3
|
import { createConnection, createServer } from 'node:net';
|
|
3
4
|
import { existsSync, unlinkSync } from 'node:fs';
|
|
4
5
|
import { join } from 'node:path';
|
|
@@ -27,24 +28,31 @@ export async function stopIpcServer(server) {
|
|
|
27
28
|
catch { /* ignore */ }
|
|
28
29
|
}
|
|
29
30
|
async function handleConnection(socket, handler) {
|
|
31
|
+
/** Newline-delimited request/response. Avoids races between `end()` writes and FIN under Bun. */
|
|
30
32
|
let buf = '';
|
|
31
33
|
socket.setEncoding('utf8');
|
|
32
|
-
socket.on('data', chunk => {
|
|
33
|
-
|
|
34
|
+
socket.on('data', async (chunk) => {
|
|
35
|
+
buf += chunk;
|
|
36
|
+
const nl = buf.indexOf('\n');
|
|
37
|
+
if (nl === -1)
|
|
38
|
+
return;
|
|
39
|
+
const line = buf.slice(0, nl).trim();
|
|
40
|
+
buf = buf.slice(nl + 1);
|
|
34
41
|
let resp;
|
|
35
42
|
try {
|
|
36
|
-
const req = JSON.parse(
|
|
43
|
+
const req = JSON.parse(line);
|
|
37
44
|
resp = await handler(req);
|
|
38
45
|
}
|
|
39
46
|
catch (err) {
|
|
40
47
|
resp = { ok: false, error: errMsg(err) };
|
|
41
48
|
}
|
|
42
|
-
socket.
|
|
49
|
+
socket.write(JSON.stringify(resp) + '\n');
|
|
50
|
+
socket.end();
|
|
43
51
|
});
|
|
44
52
|
socket.on('error', err => log.debug({ err: errMsg(err) }, 'ipc connection error'));
|
|
45
53
|
}
|
|
46
54
|
/** CLI-side: send one request, get one response. Throws if the daemon isn't running. */
|
|
47
|
-
export function ipcCall(req, timeoutMs =
|
|
55
|
+
export function ipcCall(req, timeoutMs = 60_000) {
|
|
48
56
|
return new Promise((resolve, reject) => {
|
|
49
57
|
if (!existsSync(SOCKET_PATH)) {
|
|
50
58
|
reject(new Error('metro daemon is not running (start it with `metro`)'));
|
|
@@ -56,17 +64,27 @@ export function ipcCall(req, timeoutMs = 30_000) {
|
|
|
56
64
|
socket.destroy();
|
|
57
65
|
reject(new Error(`ipc timeout after ${timeoutMs}ms`));
|
|
58
66
|
}, timeoutMs);
|
|
59
|
-
socket.on('connect', () => { socket.
|
|
60
|
-
socket.on('data', chunk => {
|
|
61
|
-
|
|
67
|
+
socket.on('connect', () => { socket.write(JSON.stringify(req) + '\n'); });
|
|
68
|
+
socket.on('data', chunk => {
|
|
69
|
+
buf += chunk.toString('utf8');
|
|
70
|
+
const nl = buf.indexOf('\n');
|
|
71
|
+
if (nl === -1)
|
|
72
|
+
return;
|
|
62
73
|
clearTimeout(timer);
|
|
74
|
+
const line = buf.slice(0, nl).trim();
|
|
75
|
+
socket.end();
|
|
63
76
|
try {
|
|
64
|
-
resolve(JSON.parse(
|
|
77
|
+
resolve(JSON.parse(line));
|
|
65
78
|
}
|
|
66
79
|
catch (err) {
|
|
67
80
|
reject(new Error(`ipc bad response: ${errMsg(err)}`));
|
|
68
81
|
}
|
|
69
82
|
});
|
|
83
|
+
socket.on('end', () => {
|
|
84
|
+
clearTimeout(timer);
|
|
85
|
+
if (!buf)
|
|
86
|
+
reject(new Error('ipc connection closed without response'));
|
|
87
|
+
});
|
|
70
88
|
socket.on('error', err => { clearTimeout(timer); reject(err); });
|
|
71
89
|
});
|
|
72
90
|
}
|
package/dist/lines.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/** Line URI helpers. The whole metro:// vocabulary. */
|
|
2
|
+
export const asLine = (s) => s;
|
|
3
|
+
const PREFIX = 'metro://';
|
|
4
|
+
const build = (station, ...seg) => asLine(`${PREFIX}${station}/${seg.map(String).join('/')}`);
|
|
5
|
+
/** Shared parser for `metro://{claude,codex}/<userId>/<sessionId>`. Skips the `/user/…` participant URI. */
|
|
6
|
+
function parseLocalSession(line, station) {
|
|
7
|
+
const p = Line.parse(line);
|
|
8
|
+
if (p?.station !== station || p.path[0] === 'user' || p.path.length < 2)
|
|
9
|
+
return null;
|
|
10
|
+
return { userId: p.path[0], sessionId: p.path[1] };
|
|
11
|
+
}
|
|
12
|
+
/** URI helpers. Lives on a const that doubles as the `Line` type's value-side namespace. */
|
|
13
|
+
export const Line = {
|
|
14
|
+
discord: (channelId) => build('discord', channelId),
|
|
15
|
+
telegram: (chatId, topicId) => topicId !== undefined ? build('telegram', chatId, topicId) : build('telegram', chatId),
|
|
16
|
+
claude: (orgId, sessionId) => build('claude', orgId, sessionId),
|
|
17
|
+
codex: (accountId, threadId) => build('codex', accountId, threadId),
|
|
18
|
+
webhook: (endpointId) => build('webhook', endpointId),
|
|
19
|
+
/** Participant URI — `metro://<station>/user/<id>`. */
|
|
20
|
+
user: (station, id) => build(station, 'user', id),
|
|
21
|
+
parse(line) {
|
|
22
|
+
if (!line.startsWith(PREFIX))
|
|
23
|
+
return null;
|
|
24
|
+
const rest = line.slice(PREFIX.length);
|
|
25
|
+
const slash = rest.indexOf('/');
|
|
26
|
+
if (slash <= 0)
|
|
27
|
+
return null;
|
|
28
|
+
const path = rest.slice(slash + 1).split('/').filter(Boolean);
|
|
29
|
+
return path.length ? { station: rest.slice(0, slash), path } : null;
|
|
30
|
+
},
|
|
31
|
+
station: (line) => Line.parse(line)?.station ?? null,
|
|
32
|
+
parseTelegram(line) {
|
|
33
|
+
const p = Line.parse(line);
|
|
34
|
+
if (p?.station !== 'telegram')
|
|
35
|
+
return null;
|
|
36
|
+
const chatId = Number(p.path[0]);
|
|
37
|
+
if (!Number.isFinite(chatId))
|
|
38
|
+
return null;
|
|
39
|
+
if (p.path.length === 1)
|
|
40
|
+
return { chatId };
|
|
41
|
+
const topicId = Number(p.path[1]);
|
|
42
|
+
return Number.isFinite(topicId) ? { chatId, topicId } : null;
|
|
43
|
+
},
|
|
44
|
+
parseClaude: (line) => parseLocalSession(line, 'claude'),
|
|
45
|
+
parseCodex: (line) => parseLocalSession(line, 'codex'),
|
|
46
|
+
parseWebhook(line) {
|
|
47
|
+
const p = Line.parse(line);
|
|
48
|
+
return p?.station === 'webhook' && p.path.length === 1 ? p.path[0] : null;
|
|
49
|
+
},
|
|
50
|
+
isLocal: (line) => {
|
|
51
|
+
const s = Line.station(line);
|
|
52
|
+
return s === 'claude' || s === 'codex';
|
|
53
|
+
},
|
|
54
|
+
};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/** Resolve the local user identity for Claude Code / Codex hosts — used to mint */
|
|
2
|
+
/** `metro://claude/<orgId>/<sessionId>` and `metro://codex/<accountId>/<threadId>` URIs. */
|
|
3
|
+
import { execFileSync } from 'node:child_process';
|
|
4
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
import { dirname, join } from 'node:path';
|
|
7
|
+
import { STATE_DIR } from './paths.js';
|
|
8
|
+
const TTL_MS = 5_000;
|
|
9
|
+
/** Memoize an account-id resolver for TTL_MS to avoid hammering `claude auth` / re-reading auth.json. */
|
|
10
|
+
function memo(loader) {
|
|
11
|
+
let cache = null;
|
|
12
|
+
return () => {
|
|
13
|
+
if (cache && Date.now() - cache.at < TTL_MS)
|
|
14
|
+
return cache.id;
|
|
15
|
+
const id = loader();
|
|
16
|
+
cache = { id, at: Date.now() };
|
|
17
|
+
return id;
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
const claudeAccountId = memo(() => {
|
|
21
|
+
let raw;
|
|
22
|
+
try {
|
|
23
|
+
raw = execFileSync('claude', ['auth', 'status', '--json'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
24
|
+
}
|
|
25
|
+
catch (e) {
|
|
26
|
+
throw new Error(`metro: failed to run 'claude auth status --json' — is Claude Code installed? (${e.message})`);
|
|
27
|
+
}
|
|
28
|
+
let p;
|
|
29
|
+
try {
|
|
30
|
+
p = JSON.parse(raw);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
throw new Error(`metro: 'claude auth status --json' returned non-JSON: ${raw.slice(0, 200)}`);
|
|
34
|
+
}
|
|
35
|
+
if (!p.loggedIn || !p.orgId)
|
|
36
|
+
throw new Error('metro: Claude Code is not logged in — run \'claude auth login\'');
|
|
37
|
+
return p.orgId;
|
|
38
|
+
});
|
|
39
|
+
const codexAccountId = memo(() => {
|
|
40
|
+
const path = join(process.env.CODEX_HOME || join(homedir(), '.codex'), 'auth.json');
|
|
41
|
+
let raw;
|
|
42
|
+
try {
|
|
43
|
+
raw = readFileSync(path, 'utf8');
|
|
44
|
+
}
|
|
45
|
+
catch (e) {
|
|
46
|
+
throw new Error(`metro: failed to read ${path} — is Codex logged in? (${e.message})`);
|
|
47
|
+
}
|
|
48
|
+
let p;
|
|
49
|
+
try {
|
|
50
|
+
p = JSON.parse(raw);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
throw new Error(`metro: ${path} is not valid JSON`);
|
|
54
|
+
}
|
|
55
|
+
const id = p.tokens?.account_id;
|
|
56
|
+
if (!id)
|
|
57
|
+
throw new Error(`metro: no Codex account_id in ${path} (auth_mode=${p.auth_mode ?? 'unknown'}) — sign in with 'codex login' (ChatGPT mode required)`);
|
|
58
|
+
return id;
|
|
59
|
+
});
|
|
60
|
+
export const claudeUserId = () => process.env.METRO_USER_ID || claudeAccountId();
|
|
61
|
+
export const codexUserId = () => process.env.METRO_USER_ID || codexAccountId();
|
|
62
|
+
export const claudeSessionId = () => process.env.METRO_USER_SESSION_ID || process.env.CLAUDE_CODE_SESSION_ID || null;
|
|
63
|
+
const CODEX_SESSION_FILE = join(STATE_DIR, 'codex-session-id');
|
|
64
|
+
export function codexSessionId() {
|
|
65
|
+
if (process.env.METRO_USER_SESSION_ID)
|
|
66
|
+
return process.env.METRO_USER_SESSION_ID;
|
|
67
|
+
try {
|
|
68
|
+
return readFileSync(CODEX_SESSION_FILE, 'utf8').trim() || null;
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
export function setCodexSessionId(threadId) {
|
|
75
|
+
try {
|
|
76
|
+
mkdirSync(dirname(CODEX_SESSION_FILE), { recursive: true });
|
|
77
|
+
writeFileSync(CODEX_SESSION_FILE, threadId ?? '');
|
|
78
|
+
}
|
|
79
|
+
catch { /* CLI just won't have a session segment */ }
|
|
80
|
+
}
|