@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 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
- export function tryAutoClaim(line, owner) {
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
- const cursorPath = (uri) => join(CURSORS_DIR, userSlug(uri));
86
- export function readCursor(uri) {
87
- const p = cursorPath(uri);
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(uri, offset) {
129
+ export function writeCursor(key, offset) {
94
130
  mkdirSync(CURSORS_DIR, { recursive: true });
95
- const p = cursorPath(uri);
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
  }
@@ -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 --no-claim or METRO_NO_AUTO_CLAIM=1; never overwrites a foreign owner. */
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 result = tryAutoClaim(line, owner);
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 the line; --no-claim or METRO_NO_AUTO_CLAIM=1 opts out.
28
- metro reply <line> <message_id> <text> [--image=… --document=… --voice=… --buttons=…] [--no-claim]
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, self) {
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 self ? readCursor(self) : 0;
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 (self)
74
- writeCursor(self, offset);
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;
@@ -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
+ }
@@ -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-user cursor | `$METRO_STATE_DIR/cursors/<user-id>` | Byte offset into `history.jsonl` — last-emitted position for one user. New. Updated atomically after each emit. |
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 (so subsequent inbounds route to you).
60
- metro send <line> <text> [--no-claim] # skip auto-claim for this call
61
- metro reply <line> <msg-id> <text> [--no-claim]
62
- metro edit <line> <msg-id> <text> [--no-claim]
63
- metro react <line> <msg-id> <emoji> [--no-claim]
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
- - If the line is already claimed by **someone else**, 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.
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 is why webhooks are excluded from personal modes (above): a webhook flowing into `--as <id>` mode that triggered a reply would auto-claim the webhook line for the responder, silently locking out other workers from future events on that endpoint. Keeping webhooks in `--unclaimed`/`--all` only ensures the router pattern explicitly decides who picks up each event.
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 |
@@ -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.12",
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",