@stage-labs/metro 0.1.0-beta.12 → 0.1.0-beta.13
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.js +42 -6
- package/dist/cache.js +1 -1
- package/dist/cli/actions.js +42 -2
- package/dist/cli/index.js +8 -5
- package/dist/cli/tail.js +9 -6
- package/dist/history.js +7 -1
- package/dist/monitor.js +194 -0
- package/dist/stations/webhook.js +4 -0
- package/docs/broker.md +49 -8
- package/docs/monitor.md +163 -0
- package/package.json +3 -2
package/dist/broker.js
CHANGED
|
@@ -4,6 +4,7 @@ import { mkdirSync } from 'node:fs';
|
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { errMsg, log } from './log.js';
|
|
6
6
|
import { STATE_DIR } from './paths.js';
|
|
7
|
+
import { Line } from './stations/index.js';
|
|
7
8
|
export const CLAIMS_FILE = join(STATE_DIR, 'claims.json');
|
|
8
9
|
const CLAIMS_LOCK = join(STATE_DIR, 'claims.json.lock');
|
|
9
10
|
const CURSORS_DIR = join(STATE_DIR, 'cursors');
|
|
@@ -62,7 +63,25 @@ export function releaseLine(line) {
|
|
|
62
63
|
return { released, claims: m };
|
|
63
64
|
});
|
|
64
65
|
}
|
|
65
|
-
|
|
66
|
+
/** Per-line decision: should auto-claim run on a successful outbound? */
|
|
67
|
+
function shouldAutoClaim(line, kind) {
|
|
68
|
+
const station = Line.station(line);
|
|
69
|
+
/** Webhook lines are a broadcast stream — claiming one is a footgun. */
|
|
70
|
+
if (station === 'webhook')
|
|
71
|
+
return { ok: false, reason: 'webhook' };
|
|
72
|
+
/** Claude/Codex cross-user lines are 1:1 by construction — always safe. */
|
|
73
|
+
if (station === 'claude' || station === 'codex')
|
|
74
|
+
return { ok: true };
|
|
75
|
+
if (kind === 'group')
|
|
76
|
+
return { ok: false, reason: 'group' };
|
|
77
|
+
return { ok: true };
|
|
78
|
+
}
|
|
79
|
+
export function tryAutoClaim(line, owner, opts = {}) {
|
|
80
|
+
if (!opts.force) {
|
|
81
|
+
const decision = shouldAutoClaim(line, opts.lineKind ?? 'unknown');
|
|
82
|
+
if (!decision.ok)
|
|
83
|
+
return { status: decision.reason, line };
|
|
84
|
+
}
|
|
66
85
|
try {
|
|
67
86
|
return withClaimsLock(m => {
|
|
68
87
|
const existing = m[line];
|
|
@@ -82,17 +101,34 @@ export function tryAutoClaim(line, owner) {
|
|
|
82
101
|
export function userSlug(uri) {
|
|
83
102
|
return uri.replace(/^metro:\/+/, '').replace(/[^A-Za-z0-9_.-]/g, '-');
|
|
84
103
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
104
|
+
/** Cursor key for a tail invocation. Derived from the *effective mode*, NOT from `userSelf()`. */
|
|
105
|
+
/** Keeps `--all` / `--unclaimed` from trampling a `--as=<id>` cursor in a CLAUDECODE shell. */
|
|
106
|
+
/** Keys: `--as=<id>`→slug(id); `+--strict`→slug+`--strict`; `--unclaimed`→`_unclaimed`; `--all`→`_all`. */
|
|
107
|
+
/** The `_` prefix can't collide with a userSlug (always has a station prefix like `claude-user-…`). */
|
|
108
|
+
/** `--include-webhooks` adds `--with-webhooks` so toggling mid-stream doesn't re-emit/skip events. */
|
|
109
|
+
export function cursorKey(mode, self, opts = {}) {
|
|
110
|
+
if (mode === 'all')
|
|
111
|
+
return '_all';
|
|
112
|
+
if (mode === 'unclaimed')
|
|
113
|
+
return '_unclaimed';
|
|
114
|
+
if (!self)
|
|
115
|
+
return null;
|
|
116
|
+
const base = userSlug(self);
|
|
117
|
+
const suffix = mode === 'mine-only' ? '--strict' : '';
|
|
118
|
+
const webhooks = opts.includeWebhooks ? '--with-webhooks' : '';
|
|
119
|
+
return `${base}${suffix}${webhooks}`;
|
|
120
|
+
}
|
|
121
|
+
const cursorPath = (key) => join(CURSORS_DIR, key);
|
|
122
|
+
export function readCursor(key) {
|
|
123
|
+
const p = cursorPath(key);
|
|
88
124
|
if (!existsSync(p))
|
|
89
125
|
return 0;
|
|
90
126
|
const n = Number(readFileSync(p, 'utf8').trim());
|
|
91
127
|
return Number.isFinite(n) && n >= 0 ? n : 0;
|
|
92
128
|
}
|
|
93
|
-
export function writeCursor(
|
|
129
|
+
export function writeCursor(key, offset) {
|
|
94
130
|
mkdirSync(CURSORS_DIR, { recursive: true });
|
|
95
|
-
const p = cursorPath(
|
|
131
|
+
const p = cursorPath(key);
|
|
96
132
|
const tmp = `${p}.tmp.${process.pid}`;
|
|
97
133
|
writeFileSync(tmp, String(offset));
|
|
98
134
|
renameSync(tmp, p);
|
package/dist/cache.js
CHANGED
|
@@ -47,7 +47,7 @@ export function noteSeen(line, name) {
|
|
|
47
47
|
export const listLines = () => Object.entries(read()).map(([line, entry]) => ({ line: line, entry }));
|
|
48
48
|
/** Bot identity cache: `{discord: "<userId>", telegram: "<userId>"}`. Daemon writes after getMe(). */
|
|
49
49
|
const botIdsFile = join(STATE_DIR, 'bot-ids.json');
|
|
50
|
-
const readBotIds = () => {
|
|
50
|
+
export const readBotIds = () => {
|
|
51
51
|
try {
|
|
52
52
|
return existsSync(botIdsFile) ? JSON.parse(readFileSync(botIdsFile, 'utf8')) : {};
|
|
53
53
|
}
|
package/dist/cli/actions.js
CHANGED
|
@@ -52,6 +52,37 @@ function destinationFor(orig, line) {
|
|
|
52
52
|
return line;
|
|
53
53
|
return orig.from;
|
|
54
54
|
}
|
|
55
|
+
/** Classify a chat line as DM/group/unknown for the auto-claim group-skip rule. */
|
|
56
|
+
/** Telegram: chat-id sign is authoritative (id < 0 ⇒ group). Discord: peek payload.guildId on */
|
|
57
|
+
/** the most-recent inbound (null ⇒ DM, set ⇒ group, none seen ⇒ unknown). Claude/Codex ⇒ dm. */
|
|
58
|
+
export function classifyLine(line) {
|
|
59
|
+
const station = Line.station(line);
|
|
60
|
+
if (station === 'telegram') {
|
|
61
|
+
const parsed = Line.parseTelegram(line);
|
|
62
|
+
if (!parsed)
|
|
63
|
+
return 'unknown';
|
|
64
|
+
return parsed.chatId < 0 ? 'group' : 'dm';
|
|
65
|
+
}
|
|
66
|
+
if (station === 'claude' || station === 'codex')
|
|
67
|
+
return 'dm';
|
|
68
|
+
if (station === 'webhook')
|
|
69
|
+
return 'group';
|
|
70
|
+
if (station === 'discord') {
|
|
71
|
+
/** Look at the most recent inbound on this line; the daemon stored the raw message in `payload`. */
|
|
72
|
+
const recent = readHistory({ line, kind: 'inbound', limit: 1 })[0];
|
|
73
|
+
if (!recent)
|
|
74
|
+
return 'unknown';
|
|
75
|
+
const payload = recent.payload;
|
|
76
|
+
if (!payload || !('guildId' in payload)) {
|
|
77
|
+
/** Older entries may not have a guildId — fall back to the `to` field: DMs route to a user URI. */
|
|
78
|
+
if (recent.to && recent.to !== recent.line)
|
|
79
|
+
return 'dm';
|
|
80
|
+
return 'unknown';
|
|
81
|
+
}
|
|
82
|
+
return payload.guildId == null ? 'dm' : 'group';
|
|
83
|
+
}
|
|
84
|
+
return 'unknown';
|
|
85
|
+
}
|
|
55
86
|
/** Append an outbound action to history.jsonl; `to` mirrors the destination per `destinationFor`. */
|
|
56
87
|
function logOutbound(f, e) {
|
|
57
88
|
const id = mintId();
|
|
@@ -64,16 +95,25 @@ function logOutbound(f, e) {
|
|
|
64
95
|
maybeAutoClaim(f, e.line, from);
|
|
65
96
|
return id;
|
|
66
97
|
}
|
|
67
|
-
/** Auto-claim on outbound — skips when
|
|
98
|
+
/** Auto-claim on outbound — skips when `--no-claim` / `METRO_NO_AUTO_CLAIM=1`, when the line is */
|
|
99
|
+
/** a group/webhook, or when already owned by someone else. `--claim` forces a group-line claim. */
|
|
68
100
|
function maybeAutoClaim(f, line, owner) {
|
|
69
101
|
if (f['no-claim'] === true)
|
|
70
102
|
return;
|
|
71
103
|
if (process.env.METRO_NO_AUTO_CLAIM === '1')
|
|
72
104
|
return;
|
|
73
|
-
const
|
|
105
|
+
const force = f['claim'] === true;
|
|
106
|
+
const lineKind = classifyLine(line);
|
|
107
|
+
const result = tryAutoClaim(line, owner, { lineKind, force });
|
|
74
108
|
if (result.status === 'skipped') {
|
|
75
109
|
process.stderr.write(`auto-claim skipped: line owned by ${result.existing}\n`);
|
|
76
110
|
}
|
|
111
|
+
else if (result.status === 'group') {
|
|
112
|
+
process.stderr.write(`auto-claim skipped: ${line} is a group/public line; pass --claim to take it explicitly\n`);
|
|
113
|
+
}
|
|
114
|
+
else if (result.status === 'webhook') {
|
|
115
|
+
process.stderr.write(`auto-claim skipped: ${line} is a webhook line (broadcast stream)\n`);
|
|
116
|
+
}
|
|
77
117
|
else if (result.status === 'error') {
|
|
78
118
|
process.stderr.write(`auto-claim failed: ${result.error}\n`);
|
|
79
119
|
}
|
package/dist/cli/index.js
CHANGED
|
@@ -22,14 +22,17 @@ Usage:
|
|
|
22
22
|
metro doctor Health check.
|
|
23
23
|
metro stations List stations + capabilities.
|
|
24
24
|
metro lines List recently-seen conversations.
|
|
25
|
-
metro send <line> <text> [--image=<path>]… [--document=<path>]… [--voice=<path>] [--buttons=<json>] [--no-claim]
|
|
25
|
+
metro send <line> <text> [--image=<path>]… [--document=<path>]… [--voice=<path>] [--buttons=<json>] [--no-claim] [--claim]
|
|
26
26
|
Post a fresh message; repeat --image/--document for multi-file albums.
|
|
27
|
-
First outbound auto-claims
|
|
28
|
-
|
|
27
|
+
First outbound on a DM auto-claims; --no-claim or METRO_NO_AUTO_CLAIM=1 opts out;
|
|
28
|
+
--claim forces auto-claim even on group/public lines.
|
|
29
|
+
Note: send/reply/edit/react read bot tokens from ~/.config/metro/.env and post
|
|
30
|
+
directly to the platform — METRO_STATE_DIR isolates claims/history but NOT creds.
|
|
31
|
+
metro reply <line> <message_id> <text> [--image=… --document=… --voice=… --buttons=…] [--no-claim] [--claim]
|
|
29
32
|
Threaded reply (same flags as send).
|
|
30
|
-
metro edit <line> <message_id> <text> [--buttons=<json>] [--no-claim]
|
|
33
|
+
metro edit <line> <message_id> <text> [--buttons=<json>] [--no-claim] [--claim]
|
|
31
34
|
Edit a previously-sent message (text + buttons).
|
|
32
|
-
metro react <line> <message_id> <emoji> [--no-claim]
|
|
35
|
+
metro react <line> <message_id> <emoji> [--no-claim] [--claim]
|
|
33
36
|
Set or clear ('') a reaction.
|
|
34
37
|
metro download <line> <message_id> [--out=<dir>]
|
|
35
38
|
Download image attachments to disk.
|
package/dist/cli/tail.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** CLI subcommands: `metro tail` (claim-aware log subscriber) + `metro claim|release|claims`. */
|
|
2
2
|
import { existsSync, watch } from 'node:fs';
|
|
3
|
-
import { CLAIMS_FILE, HISTORY_FILE, claimLine, historySize, passesMode, readClaims, readCursor, readEntriesFrom, releaseLine, writeCursor, } from '../broker.js';
|
|
3
|
+
import { CLAIMS_FILE, HISTORY_FILE, claimLine, cursorKey, historySize, passesMode, readClaims, readCursor, readEntriesFrom, releaseLine, writeCursor, } from '../broker.js';
|
|
4
4
|
import { userSelf } from '../history.js';
|
|
5
5
|
import { asLine } from '../stations/index.js';
|
|
6
6
|
import { loadMetroEnv } from '../paths.js';
|
|
@@ -30,7 +30,7 @@ function resolveSelf(f) {
|
|
|
30
30
|
const auto = userSelf();
|
|
31
31
|
return auto === GENERIC_USER ? null : auto;
|
|
32
32
|
}
|
|
33
|
-
function resolveStartOffset(f,
|
|
33
|
+
function resolveStartOffset(f, key) {
|
|
34
34
|
const since = flagOne(f, 'since');
|
|
35
35
|
if (since === 'tail')
|
|
36
36
|
return historySize();
|
|
@@ -40,7 +40,7 @@ function resolveStartOffset(f, self) {
|
|
|
40
40
|
throw exitErr(`--since must be a byte offset or 'tail' (got '${since}')`, 1);
|
|
41
41
|
return n;
|
|
42
42
|
}
|
|
43
|
-
return
|
|
43
|
+
return key ? readCursor(key) : 0;
|
|
44
44
|
}
|
|
45
45
|
export async function cmdTail(_, f) {
|
|
46
46
|
loadMetroEnv();
|
|
@@ -50,9 +50,12 @@ export async function cmdTail(_, f) {
|
|
|
50
50
|
const chatFilter = flagOne(f, 'chat');
|
|
51
51
|
const stationFilter = flagOne(f, 'station');
|
|
52
52
|
const limit = Number(flagOne(f, 'limit')) || 0;
|
|
53
|
-
const startOffset = resolveStartOffset(f, self);
|
|
54
53
|
const json = isJson(f);
|
|
55
54
|
const includeWebhooks = f['include-webhooks'] === true;
|
|
55
|
+
/** Cursor key derives from the effective mode (not userSelf), so `--all` / `--unclaimed` */
|
|
56
|
+
/** don't trample the personal `--as=<me>` cursor in a CLAUDECODE shell. */
|
|
57
|
+
const key = cursorKey(mode, self, { includeWebhooks });
|
|
58
|
+
const startOffset = resolveStartOffset(f, key);
|
|
56
59
|
let emitted = 0;
|
|
57
60
|
let offset = startOffset;
|
|
58
61
|
const drain = () => {
|
|
@@ -70,8 +73,8 @@ export async function cmdTail(_, f) {
|
|
|
70
73
|
process.stdout.write(JSON.stringify(entry) + '\n');
|
|
71
74
|
else
|
|
72
75
|
process.stdout.write(fmtRow(entry) + '\n');
|
|
73
|
-
if (
|
|
74
|
-
writeCursor(
|
|
76
|
+
if (key)
|
|
77
|
+
writeCursor(key, offset);
|
|
75
78
|
emitted++;
|
|
76
79
|
if (limit && emitted >= limit)
|
|
77
80
|
return true;
|
package/dist/history.js
CHANGED
|
@@ -35,12 +35,14 @@ export function appendHistory(entry) {
|
|
|
35
35
|
log.warn({ err: errMsg(err), path: FILE }, 'history append failed');
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
|
-
/** Read JSONL, parse, filter (most-recent-first), apply `limit`. Empty array if file is missing. */
|
|
38
|
+
/** Read JSONL, parse, filter (most-recent-first), apply `skip` then `limit`. Empty array if file is missing. */
|
|
39
39
|
export function readHistory(filter = {}) {
|
|
40
40
|
if (!existsSync(FILE))
|
|
41
41
|
return [];
|
|
42
42
|
const lines = readFileSync(FILE, 'utf8').split('\n');
|
|
43
43
|
const out = [];
|
|
44
|
+
const skip = filter.skip ?? 0;
|
|
45
|
+
let skipped = 0;
|
|
44
46
|
/** Walk backwards so `limit` clamps without scanning the whole file body twice. */
|
|
45
47
|
for (let i = lines.length - 1; i >= 0; i--) {
|
|
46
48
|
const raw = lines[i].trim();
|
|
@@ -55,6 +57,10 @@ export function readHistory(filter = {}) {
|
|
|
55
57
|
}
|
|
56
58
|
if (!matches(e, filter))
|
|
57
59
|
continue;
|
|
60
|
+
if (skipped < skip) {
|
|
61
|
+
skipped++;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
58
64
|
out.push(e);
|
|
59
65
|
if (filter.limit && out.length >= filter.limit)
|
|
60
66
|
break;
|
package/dist/monitor.js
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/** Read-only HTTP monitor endpoints. `/api/state` (snapshot) + `/api/tail` (SSE). */
|
|
2
|
+
/** Mounted on the webhook server. Bearer auth via METRO_MONITOR_TOKEN (503 when unset). */
|
|
3
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
4
|
+
import { watch } from 'node:fs';
|
|
5
|
+
import pkg from '../package.json' with { type: 'json' };
|
|
6
|
+
import { errMsg, log } from './log.js';
|
|
7
|
+
import { HISTORY_FILE, historySize, passesMode, readClaims, readEntriesFrom, } from './broker.js';
|
|
8
|
+
import { readBotIds } from './cache.js';
|
|
9
|
+
import { readHistory } from './history.js';
|
|
10
|
+
import { asLine } from './stations/index.js';
|
|
11
|
+
const HISTORY_LIMIT = 100;
|
|
12
|
+
function authorized(req) {
|
|
13
|
+
const token = process.env.METRO_MONITOR_TOKEN;
|
|
14
|
+
if (!token)
|
|
15
|
+
return { ok: false, status: 503, msg: 'monitor endpoints not configured (METRO_MONITOR_TOKEN unset)' };
|
|
16
|
+
const header = req.headers['authorization'];
|
|
17
|
+
const value = Array.isArray(header) ? header[0] : header;
|
|
18
|
+
if (!value || !value.startsWith('Bearer '))
|
|
19
|
+
return { ok: false, status: 401, msg: 'unauthorized' };
|
|
20
|
+
const given = Buffer.from(value.slice('Bearer '.length));
|
|
21
|
+
const want = Buffer.from(token);
|
|
22
|
+
if (given.length !== want.length || !timingSafeEqual(given, want)) {
|
|
23
|
+
return { ok: false, status: 401, msg: 'unauthorized' };
|
|
24
|
+
}
|
|
25
|
+
return { ok: true };
|
|
26
|
+
}
|
|
27
|
+
/** Hosts that serve `/api/*`. webhook.metro.box stays scoped to /wh/*. */
|
|
28
|
+
const MONITOR_HOSTS = new Set(['monitor.metro.box', 'localhost', '127.0.0.1']);
|
|
29
|
+
function monitorHostAllowed(req) {
|
|
30
|
+
const raw = req.headers[':authority'] ?? req.headers.host;
|
|
31
|
+
if (!raw)
|
|
32
|
+
return true;
|
|
33
|
+
return MONITOR_HOSTS.has(raw.split(':')[0].toLowerCase());
|
|
34
|
+
}
|
|
35
|
+
export function handleMonitorRequest(req, res) {
|
|
36
|
+
const url = req.url ?? '';
|
|
37
|
+
if (!url.startsWith('/api/'))
|
|
38
|
+
return false;
|
|
39
|
+
if (!monitorHostAllowed(req))
|
|
40
|
+
return false; // let outer router 404 it
|
|
41
|
+
const [pathOnly, queryString = ''] = url.split('?', 2);
|
|
42
|
+
if (req.method !== 'GET') {
|
|
43
|
+
res.writeHead(405, { 'content-type': 'application/json' });
|
|
44
|
+
res.end(JSON.stringify({ error: 'method not allowed' }));
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
const auth = authorized(req);
|
|
48
|
+
if (!auth.ok) {
|
|
49
|
+
res.writeHead(auth.status, { 'content-type': 'application/json' });
|
|
50
|
+
res.end(JSON.stringify({ error: auth.msg }));
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
const query = new URLSearchParams(queryString);
|
|
54
|
+
if (pathOnly === '/api/state') {
|
|
55
|
+
handleState(res, query);
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
if (pathOnly === '/api/tail') {
|
|
59
|
+
handleTail(req, res, query).catch(err => {
|
|
60
|
+
log.warn({ err: errMsg(err) }, 'monitor: tail handler error');
|
|
61
|
+
try {
|
|
62
|
+
if (!res.headersSent)
|
|
63
|
+
res.writeHead(500).end();
|
|
64
|
+
else
|
|
65
|
+
res.end();
|
|
66
|
+
}
|
|
67
|
+
catch { /* ignore */ }
|
|
68
|
+
});
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
res.writeHead(404, { 'content-type': 'application/json' });
|
|
72
|
+
res.end(JSON.stringify({ error: 'not found' }));
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
function parseNonNegInt(raw) {
|
|
76
|
+
if (raw === null)
|
|
77
|
+
return null;
|
|
78
|
+
const n = Number(raw);
|
|
79
|
+
if (!Number.isFinite(n) || n < 0 || !Number.isInteger(n))
|
|
80
|
+
return null;
|
|
81
|
+
return n;
|
|
82
|
+
}
|
|
83
|
+
function handleState(res, query) {
|
|
84
|
+
const before = parseNonNegInt(query.get('before'));
|
|
85
|
+
const limitRaw = parseNonNegInt(query.get('limit'));
|
|
86
|
+
/** Page mode: skip `before` newest entries, return next `limit` (default HISTORY_LIMIT, capped at 500). */
|
|
87
|
+
if (before !== null) {
|
|
88
|
+
const limit = Math.min(limitRaw ?? HISTORY_LIMIT, 500);
|
|
89
|
+
const page = readHistory({ limit, skip: before });
|
|
90
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
91
|
+
res.end(JSON.stringify({ recent_history: page }));
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
/** `readHistory` is newest-first — what the activity feed expects. */
|
|
95
|
+
const limit = Math.min(limitRaw ?? HISTORY_LIMIT, 500);
|
|
96
|
+
const recent = readHistory({ limit });
|
|
97
|
+
const claims = readClaims();
|
|
98
|
+
const linesSet = new Set();
|
|
99
|
+
for (const e of recent)
|
|
100
|
+
linesSet.add(e.line);
|
|
101
|
+
for (const line of Object.keys(claims))
|
|
102
|
+
linesSet.add(line);
|
|
103
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
104
|
+
res.end(JSON.stringify({
|
|
105
|
+
claims,
|
|
106
|
+
lines: [...linesSet],
|
|
107
|
+
recent_history: recent,
|
|
108
|
+
bot_ids: readBotIds(),
|
|
109
|
+
version: pkg.version,
|
|
110
|
+
}));
|
|
111
|
+
}
|
|
112
|
+
/** Mirrors `cli/tail.ts:resolveMode` but operates on URLSearchParams. */
|
|
113
|
+
function resolveQueryMode(query, self) {
|
|
114
|
+
const strict = query.get('strict') === 'true' || query.get('mode') === 'strict';
|
|
115
|
+
const unclaimed = query.get('unclaimed') === 'true' || query.get('mode') === 'unclaimed';
|
|
116
|
+
const all = query.get('all') === 'true' || query.get('mode') === 'all';
|
|
117
|
+
if ([strict, unclaimed, all].filter(Boolean).length > 1)
|
|
118
|
+
return 'all';
|
|
119
|
+
if (strict && self)
|
|
120
|
+
return 'mine-only';
|
|
121
|
+
if (unclaimed)
|
|
122
|
+
return 'unclaimed';
|
|
123
|
+
if (all || !self)
|
|
124
|
+
return 'all';
|
|
125
|
+
return 'mine-or-unclaimed';
|
|
126
|
+
}
|
|
127
|
+
async function handleTail(req, res, query) {
|
|
128
|
+
const asParam = query.get('as');
|
|
129
|
+
const self = asParam ? asLine(asParam) : null;
|
|
130
|
+
const mode = resolveQueryMode(query, self);
|
|
131
|
+
const chatFilter = query.get('chat');
|
|
132
|
+
const stationFilter = query.get('station');
|
|
133
|
+
const includeWebhooks = query.get('include_webhooks') === 'true';
|
|
134
|
+
res.writeHead(200, {
|
|
135
|
+
'content-type': 'text/event-stream',
|
|
136
|
+
'cache-control': 'no-cache, no-transform',
|
|
137
|
+
'connection': 'keep-alive',
|
|
138
|
+
/** Bearer auth already gates us; CORS can be permissive. */
|
|
139
|
+
'access-control-allow-origin': '*',
|
|
140
|
+
/** Cloudflare/proxies buffer SSE without this hint. */
|
|
141
|
+
'x-accel-buffering': 'no',
|
|
142
|
+
});
|
|
143
|
+
/** `since=tail` (default) starts at EOF; `since=0` replays the full file. */
|
|
144
|
+
const since = query.get('since');
|
|
145
|
+
let offset = since === '0' ? 0 : historySize();
|
|
146
|
+
if (since && since !== '0' && since !== 'tail') {
|
|
147
|
+
const n = Number(since);
|
|
148
|
+
if (Number.isFinite(n) && n >= 0)
|
|
149
|
+
offset = n;
|
|
150
|
+
}
|
|
151
|
+
/** 4 KiB padding so Cloudflare's HTTP/2 buffer flushes (else holds 30+ s on free tier). */
|
|
152
|
+
res.write(`: metro monitor tail (mode=${mode}${self ? `, as=${self}` : ''})\n`);
|
|
153
|
+
res.write(`: ${'-'.repeat(4096)}\n\n`);
|
|
154
|
+
const drain = () => {
|
|
155
|
+
const claims = readClaims();
|
|
156
|
+
for (const { entry, offset: next } of readEntriesFrom(offset)) {
|
|
157
|
+
offset = next;
|
|
158
|
+
if (chatFilter && entry.line !== chatFilter)
|
|
159
|
+
continue;
|
|
160
|
+
if (stationFilter && entry.station !== stationFilter)
|
|
161
|
+
continue;
|
|
162
|
+
if (!passesMode(entry, mode, self, claims, { includeWebhooks }))
|
|
163
|
+
continue;
|
|
164
|
+
res.write(`id: ${entry.id}\n`);
|
|
165
|
+
res.write('event: history\n');
|
|
166
|
+
res.write(`data: ${JSON.stringify(entry)}\n\n`);
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
drain();
|
|
170
|
+
/** fs.watch coalesces on macOS — poll every 1s as a backstop. */
|
|
171
|
+
let watcher = null;
|
|
172
|
+
try {
|
|
173
|
+
watcher = watch(HISTORY_FILE, () => drain());
|
|
174
|
+
}
|
|
175
|
+
catch { /* file may not exist yet */ }
|
|
176
|
+
const poll = setInterval(drain, 1_000);
|
|
177
|
+
const keepalive = setInterval(() => res.write(': keepalive\n\n'), 25_000);
|
|
178
|
+
const cleanup = () => {
|
|
179
|
+
clearInterval(poll);
|
|
180
|
+
clearInterval(keepalive);
|
|
181
|
+
if (watcher) {
|
|
182
|
+
try {
|
|
183
|
+
watcher.close();
|
|
184
|
+
}
|
|
185
|
+
catch { /* ignore */ }
|
|
186
|
+
}
|
|
187
|
+
try {
|
|
188
|
+
res.end();
|
|
189
|
+
}
|
|
190
|
+
catch { /* ignore */ }
|
|
191
|
+
};
|
|
192
|
+
req.on('close', cleanup);
|
|
193
|
+
req.on('error', cleanup);
|
|
194
|
+
}
|
package/dist/stations/webhook.js
CHANGED
|
@@ -3,6 +3,7 @@ import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
|
3
3
|
import { createServer } from 'node:http';
|
|
4
4
|
import { errMsg, log } from '../log.js';
|
|
5
5
|
import { mintId } from '../history.js';
|
|
6
|
+
import { handleMonitorRequest } from '../monitor.js';
|
|
6
7
|
import { findEndpoint, listEndpoints, webhookPort } from '../webhooks.js';
|
|
7
8
|
import { Line } from './index.js';
|
|
8
9
|
/** Synthesize an `event` tag from common provider-specific headers (GitHub, Intercom). */
|
|
@@ -51,6 +52,9 @@ export class WebhookStation {
|
|
|
51
52
|
this.server = null;
|
|
52
53
|
}
|
|
53
54
|
async handle(req, res) {
|
|
55
|
+
/** Read-only monitor endpoints (`/api/state`, `/api/tail`) share this port. */
|
|
56
|
+
if (handleMonitorRequest(req, res))
|
|
57
|
+
return;
|
|
54
58
|
const m = req.url?.match(/^\/wh\/([A-Za-z0-9_-]+)/);
|
|
55
59
|
if (!m) {
|
|
56
60
|
res.writeHead(404).end();
|
package/docs/broker.md
CHANGED
|
@@ -21,7 +21,21 @@ One concept — a **claim** — and three on-disk files you can `cat`:
|
|
|
21
21
|
|----------------------|-----------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------|
|
|
22
22
|
| Event log | `$METRO_STATE_DIR/history.jsonl` | Append-only JSONL — every inbound/outbound/edit/react. Already exists. The single source of truth. |
|
|
23
23
|
| Claims | `$METRO_STATE_DIR/claims.json` | `{ <line>: <user-id> }` — flat map. A line in here is *exclusively* owned by that user. Absence = broadcast. New. |
|
|
24
|
-
| Per-
|
|
24
|
+
| Per-mode cursor | `$METRO_STATE_DIR/cursors/<key>` | Byte offset into `history.jsonl` — last-emitted position for one tail mode. New. Updated atomically after each emit. |
|
|
25
|
+
|
|
26
|
+
Cursor keys are derived from the *effective mode* (not from `userSelf()`), so `--all` and `--unclaimed` don't collide with a personal `--as=<id>` tail:
|
|
27
|
+
|
|
28
|
+
| Tail invocation | Cursor key |
|
|
29
|
+
|----------------------------------|------------------------------------|
|
|
30
|
+
| `metro tail --as=<id>` | `<userSlug(id)>` |
|
|
31
|
+
| `metro tail --as=<id> --strict` | `<userSlug(id)>--strict` |
|
|
32
|
+
| `metro tail --as=<id> --include-webhooks` | `<userSlug(id)>--with-webhooks` (or `…--strict--with-webhooks`) |
|
|
33
|
+
| `metro tail --unclaimed` | `_unclaimed` |
|
|
34
|
+
| `metro tail --all` | `_all` |
|
|
35
|
+
|
|
36
|
+
The `_` prefix on the mode-keys can't collide with a real `userSelf()` slug (which always contains a station name like `claude-user-…`). Switching modes mid-stream keeps each cursor independent — a `tail --all` from a `CLAUDECODE=1` shell does **not** advance the personal `--as=<me>` cursor.
|
|
37
|
+
|
|
38
|
+
`--chat=<line>` and `--station=<name>` are post-filters applied **after** cursor advancement, so they don't need their own cursor keys.
|
|
25
39
|
|
|
26
40
|
Subscribers do not register with the daemon. They tail the log; the broker semantics emerge from one filtering rule applied at read time:
|
|
27
41
|
|
|
@@ -56,11 +70,12 @@ metro claim <line> [--as <user-id>] # add/overwrite — last writer wins
|
|
|
56
70
|
metro release <line> # remove (line returns to broadcast)
|
|
57
71
|
metro claims # print current claims.json
|
|
58
72
|
|
|
59
|
-
# Outbound actions auto-claim the line on first contact
|
|
60
|
-
|
|
61
|
-
metro
|
|
62
|
-
metro
|
|
63
|
-
metro
|
|
73
|
+
# Outbound actions auto-claim the line on first contact when topology is 1:1 (DM, claude/codex line).
|
|
74
|
+
# Group / public / webhook lines are skipped by default — pass --claim to force.
|
|
75
|
+
metro send <line> <text> [--no-claim] [--claim]
|
|
76
|
+
metro reply <line> <msg-id> <text> [--no-claim] [--claim]
|
|
77
|
+
metro edit <line> <msg-id> <text> [--no-claim] [--claim]
|
|
78
|
+
metro react <line> <msg-id> <emoji> [--no-claim] [--claim]
|
|
64
79
|
# Or disable globally: METRO_NO_AUTO_CLAIM=1
|
|
65
80
|
|
|
66
81
|
# Lease/ack — optional, v2. When enabled, an event is "in flight" with the
|
|
@@ -97,11 +112,25 @@ Direct messages between users (`event.to == user-line`) always pass the filter r
|
|
|
97
112
|
|
|
98
113
|
`metro send`, `reply`, `edit`, and `react` claim the target `<line>` for the actor (`userSelf()`) the first time they touch it, atomically — same lockfile as `metro claim`. The intent: when a user picks up a conversation by replying, subsequent inbound events on that line route to them without any explicit `metro claim` call.
|
|
99
114
|
|
|
100
|
-
-
|
|
115
|
+
Auto-claim only fires when **the line topology is 1:1** (DM, or a Claude/Codex cross-user line). Shared lines — group chats, public channels, webhook streams — would lock out other workers, so they're skipped by default:
|
|
116
|
+
|
|
117
|
+
| Line | Classification | Auto-claim default? | How |
|
|
118
|
+
|-------------------------------------------------|----------------|---------------------|--------------------------------------------------------------|
|
|
119
|
+
| `metro://telegram/<positive-id>` (incl. topics) | DM | Yes | Telegram chat-id > 0 ⇒ private chat |
|
|
120
|
+
| `metro://telegram/<negative-id>` / `-100…` | group | **No** | Telegram chat-id < 0 ⇒ group/supergroup |
|
|
121
|
+
| `metro://discord/<channel-id>` (no guild) | DM | Yes | Recent inbound payload `guildId == null` |
|
|
122
|
+
| `metro://discord/<channel-id>` (in guild) | group | **No** | Recent inbound payload `guildId != null` |
|
|
123
|
+
| `metro://discord/<channel-id>` (no inbound) | unknown | Yes (conservative) | No metadata cached — treat as DM-eligible until proven group |
|
|
124
|
+
| `metro://claude/...` / `metro://codex/...` | 1:1 | Yes | Cross-user notify is inherently 1:1 by construction |
|
|
125
|
+
| `metro://webhook/<id>` | broadcast | **Never** | Webhook lines are conceptually a stream, not a conversation |
|
|
126
|
+
|
|
127
|
+
- If the line is already claimed by **someone else** (and topology check passed), the action still proceeds (sending doesn't require ownership) but the claim is **not overwritten**. A single-line stderr note (`auto-claim skipped: line owned by <other-id>`) signals the no-op.
|
|
128
|
+
- On a group-line skip you'll see `auto-claim skipped: <line> is a group/public line; pass --claim to take it explicitly` on stderr.
|
|
101
129
|
- Opt-out per command with `--no-claim`, or globally with the env var `METRO_NO_AUTO_CLAIM=1`.
|
|
130
|
+
- Opt-IN for groups: `--claim` forces auto-claim even on a group/public line (operator explicitly takes responsibility).
|
|
102
131
|
- Cross-user sends (`metro send metro://claude/... ...` from a different user) auto-claim the target line too — the sender is taking ownership of the conversation.
|
|
103
132
|
|
|
104
|
-
This
|
|
133
|
+
This default plus the webhook-exclusion above means: a webhook or a busy group channel flowing through the daemon won't auto-claim under any worker, so the router pattern (`--unclaimed`) can still see them.
|
|
105
134
|
|
|
106
135
|
### `metro tail` mechanics
|
|
107
136
|
|
|
@@ -140,6 +169,18 @@ Multiple processes already write `history.jsonl` today: the daemon's `emit()` an
|
|
|
140
169
|
|
|
141
170
|
`claims.json` is read on every event by every tail, but writes are infrequent (`metro claim`/`release`). An `O_EXCL` lockfile around writes is enough; tails do an unlocked read with a malformed-JSON retry (one read can race with one write; the retry resolves it).
|
|
142
171
|
|
|
172
|
+
## Isolation
|
|
173
|
+
|
|
174
|
+
`METRO_STATE_DIR` isolates state-dir-scoped artifacts (`history.jsonl`, `claims.json`, `cursors/`, `lines.json`, `bot-ids.json`, the daemon socket, the webhook port). It does **not** isolate platform credentials: `metro send`, `reply`, `edit`, and `react` always read bot tokens from `$XDG_CONFIG_HOME/metro/.env` (defaulting to `~/.config/metro/.env`) and post directly to Discord/Telegram regardless of where `METRO_STATE_DIR` points.
|
|
175
|
+
|
|
176
|
+
This means a test invocation with `METRO_STATE_DIR=/tmp/metro-test metro send …` will hit the **production** Discord/Telegram bot with production tokens. To avoid leaking real messages from a test/sandbox:
|
|
177
|
+
|
|
178
|
+
- Use lines whose channel/chat IDs you know don't exist (the platform will 4xx before any side-effect).
|
|
179
|
+
- Or unset/move `~/.config/metro/.env` for the test process — `metro send` will fail fast with a missing-token error.
|
|
180
|
+
- Or use `metro tail` + manual `history.jsonl` seeding to exercise the read path without any platform contact.
|
|
181
|
+
|
|
182
|
+
The auto-claim write happens **after** platform-API success, so a failed `metro send` never writes to `claims.json`. (Tests can rely on this: a failing send leaves the test state dir unchanged apart from the `history.jsonl` line the daemon would emit, if one were running.)
|
|
183
|
+
|
|
143
184
|
## Failure modes & guardrails
|
|
144
185
|
|
|
145
186
|
| Failure | Behavior |
|
package/docs/monitor.md
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# Metro monitor endpoints
|
|
2
|
+
|
|
3
|
+
Read-only HTTP endpoints for an external observer (the `apps/app` mobile app, an admin
|
|
4
|
+
dashboard, a curl one-liner) to view live daemon state without touching the JSONL files
|
|
5
|
+
directly.
|
|
6
|
+
|
|
7
|
+
These endpoints mount on the **existing** webhook HTTP server (default port `8420`).
|
|
8
|
+
There is no separate daemon, no separate port, no extra process to launch.
|
|
9
|
+
|
|
10
|
+
## Routes
|
|
11
|
+
|
|
12
|
+
| Method | Path | Returns |
|
|
13
|
+
|--------|--------------|------------------------------------------------------------------------------------------|
|
|
14
|
+
| GET | `/api/state` | JSON snapshot — `{ claims, lines, recent_history (last 100), bot_ids }`. |
|
|
15
|
+
| GET | `/api/tail` | Server-Sent Events stream — `history.jsonl` entries, claim-aware filtered. |
|
|
16
|
+
|
|
17
|
+
Both routes are **read-only**. The daemon never mutates state on receipt. The handlers
|
|
18
|
+
read the same files the broker reads (`history.jsonl`, `claims.json`, `bot-ids.json`)
|
|
19
|
+
under whatever `METRO_STATE_DIR` resolves to.
|
|
20
|
+
|
|
21
|
+
## Authentication
|
|
22
|
+
|
|
23
|
+
Bearer token in env: `METRO_MONITOR_TOKEN`.
|
|
24
|
+
|
|
25
|
+
- If unset, both routes respond **503** with `{"error":"monitor endpoints not configured (METRO_MONITOR_TOKEN unset)"}`. Anonymous access is never allowed by accident.
|
|
26
|
+
- If set, requests **must** carry `Authorization: Bearer <token>`. Missing/wrong/malformed → **401**. The comparison is constant-time (`crypto.timingSafeEqual`).
|
|
27
|
+
|
|
28
|
+
Set in `~/.config/metro/.env`:
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
METRO_MONITOR_TOKEN=<a long random string — `openssl rand -base64 32`>
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
The daemon picks up the env var on next start.
|
|
35
|
+
|
|
36
|
+
## `GET /api/state`
|
|
37
|
+
|
|
38
|
+
Returns a one-shot JSON snapshot:
|
|
39
|
+
|
|
40
|
+
```jsonc
|
|
41
|
+
{
|
|
42
|
+
"claims": {
|
|
43
|
+
"metro://discord/123456789": "metro://claude/user/abc"
|
|
44
|
+
},
|
|
45
|
+
"lines": [
|
|
46
|
+
"metro://discord/123456789",
|
|
47
|
+
"metro://telegram/-100…"
|
|
48
|
+
],
|
|
49
|
+
"recent_history": [/* most-recent-first, up to 100 HistoryEntry objects */],
|
|
50
|
+
"bot_ids": { "discord": "1234567890", "telegram": "987654321" }
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
- `claims` — verbatim contents of `claims.json`.
|
|
55
|
+
- `lines` — the set of conversation URIs seen across recent history and current claims (good-enough proxy for "what lines exist right now"). Subject to refinement; not authoritative.
|
|
56
|
+
- `recent_history` — same shape as `HistoryEntry` in `src/history.ts`, ordered most-recent-first, capped at 100 entries.
|
|
57
|
+
- `bot_ids` — verbatim contents of `bot-ids.json`.
|
|
58
|
+
|
|
59
|
+
### Example
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
curl -H "Authorization: Bearer $METRO_MONITOR_TOKEN" \
|
|
63
|
+
https://monitor.metro.box/api/state | jq
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## `GET /api/tail` (SSE)
|
|
67
|
+
|
|
68
|
+
Server-Sent Events stream of new `history.jsonl` entries. Each event has:
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
id: <metro-msg-id>
|
|
72
|
+
event: history
|
|
73
|
+
data: <one HistoryEntry as JSON>
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
The stream stays open until the client disconnects. A `: keepalive` comment is emitted
|
|
78
|
+
every 25 seconds to keep proxies happy.
|
|
79
|
+
|
|
80
|
+
### Query parameters
|
|
81
|
+
|
|
82
|
+
All optional. Mirror the `metro tail` CLI flags.
|
|
83
|
+
|
|
84
|
+
| Param | Default | Effect |
|
|
85
|
+
|--------------------|-------------|-------------------------------------------------------------------------|
|
|
86
|
+
| `as=<line>` | (none) | Self URI — enables "mine + free" claim-aware filtering. |
|
|
87
|
+
| `mode=strict\|unclaimed\|all` | derived | Override the mode (`strict` needs `as=`). |
|
|
88
|
+
| `chat=<line>` | (none) | Only emit events matching this exact `line`. |
|
|
89
|
+
| `station=<name>` | (none) | Only emit events matching this station (`discord`, `telegram`, …). |
|
|
90
|
+
| `include_webhooks=true` | `false` | Include webhook-station events in personal modes. |
|
|
91
|
+
| `since=tail\|0\|<offset>` | `tail` | Where to start in `history.jsonl`. `tail` = EOF; `0` = full replay; or a byte offset. |
|
|
92
|
+
|
|
93
|
+
### Filter rule
|
|
94
|
+
|
|
95
|
+
Same predicate as `metro tail --as=<id> [--strict|--unclaimed|--all] [--include-webhooks]`:
|
|
96
|
+
|
|
97
|
+
> An event is delivered when its `line` is **claimed by `as=`** *or* **claimed by no one**,
|
|
98
|
+
> minus webhooks (unless `include_webhooks=true`), minus anything failing `chat=`/`station=`.
|
|
99
|
+
|
|
100
|
+
See [broker.md](./broker.md) for the underlying broker semantics.
|
|
101
|
+
|
|
102
|
+
### Example: live tail for a claude user
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
curl -N \
|
|
106
|
+
-H "Authorization: Bearer $METRO_MONITOR_TOKEN" \
|
|
107
|
+
"https://monitor.metro.box/api/tail?as=metro://claude/user/abc&include_webhooks=true"
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
The `-N` flag disables curl's output buffering so SSE frames appear as they arrive.
|
|
111
|
+
|
|
112
|
+
### Example: full backlog replay for debugging
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
curl -N \
|
|
116
|
+
-H "Authorization: Bearer $METRO_MONITOR_TOKEN" \
|
|
117
|
+
"https://monitor.metro.box/api/tail?since=0"
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## Exposing publicly via Cloudflare tunnel
|
|
121
|
+
|
|
122
|
+
The daemon listens on `127.0.0.1:8420` only. To reach `/api/*` from a phone or a
|
|
123
|
+
remote machine, route a public hostname through the existing `webhook.metro.box`
|
|
124
|
+
cloudflared tunnel.
|
|
125
|
+
|
|
126
|
+
Add a second hostname route to your `cloudflared` config (typically
|
|
127
|
+
`~/.cloudflared/config.yml`):
|
|
128
|
+
|
|
129
|
+
```yaml
|
|
130
|
+
ingress:
|
|
131
|
+
- hostname: webhook.metro.box
|
|
132
|
+
service: http://127.0.0.1:8420
|
|
133
|
+
- hostname: monitor.metro.box # new — same backing service
|
|
134
|
+
service: http://127.0.0.1:8420
|
|
135
|
+
- service: http_status:404
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Then create the DNS record:
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
cloudflared tunnel route dns <tunnel-name> monitor.metro.box
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Restart the tunnel; both hostnames now reach the same metro daemon. `webhook.metro.box`
|
|
145
|
+
keeps serving inbound webhooks; `monitor.metro.box` serves the bearer-token-gated
|
|
146
|
+
monitor routes.
|
|
147
|
+
|
|
148
|
+
There's no harm in serving `/api/*` from `webhook.metro.box` too — the bearer-token
|
|
149
|
+
gate is the same either way. The separate hostname is purely a routing convenience
|
|
150
|
+
(and lets you put different access policies in front of each, e.g., Cloudflare Access
|
|
151
|
+
on `monitor.metro.box` only).
|
|
152
|
+
|
|
153
|
+
## Failure modes
|
|
154
|
+
|
|
155
|
+
| Condition | Response |
|
|
156
|
+
|------------------------------------|-------------------------------------------------------------------|
|
|
157
|
+
| `METRO_MONITOR_TOKEN` not set | 503 `{"error":"monitor endpoints not configured (...)"}` |
|
|
158
|
+
| Missing `Authorization` header | 401 `{"error":"unauthorized"}` |
|
|
159
|
+
| Wrong / malformed token | 401 `{"error":"unauthorized"}` |
|
|
160
|
+
| Unknown `/api/*` path | 404 `{"error":"not found"}` |
|
|
161
|
+
| `POST` (or any non-`GET`) to `/api/*` | 405 `{"error":"method not allowed"}` |
|
|
162
|
+
| `history.jsonl` doesn't exist yet | 200 with empty `recent_history` / SSE stream w/ no events. |
|
|
163
|
+
| Client disconnects mid-SSE | Handler clears its interval + closes the file watcher cleanly. |
|
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stage-labs/metro",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.13",
|
|
4
|
+
"private": false,
|
|
4
5
|
"description": "Live JSON stream of Telegram + Discord messages for your local Claude Code / Codex session. The user launches metro; metro emits inbounds on stdout and accepts replies via CLI subcommands.",
|
|
5
6
|
"license": "MIT",
|
|
6
7
|
"repository": {
|
|
@@ -32,7 +33,7 @@
|
|
|
32
33
|
"lint": "eslint src/",
|
|
33
34
|
"lint:fix": "eslint src/ --fix",
|
|
34
35
|
"typecheck": "tsc --noEmit",
|
|
35
|
-
"test": "bun test test/"
|
|
36
|
+
"test": "METRO_STATE_DIR=\"$(mktemp -d /tmp/metro-test.XXXXXX)\" bun test test/"
|
|
36
37
|
},
|
|
37
38
|
"dependencies": {
|
|
38
39
|
"discord.js": "^14.14.0",
|