@stage-labs/metro 0.1.0-beta.14 → 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/dist/broker/claims.js +2 -2
- package/dist/broker/history-stream.js +2 -0
- package/dist/cli/index.js +32 -7
- 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 +13 -130
- package/dist/dispatcher/server.js +5 -13
- package/dist/dispatcher.js +1 -1
- package/dist/history.js +6 -9
- package/docs/broker.md +1 -1
- package/docs/monitor.md +71 -10
- package/package.json +1 -1
- package/skills/metro/SKILL.md +38 -32
package/dist/broker/claims.js
CHANGED
|
@@ -91,7 +91,7 @@ export function classifyLine(line) {
|
|
|
91
91
|
}
|
|
92
92
|
return 'unknown';
|
|
93
93
|
}
|
|
94
|
-
/** Most-recent inbound on `line`. Walks `history.jsonl` from the tail
|
|
94
|
+
/** Most-recent inbound (from-someone-else) on `line`. Walks `history.jsonl` from the tail. */
|
|
95
95
|
function readRecentInbound(line) {
|
|
96
96
|
if (!existsSync(HISTORY_FILE))
|
|
97
97
|
return undefined;
|
|
@@ -101,7 +101,7 @@ function readRecentInbound(line) {
|
|
|
101
101
|
continue;
|
|
102
102
|
try {
|
|
103
103
|
const e = JSON.parse(lines[i]);
|
|
104
|
-
if (e.line === line && e.
|
|
104
|
+
if (e.line === line && !Line.isLocal(e.from))
|
|
105
105
|
return e;
|
|
106
106
|
}
|
|
107
107
|
catch { /* skip */ }
|
|
@@ -117,6 +117,8 @@ export function drainTail(offset, opts, onEntry) {
|
|
|
117
117
|
continue;
|
|
118
118
|
if (opts.stationFilter && entry.station !== opts.stationFilter)
|
|
119
119
|
continue;
|
|
120
|
+
if (opts.excludeFrom && opts.excludeFrom.includes(entry.from))
|
|
121
|
+
continue;
|
|
120
122
|
if (!passesMode(entry, opts.mode, opts.self, claims, { includeWebhooks: opts.includeWebhooks }))
|
|
121
123
|
continue;
|
|
122
124
|
if (onEntry(entry) === true)
|
package/dist/cli/index.js
CHANGED
|
@@ -1,13 +1,32 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
/** Metro CLI: parses argv, dispatches to subcommands. Bun runtime required (uses Bun.spawn for trains). */
|
|
3
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
3
5
|
import pkg from '../../package.json' with { type: 'json' };
|
|
4
|
-
import { errMsg } from '../log.js';
|
|
5
|
-
import { listLines, loadMetroEnv } from '../paths.js';
|
|
6
|
+
import { errMsg, log } from '../log.js';
|
|
7
|
+
import { listLines, loadMetroEnv, STATE_DIR } from '../paths.js';
|
|
6
8
|
import { readHistory } from '../history.js';
|
|
7
9
|
import { cmdDoctor, cmdSetup, cmdUpdate } from './config.js';
|
|
8
10
|
import { cmdClaim, cmdClaims, cmdRelease, cmdTail } from './tail.js';
|
|
9
11
|
import { cmdCall, cmdTrains, cmdTunnel, cmdWebhook } from './webhook.js';
|
|
10
12
|
import { flagOne, isJson, parseArgs, writeJson, } from './util.js';
|
|
13
|
+
/** True if another live process owns the dispatcher lockfile. Mirrors paths.acquireLock'
|
|
14
|
+
* s detection but as a peek — no claim, no exit. */
|
|
15
|
+
function anotherDispatcherRunning() {
|
|
16
|
+
const lockFile = join(STATE_DIR, '.tail-lock');
|
|
17
|
+
if (!existsSync(lockFile))
|
|
18
|
+
return false;
|
|
19
|
+
const pid = Number(readFileSync(lockFile, 'utf8').trim());
|
|
20
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
21
|
+
return false;
|
|
22
|
+
try {
|
|
23
|
+
process.kill(pid, 0);
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
11
30
|
const USAGE = `metro — event-interception wire. Trains in ~/.metro/trains/ produce events;
|
|
12
31
|
metro multiplexes them onto stdout. Outbound action calls flow back via \`metro call\`.
|
|
13
32
|
|
|
@@ -22,7 +41,7 @@ Usage:
|
|
|
22
41
|
metro trains new <name> Scaffold ~/.metro/trains/<name>.ts from the example.
|
|
23
42
|
metro call <train> <action> [args] Forward an action call to a train via its stdin.
|
|
24
43
|
[args] is JSON, '@file', '-' (stdin), or a bare string.
|
|
25
|
-
metro history [--limit=N] [--line=…] [--station=…] [--
|
|
44
|
+
metro history [--limit=N] [--line=…] [--station=…] [--from=…] [--text=…] [--since=…]
|
|
26
45
|
Read the universal message log (newest first).
|
|
27
46
|
metro tail [--as=<user-uri>] [--follow] [--strict | --unclaimed | --all] [--include-webhooks]
|
|
28
47
|
[--chat=<line>] [--station=…] [--since=<offset|tail>] [--limit=N]
|
|
@@ -75,7 +94,6 @@ async function cmdHistory(_, f) {
|
|
|
75
94
|
const entries = readHistory({
|
|
76
95
|
line: flagOne(f, 'line'),
|
|
77
96
|
station: flagOne(f, 'station'),
|
|
78
|
-
kind: flagOne(f, 'kind'),
|
|
79
97
|
from: flagOne(f, 'from'),
|
|
80
98
|
textContains: flagOne(f, 'text'),
|
|
81
99
|
since: since ? new Date(since) : undefined,
|
|
@@ -87,14 +105,14 @@ async function cmdHistory(_, f) {
|
|
|
87
105
|
process.stdout.write('(no matching history entries)\n');
|
|
88
106
|
return;
|
|
89
107
|
}
|
|
90
|
-
process.stdout.write('time id
|
|
108
|
+
process.stdout.write('time id from → to body\n');
|
|
91
109
|
for (const e of entries.reverse()) {
|
|
92
110
|
const ts = e.ts.slice(11, 19);
|
|
93
111
|
const from = pad(fmtActor(e.from, e.fromName), 24);
|
|
94
112
|
const to = pad(fmtActor(e.to), 24);
|
|
95
|
-
const body = e.text ??
|
|
113
|
+
const body = e.text ?? '';
|
|
96
114
|
const text = body.length > 60 ? body.slice(0, 59) + '…' : body;
|
|
97
|
-
process.stdout.write(`${ts} ${e.id.padEnd(12)} ${
|
|
115
|
+
process.stdout.write(`${ts} ${e.id.padEnd(12)} ${from} → ${to} ${text}\n`);
|
|
98
116
|
}
|
|
99
117
|
}
|
|
100
118
|
/** Compact display: fromName if known; else `station:@<id>` (user) or `station:<id>`. */
|
|
@@ -126,6 +144,13 @@ async function main() {
|
|
|
126
144
|
if (cmd === '--help' || cmd === '-h')
|
|
127
145
|
return void process.stdout.write(USAGE);
|
|
128
146
|
if (!cmd) {
|
|
147
|
+
/** Multi-agent: another `metro` already owns the dispatcher → drop into tail mode so
|
|
148
|
+
* a second agent (e.g. Codex while Claude is running) still gets the event stream. */
|
|
149
|
+
if (anotherDispatcherRunning()) {
|
|
150
|
+
log.info({}, 'dispatcher already running; subscribing as tail (--follow --json --since=tail)');
|
|
151
|
+
await cmdTail([], { follow: true, json: true, since: 'tail' });
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
129
154
|
await import('../dispatcher.js');
|
|
130
155
|
return;
|
|
131
156
|
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/** Messenger endpoints: send + react + register, plus Expo push delivery. Upload/serve in messenger-uploads.ts. */
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { basename, join } from 'node:path';
|
|
4
|
+
import { mintId, readHistory, userSelf } from '../history.js';
|
|
5
|
+
import { errMsg, log } from '../log.js';
|
|
6
|
+
import { STATE_DIR } from '../paths.js';
|
|
7
|
+
import { handleMessengerFile, handleMessengerUpload } from './messenger-uploads.js';
|
|
8
|
+
import { transcribeAudio } from './messenger-transcribe.js';
|
|
9
|
+
const UPLOADS_DIR = join(STATE_DIR, 'messenger-uploads');
|
|
10
|
+
const MESSENGER_LINE = 'metro://messenger/owner';
|
|
11
|
+
const MESSENGER_USER = 'metro://messenger/user/owner';
|
|
12
|
+
const PUSH_TOKENS_FILE = join(STATE_DIR, 'push-tokens.json');
|
|
13
|
+
function readPushTokens() {
|
|
14
|
+
try {
|
|
15
|
+
return existsSync(PUSH_TOKENS_FILE) ? JSON.parse(readFileSync(PUSH_TOKENS_FILE, 'utf8')) : [];
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return [];
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function writePushTokens(tokens) {
|
|
22
|
+
writeFileSync(PUSH_TOKENS_FILE, JSON.stringify([...new Set(tokens)]));
|
|
23
|
+
}
|
|
24
|
+
async function pushExpo(tokens, title, body, replyTo) {
|
|
25
|
+
if (tokens.length === 0)
|
|
26
|
+
return;
|
|
27
|
+
/** `categoryId: 'messenger.reply'` opts the notification into the inline Reply
|
|
28
|
+
* action the app registered at startup; `data.replyTo` lets the response
|
|
29
|
+
* listener thread the answer back to the originating message. */
|
|
30
|
+
const messages = tokens.map(to => ({
|
|
31
|
+
to, title, body, sound: 'default',
|
|
32
|
+
categoryId: 'messenger.reply',
|
|
33
|
+
...(replyTo ? { data: { replyTo } } : {}),
|
|
34
|
+
}));
|
|
35
|
+
try {
|
|
36
|
+
await fetch('https://exp.host/--/api/v2/push/send', {
|
|
37
|
+
method: 'POST',
|
|
38
|
+
headers: { 'Content-Type': 'application/json', 'Accept-Encoding': 'gzip, deflate' },
|
|
39
|
+
body: JSON.stringify(messages),
|
|
40
|
+
signal: AbortSignal.timeout(10_000),
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
catch (err) {
|
|
44
|
+
log.warn({ err: errMsg(err) }, 'expo push failed');
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
async function readJsonBody(req) {
|
|
48
|
+
const chunks = [];
|
|
49
|
+
for await (const c of req)
|
|
50
|
+
chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(c));
|
|
51
|
+
const raw = Buffer.concat(chunks).toString('utf8').trim();
|
|
52
|
+
if (!raw)
|
|
53
|
+
return {};
|
|
54
|
+
try {
|
|
55
|
+
return JSON.parse(raw);
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
return { __error: `bad JSON body: ${errMsg(err)}` };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/** Route all /api/messenger/* paths. Returns true if handled. */
|
|
62
|
+
export function routeMessenger(req, res, path, emit, send) {
|
|
63
|
+
const guard = (p, label, methodOk = true) => {
|
|
64
|
+
if (!methodOk) {
|
|
65
|
+
send(res, req, 405, { error: 'method not allowed' });
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
Promise.resolve(p()).catch(err => {
|
|
69
|
+
log.warn({ err: errMsg(err) }, `monitor: ${label} error`);
|
|
70
|
+
try {
|
|
71
|
+
send(res, req, 500, { error: errMsg(err) });
|
|
72
|
+
}
|
|
73
|
+
catch { /* ignore */ }
|
|
74
|
+
});
|
|
75
|
+
return true;
|
|
76
|
+
};
|
|
77
|
+
if (path === '/api/messenger/send') {
|
|
78
|
+
if (!emit) {
|
|
79
|
+
send(res, req, 500, { error: 'emit not wired' });
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
return guard(() => handleMessengerSend(req, res, emit, send), 'messenger-send', req.method === 'POST');
|
|
83
|
+
}
|
|
84
|
+
if (path === '/api/messenger/react') {
|
|
85
|
+
if (!emit) {
|
|
86
|
+
send(res, req, 500, { error: 'emit not wired' });
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
return guard(() => handleMessengerReact(req, res, emit, send), 'messenger-react', req.method === 'POST');
|
|
90
|
+
}
|
|
91
|
+
if (path === '/api/messenger/register') {
|
|
92
|
+
return guard(() => handleMessengerRegister(req, res, send), 'messenger-register', req.method === 'POST');
|
|
93
|
+
}
|
|
94
|
+
if (path === '/api/messenger/upload') {
|
|
95
|
+
return guard(() => handleMessengerUpload(req, res, send), 'messenger-upload', req.method === 'POST');
|
|
96
|
+
}
|
|
97
|
+
const fileMatch = path.match(/^\/api\/messenger\/files\/([^/]+)$/);
|
|
98
|
+
if (fileMatch) {
|
|
99
|
+
if (req.method !== 'GET') {
|
|
100
|
+
send(res, req, 405, { error: 'method not allowed' });
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
handleMessengerFile(req, res, decodeURIComponent(fileMatch[1]), send);
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
export async function handleMessengerSend(req, res, emit, send) {
|
|
109
|
+
const body = await readJsonBody(req);
|
|
110
|
+
if ('__error' in body)
|
|
111
|
+
return send(res, req, 400, { error: body.__error });
|
|
112
|
+
const text = (body.text ?? '').trim();
|
|
113
|
+
const attachments = Array.isArray(body.attachments) ? body.attachments : [];
|
|
114
|
+
const question = body.question && Array.isArray(body.question.options) && body.question.options.length > 0
|
|
115
|
+
? body.question : undefined;
|
|
116
|
+
if (!text && attachments.length === 0 && !question) {
|
|
117
|
+
return send(res, req, 400, { error: 'text, attachments, or question required' });
|
|
118
|
+
}
|
|
119
|
+
const fromAgent = body.as === 'agent';
|
|
120
|
+
const agent = userSelf();
|
|
121
|
+
const replyTo = typeof body.replyTo === 'string' && body.replyTo ? body.replyTo : undefined;
|
|
122
|
+
/** Merge attachments + question into a single payload object so the client can read
|
|
123
|
+
* both off `payload.attachments` and `payload.question`. */
|
|
124
|
+
const payload = (attachments.length > 0 || question)
|
|
125
|
+
? { ...(attachments.length > 0 ? { attachments } : {}), ...(question ? { question } : {}) }
|
|
126
|
+
: undefined;
|
|
127
|
+
const entry = {
|
|
128
|
+
id: mintId(),
|
|
129
|
+
ts: new Date().toISOString(),
|
|
130
|
+
station: 'messenger',
|
|
131
|
+
line: MESSENGER_LINE,
|
|
132
|
+
from: fromAgent ? agent : MESSENGER_USER,
|
|
133
|
+
to: fromAgent ? MESSENGER_USER : agent,
|
|
134
|
+
text: text || undefined,
|
|
135
|
+
...(replyTo ? { replyTo } : {}),
|
|
136
|
+
...(payload ? { payload } : {}),
|
|
137
|
+
};
|
|
138
|
+
emit(entry);
|
|
139
|
+
/** Agent → user: push to registered tokens. User → agent: their own message; skip. */
|
|
140
|
+
if (fromAgent) {
|
|
141
|
+
const a = attachments[0];
|
|
142
|
+
const kindLabel = a?.kind === 'image' ? '📷 Photo'
|
|
143
|
+
: a?.kind === 'audio' ? '🎤 Voice message'
|
|
144
|
+
: a?.kind === 'video' ? '🎬 Video'
|
|
145
|
+
: a ? `📎 ${a.name ?? 'File'}` : '';
|
|
146
|
+
const questionLabel = question ? `❓ ${question.header ?? 'Choose an option'}` : '';
|
|
147
|
+
const summary = text || kindLabel || questionLabel || '(empty)';
|
|
148
|
+
void pushExpo(readPushTokens(), 'Metro', summary.slice(0, 200), entry.id);
|
|
149
|
+
}
|
|
150
|
+
/** Fire-and-forget: transcribe any audio attachments and emit a follow-up event. */
|
|
151
|
+
for (const att of attachments) {
|
|
152
|
+
if (att.kind !== 'audio')
|
|
153
|
+
continue;
|
|
154
|
+
const filename = basename(att.url);
|
|
155
|
+
void transcribeAudio(join(UPLOADS_DIR, filename)).then(transcript => {
|
|
156
|
+
if (!transcript)
|
|
157
|
+
return;
|
|
158
|
+
emit({
|
|
159
|
+
id: mintId(), ts: new Date().toISOString(),
|
|
160
|
+
station: 'messenger', line: MESSENGER_LINE,
|
|
161
|
+
from: entry.from, to: entry.to,
|
|
162
|
+
payload: { transcribeFor: entry.id, transcript },
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
send(res, req, 200, { id: entry.id, line: entry.line });
|
|
167
|
+
}
|
|
168
|
+
/** Toggle a reaction; if already-active, emit `{removed: true}` so the client folds pairs. */
|
|
169
|
+
export async function handleMessengerReact(req, res, emit, send) {
|
|
170
|
+
const body = await readJsonBody(req);
|
|
171
|
+
if ('__error' in body)
|
|
172
|
+
return send(res, req, 400, { error: body.__error });
|
|
173
|
+
const messageId = (body.messageId ?? '').trim();
|
|
174
|
+
const emoji = (body.emoji ?? '').trim();
|
|
175
|
+
if (!messageId || !emoji)
|
|
176
|
+
return send(res, req, 400, { error: 'messageId + emoji required' });
|
|
177
|
+
const fromAgent = body.as === 'agent';
|
|
178
|
+
const agent = userSelf();
|
|
179
|
+
const sender = fromAgent ? agent : MESSENGER_USER;
|
|
180
|
+
/** Scan recent messenger history for an already-active reaction from this sender. */
|
|
181
|
+
/** readHistory returns newest-first; break on first match so we get the latest event's state. */
|
|
182
|
+
const recent = readHistory({ limit: 500, station: 'messenger' });
|
|
183
|
+
let active = false;
|
|
184
|
+
for (const e of recent) {
|
|
185
|
+
const p = e.payload;
|
|
186
|
+
if (!p?.reactTo || p.reactTo !== messageId || p.emoji !== emoji || e.from !== sender)
|
|
187
|
+
continue;
|
|
188
|
+
active = !p.removed;
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
const entry = {
|
|
192
|
+
id: mintId(),
|
|
193
|
+
ts: new Date().toISOString(),
|
|
194
|
+
station: 'messenger',
|
|
195
|
+
line: MESSENGER_LINE,
|
|
196
|
+
from: sender,
|
|
197
|
+
to: fromAgent ? MESSENGER_USER : agent,
|
|
198
|
+
payload: { reactTo: messageId, emoji, ...(active ? { removed: true } : {}) },
|
|
199
|
+
};
|
|
200
|
+
emit(entry);
|
|
201
|
+
send(res, req, 200, { id: entry.id, removed: active });
|
|
202
|
+
}
|
|
203
|
+
export async function handleMessengerRegister(req, res, send) {
|
|
204
|
+
const body = await readJsonBody(req);
|
|
205
|
+
if ('__error' in body)
|
|
206
|
+
return send(res, req, 400, { error: body.__error });
|
|
207
|
+
const token = (body.pushToken ?? '').trim();
|
|
208
|
+
if (!token)
|
|
209
|
+
return send(res, req, 400, { error: 'pushToken is required' });
|
|
210
|
+
const tokens = readPushTokens();
|
|
211
|
+
if (!tokens.includes(token))
|
|
212
|
+
writePushTokens([...tokens, token]);
|
|
213
|
+
send(res, req, 200, { ok: true, count: tokens.includes(token) ? tokens.length : tokens.length + 1 });
|
|
214
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** Local whisper.cpp audio transcription. Opt-in: requires whisper-cli + ggml model on disk. */
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { existsSync, readFileSync, unlinkSync } from 'node:fs';
|
|
4
|
+
import { homedir, tmpdir } from 'node:os';
|
|
5
|
+
import { basename, extname, join } from 'node:path';
|
|
6
|
+
import { errMsg, log } from '../log.js';
|
|
7
|
+
const WHISPER_BIN = process.env.METRO_WHISPER_BIN ?? 'whisper-cli';
|
|
8
|
+
const WHISPER_MODEL = process.env.METRO_WHISPER_MODEL
|
|
9
|
+
?? join(homedir(), '.cache', 'whisper-cpp', 'ggml-base.bin');
|
|
10
|
+
const FFMPEG_BIN = process.env.METRO_FFMPEG_BIN ?? 'ffmpeg';
|
|
11
|
+
function runCmd(bin, args) {
|
|
12
|
+
return new Promise((resolve, reject) => {
|
|
13
|
+
const child = spawn(bin, args, { stdio: 'ignore' });
|
|
14
|
+
child.on('error', reject);
|
|
15
|
+
child.on('exit', code => (code === 0 ? resolve() : reject(new Error(`${bin} exited ${code}`))));
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
/** Transcribe a local audio file to text. Returns null on any failure (incl. missing tooling). */
|
|
19
|
+
export async function transcribeAudio(filePath) {
|
|
20
|
+
if (!existsSync(WHISPER_MODEL) || !existsSync(filePath))
|
|
21
|
+
return null;
|
|
22
|
+
const base = basename(filePath, extname(filePath));
|
|
23
|
+
const wav = join(tmpdir(), `metro-tx-${base}.wav`);
|
|
24
|
+
const outPrefix = join(tmpdir(), `metro-tx-${base}`);
|
|
25
|
+
try {
|
|
26
|
+
await runCmd(FFMPEG_BIN, ['-y', '-i', filePath, '-ar', '16000', '-ac', '1', wav]);
|
|
27
|
+
await runCmd(WHISPER_BIN, ['-m', WHISPER_MODEL, '-f', wav, '--output-txt', '-of', outPrefix]);
|
|
28
|
+
const text = readFileSync(`${outPrefix}.txt`, 'utf8').trim();
|
|
29
|
+
return text || null;
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
log.warn({ err: errMsg(err) }, 'messenger transcribe failed');
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
finally {
|
|
36
|
+
for (const f of [wav, `${outPrefix}.txt`]) {
|
|
37
|
+
try {
|
|
38
|
+
unlinkSync(f);
|
|
39
|
+
}
|
|
40
|
+
catch { /* ignore */ }
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/** Messenger upload + serve: POST /api/messenger/upload, GET /api/messenger/files/:name. */
|
|
2
|
+
import { createReadStream, createWriteStream, existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from 'node:fs';
|
|
3
|
+
import { extname, join } from 'node:path';
|
|
4
|
+
import { pipeline } from 'node:stream/promises';
|
|
5
|
+
import { mintId } from '../history.js';
|
|
6
|
+
import { errMsg, log } from '../log.js';
|
|
7
|
+
import { STATE_DIR } from '../paths.js';
|
|
8
|
+
const UPLOADS_DIR = join(STATE_DIR, 'messenger-uploads');
|
|
9
|
+
const UPLOAD_MAX = 25 * 1024 * 1024;
|
|
10
|
+
/** Tunable via env: how long uploaded files stick around before they're pruned (ms). */
|
|
11
|
+
const UPLOAD_TTL_MS = Number(process.env.METRO_UPLOAD_TTL_MS ?? String(90 * 24 * 60 * 60 * 1000));
|
|
12
|
+
mkdirSync(UPLOADS_DIR, { recursive: true });
|
|
13
|
+
/** Best-effort: delete uploads older than UPLOAD_TTL_MS. Called once at module load + periodically. */
|
|
14
|
+
function pruneOldUploads() {
|
|
15
|
+
const cutoff = Date.now() - UPLOAD_TTL_MS;
|
|
16
|
+
let removed = 0;
|
|
17
|
+
try {
|
|
18
|
+
for (const name of readdirSync(UPLOADS_DIR)) {
|
|
19
|
+
const path = join(UPLOADS_DIR, name);
|
|
20
|
+
try {
|
|
21
|
+
if (statSync(path).mtimeMs < cutoff) {
|
|
22
|
+
unlinkSync(path);
|
|
23
|
+
removed += 1;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
catch { /* ignore individual file errors */ }
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
catch { /* dir missing — nothing to prune */ }
|
|
30
|
+
if (removed > 0)
|
|
31
|
+
log.info({ removed, dir: UPLOADS_DIR }, 'messenger-uploads: pruned old files');
|
|
32
|
+
}
|
|
33
|
+
pruneOldUploads();
|
|
34
|
+
setInterval(pruneOldUploads, 12 * 60 * 60 * 1000).unref();
|
|
35
|
+
/** Map MIME prefix → attachment kind. */
|
|
36
|
+
export function kindFromMime(mime) {
|
|
37
|
+
if (mime.startsWith('image/'))
|
|
38
|
+
return 'image';
|
|
39
|
+
if (mime.startsWith('audio/'))
|
|
40
|
+
return 'audio';
|
|
41
|
+
if (mime.startsWith('video/'))
|
|
42
|
+
return 'video';
|
|
43
|
+
return 'file';
|
|
44
|
+
}
|
|
45
|
+
const MIME_TO_EXT = {
|
|
46
|
+
'image/png': '.png', 'image/jpeg': '.jpg', 'image/jpg': '.jpg',
|
|
47
|
+
'image/webp': '.webp', 'image/gif': '.gif', 'image/heic': '.heic',
|
|
48
|
+
'audio/mp4': '.m4a', 'audio/m4a': '.m4a', 'audio/aac': '.aac',
|
|
49
|
+
'audio/mpeg': '.mp3', 'audio/ogg': '.ogg', 'audio/webm': '.webm', 'audio/wav': '.wav',
|
|
50
|
+
'video/mp4': '.mp4', 'video/webm': '.webm', 'video/quicktime': '.mov',
|
|
51
|
+
'application/pdf': '.pdf', 'application/zip': '.zip',
|
|
52
|
+
'text/plain': '.txt', 'text/markdown': '.md',
|
|
53
|
+
};
|
|
54
|
+
/** mime → file extension. Best-effort, falls back to '.bin'. */
|
|
55
|
+
function extFromMime(mime) {
|
|
56
|
+
return MIME_TO_EXT[mime.toLowerCase().split(';')[0]] ?? '.bin';
|
|
57
|
+
}
|
|
58
|
+
/** ext → mime, used when serving uploads so browsers / image tags interpret them correctly. */
|
|
59
|
+
function mimeFromExt(ext) {
|
|
60
|
+
const reverse = Object.entries(MIME_TO_EXT).find(([, e]) => e === ext.toLowerCase());
|
|
61
|
+
return reverse ? reverse[0] : 'application/octet-stream';
|
|
62
|
+
}
|
|
63
|
+
/** Raw binary upload: body = file bytes, headers `Content-Type` and `X-Filename` (optional). */
|
|
64
|
+
export async function handleMessengerUpload(req, res, send) {
|
|
65
|
+
const mime = (req.headers['content-type'] ?? 'application/octet-stream').toString().split(';')[0].trim();
|
|
66
|
+
const declared = Number(req.headers['content-length'] ?? '0');
|
|
67
|
+
if (declared > UPLOAD_MAX)
|
|
68
|
+
return send(res, req, 413, { error: `upload exceeds ${UPLOAD_MAX} bytes` });
|
|
69
|
+
const name = req.headers['x-filename']?.toString().slice(0, 256);
|
|
70
|
+
const id = mintId();
|
|
71
|
+
const ext = name ? extname(name) || extFromMime(mime) : extFromMime(mime);
|
|
72
|
+
const filename = `${id}${ext}`;
|
|
73
|
+
const dest = join(UPLOADS_DIR, filename);
|
|
74
|
+
let total = 0;
|
|
75
|
+
const out = createWriteStream(dest);
|
|
76
|
+
req.on('data', (chunk) => {
|
|
77
|
+
total += chunk.length;
|
|
78
|
+
if (total > UPLOAD_MAX) {
|
|
79
|
+
req.destroy(new Error('upload too large'));
|
|
80
|
+
out.destroy();
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
try {
|
|
84
|
+
await pipeline(req, out);
|
|
85
|
+
}
|
|
86
|
+
catch (err) {
|
|
87
|
+
try {
|
|
88
|
+
send(res, req, 413, { error: errMsg(err) });
|
|
89
|
+
}
|
|
90
|
+
catch { /* ignore */ }
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
/** Path-only URL; the client adds host + token. Stable, host-independent across tunnels. */
|
|
94
|
+
send(res, req, 200, {
|
|
95
|
+
id, url: `/api/messenger/files/${filename}`, kind: kindFromMime(mime), mime, size: total, name,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
/** GET /api/messenger/files/:filename — stream a previously uploaded file back. */
|
|
99
|
+
export function handleMessengerFile(req, res, filename, send) {
|
|
100
|
+
/** Guard path traversal — only basenames allowed. */
|
|
101
|
+
if (filename.includes('/') || filename.includes('..') || !filename) {
|
|
102
|
+
return send(res, req, 400, { error: 'bad filename' });
|
|
103
|
+
}
|
|
104
|
+
const path = join(UPLOADS_DIR, filename);
|
|
105
|
+
if (!existsSync(path))
|
|
106
|
+
return send(res, req, 404, { error: 'not found' });
|
|
107
|
+
const stat = statSync(path);
|
|
108
|
+
const dot = filename.lastIndexOf('.');
|
|
109
|
+
const ext = dot >= 0 ? filename.slice(dot) : '';
|
|
110
|
+
res.writeHead(200, {
|
|
111
|
+
'content-type': mimeFromExt(ext),
|
|
112
|
+
'content-length': stat.size.toString(),
|
|
113
|
+
'cache-control': 'private, max-age=31536000',
|
|
114
|
+
});
|
|
115
|
+
createReadStream(path).pipe(res);
|
|
116
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/** HTTP monitor endpoints: GET /api/state, GET /api/tail (SSE), POST /api/call/<train>/<action>. */
|
|
2
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
3
|
+
import pkg from '../../package.json' with { type: 'json' };
|
|
4
|
+
import { readClaims } from '../broker/claims.js';
|
|
5
|
+
import { drainTail, followTail, historySize, } from '../broker/history-stream.js';
|
|
6
|
+
import { readHistory } from '../history.js';
|
|
7
|
+
import { ipcCall } from '../ipc.js';
|
|
8
|
+
import { asLine } from '../lines.js';
|
|
9
|
+
import { errMsg, log } from '../log.js';
|
|
10
|
+
import { readBotIds } from '../paths.js';
|
|
11
|
+
import { routeMessenger } from './messenger-api.js';
|
|
12
|
+
/** Monitor endpoints answer only on dedicated hostnames so webhook tunnel can't double-serve them. */
|
|
13
|
+
const MONITOR_HOSTS = new Set((process.env.METRO_MONITOR_HOSTS ?? 'monitor.metro.box,localhost,127.0.0.1')
|
|
14
|
+
.toLowerCase().split(',').map(s => s.trim()).filter(Boolean));
|
|
15
|
+
const CALL_BODY_MAX = 256 * 1024;
|
|
16
|
+
/** Reflect request Origin so browsers (Netlify, custom domains, file://) can call cross-origin. */
|
|
17
|
+
function cors(req) {
|
|
18
|
+
return {
|
|
19
|
+
'access-control-allow-origin': req.headers.origin ?? '*',
|
|
20
|
+
'access-control-allow-methods': 'GET, POST, OPTIONS',
|
|
21
|
+
'access-control-allow-headers': 'Authorization, Content-Type',
|
|
22
|
+
'access-control-max-age': '86400',
|
|
23
|
+
vary: 'Origin',
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
function send(res, req, status, body) {
|
|
27
|
+
res.writeHead(status, { 'content-type': 'application/json', ...cors(req) });
|
|
28
|
+
res.end(JSON.stringify(body));
|
|
29
|
+
}
|
|
30
|
+
function tokenEq(given, want) {
|
|
31
|
+
const g = Buffer.from(given), w = Buffer.from(want);
|
|
32
|
+
return g.length === w.length && timingSafeEqual(g, w);
|
|
33
|
+
}
|
|
34
|
+
function authorized(req, q) {
|
|
35
|
+
const token = process.env.METRO_MONITOR_TOKEN;
|
|
36
|
+
if (!token)
|
|
37
|
+
return { status: 503, msg: 'monitor endpoints not configured (METRO_MONITOR_TOKEN unset)' };
|
|
38
|
+
const header = [].concat(req.headers['authorization'] ?? [])[0];
|
|
39
|
+
if (header?.startsWith('Bearer ') && tokenEq(header.slice(7), token))
|
|
40
|
+
return null;
|
|
41
|
+
/** Query-token path is for media tags (img/audio) that can't send Authorization headers. */
|
|
42
|
+
const qt = q?.get('token');
|
|
43
|
+
if (qt && tokenEq(qt, token))
|
|
44
|
+
return null;
|
|
45
|
+
return { status: 401, msg: 'unauthorized' };
|
|
46
|
+
}
|
|
47
|
+
/** Mode picker — shared with CLI tail. Conflict/strict-no-self routed through `onErr`. */
|
|
48
|
+
export function pickMode(strict, unclaimed, all, self, onErr) {
|
|
49
|
+
if ([strict, unclaimed, all].filter(Boolean).length > 1) {
|
|
50
|
+
return onErr('strict/unclaimed/all are mutually exclusive');
|
|
51
|
+
}
|
|
52
|
+
if (strict)
|
|
53
|
+
return self ? 'mine-only' : onErr('strict requires --as <user-uri>');
|
|
54
|
+
if (unclaimed)
|
|
55
|
+
return 'unclaimed';
|
|
56
|
+
if (all || !self)
|
|
57
|
+
return 'all';
|
|
58
|
+
return 'mine-or-unclaimed';
|
|
59
|
+
}
|
|
60
|
+
export function handleMonitorRequest(req, res, emit) {
|
|
61
|
+
const url = req.url ?? '';
|
|
62
|
+
if (!url.startsWith('/api/'))
|
|
63
|
+
return false;
|
|
64
|
+
const host = req.headers[':authority'] ?? req.headers.host;
|
|
65
|
+
if (host && !MONITOR_HOSTS.has(host.split(':')[0].toLowerCase()))
|
|
66
|
+
return false;
|
|
67
|
+
/** CORS preflight — short-circuit before auth so browsers can OPTIONS without a token. */
|
|
68
|
+
if (req.method === 'OPTIONS') {
|
|
69
|
+
res.writeHead(204, cors(req));
|
|
70
|
+
res.end();
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
const [path, qs = ''] = url.split('?', 2);
|
|
74
|
+
const q = new URLSearchParams(qs);
|
|
75
|
+
const auth = authorized(req, q);
|
|
76
|
+
if (auth) {
|
|
77
|
+
send(res, req, auth.status, { error: auth.msg });
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
if (req.method === 'GET' && path === '/api/state') {
|
|
81
|
+
handleState(res, req, q);
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
if (req.method === 'GET' && path === '/api/tail') {
|
|
85
|
+
handleTail(req, res, q).catch(err => {
|
|
86
|
+
log.warn({ err: errMsg(err) }, 'monitor: tail handler error');
|
|
87
|
+
try {
|
|
88
|
+
if (!res.headersSent)
|
|
89
|
+
res.writeHead(500).end();
|
|
90
|
+
else
|
|
91
|
+
res.end();
|
|
92
|
+
}
|
|
93
|
+
catch { /* ignore */ }
|
|
94
|
+
});
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
/** POST /api/call/<train>/<action> — JSON body {args} forwarded to train via IPC forward-call. */
|
|
98
|
+
const callMatch = path.match(/^\/api\/call\/([^/]+)\/([^/]+)$/);
|
|
99
|
+
if (callMatch) {
|
|
100
|
+
if (req.method !== 'POST') {
|
|
101
|
+
send(res, req, 405, { error: 'method not allowed' });
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
handleCall(req, res, callMatch[1], callMatch[2]).catch(err => {
|
|
105
|
+
log.warn({ err: errMsg(err) }, 'monitor: call handler error');
|
|
106
|
+
try {
|
|
107
|
+
send(res, req, 500, { error: errMsg(err) });
|
|
108
|
+
}
|
|
109
|
+
catch { /* ignore */ }
|
|
110
|
+
});
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
/** /api/messenger/* — send, register, upload, files/:name. */
|
|
114
|
+
if (path.startsWith('/api/messenger/') && routeMessenger(req, res, path, emit, send))
|
|
115
|
+
return true;
|
|
116
|
+
/** GET-only paths reject other verbs with 405. Anything else → 404. */
|
|
117
|
+
if (req.method !== 'GET' && (path === '/api/state' || path === '/api/tail')) {
|
|
118
|
+
send(res, req, 405, { error: 'method not allowed' });
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
send(res, req, 404, { error: 'not found' });
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
function nonNegInt(raw) {
|
|
125
|
+
const n = raw == null ? NaN : Number(raw);
|
|
126
|
+
return Number.isInteger(n) && n >= 0 ? n : null;
|
|
127
|
+
}
|
|
128
|
+
function handleState(res, req, q) {
|
|
129
|
+
const before = nonNegInt(q.get('before'));
|
|
130
|
+
const limit = Math.min(nonNegInt(q.get('limit')) ?? 100, 500);
|
|
131
|
+
if (before !== null)
|
|
132
|
+
return send(res, req, 200, { recent_history: readHistory({ limit, skip: before }) });
|
|
133
|
+
const recent = readHistory({ limit }), claims = readClaims();
|
|
134
|
+
const lines = new Set([...recent.map(e => e.line), ...Object.keys(claims)]);
|
|
135
|
+
send(res, req, 200, {
|
|
136
|
+
claims, lines: [...lines], recent_history: recent, bot_ids: readBotIds(), version: pkg.version,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
async function handleTail(req, res, q) {
|
|
140
|
+
const asParam = q.get('as');
|
|
141
|
+
const self = asParam ? asLine(asParam) : null;
|
|
142
|
+
const isOn = (k) => q.get(k) === 'true' || q.get('mode') === k;
|
|
143
|
+
const mode = pickMode(isOn('strict'), isOn('unclaimed'), isOn('all'), self, () => 'all');
|
|
144
|
+
const excludeFromCsv = q.get('exclude_from');
|
|
145
|
+
const opts = {
|
|
146
|
+
mode, self, chatFilter: q.get('chat') ?? undefined,
|
|
147
|
+
stationFilter: q.get('station') ?? undefined,
|
|
148
|
+
includeWebhooks: q.get('include_webhooks') === 'true',
|
|
149
|
+
excludeFrom: excludeFromCsv ? excludeFromCsv.split(',').map(s => s.trim()).filter(Boolean) : undefined,
|
|
150
|
+
};
|
|
151
|
+
res.writeHead(200, {
|
|
152
|
+
'content-type': 'text/event-stream', 'cache-control': 'no-cache, no-transform',
|
|
153
|
+
'connection': 'keep-alive', ...cors(req),
|
|
154
|
+
/** Cloudflare/proxies buffer SSE without this hint. */
|
|
155
|
+
'x-accel-buffering': 'no',
|
|
156
|
+
});
|
|
157
|
+
/** `since=tail` (default) starts at EOF; `since=0` replays the full file; numeric = byte offset. */
|
|
158
|
+
const since = q.get('since');
|
|
159
|
+
const sinceN = since && since !== 'tail' ? Number(since) : NaN;
|
|
160
|
+
let offset = Number.isFinite(sinceN) && sinceN >= 0 ? sinceN : historySize();
|
|
161
|
+
/** 4 KiB padding so Cloudflare's HTTP/2 buffer flushes (else holds 30+ s on free tier). */
|
|
162
|
+
res.write(`: metro monitor tail (mode=${opts.mode}${self ? `, as=${self}` : ''})\n: ${'-'.repeat(4096)}\n\n`);
|
|
163
|
+
const sse = (e) => {
|
|
164
|
+
res.write(`id: ${e.id}\nevent: history\ndata: ${JSON.stringify(e)}\n\n`);
|
|
165
|
+
};
|
|
166
|
+
offset = drainTail(offset, opts, sse);
|
|
167
|
+
const stop = followTail(offset, opts, sse, 1_000);
|
|
168
|
+
const keepalive = setInterval(() => res.write(': keepalive\n\n'), 25_000);
|
|
169
|
+
const cleanup = () => { stop(); clearInterval(keepalive); try {
|
|
170
|
+
res.end();
|
|
171
|
+
}
|
|
172
|
+
catch { /* ignore */ } };
|
|
173
|
+
req.on('close', cleanup);
|
|
174
|
+
req.on('error', cleanup);
|
|
175
|
+
}
|
|
176
|
+
async function handleCall(req, res, train, action) {
|
|
177
|
+
const chunks = [];
|
|
178
|
+
let total = 0;
|
|
179
|
+
for await (const chunk of req) {
|
|
180
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
181
|
+
total += buf.length;
|
|
182
|
+
if (total > CALL_BODY_MAX)
|
|
183
|
+
throw new Error(`request body exceeds ${CALL_BODY_MAX} bytes`);
|
|
184
|
+
chunks.push(buf);
|
|
185
|
+
}
|
|
186
|
+
const raw = Buffer.concat(chunks).toString('utf8').trim();
|
|
187
|
+
let args = {};
|
|
188
|
+
if (raw) {
|
|
189
|
+
try {
|
|
190
|
+
const parsed = JSON.parse(raw);
|
|
191
|
+
args = parsed && typeof parsed === 'object' && 'args' in parsed ? parsed.args : parsed;
|
|
192
|
+
}
|
|
193
|
+
catch (err) {
|
|
194
|
+
return send(res, req, 400, { error: `bad JSON body: ${errMsg(err)}` });
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
const resp = await ipcCall({ op: 'forward-call', train, action, args });
|
|
198
|
+
if (!resp.ok)
|
|
199
|
+
return send(res, req, 502, { error: resp.error });
|
|
200
|
+
if (!('response' in resp))
|
|
201
|
+
return send(res, req, 502, { error: 'malformed daemon response' });
|
|
202
|
+
if (resp.response.error)
|
|
203
|
+
return send(res, req, 502, { error: resp.response.error });
|
|
204
|
+
send(res, req, 200, { result: resp.response.result ?? null });
|
|
205
|
+
}
|
package/dist/cli/tail.js
CHANGED
|
@@ -1,36 +1,26 @@
|
|
|
1
|
-
/** CLI tail/claim/release/claims
|
|
2
|
-
import { timingSafeEqual } from 'node:crypto';
|
|
1
|
+
/** CLI: `metro tail / claim / release / claims`. HTTP monitor endpoints live in monitor-api.ts. */
|
|
3
2
|
import { existsSync } from 'node:fs';
|
|
4
|
-
import pkg from '../../package.json' with { type: 'json' };
|
|
5
3
|
import { CLAIMS_FILE, claimLine, readClaims, releaseLine } from '../broker/claims.js';
|
|
6
4
|
import { cursorKey, drainTail, followTail, historySize, readCursor, writeCursor, } from '../broker/history-stream.js';
|
|
7
|
-
import {
|
|
5
|
+
import { userSelf } from '../history.js';
|
|
8
6
|
import { asLine } from '../lines.js';
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
7
|
+
import { loadMetroEnv } from '../paths.js';
|
|
8
|
+
import { pickMode } from './monitor-api.js';
|
|
11
9
|
import { emit, exitErr, flagOne, isJson, need, writeJson } from './util.js';
|
|
12
|
-
|
|
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>');
|
|
19
|
-
if (unclaimed)
|
|
20
|
-
return 'unclaimed';
|
|
21
|
-
if (all || !self)
|
|
22
|
-
return 'all';
|
|
23
|
-
return 'mine-or-unclaimed';
|
|
24
|
-
}
|
|
10
|
+
export { handleMonitorRequest } from './monitor-api.js';
|
|
25
11
|
export async function cmdTail(_, f) {
|
|
26
12
|
loadMetroEnv();
|
|
27
13
|
const raw = flagOne(f, 'as');
|
|
28
14
|
const auto = userSelf();
|
|
29
15
|
const self = raw !== undefined ? asLine(raw) : auto === 'metro://user' ? null : auto;
|
|
30
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');
|
|
31
18
|
const tail = {
|
|
32
19
|
mode, self, chatFilter: flagOne(f, 'chat'), stationFilter: flagOne(f, 'station'),
|
|
33
20
|
includeWebhooks: f['include-webhooks'] === true,
|
|
21
|
+
excludeFrom: excludeFromFlag
|
|
22
|
+
? excludeFromFlag.split(',').map(s => s.trim()).filter(Boolean)
|
|
23
|
+
: undefined,
|
|
34
24
|
};
|
|
35
25
|
const follow = f.follow === true;
|
|
36
26
|
const limit = Number(flagOne(f, 'limit')) || 0;
|
|
@@ -64,11 +54,11 @@ export async function cmdTail(_, f) {
|
|
|
64
54
|
});
|
|
65
55
|
}
|
|
66
56
|
function fmtRow(e) {
|
|
67
|
-
const body = e.text ??
|
|
57
|
+
const body = e.text ?? '';
|
|
68
58
|
const text = body.length > 80 ? body.slice(0, 79) + '…' : body;
|
|
69
59
|
const who = (e.fromName ?? e.from).padEnd(28).slice(0, 28);
|
|
70
60
|
const where = e.line.padEnd(40).slice(0, 40);
|
|
71
|
-
return `${e.ts.slice(11, 19)} ${e.id.padEnd(12)} ${
|
|
61
|
+
return `${e.ts.slice(11, 19)} ${e.id.padEnd(12)} ${who} ${where} ${text}`;
|
|
72
62
|
}
|
|
73
63
|
export async function cmdClaim(p, f) {
|
|
74
64
|
loadMetroEnv();
|
|
@@ -93,7 +83,8 @@ export async function cmdClaims(_, f) {
|
|
|
93
83
|
if (isJson(f))
|
|
94
84
|
return writeJson({ claims });
|
|
95
85
|
if (!entries.length) {
|
|
96
|
-
process.stdout.write(
|
|
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`);
|
|
97
88
|
return;
|
|
98
89
|
}
|
|
99
90
|
const w = Math.max(...entries.map(([l]) => l.length));
|
|
@@ -102,111 +93,3 @@ export async function cmdClaims(_, f) {
|
|
|
102
93
|
process.stdout.write(` ${l.padEnd(w)} → ${o}\n`);
|
|
103
94
|
process.stdout.write(`\n${entries.length} claim${entries.length === 1 ? '' : 's'} · ${CLAIMS_FILE}\n`);
|
|
104
95
|
}
|
|
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
|
-
}
|
|
@@ -23,14 +23,6 @@ export function makeEmit(codexRc) {
|
|
|
23
23
|
}
|
|
24
24
|
/** Translate the snake_case train wire envelope to a camelCase `HistoryEntry`. */
|
|
25
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
26
|
export function trainEventToHistoryEntry(env, trainName) {
|
|
35
27
|
const line = env.line;
|
|
36
28
|
if (typeof line !== 'string') {
|
|
@@ -39,18 +31,18 @@ export function trainEventToHistoryEntry(env, trainName) {
|
|
|
39
31
|
}
|
|
40
32
|
const station = env.station ?? Line.station(line) ?? trainName;
|
|
41
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);
|
|
42
36
|
return {
|
|
43
37
|
id: env.id ?? mintId(),
|
|
44
38
|
ts: env.ts ?? new Date().toISOString(),
|
|
45
|
-
kind: normalizeKind(env.kind),
|
|
46
39
|
station,
|
|
47
40
|
line: line,
|
|
48
41
|
lineName: env.line_name,
|
|
49
42
|
from: (env.from ?? `metro://${station}`),
|
|
50
43
|
fromName: env.from_name,
|
|
51
44
|
to: (env.to ?? (isPrivate ? userSelf() : line)),
|
|
52
|
-
text
|
|
53
|
-
emoji: env.emoji,
|
|
45
|
+
text,
|
|
54
46
|
messageId: env.message_id,
|
|
55
47
|
replyTo: env.reply_to,
|
|
56
48
|
payload: env.payload,
|
|
@@ -75,7 +67,7 @@ export async function startWebhookServer(emit) {
|
|
|
75
67
|
return server;
|
|
76
68
|
}
|
|
77
69
|
async function handleRequest(req, res, emit) {
|
|
78
|
-
if (handleMonitorRequest(req, res))
|
|
70
|
+
if (handleMonitorRequest(req, res, emit))
|
|
79
71
|
return;
|
|
80
72
|
const m = req.url?.match(/^\/wh\/([A-Za-z0-9_-]+)/);
|
|
81
73
|
if (!m) {
|
|
@@ -113,7 +105,7 @@ async function handleRequest(req, res, emit) {
|
|
|
113
105
|
catch { /* keep as string */ }
|
|
114
106
|
const line = Line.webhook(endpointId);
|
|
115
107
|
emit({
|
|
116
|
-
id: mintId(), ts: new Date().toISOString(),
|
|
108
|
+
id: mintId(), ts: new Date().toISOString(), station: 'webhook',
|
|
117
109
|
line, lineName: endpoint.label, from: line, to: line,
|
|
118
110
|
messageId: headers['x-github-delivery'] || headers['x-request-id'] || randomUUID(),
|
|
119
111
|
text: `${headers['x-github-event'] ?? headers['x-intercom-topic'] ?? 'event'} ${req.method} ${req.url}`,
|
package/dist/dispatcher.js
CHANGED
|
@@ -42,7 +42,7 @@ const ipc = startIpcServer(async (req) => {
|
|
|
42
42
|
if (req.op === 'notify') {
|
|
43
43
|
const line = req.line;
|
|
44
44
|
emit({
|
|
45
|
-
id: mintId(), ts: new Date().toISOString(),
|
|
45
|
+
id: mintId(), ts: new Date().toISOString(),
|
|
46
46
|
station: Line.station(line) ?? '?', line,
|
|
47
47
|
from: (req.from ?? userSelf()), to: line, text: req.text,
|
|
48
48
|
});
|
package/dist/history.js
CHANGED
|
@@ -6,21 +6,20 @@ import { errMsg, log } from './log.js';
|
|
|
6
6
|
import { STATE_DIR } from './paths.js';
|
|
7
7
|
import { Line } from './lines.js';
|
|
8
8
|
import { claudeUserId, claudeSessionId, codexUserId, codexSessionId } from './local-identity.js';
|
|
9
|
-
/** Pre-render a chat-bubble line
|
|
9
|
+
/** Pre-render a chat-bubble line. Direction is derived: from === local agent → outbound (📤), else inbound (📩). */
|
|
10
10
|
export function formatDisplay(e) {
|
|
11
11
|
const headerFor = (icon, parts) => `**${icon} ${parts.filter(Boolean).join(' · ')}**`;
|
|
12
|
-
const body = e.text ??
|
|
13
|
-
if (e.
|
|
12
|
+
const body = e.text ?? '';
|
|
13
|
+
if (e.station === 'webhook' && !Line.isLocal(e.from)) {
|
|
14
14
|
const ev = e.payload
|
|
15
15
|
?.headers?.['x-github-event'] ?? e.payload
|
|
16
16
|
?.headers?.['x-intercom-topic'];
|
|
17
17
|
return `${headerFor('🪝', ['webhook', e.lineName, ev])}\n> ${body}`;
|
|
18
18
|
}
|
|
19
|
-
if (
|
|
20
|
-
|
|
21
|
-
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}`;
|
|
22
21
|
}
|
|
23
|
-
return `${headerFor('
|
|
22
|
+
return `${headerFor('📩', [e.station, e.fromName ?? e.from, e.lineName])}\n> ${body}`;
|
|
24
23
|
}
|
|
25
24
|
const FILE = join(STATE_DIR, 'history.jsonl');
|
|
26
25
|
/** Mint a universal metro message ID. Short, prefixed, URL-safe. */
|
|
@@ -71,8 +70,6 @@ function matches(e, f) {
|
|
|
71
70
|
return false;
|
|
72
71
|
if (f.station && e.station !== f.station)
|
|
73
72
|
return false;
|
|
74
|
-
if (f.kind && e.kind !== f.kind)
|
|
75
|
-
return false;
|
|
76
73
|
if (f.from && e.from !== f.from)
|
|
77
74
|
return false;
|
|
78
75
|
if (f.textContains && !(e.text ?? '').toLowerCase().includes(f.textContains.toLowerCase()))
|
package/docs/broker.md
CHANGED
|
@@ -10,7 +10,7 @@ One concept — a **claim** — and three on-disk files you can `cat`:
|
|
|
10
10
|
|
|
11
11
|
| Concern | File | Role |
|
|
12
12
|
|----------------------|-----------------------------------------|-------------------------------------------------------------------------------------------------------------------|
|
|
13
|
-
| Event log | `$METRO_STATE_DIR/history.jsonl` | Append-only JSONL — every
|
|
13
|
+
| Event log | `$METRO_STATE_DIR/history.jsonl` | Append-only JSONL — every event (chat messages, webhooks, reactions, transcripts, …). Single source of truth. |
|
|
14
14
|
| Claims | `$METRO_STATE_DIR/claims.json` | `{ <line>: <user-id> }` — flat map. A line in here is *exclusively* owned by that user. Absence = broadcast. |
|
|
15
15
|
| Per-mode cursor | `$METRO_STATE_DIR/cursors/<key>` | Byte offset into `history.jsonl` — last-emitted position for one tail mode. Updated atomically after each emit. |
|
|
16
16
|
|
package/docs/monitor.md
CHANGED
|
@@ -6,20 +6,26 @@ directly.
|
|
|
6
6
|
|
|
7
7
|
These endpoints mount on the **existing** webhook HTTP server (default port `8420`).
|
|
8
8
|
There is no separate daemon, no separate port, no extra process to launch. The
|
|
9
|
-
implementation lives in [`src/cli/
|
|
10
|
-
|
|
9
|
+
implementation lives in [`src/cli/monitor-api.ts`](../src/cli/monitor-api.ts) (re-exported
|
|
10
|
+
by `src/cli/tail.ts` for backwards compatibility) and is wired into the HTTP server in
|
|
11
11
|
[`src/dispatcher/server.ts`](../src/dispatcher/server.ts) via `handleMonitorRequest`.
|
|
12
12
|
|
|
13
13
|
## Routes
|
|
14
14
|
|
|
15
|
-
| Method | Path
|
|
16
|
-
|
|
17
|
-
| GET | `/api/state`
|
|
18
|
-
| GET | `/api/tail`
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
15
|
+
| Method | Path | Returns |
|
|
16
|
+
|--------|---------------------------------|------------------------------------------------------------------------------------------|
|
|
17
|
+
| GET | `/api/state` | JSON snapshot — `{ claims, lines, recent_history (last 100), bot_ids }`. |
|
|
18
|
+
| GET | `/api/tail` | Server-Sent Events stream — `history.jsonl` entries, claim-aware filtered. |
|
|
19
|
+
| POST | `/api/call/<train>/<action>` | Forward an action call to a train via `forward-call` IPC; returns `{result}`. |
|
|
20
|
+
| POST | `/api/messenger/send` | In-daemon chat: emits a history entry on `metro://messenger/owner`. Accepts `{text?, attachments?[], as?}`. |
|
|
21
|
+
| POST | `/api/messenger/register` | Store an Expo push token so agent replies push to the phone. |
|
|
22
|
+
| POST | `/api/messenger/upload` | Raw binary upload (up to 25 MiB). Body = file bytes; headers `Content-Type`, optional `X-Filename`. Returns `{id, url, kind, mime, size, name?}`. |
|
|
23
|
+
| GET | `/api/messenger/files/<name>` | Stream a previously uploaded attachment. Accepts `?token=…` as an alternative to the bearer header for `<img>` / `<audio>` tags. |
|
|
24
|
+
|
|
25
|
+
`/api/state` and `/api/tail` are read-only. `/api/call/<train>/<action>` is the single
|
|
26
|
+
write endpoint — it never touches the on-disk history; the train running on the daemon
|
|
27
|
+
emits its own outbound event after delivering the message, which then flows through the
|
|
28
|
+
normal SSE stream like any other entry.
|
|
23
29
|
|
|
24
30
|
## Authentication
|
|
25
31
|
|
|
@@ -134,6 +140,61 @@ curl -N \
|
|
|
134
140
|
"https://monitor.metro.box/api/tail?since=0"
|
|
135
141
|
```
|
|
136
142
|
|
|
143
|
+
## `POST /api/call/<train>/<action>`
|
|
144
|
+
|
|
145
|
+
Forwards an action call to a train (same as `metro call <train> <action> <args>` on the
|
|
146
|
+
command line) via the daemon's existing `forward-call` IPC. Use this from the mobile or
|
|
147
|
+
web app to send a message, react, edit, etc. without needing shell access.
|
|
148
|
+
|
|
149
|
+
### Request
|
|
150
|
+
|
|
151
|
+
```http
|
|
152
|
+
POST /api/call/discord/send HTTP/1.1
|
|
153
|
+
Host: monitor.metro.box
|
|
154
|
+
Authorization: Bearer <METRO_MONITOR_TOKEN>
|
|
155
|
+
Content-Type: application/json
|
|
156
|
+
|
|
157
|
+
{"args": {"line": "metro://discord/123", "text": "hello from the web"}}
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
The body is one of:
|
|
161
|
+
|
|
162
|
+
- `{"args": <object|array|string>}` — explicit `args` wrapper (recommended).
|
|
163
|
+
- `<object>` — any other JSON object is forwarded as the args verbatim (useful for terse
|
|
164
|
+
clients).
|
|
165
|
+
- Empty body — forwarded as `{}`.
|
|
166
|
+
|
|
167
|
+
`<train>` must match a train running under `~/.metro/trains/`; `<action>` is whatever
|
|
168
|
+
that train expects (`send`, `react`, `edit`, …).
|
|
169
|
+
|
|
170
|
+
### Response
|
|
171
|
+
|
|
172
|
+
| Status | Body | Meaning |
|
|
173
|
+
|--------|-----------------------------------------------------|----------------------------------------------------------|
|
|
174
|
+
| 200 | `{"result": <whatever the train returned>}` | Train accepted the call and returned a result. |
|
|
175
|
+
| 400 | `{"error": "bad JSON body: …"}` | Body was not valid JSON. |
|
|
176
|
+
| 401 | `{"error": "unauthorized"}` | Missing / wrong bearer token. |
|
|
177
|
+
| 405 | `{"error": "method not allowed"}` | Wrong verb (only `POST` is accepted on this path). |
|
|
178
|
+
| 500 | `{"error": "…"}` | Daemon IPC unavailable (e.g. socket missing). |
|
|
179
|
+
| 502 | `{"error": "…"}` | Train returned an error or the IPC handshake malformed. |
|
|
180
|
+
|
|
181
|
+
Request bodies larger than 256 KiB are rejected with HTTP 500.
|
|
182
|
+
|
|
183
|
+
### Example: send a message
|
|
184
|
+
|
|
185
|
+
```bash
|
|
186
|
+
curl -X POST \
|
|
187
|
+
-H "Authorization: Bearer $METRO_MONITOR_TOKEN" \
|
|
188
|
+
-H "Content-Type: application/json" \
|
|
189
|
+
-d '{"args":{"line":"metro://discord/123","text":"hi"}}' \
|
|
190
|
+
https://monitor.metro.box/api/call/discord/send
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Because the daemon's `send` adapter writes a history entry once the message lands, an
|
|
194
|
+
active `/api/tail` subscriber will receive the corresponding event a moment later
|
|
195
|
+
(`from` = local user). UIs typically clear the input on HTTP 200 and let the SSE replay
|
|
196
|
+
show the sent message.
|
|
197
|
+
|
|
137
198
|
## Exposing publicly via Cloudflare tunnel
|
|
138
199
|
|
|
139
200
|
The daemon listens on `127.0.0.1:8420` only. To reach `/api/*` from a phone or a
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stage-labs/metro",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.15",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Event-interception wire. Supervises train subprocesses in ~/.metro/trains/, multiplexes their JSON event stream onto stdout, and routes outbound action calls back via stdin. Per-platform code is written by the agent (or user) as train scripts — metro core is pure transport.",
|
|
6
6
|
"license": "MIT",
|
package/skills/metro/SKILL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: metro
|
|
3
|
-
description: Run the metro train-supervisor in this session — launch `metro` in the background, watch its stdout for inbound JSON events, and act on each. Use when the user asks to start/run/launch metro, when you see JSON lines on stdout shaped `{"
|
|
3
|
+
description: Run the metro train-supervisor in this session — launch `metro` in the background, watch its stdout for inbound JSON events, and act on each. Use when the user asks to start/run/launch metro, when you see JSON lines on stdout shaped `{"station":"…","line":"metro://…","from":"…","to":"…","text":"…"}`, or when handling a chat/webhook reply/edit/react/send via `metro call`.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Metro — event-interception wire
|
|
@@ -13,8 +13,8 @@ replace on demand.
|
|
|
13
13
|
|
|
14
14
|
1. Spawns each file in `~/.metro/trains/*.{ts,js,mjs}` as a long-running Bun subprocess.
|
|
15
15
|
2. Multiplexes their stdout (JSON lines) into one unified event stream on metro's stdout.
|
|
16
|
-
3. Routes `metro call <train> <action> <args>` requests back to the matching train's stdin
|
|
17
|
-
4. Two builtin event sources stay in core: **webhooks** (HTTP receiver) and
|
|
16
|
+
3. Routes `metro call <train> <action> <args>` requests back to the matching train's stdin.
|
|
17
|
+
4. Two builtin event sources stay in core: **webhooks** (HTTP receiver) and **messenger** (in-daemon chat).
|
|
18
18
|
|
|
19
19
|
## Starting metro
|
|
20
20
|
|
|
@@ -24,19 +24,19 @@ replace on demand.
|
|
|
24
24
|
|
|
25
25
|
`metro doctor` reports trains found, deps installed, dispatcher running, codex-rc, skill install.
|
|
26
26
|
|
|
27
|
-
##
|
|
27
|
+
## Envelope
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
Every event on stdout is a single JSON line:
|
|
30
30
|
|
|
31
31
|
```json
|
|
32
|
-
{"
|
|
32
|
+
{"id":"msg_…","ts":"2026-05-17T18:00:00Z","station":"discord","line":"metro://discord/123","line_name":"infra","from":"metro://discord/user/456","from_name":"alice","to":"metro://claude/user/9bfc…","text":"hi","message_id":"789","reply_to":"…","payload":{…}}
|
|
33
33
|
```
|
|
34
34
|
|
|
35
|
-
Wire fields are `snake_case` on the train protocol
|
|
35
|
+
Wire fields are `snake_case` on the train protocol; the dispatcher translates to camelCase for `history.jsonl`. Trains supply `line`, `from`, `to`, `text`, plus `payload` (the platform's native message). Metro mints `id` + `display` if missing.
|
|
36
36
|
|
|
37
|
-
**
|
|
37
|
+
**No `kind` field.** Direction is derived: `Line.isLocal(from)` → outbound (📤), else inbound (📩). Reactions and edits are train-specific — encode them in `text` (e.g. `[react 👀]`) plus whatever you need in `payload`.
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
## Outbound: `metro call <train> <action> <args>`
|
|
40
40
|
|
|
41
41
|
```
|
|
42
42
|
metro call discord send '{"line":"metro://discord/123","text":"hi","replyTo":"789"}'
|
|
@@ -44,21 +44,19 @@ metro call telegram react '{"line":"metro://telegram/-100/1","messageId":"42","e
|
|
|
44
44
|
metro call discord edit '{"line":"metro://discord/123","messageId":"999","text":"new"}'
|
|
45
45
|
```
|
|
46
46
|
|
|
47
|
-
`[args]` can be JSON, `@path/to/args.json`, `-` (stdin), or a bare string. Action names are whatever the train exposes — metro core knows nothing about them.
|
|
47
|
+
`[args]` can be JSON, `@path/to/args.json`, `-` (stdin), or a bare string. Action names are whatever the train exposes — metro core knows nothing about them.
|
|
48
48
|
|
|
49
49
|
## Writing a new train
|
|
50
50
|
|
|
51
|
-
1. Start from `node_modules/@stage-labs/metro/examples/telegram.ts
|
|
52
|
-
2. Copy → `~/.metro/trains/<name>.ts` and edit. Keep the inbound shape and the `op:"call"` → `op:"response"` protocol.
|
|
51
|
+
1. Start from `node_modules/@stage-labs/metro/examples/telegram.ts`.
|
|
52
|
+
2. Copy → `~/.metro/trains/<name>.ts` and edit. Keep the inbound envelope shape and the `op:"call"` → `op:"response"` protocol.
|
|
53
53
|
3. Deps (if needed): `cd ~/.metro && bun add <pkg>`. Credentials: `echo 'FOO_TOKEN=…' >> ~/.metro/.env`.
|
|
54
|
-
4. Restart the metro daemon to pick up the new train.
|
|
54
|
+
4. Restart the metro daemon (or just `metro trains restart <name>`) to pick up the new train.
|
|
55
55
|
|
|
56
56
|
Trains are throwaway — if the user asks for new functionality, rewrite the train rather than adding glue in core.
|
|
57
57
|
|
|
58
58
|
## First-run setup (once per machine)
|
|
59
59
|
|
|
60
|
-
Telegram (no npm deps — uses native fetch + long polling):
|
|
61
|
-
|
|
62
60
|
```
|
|
63
61
|
mkdir -p ~/.metro && cd ~/.metro && bun init -y
|
|
64
62
|
cp node_modules/@stage-labs/metro/examples/telegram.ts ~/.metro/trains/
|
|
@@ -67,11 +65,6 @@ metro setup skill # optional — installs this SKILL.md into ~/.claude / ~/.c
|
|
|
67
65
|
metro
|
|
68
66
|
```
|
|
69
67
|
|
|
70
|
-
Discord port: copy `telegram.ts` to `~/.metro/trains/discord.ts`, swap the API
|
|
71
|
-
base for `https://discord.com/api/v10` with `Authorization: Bot $TOKEN`,
|
|
72
|
-
`bun add discord.js` for the gateway, and keep the envelope + call/response
|
|
73
|
-
protocol unchanged.
|
|
74
|
-
|
|
75
68
|
## Detecting "is this for me?"
|
|
76
69
|
|
|
77
70
|
Trains should set `is_private: true` for DMs. For groups, narrow on `payload`:
|
|
@@ -79,26 +72,39 @@ Trains should set `is_private: true` for DMs. For groups, narrow on `payload`:
|
|
|
79
72
|
- **discord** — DM when `payload.guildId == null`; otherwise look at `payload.mentions.users`.
|
|
80
73
|
- **telegram** — DM when `payload.chat.type === 'private'`; otherwise look at `payload.entities` mentions.
|
|
81
74
|
- **webhook** — every POST is for you by design. Route on `payload.headers['x-github-event']` / `x-intercom-topic`.
|
|
75
|
+
- **messenger** — every event on the messenger line is between the user and the agent. No filtering needed.
|
|
82
76
|
|
|
83
77
|
## CLI cheat sheet
|
|
84
78
|
|
|
85
79
|
```
|
|
86
|
-
metro
|
|
87
|
-
metro trains list
|
|
88
|
-
metro trains new <name>
|
|
89
|
-
metro trains restart <name>
|
|
90
|
-
metro call <train> <action> <args>
|
|
91
|
-
metro tail --as=<user-uri> [--follow]
|
|
92
|
-
metro history --limit=
|
|
93
|
-
metro webhook add <label>
|
|
94
|
-
metro tunnel setup <name> <hostname>
|
|
95
|
-
metro doctor
|
|
80
|
+
metro # start the daemon
|
|
81
|
+
metro trains [list] # list trains + state
|
|
82
|
+
metro trains new <name> # scaffold ~/.metro/trains/<name>.ts
|
|
83
|
+
metro trains restart <name> # kill + respawn a train (resets backoff)
|
|
84
|
+
metro call <train> <action> <args> # forward an action call
|
|
85
|
+
metro tail [--as=<user-uri>] [--follow] # subscribe to the event log
|
|
86
|
+
metro history [--limit=N] [--line=…] [--from=…] [--text=…] [--since=…]
|
|
87
|
+
metro webhook add <label> # register an HTTP receive endpoint
|
|
88
|
+
metro tunnel setup <name> <hostname> # configure a Cloudflare named tunnel
|
|
89
|
+
metro doctor # health check
|
|
96
90
|
```
|
|
97
91
|
|
|
98
92
|
## Webhooks (builtin source)
|
|
99
93
|
|
|
100
|
-
Webhooks stay in core because they're shared HTTP infra (one Cloudflare tunnel routes many endpoints). `metro webhook add <label>` issues an endpoint id; the full URL is `https://<tunnel-host>/wh/<id>` (or `http://127.0.0.1:8420/wh/<id>` locally). Events arrive
|
|
94
|
+
Webhooks stay in core because they're shared HTTP infra (one Cloudflare tunnel routes many endpoints). `metro webhook add <label>` issues an endpoint id; the full URL is `https://<tunnel-host>/wh/<id>` (or `http://127.0.0.1:8420/wh/<id>` locally). Events arrive on `metro://webhook/<id>`.
|
|
95
|
+
|
|
96
|
+
## Messenger (builtin source)
|
|
97
|
+
|
|
98
|
+
Direct chat between the agent and the device. Five endpoints — all under the same bearer-token guard as `/api/state`:
|
|
99
|
+
|
|
100
|
+
- `POST /api/messenger/send {text?, attachments?, as?}` — emit an envelope on `metro://messenger/owner`. Either text or attachments required.
|
|
101
|
+
- `POST /api/messenger/react {messageId, emoji, as?}` — emit a slim `payload.reactTo` envelope. If the sender already has an active reaction with this emoji on this target, emits `{removed: true}` instead — reactions are toggleable.
|
|
102
|
+
- `POST /api/messenger/upload` — raw binary upload (≤ 25 MiB, body = file bytes, `Content-Type` + optional `X-Filename` headers). Returns `{id, url, kind, mime, size, name?}`; files land in `$METRO_STATE_DIR/messenger-uploads/`.
|
|
103
|
+
- `GET /api/messenger/files/<name>` — stream a stored upload. Accepts `Authorization: Bearer …` header *or* `?token=…` query (for `<img>` / `<audio>` tags). Content-Type derived from extension.
|
|
104
|
+
- `POST /api/messenger/register {pushToken}` — store an Expo push token so agent replies push to the device.
|
|
105
|
+
|
|
106
|
+
The mobile + web companion apps use these for chat with the agent. The envelope is the standard slim shape — no `kind` / `emoji` fields, direction derived from `Line.isLocal(from)`.
|
|
101
107
|
|
|
102
108
|
## Crashes
|
|
103
109
|
|
|
104
|
-
If a train crashes, metro restarts it with backoff (1s → 5s → 30s, then gives up after 5 consecutive failures). Use `metro trains
|
|
110
|
+
If a train crashes, metro restarts it with backoff (1s → 5s → 30s, then gives up after 5 consecutive failures). Use `metro trains` to check state.
|