@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/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
|
-
id: mintId(), ts: new Date().toISOString(),
|
|
45
|
+
id: mintId(), ts: new Date().toISOString(),
|
|
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,27 +1,25 @@
|
|
|
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
|
-
|
|
10
|
-
/** Pre-render a chat-bubble line — the user echoes `event.display` verbatim instead of composing markdown itself. */
|
|
7
|
+
import { Line } from './lines.js';
|
|
8
|
+
import { claudeUserId, claudeSessionId, codexUserId, codexSessionId } from './local-identity.js';
|
|
9
|
+
/** Pre-render a chat-bubble line. Direction is derived: from === local agent → outbound (📤), else inbound (📩). */
|
|
11
10
|
export function formatDisplay(e) {
|
|
12
11
|
const headerFor = (icon, parts) => `**${icon} ${parts.filter(Boolean).join(' · ')}**`;
|
|
13
|
-
const body = e.text ??
|
|
14
|
-
if (e.
|
|
12
|
+
const body = e.text ?? '';
|
|
13
|
+
if (e.station === 'webhook' && !Line.isLocal(e.from)) {
|
|
15
14
|
const ev = e.payload
|
|
16
15
|
?.headers?.['x-github-event'] ?? e.payload
|
|
17
16
|
?.headers?.['x-intercom-topic'];
|
|
18
17
|
return `${headerFor('🪝', ['webhook', e.lineName, ev])}\n> ${body}`;
|
|
19
18
|
}
|
|
20
|
-
if (
|
|
21
|
-
|
|
22
|
-
return `${headerFor('📩', [e.station, e.fromName ?? e.from, e.lineName])}\n> ${reactBody}`;
|
|
19
|
+
if (Line.isLocal(e.from)) {
|
|
20
|
+
return `${headerFor('📤', [e.station, '→', e.fromName ?? e.to])}\n> ${body}`;
|
|
23
21
|
}
|
|
24
|
-
return `${headerFor('
|
|
22
|
+
return `${headerFor('📩', [e.station, e.fromName ?? e.from, e.lineName])}\n> ${body}`;
|
|
25
23
|
}
|
|
26
24
|
const FILE = join(STATE_DIR, 'history.jsonl');
|
|
27
25
|
/** Mint a universal metro message ID. Short, prefixed, URL-safe. */
|
|
@@ -72,8 +70,6 @@ function matches(e, f) {
|
|
|
72
70
|
return false;
|
|
73
71
|
if (f.station && e.station !== f.station)
|
|
74
72
|
return false;
|
|
75
|
-
if (f.kind && e.kind !== f.kind)
|
|
76
|
-
return false;
|
|
77
73
|
if (f.from && e.from !== f.from)
|
|
78
74
|
return false;
|
|
79
75
|
if (f.textContains && !(e.text ?? '').toLowerCase().includes(f.textContains.toLowerCase()))
|
|
@@ -82,20 +78,6 @@ function matches(e, f) {
|
|
|
82
78
|
return false;
|
|
83
79
|
return true;
|
|
84
80
|
}
|
|
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
81
|
/** The current user's **participant** URI for `from`/`to`. Precedence: METRO_FROM > runtime env > generic. */
|
|
100
82
|
export function userSelf() {
|
|
101
83
|
const explicit = process.env.METRO_FROM;
|
|
@@ -119,3 +101,43 @@ export function selfLine() {
|
|
|
119
101
|
}
|
|
120
102
|
return null;
|
|
121
103
|
}
|
|
104
|
+
/* ──────────── user-registry: append-only (station, userId, sessions[]) tuples ──────────── */
|
|
105
|
+
const REGISTRY_FILE = join(STATE_DIR, 'user-registry.json');
|
|
106
|
+
function readRegistry() {
|
|
107
|
+
if (!existsSync(REGISTRY_FILE))
|
|
108
|
+
return {};
|
|
109
|
+
try {
|
|
110
|
+
return JSON.parse(readFileSync(REGISTRY_FILE, 'utf8'));
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
log.warn({ err: errMsg(err) }, 'user-registry: malformed, resetting');
|
|
114
|
+
return {};
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
function record(station, userId, sessionId) {
|
|
118
|
+
const reg = readRegistry();
|
|
119
|
+
const rows = (reg[station] ??= []);
|
|
120
|
+
let row = rows.find(r => r.userId === userId);
|
|
121
|
+
if (!row) {
|
|
122
|
+
row = { userId, sessions: [], lastSeen: '' };
|
|
123
|
+
rows.push(row);
|
|
124
|
+
}
|
|
125
|
+
if (sessionId && !row.sessions.includes(sessionId))
|
|
126
|
+
row.sessions.push(sessionId);
|
|
127
|
+
row.lastSeen = new Date().toISOString();
|
|
128
|
+
try {
|
|
129
|
+
writeFileSync(REGISTRY_FILE, JSON.stringify(reg, null, 2));
|
|
130
|
+
}
|
|
131
|
+
catch (err) {
|
|
132
|
+
log.warn({ err: errMsg(err) }, 'user-registry: write failed');
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/** Scan a line URI for `(station, userId, sessionId)` and record it. No-op on non-user or participant URIs. */
|
|
136
|
+
export function noteUserFromLine(line) {
|
|
137
|
+
const station = Line.station(line);
|
|
138
|
+
if (station !== 'claude' && station !== 'codex')
|
|
139
|
+
return;
|
|
140
|
+
const p = station === 'claude' ? Line.parseClaude(line) : Line.parseCodex(line);
|
|
141
|
+
if (p)
|
|
142
|
+
record(station, p.userId, p.sessionId);
|
|
143
|
+
}
|
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
|
+
}
|
package/dist/paths.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import { dirname, join } from 'node:path';
|
|
4
|
-
import { log } from './log.js';
|
|
4
|
+
import { errMsg, log } from './log.js';
|
|
5
5
|
export const STATE_DIR = process.env.METRO_STATE_DIR ?? join(homedir(), '.cache', 'metro');
|
|
6
6
|
mkdirSync(STATE_DIR, { recursive: true });
|
|
7
7
|
const CONFIG_DIR = process.env.METRO_CONFIG_DIR ?? join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'metro');
|
|
8
8
|
export const CONFIG_ENV_FILE = join(CONFIG_DIR, '.env');
|
|
9
|
+
/** Train-owned env file. Trains read their tokens from here (passed through via process.env). */
|
|
10
|
+
const TRAINS_ENV_FILE = join(homedir(), '.metro', '.env');
|
|
9
11
|
const LINE_RE = /^\s*([A-Za-z_]\w*)\s*=\s*(.*?)\s*$/;
|
|
10
12
|
const QUOTED_RE = /^(['"])(.*)\1$/;
|
|
11
13
|
export function readDotenv(path) {
|
|
@@ -24,24 +26,16 @@ export function writeDotenv(path, env) {
|
|
|
24
26
|
writeFileSync(path, Object.entries(env).map(([k, v]) => `${k}=${v}`).join('\n') + '\n');
|
|
25
27
|
chmodSync(path, 0o600);
|
|
26
28
|
}
|
|
27
|
-
/** Precedence: process.env > cwd/.env > $METRO_CONFIG_DIR/.env. First-set wins. */
|
|
29
|
+
/** Precedence: process.env > cwd/.env > ~/.metro/.env > $METRO_CONFIG_DIR/.env. First-set wins. */
|
|
30
|
+
/** ~/.metro/.env is the canonical location for train credentials. */
|
|
28
31
|
export function loadMetroEnv() {
|
|
29
|
-
for (const path of [join(process.cwd(), '.env'), CONFIG_ENV_FILE]) {
|
|
32
|
+
for (const path of [join(process.cwd(), '.env'), TRAINS_ENV_FILE, CONFIG_ENV_FILE]) {
|
|
30
33
|
for (const [k, v] of Object.entries(readDotenv(path))) {
|
|
31
34
|
if (process.env[k] === undefined)
|
|
32
35
|
process.env[k] = v;
|
|
33
36
|
}
|
|
34
37
|
}
|
|
35
38
|
}
|
|
36
|
-
export function configuredPlatforms() {
|
|
37
|
-
return { telegram: !!process.env.TELEGRAM_BOT_TOKEN, discord: !!process.env.DISCORD_BOT_TOKEN };
|
|
38
|
-
}
|
|
39
|
-
export function requireConfiguredPlatform(p, hasWebhooks) {
|
|
40
|
-
if (p.telegram || p.discord || hasWebhooks)
|
|
41
|
-
return;
|
|
42
|
-
log.fatal('no inputs configured — run `metro setup telegram <token>`, `metro setup discord <token>`, or `metro webhook add <label>`');
|
|
43
|
-
process.exit(2);
|
|
44
|
-
}
|
|
45
39
|
/** Singleton pidfile. Exits if another instance owns it; reclaims stale locks. */
|
|
46
40
|
export function acquireLock(lockFile) {
|
|
47
41
|
if (existsSync(lockFile)) {
|
|
@@ -66,3 +60,55 @@ export function acquireLock(lockFile) {
|
|
|
66
60
|
}
|
|
67
61
|
catch { /* ignore */ } });
|
|
68
62
|
}
|
|
63
|
+
const cacheFile = join(STATE_DIR, 'lines.json');
|
|
64
|
+
const FLUSH_DELAY_MS = 5_000;
|
|
65
|
+
let cache = null;
|
|
66
|
+
let dirty = false;
|
|
67
|
+
let flushTimer = null;
|
|
68
|
+
function readCache() {
|
|
69
|
+
if (cache)
|
|
70
|
+
return cache;
|
|
71
|
+
if (!existsSync(cacheFile))
|
|
72
|
+
return cache = {};
|
|
73
|
+
try {
|
|
74
|
+
cache = JSON.parse(readFileSync(cacheFile, 'utf8'));
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
log.warn({ err: errMsg(err), path: cacheFile }, 'lines cache read failed; treating as empty');
|
|
78
|
+
cache = {};
|
|
79
|
+
}
|
|
80
|
+
return cache;
|
|
81
|
+
}
|
|
82
|
+
function flush() {
|
|
83
|
+
if (!dirty || !cache)
|
|
84
|
+
return;
|
|
85
|
+
try {
|
|
86
|
+
writeFileSync(cacheFile, JSON.stringify(cache, null, 2));
|
|
87
|
+
dirty = false;
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
log.warn({ err: errMsg(err), path: cacheFile }, 'lines cache write failed');
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
process.on('exit', flush);
|
|
94
|
+
export function noteSeen(line, name) {
|
|
95
|
+
const c = readCache();
|
|
96
|
+
const entry = c[line] ??= { createdAt: new Date().toISOString() };
|
|
97
|
+
entry.lastSeenAt = new Date().toISOString();
|
|
98
|
+
if (name && entry.name !== name)
|
|
99
|
+
entry.name = name;
|
|
100
|
+
dirty = true;
|
|
101
|
+
if (!flushTimer)
|
|
102
|
+
flushTimer = setTimeout(() => { flushTimer = null; flush(); }, FLUSH_DELAY_MS);
|
|
103
|
+
}
|
|
104
|
+
export const listLines = () => Object.entries(readCache()).map(([line, entry]) => ({ line: line, entry }));
|
|
105
|
+
/** Bot identity cache: `{discord: "<userId>", telegram: "<userId>"}`. Trains may populate this. */
|
|
106
|
+
const botIdsFile = join(STATE_DIR, 'bot-ids.json');
|
|
107
|
+
export const readBotIds = () => {
|
|
108
|
+
try {
|
|
109
|
+
return existsSync(botIdsFile) ? JSON.parse(readFileSync(botIdsFile, 'utf8')) : {};
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return {};
|
|
113
|
+
}
|
|
114
|
+
};
|