@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.
@@ -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; returns undefined if missing. */
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.kind === 'inbound')
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=…] [--kind=…] [--from=…] [--text=…] [--since=…]
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 kind from → to body\n');
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 ?? (e.emoji ? `[react ${e.emoji}]` : '');
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)} ${e.kind.padEnd(12)} ${from} → ${to} ${text}\n`);
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 + read-only /api/state + /api/tail HTTP. Share drain/watch primitives. */
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 { readHistory, userSelf } from '../history.js';
5
+ import { userSelf } from '../history.js';
8
6
  import { asLine } from '../lines.js';
9
- import { errMsg, log } from '../log.js';
10
- import { loadMetroEnv, readBotIds } from '../paths.js';
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
- /* ──────────── CLI: metro tail / claim / release / claims ──────────── */
13
- /** Pick mode from 3 booleans + optional self. Conflict/strict-no-self routed through `onErr`. */
14
- function pickMode(strict, unclaimed, all, self, onErr) {
15
- if ([strict, unclaimed, all].filter(Boolean).length > 1)
16
- return onErr('strict/unclaimed/all are mutually exclusive');
17
- if (strict)
18
- return self ? 'mine-only' : onErr('strict requires --as <user-uri>');
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 ?? (e.emoji ? `[react ${e.emoji}]` : '');
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)} ${e.kind.padEnd(8)} ${who} ${where} ${text}`;
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(`(no claims — every tail with matching filters receives every event)\nfile: ${CLAIMS_FILE}${existsSync(CLAIMS_FILE) ? '' : ' (not created yet)'}\n`);
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: env.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(), kind: 'inbound', station: 'webhook',
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}`,
@@ -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(), kind: 'inbound',
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 the user echoes `event.display` verbatim instead of composing markdown itself. */
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 ?? (e.emoji ? `[react ${e.emoji}]` : '');
13
- if (e.kind === 'inbound' && e.station === 'webhook') {
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 (e.kind === 'inbound' || (e.kind === 'react' && !Line.isLocal(e.from))) {
20
- const reactBody = e.kind === 'react' ? `reacted ${e.emoji ?? ''}`.trim() : body;
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('📤', [e.station, '→', e.fromName ?? e.to])}\n> ${body}`;
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 inbound/outbound/edit/react. Single source of truth. |
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/tail.ts`](../src/cli/tail.ts) (the same module that
10
- backs the `metro tail` CLI) and is wired into the HTTP server in
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 | 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
-
20
- Both routes are **read-only**. The daemon never mutates state on receipt. The handlers
21
- read the same files the broker reads (`history.jsonl`, `claims.json`, `bot-ids.json`)
22
- under whatever `METRO_STATE_DIR` resolves to.
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.14",
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",
@@ -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 `{"kind":"inbound","station":...,"line":"metro://...","message_id":...,"text":...}`, or when handling a chat/webhook reply/edit/react/send via `metro call`.
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 and prints the response.
17
- 4. Two builtin event sources stay in core: **webhooks** (HTTP receiver) and cross-user **notify** IPC.
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
- ## Train protocol
27
+ ## Envelope
28
28
 
29
- **Inbound (train metro stdout)** one JSON line per event:
29
+ Every event on stdout is a single JSON line:
30
30
 
31
31
  ```json
32
- {"kind":"inbound","station":"discord","line":"metro://discord/123","from":"metro://discord/user/456","from_name":"alice","message_id":"789","text":"hi","is_private":false,"ts":"2026-05-17T18:00:00Z","payload":{...}}
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: `from_name`, `message_id`, `line_name`, `is_private`, `reply_to`. The dispatcher translates these to camelCase for `history.jsonl` and the broker. Trains supply `line`, `from`, `from_name`, `text`, `is_private`. Metro mints `id` + `display` if missing and appends to `history.jsonl`. `payload` is the platform's native message shape use it for mentions, replies, embeds.
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
- **Canonical `kind` enum**: `inbound | outbound | edit | react`. The dispatcher normalizes legacy aliases (`message` `inbound`, `reaction` `react`), but new trains should emit the canonical values directly. Anything else is passed through unchanged.
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
- **Outbound (`metro call <train> <action> <args>`)**:
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. The shipped example train (`telegram.ts`) exposes `send` and `react`; trains you write can expose anything.
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` (the only shipped example — pattern is platform-independent).
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 # start the daemon
87
- metro trains list # list trains + state
88
- metro trains new <name> # scaffold ~/.metro/trains/<name>.ts from the example
89
- metro trains restart <name> # kill + respawn a train (resets backoff)
90
- metro call <train> <action> <args> # forward an action call
91
- metro tail --as=<user-uri> [--follow] # subscribe to the event log
92
- metro history --limit=50 # recent history (newest first)
93
- metro webhook add <label> # register an HTTP receive endpoint
94
- metro tunnel setup <name> <hostname> # configure a Cloudflare named tunnel
95
- metro doctor # health check (trains, deps, tunnel, webhooks, env vars)
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 with `kind:"inbound", station:"webhook"`.
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 list` to check state.
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.