@stage-labs/metro 0.1.0-beta.11 → 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 ADDED
@@ -0,0 +1,202 @@
1
+ /** Broker primitives: claims map + per-user byte-offset cursors over history.jsonl. */
2
+ import { closeSync, existsSync, openSync, readFileSync, readSync, renameSync, unlinkSync, writeFileSync, } from 'node:fs';
3
+ import { mkdirSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { errMsg, log } from './log.js';
6
+ import { STATE_DIR } from './paths.js';
7
+ import { Line } from './stations/index.js';
8
+ export const CLAIMS_FILE = join(STATE_DIR, 'claims.json');
9
+ const CLAIMS_LOCK = join(STATE_DIR, 'claims.json.lock');
10
+ const CURSORS_DIR = join(STATE_DIR, 'cursors');
11
+ export const HISTORY_FILE = join(STATE_DIR, 'history.jsonl');
12
+ /** Read claims.json. Returns empty map if missing or malformed (retries once on race). */
13
+ export function readClaims() {
14
+ if (!existsSync(CLAIMS_FILE))
15
+ return {};
16
+ for (let attempt = 0; attempt < 2; attempt++) {
17
+ try {
18
+ return JSON.parse(readFileSync(CLAIMS_FILE, 'utf8'));
19
+ }
20
+ catch { /* race with writer — retry once */ }
21
+ }
22
+ log.warn({ path: CLAIMS_FILE }, 'claims: malformed, treating as empty');
23
+ return {};
24
+ }
25
+ /** Mutate claims under an O_EXCL lockfile. Throws if another writer holds the lock past timeout. */
26
+ function withClaimsLock(fn) {
27
+ const deadline = Date.now() + 2_000;
28
+ while (true) {
29
+ try {
30
+ closeSync(openSync(CLAIMS_LOCK, 'wx'));
31
+ break;
32
+ }
33
+ catch (err) {
34
+ if (err.code !== 'EEXIST')
35
+ throw err;
36
+ if (Date.now() > deadline)
37
+ throw new Error('claims.json: lock contention (held >2s)');
38
+ }
39
+ }
40
+ try {
41
+ const next = readClaims();
42
+ const result = fn(next);
43
+ /** atomic publish: tmpfile + rename so readers never see a half-written file */
44
+ const tmp = `${CLAIMS_FILE}.tmp.${process.pid}`;
45
+ writeFileSync(tmp, JSON.stringify(next, null, 2) + '\n');
46
+ renameSync(tmp, CLAIMS_FILE);
47
+ return result;
48
+ }
49
+ finally {
50
+ try {
51
+ unlinkSync(CLAIMS_LOCK);
52
+ }
53
+ catch { /* ignore */ }
54
+ }
55
+ }
56
+ export function claimLine(line, owner) {
57
+ return withClaimsLock(m => { m[line] = owner; return m; });
58
+ }
59
+ export function releaseLine(line) {
60
+ return withClaimsLock(m => {
61
+ const released = line in m;
62
+ delete m[line];
63
+ return { released, claims: m };
64
+ });
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
+ }
85
+ try {
86
+ return withClaimsLock(m => {
87
+ const existing = m[line];
88
+ if (existing && existing !== owner)
89
+ return { status: 'skipped', existing };
90
+ if (existing === owner)
91
+ return { status: 'kept', owner };
92
+ m[line] = owner;
93
+ return { status: 'claimed', owner };
94
+ });
95
+ }
96
+ catch (err) {
97
+ return { status: 'error', error: err.message };
98
+ }
99
+ }
100
+ /** Filename-safe slug for a participant URI. `metro://claude/user/9bfc…` → `claude-user-9bfc…`. */
101
+ export function userSlug(uri) {
102
+ return uri.replace(/^metro:\/+/, '').replace(/[^A-Za-z0-9_.-]/g, '-');
103
+ }
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);
124
+ if (!existsSync(p))
125
+ return 0;
126
+ const n = Number(readFileSync(p, 'utf8').trim());
127
+ return Number.isFinite(n) && n >= 0 ? n : 0;
128
+ }
129
+ export function writeCursor(key, offset) {
130
+ mkdirSync(CURSORS_DIR, { recursive: true });
131
+ const p = cursorPath(key);
132
+ const tmp = `${p}.tmp.${process.pid}`;
133
+ writeFileSync(tmp, String(offset));
134
+ renameSync(tmp, p);
135
+ }
136
+ /** Byte size of history.jsonl right now (for `--since=tail`). */
137
+ export function historySize() {
138
+ if (!existsSync(HISTORY_FILE))
139
+ return 0;
140
+ try {
141
+ return readFileSync(HISTORY_FILE).length;
142
+ }
143
+ catch {
144
+ return 0;
145
+ }
146
+ }
147
+ /** Yield each complete JSONL line from `offset` to EOF; the returned offset is the position right after the `\n`. */
148
+ export function* readEntriesFrom(offset) {
149
+ if (!existsSync(HISTORY_FILE))
150
+ return;
151
+ const fd = openSync(HISTORY_FILE, 'r');
152
+ try {
153
+ const chunk = Buffer.alloc(64 * 1024);
154
+ let pending = Buffer.alloc(0);
155
+ let pos = offset;
156
+ while (true) {
157
+ const n = readSync(fd, chunk, 0, chunk.length, pos);
158
+ if (n === 0)
159
+ break;
160
+ pending = Buffer.concat([pending, chunk.subarray(0, n)]);
161
+ pos += n;
162
+ let nl;
163
+ while ((nl = pending.indexOf(0x0a)) !== -1) {
164
+ const raw = pending.subarray(0, nl).toString('utf8').trim();
165
+ pending = pending.subarray(nl + 1);
166
+ if (!raw)
167
+ continue;
168
+ try {
169
+ const entry = JSON.parse(raw);
170
+ /** offsetAfter = read-cursor in file - bytes still in pending buffer */
171
+ yield { entry, offset: pos - pending.length };
172
+ }
173
+ catch (err) {
174
+ log.warn({ err: errMsg(err) }, 'broker: skipped malformed history line');
175
+ }
176
+ }
177
+ }
178
+ }
179
+ finally {
180
+ closeSync(fd);
181
+ }
182
+ }
183
+ /**
184
+ * Claim-aware filter. Webhooks excluded from personal modes unless `includeWebhooks`.
185
+ */
186
+ export function passesMode(event, mode, self, claims, opts = {}) {
187
+ if (self && event.to === self)
188
+ return true;
189
+ if (mode === 'all')
190
+ return true;
191
+ const isWebhook = event.station === 'webhook';
192
+ if (mode === 'unclaimed')
193
+ return !claims[event.line];
194
+ /** webhooks are filtered out of personal modes unless opted in */
195
+ if (isWebhook && !opts.includeWebhooks)
196
+ return false;
197
+ const owner = claims[event.line];
198
+ if (mode === 'mine-only')
199
+ return owner === self;
200
+ /** mode === 'mine-or-unclaimed' */
201
+ return !owner || owner === self;
202
+ }
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
  }
@@ -9,6 +9,7 @@ import { ipcCall } from '../ipc.js';
9
9
  import { userSelf, appendHistory, lookupEntry, mintId, readHistory, resolvePlatformId, } from '../history.js';
10
10
  import { asLine, Line } from '../stations/index.js';
11
11
  import { loadMetroEnv } from '../paths.js';
12
+ import { tryAutoClaim } from '../broker.js';
12
13
  import { emit, flagList, flagOne, isJson, need, resolveText, writeJson, } from './util.js';
13
14
  export function chatStationOf(line) {
14
15
  const s = Line.station(line);
@@ -51,25 +52,83 @@ function destinationFor(orig, line) {
51
52
  return line;
52
53
  return orig.from;
53
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
+ }
54
86
  /** Append an outbound action to history.jsonl; `to` mirrors the destination per `destinationFor`. */
55
87
  function logOutbound(f, e) {
56
88
  const id = mintId();
57
89
  const fromOverride = flagOne(f, 'from');
90
+ const from = fromOverride ? asLine(fromOverride) : userSelf();
58
91
  appendHistory({
59
92
  id, ts: new Date().toISOString(), station: Line.station(e.line) ?? '?',
60
- from: fromOverride ? asLine(fromOverride) : userSelf(), to: e.to ?? e.line, ...e,
93
+ from, to: e.to ?? e.line, ...e,
61
94
  });
95
+ maybeAutoClaim(f, e.line, from);
62
96
  return id;
63
97
  }
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. */
100
+ function maybeAutoClaim(f, line, owner) {
101
+ if (f['no-claim'] === true)
102
+ return;
103
+ if (process.env.METRO_NO_AUTO_CLAIM === '1')
104
+ return;
105
+ const force = f['claim'] === true;
106
+ const lineKind = classifyLine(line);
107
+ const result = tryAutoClaim(line, owner, { lineKind, force });
108
+ if (result.status === 'skipped') {
109
+ process.stderr.write(`auto-claim skipped: line owned by ${result.existing}\n`);
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
+ }
117
+ else if (result.status === 'error') {
118
+ process.stderr.write(`auto-claim failed: ${result.error}\n`);
119
+ }
120
+ }
64
121
  export async function cmdSend(p, f) {
65
122
  need(p, 1, 'metro send <line> <text> [--image=<path>]… [--document=<path>]… [--voice=<path>] [--buttons=<json>]');
66
123
  loadMetroEnv();
67
124
  const text = await resolveText(p, 1), line = asLine(p[0]);
68
125
  if (Line.isLocal(line)) {
69
- const from = flagOne(f, 'from');
70
- const resp = await ipcCall({ op: 'notify', line, from, text });
126
+ const fromFlag = flagOne(f, 'from');
127
+ const resp = await ipcCall({ op: 'notify', line, from: fromFlag, text });
71
128
  if (!resp.ok)
72
129
  throw new Error(resp.error);
130
+ /** cross-user notify still counts as the sender taking the line: auto-claim if unclaimed */
131
+ maybeAutoClaim(f, line, fromFlag ? asLine(fromFlag) : userSelf());
73
132
  return emit(f, `notified ${line}`, { ok: true, line, id: null, messageId: null });
74
133
  }
75
134
  const messageId = await chatStationOf(line).send(line, text, richOpts(f));
package/dist/cli/index.js CHANGED
@@ -9,6 +9,7 @@ import { loadMetroEnv } from '../paths.js';
9
9
  import { readHistory } from '../history.js';
10
10
  import { cmdDoctor, cmdSetup, cmdUpdate } from './config.js';
11
11
  import { cmdDownload, cmdEdit, cmdFetch, cmdReact, cmdReply, cmdSend, } from './actions.js';
12
+ import { cmdClaim, cmdClaims, cmdRelease, cmdTail } from './tail.js';
12
13
  import { cmdTunnel, cmdWebhook } from './webhook.js';
13
14
  import { flagOne, isJson, parseArgs, writeJson, } from './util.js';
14
15
  const USAGE = `metro — Telegram + Discord stream for your Claude Code / Codex user
@@ -21,18 +22,30 @@ Usage:
21
22
  metro doctor Health check.
22
23
  metro stations List stations + capabilities.
23
24
  metro lines List recently-seen conversations.
24
- metro send <line> <text> [--image=<path>]… [--document=<path>]… [--voice=<path>] [--buttons=<json>]
25
+ metro send <line> <text> [--image=<path>]… [--document=<path>]… [--voice=<path>] [--buttons=<json>] [--no-claim] [--claim]
25
26
  Post a fresh message; repeat --image/--document for multi-file albums.
26
- metro reply <line> <message_id> <text> [--image=… --document=… --voice=… --buttons=…]
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]
27
32
  Threaded reply (same flags as send).
28
- metro edit <line> <message_id> <text> [--buttons=<json>]
33
+ metro edit <line> <message_id> <text> [--buttons=<json>] [--no-claim] [--claim]
29
34
  Edit a previously-sent message (text + buttons).
30
- metro react <line> <message_id> <emoji> Set or clear ('') a reaction.
35
+ metro react <line> <message_id> <emoji> [--no-claim] [--claim]
36
+ Set or clear ('') a reaction.
31
37
  metro download <line> <message_id> [--out=<dir>]
32
38
  Download image attachments to disk.
33
39
  metro fetch <line> [--limit=N] Recent-message lookback (Discord only).
34
40
  metro history [--limit=N] [--line=…] [--station=…] [--kind=…] [--from=…] [--text=…] [--since=…]
35
41
  Read the universal message log (newest first).
42
+ metro tail [--as=<user-uri>] [--follow] [--strict | --unclaimed | --all] [--include-webhooks]
43
+ [--chat=<line>] [--station=…] [--since=<offset|tail>] [--limit=N]
44
+ Subscribe to the event log; claim-aware by default. See docs/broker.md.
45
+ Webhooks are hidden in personal modes unless --include-webhooks is set.
46
+ metro claim <line> [--as=<user-uri>] Take exclusive ownership of a line (so only you receive its events).
47
+ metro release <line> Release a line (it returns to broadcast).
48
+ metro claims Print the current claims map.
36
49
  metro webhook add <label> [--secret=…] Register an HTTP receive endpoint (GitHub, Intercom, …).
37
50
  metro webhook list | remove <id> List or remove webhook endpoints.
38
51
  metro tunnel setup <name> <hostname> Configure a Cloudflare named tunnel (run cloudflared tunnel login first).
@@ -140,7 +153,9 @@ const COMMANDS = {
140
153
  send: cmdSend, reply: cmdReply, edit: cmdEdit, react: cmdReact,
141
154
  download: cmdDownload, fetch: cmdFetch,
142
155
  webhook: cmdWebhook, tunnel: cmdTunnel,
143
- history: cmdHistory, update: cmdUpdate,
156
+ history: cmdHistory, tail: cmdTail,
157
+ claim: cmdClaim, release: cmdRelease, claims: cmdClaims,
158
+ update: cmdUpdate,
144
159
  };
145
160
  async function main() {
146
161
  const cmd = process.argv[2];
@@ -0,0 +1,164 @@
1
+ /** CLI subcommands: `metro tail` (claim-aware log subscriber) + `metro claim|release|claims`. */
2
+ import { existsSync, watch } from 'node:fs';
3
+ import { CLAIMS_FILE, HISTORY_FILE, claimLine, cursorKey, historySize, passesMode, readClaims, readCursor, readEntriesFrom, releaseLine, writeCursor, } from '../broker.js';
4
+ import { userSelf } from '../history.js';
5
+ import { asLine } from '../stations/index.js';
6
+ import { loadMetroEnv } from '../paths.js';
7
+ import { emit, exitErr, flagOne, isJson, need, writeJson } from './util.js';
8
+ function resolveMode(f, self) {
9
+ const strict = f.strict === true, unclaimed = f.unclaimed === true, all = f.all === true;
10
+ if ([strict, unclaimed, all].filter(Boolean).length > 1) {
11
+ throw exitErr('--strict, --unclaimed, --all are mutually exclusive', 1);
12
+ }
13
+ if (strict) {
14
+ if (!self)
15
+ throw exitErr('--strict requires --as <user-uri>', 1);
16
+ return 'mine-only';
17
+ }
18
+ if (unclaimed)
19
+ return 'unclaimed';
20
+ if (all || !self)
21
+ return 'all';
22
+ return 'mine-or-unclaimed';
23
+ }
24
+ /** Generic fallback returned by `userSelf()` when there's no Claude/Codex env — treat as "no identity". */
25
+ const GENERIC_USER = 'metro://user';
26
+ function resolveSelf(f) {
27
+ const raw = flagOne(f, 'as');
28
+ if (raw !== undefined)
29
+ return asLine(raw);
30
+ const auto = userSelf();
31
+ return auto === GENERIC_USER ? null : auto;
32
+ }
33
+ function resolveStartOffset(f, key) {
34
+ const since = flagOne(f, 'since');
35
+ if (since === 'tail')
36
+ return historySize();
37
+ if (since !== undefined) {
38
+ const n = Number(since);
39
+ if (!Number.isFinite(n) || n < 0)
40
+ throw exitErr(`--since must be a byte offset or 'tail' (got '${since}')`, 1);
41
+ return n;
42
+ }
43
+ return key ? readCursor(key) : 0;
44
+ }
45
+ export async function cmdTail(_, f) {
46
+ loadMetroEnv();
47
+ const self = resolveSelf(f);
48
+ const mode = resolveMode(f, self);
49
+ const follow = f.follow === true;
50
+ const chatFilter = flagOne(f, 'chat');
51
+ const stationFilter = flagOne(f, 'station');
52
+ const limit = Number(flagOne(f, 'limit')) || 0;
53
+ const json = isJson(f);
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);
59
+ let emitted = 0;
60
+ let offset = startOffset;
61
+ const drain = () => {
62
+ /** read claims once per drain so a burst of events shares a snapshot */
63
+ const claims = readClaims();
64
+ for (const { entry, offset: next } of readEntriesFrom(offset)) {
65
+ offset = next;
66
+ if (chatFilter && entry.line !== chatFilter)
67
+ continue;
68
+ if (stationFilter && entry.station !== stationFilter)
69
+ continue;
70
+ if (!passesMode(entry, mode, self, claims, { includeWebhooks }))
71
+ continue;
72
+ if (json)
73
+ process.stdout.write(JSON.stringify(entry) + '\n');
74
+ else
75
+ process.stdout.write(fmtRow(entry) + '\n');
76
+ if (key)
77
+ writeCursor(key, offset);
78
+ emitted++;
79
+ if (limit && emitted >= limit)
80
+ return true;
81
+ }
82
+ return false;
83
+ };
84
+ if (drain() && !follow)
85
+ return;
86
+ if (!follow)
87
+ return;
88
+ /** fs.watch on macOS sometimes coalesces or drops events — poll every 500ms as a backstop. */
89
+ await new Promise(resolve => {
90
+ let watcher = null;
91
+ const trigger = () => { if (drain())
92
+ cleanup(); };
93
+ const cleanup = () => {
94
+ if (watcher) {
95
+ try {
96
+ watcher.close();
97
+ }
98
+ catch { /* ignore */ }
99
+ watcher = null;
100
+ }
101
+ clearInterval(poll);
102
+ resolve();
103
+ };
104
+ const poll = setInterval(trigger, 500);
105
+ const startWatcher = () => {
106
+ if (!existsSync(HISTORY_FILE))
107
+ return;
108
+ try {
109
+ watcher = watch(HISTORY_FILE, () => trigger());
110
+ }
111
+ catch { /* ignore — poll will catch */ }
112
+ };
113
+ startWatcher();
114
+ process.on('SIGINT', cleanup);
115
+ process.on('SIGTERM', cleanup);
116
+ process.stdin.on('end', cleanup).on('close', cleanup);
117
+ });
118
+ }
119
+ function fmtRow(e) {
120
+ const ts = e.ts.slice(11, 19);
121
+ const body = e.text ?? (e.emoji ? `[react ${e.emoji}]` : '');
122
+ const text = body.length > 80 ? body.slice(0, 79) + '…' : body;
123
+ const who = (e.fromName ?? e.from).padEnd(28).slice(0, 28);
124
+ const where = e.line.padEnd(40).slice(0, 40);
125
+ return `${ts} ${e.id.padEnd(12)} ${e.kind.padEnd(8)} ${who} ${where} ${text}`;
126
+ }
127
+ export async function cmdClaim(p, f) {
128
+ loadMetroEnv();
129
+ need(p, 1, 'metro claim <line> [--as <user-uri>]');
130
+ const line = asLine(p[0]);
131
+ const asRaw = flagOne(f, 'as');
132
+ const owner = asRaw ? asLine(asRaw) : userSelf();
133
+ const claims = claimLine(line, owner);
134
+ emit(f, `claimed ${line} → ${owner}`, { ok: true, line, owner, claims });
135
+ }
136
+ export async function cmdRelease(p, f) {
137
+ loadMetroEnv();
138
+ need(p, 1, 'metro release <line>');
139
+ const line = asLine(p[0]);
140
+ const { released, claims } = releaseLine(line);
141
+ if (!released) {
142
+ emit(f, `${line} was not claimed`, { ok: true, released: false, line, claims });
143
+ return;
144
+ }
145
+ emit(f, `released ${line}`, { ok: true, released: true, line, claims });
146
+ }
147
+ export async function cmdClaims(_, f) {
148
+ loadMetroEnv();
149
+ const claims = readClaims();
150
+ const entries = Object.entries(claims);
151
+ if (isJson(f))
152
+ return writeJson({ claims });
153
+ if (!entries.length) {
154
+ process.stdout.write('(no claims — every tail with matching filters receives every event)\n');
155
+ process.stdout.write(`file: ${CLAIMS_FILE}${existsSync(CLAIMS_FILE) ? '' : ' (not created yet)'}\n`);
156
+ return;
157
+ }
158
+ const widest = Math.max(...entries.map(([l]) => l.length));
159
+ process.stdout.write('metro claims\n\n');
160
+ for (const [line, owner] of entries) {
161
+ process.stdout.write(` ${line.padEnd(widest)} → ${owner}\n`);
162
+ }
163
+ process.stdout.write(`\n${entries.length} claim${entries.length === 1 ? '' : 's'} · ${CLAIMS_FILE}\n`);
164
+ }
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 ADDED
@@ -0,0 +1,214 @@
1
+ # Metro broker
2
+
3
+ Multi-user event routing. Turns metro from "one daemon → one stdout consumer" into "one daemon → N independently-subscribed users (Claude Code, Codex, anything) with durable, replayable delivery".
4
+
5
+ ## Why
6
+
7
+ Today the dispatcher writes every inbound event to **its own stdout**, which only the parent process (one Claude Code, monitoring the daemon via `Monitor`) can read. Consequences:
8
+
9
+ - **Throughput bottleneck**: bursts of inbound messages serialize behind whatever the single user is currently doing.
10
+ - **No real sub-users**: `Agent`-tool sub-users can call `metro send` (IPC works from anywhere), but they cannot *receive* events — they have no stdout subscription.
11
+ - **No multi-instance**: a second `claude` window or a separate `codex` process can't join in; the stream has one reader.
12
+ - **No durability**: a user crashes mid-conversation → events emitted during the gap are lost; on restart it starts deaf.
13
+
14
+ The fix is to treat metro as a tiny **durable message broker** over the event log that already exists ([history.ts](../src/history.ts), [user-registry.json](../src/registry.ts)).
15
+
16
+ ## Core idea
17
+
18
+ One concept — a **claim** — and three on-disk files you can `cat`:
19
+
20
+ | Concern | File | Role |
21
+ |----------------------|-----------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------|
22
+ | Event log | `$METRO_STATE_DIR/history.jsonl` | Append-only JSONL — every inbound/outbound/edit/react. Already exists. The single source of truth. |
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-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.
39
+
40
+ Subscribers do not register with the daemon. They tail the log; the broker semantics emerge from one filtering rule applied at read time:
41
+
42
+ > An event is delivered to a user when its `line` is **claimed by that user** *or* **claimed by no one**.
43
+
44
+ That single rule covers every case the design needs to handle:
45
+
46
+ - **Chat with one responder** — user claims the chat; other tailing users stop receiving it. No race.
47
+ - **Webhook fan-out** — nobody claims; every user tailing a matching filter sees it.
48
+ - **Operator observability** — `metro tail` with no `--as` (or `--all`) shows everything regardless of claims; doesn't take ownership.
49
+ - **Sub-user onboarding** — sub-user claims its assigned chat before reading; parent stops receiving that chat without any coordination.
50
+
51
+ There is no separate concept for "subscription" or "fan-out mode" — claims and their absence cover both. The dispatcher writes; tails filter; claims gate exclusivity. Three primitives, one rule.
52
+
53
+ ```
54
+ ┌──────────────────────────┐
55
+ inbound (Discord/TG/web) ──► │ dispatcher │ ──► history.jsonl ◄── metro tail --as claude-A
56
+ │ (writes log, no routing)│ ◄── metro tail --as codex-B
57
+ └──────────────────────────┘ ◄── metro tail --as claude-sub-1
58
+ (each holds its own cursor)
59
+ ```
60
+
61
+ ## CLI surface
62
+
63
+ ```bash
64
+ # Tail the event log. --follow streams new entries via fs.watch.
65
+ metro tail [--as <user-id>] [--follow] [--strict | --unclaimed | --all] [--include-webhooks]
66
+ [--chat <line>] [--station <name>] [--since <offset|tail>] [--limit <n>]
67
+
68
+ # Claims: assert/release exclusive ownership of a line. Updates claims.json.
69
+ metro claim <line> [--as <user-id>] # add/overwrite — last writer wins
70
+ metro release <line> # remove (line returns to broadcast)
71
+ metro claims # print current claims.json
72
+
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]
79
+ # Or disable globally: METRO_NO_AUTO_CLAIM=1
80
+
81
+ # Lease/ack — optional, v2. When enabled, an event is "in flight" with the
82
+ # claimant; if no ack in N seconds the cursor isn't advanced and the next
83
+ # `metro tail` re-emits.
84
+ metro ack <event-id> --as <user-id>
85
+ ```
86
+
87
+ `--as <user-id>` defaults to `userSelf()` ([history.ts:121](../src/history.ts#L121)) — the same stable identity already used in routing-aware code.
88
+
89
+ ### Subscription modes
90
+
91
+ The same `metro tail` command serves four distinct callers — a working user, a strict worker, a router, and a human observer. Each maps to one mutually-exclusive flag controlling the claim-aware filter:
92
+
93
+ | Mode | Flag | Predicate | Who uses it |
94
+ |--------------------|----------------------------|---------------------------------------------------------------------------------|------------------------------------------------------------|
95
+ | **Mine + free** | `--as <id>` (default) | `(claims[line] == <id> ∨ line ∉ claims) ∧ station ≠ 'webhook'` | Default working user. Zero-config single-user setup. |
96
+ | **Mine only** | `--as <id> --strict` | `claims[line] == <id> ∧ station ≠ 'webhook'` | Disciplined worker that won't race on unclaimed events. |
97
+ | **Unclaimed only** | `--unclaimed` | `line ∉ claims` | Router/first-responder user that finds work to claim. |
98
+ | **All** | (no `--as`) or `--all` | `true` | Operator/auditor/debugger; never takes ownership. |
99
+
100
+ Webhooks (`station == 'webhook'`) are excluded from the personal modes by default — they're broadcast traffic (GitHub pushes, Intercom pings, etc.) that should flow to the *router* (`--unclaimed`) or *operator* (`--all`) feed, not firehose into every `--as <id>` tail. Opt back in with `metro tail --as <id> --include-webhooks` when you genuinely want a worker to see them.
101
+
102
+ Two UX defaults worth being explicit about:
103
+
104
+ 1. **`--as <id>` with no mode flag = "mine + free".** Single-user setups (the common case) get zero-config metro: nothing claimed yet, so the only tail sees everything. Adding a second user means claiming first — surfaced in docs, not enforced by the daemon. `--strict` is the opt-in for setups that want stricter separation.
105
+ 2. **No `--as` = "all".** Matches the unix `tail -f` mental model. Operators just want to read the log without registering an identity or accidentally taking ownership of anything.
106
+
107
+ `--unclaimed` is the genuinely new primitive: it enables a "router" user pattern where one process watches for ownerless events and either responds directly or claims and delegates. It works with or without `--as` — with `--as`, outbound replies are still attributed correctly.
108
+
109
+ Direct messages between users (`event.to == user-line`) always pass the filter regardless of mode — they're inherently 1:1 and can't be "claimed" by someone else.
110
+
111
+ ### Auto-claim on outbound
112
+
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.
114
+
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.
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).
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.
132
+
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.
134
+
135
+ ### `metro tail` mechanics
136
+
137
+ - Reads `history.jsonl`, applies the mode predicate + any `--chat`/`--station` filters (AND), prints one JSONL line per event to stdout.
138
+ - With `--follow`: stays open, watches the file via `fs.watch`, emits new matching lines as they're appended.
139
+ - Maintains a per-user cursor (byte offset) at `cursors/<user-id>`. On startup, resumes from cursor; on each emitted line, the offset is advanced *after* the write succeeds. Byte offsets give O(1) resume — no file scan.
140
+ - `--since <offset>` overrides the cursor; `--since=tail` starts from EOF, ignoring backlog. Useful for fresh-start without losing the persisted cursor.
141
+ - Claim lookups read `claims.json` once per emitted event. The file is small (a few KB) and OS-cached; cost is sub-microsecond per event.
142
+
143
+ ### `metro claim` semantics
144
+
145
+ - Pure metadata edit on `claims.json`. Does **not** notify the daemon — claims are read by tails, not the dispatcher (see "Dispatcher changes" below).
146
+ - Re-claiming a line re-assigns it (last writer wins). `metro claims` prints the current map so a human can audit.
147
+ - Releasing a line returns it to broadcast — every matching tail picks it up again.
148
+ - Writes to `claims.json` are wrapped in an `O_EXCL` lockfile to serialize concurrent `metro claim` invocations on the same host.
149
+
150
+ ## Dispatcher changes
151
+
152
+ Almost none. `emit()` still appends to history, pushes to codex-rc, and writes to stdout. The broker model lives entirely on the read side — claims and cursors are consulted by `metro tail`, not by the dispatcher. The dispatcher doesn't need to know who's listening or who's claimed what.
153
+
154
+ This is the design's key simplification: **the daemon stays dumb**. It's still a single-writer to a JSONL file. All the routing intelligence is in `metro tail`'s filter, which reads two small files (`claims.json` and its own cursor) on each event.
155
+
156
+ No new sockets. No fan-out bookkeeping. No coupling between subscriber count and daemon state.
157
+
158
+ ## What this enables
159
+
160
+ - **Sub-users that actually receive events**: `Agent` spawns a sub-user whose first action is `metro tail --as <its-id> --chat <line> --follow &` — it then `Monitor`s that background process and gets *only* its assigned chat's events.
161
+ - **Two manual Claude Code windows**: each runs `metro tail --as claude-A` / `claude-B`, claims disjoint chats. No coordination beyond `metro claim`.
162
+ - **Codex alongside Claude**: same model — `metro tail --as codex-1 --station telegram` etc. The codex-rc push becomes optional: a Codex worker can subscribe via `metro tail` directly and bypass the rc file.
163
+ - **Crash recovery**: process dies → restarts → `metro tail` resumes from cursor → backlog replays in order. No double-replies (the cursor is advanced on emit, not on reply).
164
+ - **Replay for new joiners**: `metro tail --as new-user --since <offset-from-5-min-ago>` lets a freshly-spawned process backfill recent history before going live.
165
+
166
+ ## Concurrency
167
+
168
+ Multiple processes already write `history.jsonl` today: the daemon's `emit()` and every short-lived CLI invocation (`metro send`/`reply`/`react` — see [actions.ts](../src/cli/actions.ts)). It works because `appendFileSync` opens with `O_APPEND`, and POSIX guarantees that `O_APPEND` writes atomically seek-to-end-and-write in one operation — concurrent writers produce whole lines in some order, never interleaved halves. Node issues one `write(2)` per `appendFileSync` call, and our entries (even fat webhook payloads) stay well under per-syscall atomicity limits on both Linux (~2GB) and macOS (`INT_MAX`). The broker model adds **only readers**, so the existing safety property is preserved.
169
+
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).
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
+
184
+ ## Failure modes & guardrails
185
+
186
+ | Failure | Behavior |
187
+ |-------------------------------------|---------------------------------------------------------------------------------------------------|
188
+ | Process crashes mid-event | Cursor not advanced → event redelivered on next `metro tail`. At-least-once. |
189
+ | Two users claim same line | `claims.json` last-write-wins. `metro claims` shows current owner; humans resolve. |
190
+ | No user claims a chat | Event broadcasts to every tail whose filters match. Two tails without filters → both reply (operator error — claim should have been set first). |
191
+ | User silently slow (no ack) | v1: not detected. v2: `metro ack` + lease TTL — cursor doesn't advance, next `metro tail` re-emits, can surface "X went dark on chat Y" via an inbound event from another user. |
192
+ | `history.jsonl` grows unboundedly | Existing concern; out of scope for this doc. (Rotate by date, prune by age.) |
193
+
194
+ ## Migration
195
+
196
+ All changes are additive. With no subscribers, the dispatcher behaves exactly as today (parent reads stdout, single-user throughput, no routing). The broker model layers on top:
197
+
198
+ 1. Ship `metro tail` (read-only, no daemon changes, no claim file). Users can subscribe and filter; multi-cast works for everything.
199
+ 2. Ship `metro claim`/`release`/`claims` + claim-aware filtering in `metro tail`. Exclusivity works.
200
+ 3. Optional v2: lease/ack — only if silent drops become a real problem.
201
+
202
+ Each step is independently shippable.
203
+
204
+ ## Open questions
205
+
206
+ - **Routing-key granularity**: claims map to `user-id` (orgId-level — same across sessions/devices) rather than `user-line` (`<user-id>/<session-id>`). This means two Claude Code windows logged into the same account share claims. The session-scoped alternative is more flexible but requires the claimant to write its current `selfLine()` into `claims.json` and refresh it when the session changes. **Default: user-id.** Override per-claim with `metro claim <line> --as <full-line>` if needed.
207
+ - **codex-rc deprecation**: today the dispatcher mirrors every event into a codex-rc file so Codex sees them. Once `metro tail` exists, Codex workers could subscribe directly. The rc-push stays for compatibility; the next major version can drop it.
208
+
209
+ ## Non-goals
210
+
211
+ - **Strict ordering across chats**: events within one `line` are ordered by JSONL append order; cross-chat ordering is best-effort. Subscribers shouldn't rely on it.
212
+ - **Exactly-once delivery**: at-least-once via cursor + redelivery. Idempotency is the subscriber's problem (the daemon already mints stable `msg_*` ids).
213
+ - **Authn between users**: any process with filesystem access to `$METRO_STATE_DIR` can tail and claim. Same trust model as today.
214
+ - **Remote users**: broker is local-only. Cross-host fan-out is a separate problem (likely solved by running metro on each host and bridging at the chat layer).
@@ -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.11",
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": {
@@ -31,7 +32,8 @@
31
32
  "prepublishOnly": "tsc",
32
33
  "lint": "eslint src/",
33
34
  "lint:fix": "eslint src/ --fix",
34
- "typecheck": "tsc --noEmit"
35
+ "typecheck": "tsc --noEmit",
36
+ "test": "METRO_STATE_DIR=\"$(mktemp -d /tmp/metro-test.XXXXXX)\" bun test test/"
35
37
  },
36
38
  "dependencies": {
37
39
  "discord.js": "^14.14.0",