@stage-labs/metro 0.1.0-beta.11 → 0.1.0-beta.12

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,166 @@
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
+ export const CLAIMS_FILE = join(STATE_DIR, 'claims.json');
8
+ const CLAIMS_LOCK = join(STATE_DIR, 'claims.json.lock');
9
+ const CURSORS_DIR = join(STATE_DIR, 'cursors');
10
+ export const HISTORY_FILE = join(STATE_DIR, 'history.jsonl');
11
+ /** Read claims.json. Returns empty map if missing or malformed (retries once on race). */
12
+ export function readClaims() {
13
+ if (!existsSync(CLAIMS_FILE))
14
+ return {};
15
+ for (let attempt = 0; attempt < 2; attempt++) {
16
+ try {
17
+ return JSON.parse(readFileSync(CLAIMS_FILE, 'utf8'));
18
+ }
19
+ catch { /* race with writer — retry once */ }
20
+ }
21
+ log.warn({ path: CLAIMS_FILE }, 'claims: malformed, treating as empty');
22
+ return {};
23
+ }
24
+ /** Mutate claims under an O_EXCL lockfile. Throws if another writer holds the lock past timeout. */
25
+ function withClaimsLock(fn) {
26
+ const deadline = Date.now() + 2_000;
27
+ while (true) {
28
+ try {
29
+ closeSync(openSync(CLAIMS_LOCK, 'wx'));
30
+ break;
31
+ }
32
+ catch (err) {
33
+ if (err.code !== 'EEXIST')
34
+ throw err;
35
+ if (Date.now() > deadline)
36
+ throw new Error('claims.json: lock contention (held >2s)');
37
+ }
38
+ }
39
+ try {
40
+ const next = readClaims();
41
+ const result = fn(next);
42
+ /** atomic publish: tmpfile + rename so readers never see a half-written file */
43
+ const tmp = `${CLAIMS_FILE}.tmp.${process.pid}`;
44
+ writeFileSync(tmp, JSON.stringify(next, null, 2) + '\n');
45
+ renameSync(tmp, CLAIMS_FILE);
46
+ return result;
47
+ }
48
+ finally {
49
+ try {
50
+ unlinkSync(CLAIMS_LOCK);
51
+ }
52
+ catch { /* ignore */ }
53
+ }
54
+ }
55
+ export function claimLine(line, owner) {
56
+ return withClaimsLock(m => { m[line] = owner; return m; });
57
+ }
58
+ export function releaseLine(line) {
59
+ return withClaimsLock(m => {
60
+ const released = line in m;
61
+ delete m[line];
62
+ return { released, claims: m };
63
+ });
64
+ }
65
+ export function tryAutoClaim(line, owner) {
66
+ try {
67
+ return withClaimsLock(m => {
68
+ const existing = m[line];
69
+ if (existing && existing !== owner)
70
+ return { status: 'skipped', existing };
71
+ if (existing === owner)
72
+ return { status: 'kept', owner };
73
+ m[line] = owner;
74
+ return { status: 'claimed', owner };
75
+ });
76
+ }
77
+ catch (err) {
78
+ return { status: 'error', error: err.message };
79
+ }
80
+ }
81
+ /** Filename-safe slug for a participant URI. `metro://claude/user/9bfc…` → `claude-user-9bfc…`. */
82
+ export function userSlug(uri) {
83
+ return uri.replace(/^metro:\/+/, '').replace(/[^A-Za-z0-9_.-]/g, '-');
84
+ }
85
+ const cursorPath = (uri) => join(CURSORS_DIR, userSlug(uri));
86
+ export function readCursor(uri) {
87
+ const p = cursorPath(uri);
88
+ if (!existsSync(p))
89
+ return 0;
90
+ const n = Number(readFileSync(p, 'utf8').trim());
91
+ return Number.isFinite(n) && n >= 0 ? n : 0;
92
+ }
93
+ export function writeCursor(uri, offset) {
94
+ mkdirSync(CURSORS_DIR, { recursive: true });
95
+ const p = cursorPath(uri);
96
+ const tmp = `${p}.tmp.${process.pid}`;
97
+ writeFileSync(tmp, String(offset));
98
+ renameSync(tmp, p);
99
+ }
100
+ /** Byte size of history.jsonl right now (for `--since=tail`). */
101
+ export function historySize() {
102
+ if (!existsSync(HISTORY_FILE))
103
+ return 0;
104
+ try {
105
+ return readFileSync(HISTORY_FILE).length;
106
+ }
107
+ catch {
108
+ return 0;
109
+ }
110
+ }
111
+ /** Yield each complete JSONL line from `offset` to EOF; the returned offset is the position right after the `\n`. */
112
+ export function* readEntriesFrom(offset) {
113
+ if (!existsSync(HISTORY_FILE))
114
+ return;
115
+ const fd = openSync(HISTORY_FILE, 'r');
116
+ try {
117
+ const chunk = Buffer.alloc(64 * 1024);
118
+ let pending = Buffer.alloc(0);
119
+ let pos = offset;
120
+ while (true) {
121
+ const n = readSync(fd, chunk, 0, chunk.length, pos);
122
+ if (n === 0)
123
+ break;
124
+ pending = Buffer.concat([pending, chunk.subarray(0, n)]);
125
+ pos += n;
126
+ let nl;
127
+ while ((nl = pending.indexOf(0x0a)) !== -1) {
128
+ const raw = pending.subarray(0, nl).toString('utf8').trim();
129
+ pending = pending.subarray(nl + 1);
130
+ if (!raw)
131
+ continue;
132
+ try {
133
+ const entry = JSON.parse(raw);
134
+ /** offsetAfter = read-cursor in file - bytes still in pending buffer */
135
+ yield { entry, offset: pos - pending.length };
136
+ }
137
+ catch (err) {
138
+ log.warn({ err: errMsg(err) }, 'broker: skipped malformed history line');
139
+ }
140
+ }
141
+ }
142
+ }
143
+ finally {
144
+ closeSync(fd);
145
+ }
146
+ }
147
+ /**
148
+ * Claim-aware filter. Webhooks excluded from personal modes unless `includeWebhooks`.
149
+ */
150
+ export function passesMode(event, mode, self, claims, opts = {}) {
151
+ if (self && event.to === self)
152
+ return true;
153
+ if (mode === 'all')
154
+ return true;
155
+ const isWebhook = event.station === 'webhook';
156
+ if (mode === 'unclaimed')
157
+ return !claims[event.line];
158
+ /** webhooks are filtered out of personal modes unless opted in */
159
+ if (isWebhook && !opts.includeWebhooks)
160
+ return false;
161
+ const owner = claims[event.line];
162
+ if (mode === 'mine-only')
163
+ return owner === self;
164
+ /** mode === 'mine-or-unclaimed' */
165
+ return !owner || owner === self;
166
+ }
@@ -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);
@@ -55,21 +56,39 @@ function destinationFor(orig, line) {
55
56
  function logOutbound(f, e) {
56
57
  const id = mintId();
57
58
  const fromOverride = flagOne(f, 'from');
59
+ const from = fromOverride ? asLine(fromOverride) : userSelf();
58
60
  appendHistory({
59
61
  id, ts: new Date().toISOString(), station: Line.station(e.line) ?? '?',
60
- from: fromOverride ? asLine(fromOverride) : userSelf(), to: e.to ?? e.line, ...e,
62
+ from, to: e.to ?? e.line, ...e,
61
63
  });
64
+ maybeAutoClaim(f, e.line, from);
62
65
  return id;
63
66
  }
67
+ /** Auto-claim on outbound — skips when --no-claim or METRO_NO_AUTO_CLAIM=1; never overwrites a foreign owner. */
68
+ function maybeAutoClaim(f, line, owner) {
69
+ if (f['no-claim'] === true)
70
+ return;
71
+ if (process.env.METRO_NO_AUTO_CLAIM === '1')
72
+ return;
73
+ const result = tryAutoClaim(line, owner);
74
+ if (result.status === 'skipped') {
75
+ process.stderr.write(`auto-claim skipped: line owned by ${result.existing}\n`);
76
+ }
77
+ else if (result.status === 'error') {
78
+ process.stderr.write(`auto-claim failed: ${result.error}\n`);
79
+ }
80
+ }
64
81
  export async function cmdSend(p, f) {
65
82
  need(p, 1, 'metro send <line> <text> [--image=<path>]… [--document=<path>]… [--voice=<path>] [--buttons=<json>]');
66
83
  loadMetroEnv();
67
84
  const text = await resolveText(p, 1), line = asLine(p[0]);
68
85
  if (Line.isLocal(line)) {
69
- const from = flagOne(f, 'from');
70
- const resp = await ipcCall({ op: 'notify', line, from, text });
86
+ const fromFlag = flagOne(f, 'from');
87
+ const resp = await ipcCall({ op: 'notify', line, from: fromFlag, text });
71
88
  if (!resp.ok)
72
89
  throw new Error(resp.error);
90
+ /** cross-user notify still counts as the sender taking the line: auto-claim if unclaimed */
91
+ maybeAutoClaim(f, line, fromFlag ? asLine(fromFlag) : userSelf());
73
92
  return emit(f, `notified ${line}`, { ok: true, line, id: null, messageId: null });
74
93
  }
75
94
  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,27 @@ 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]
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 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
29
  Threaded reply (same flags as send).
28
- metro edit <line> <message_id> <text> [--buttons=<json>]
30
+ metro edit <line> <message_id> <text> [--buttons=<json>] [--no-claim]
29
31
  Edit a previously-sent message (text + buttons).
30
- metro react <line> <message_id> <emoji> Set or clear ('') a reaction.
32
+ metro react <line> <message_id> <emoji> [--no-claim]
33
+ Set or clear ('') a reaction.
31
34
  metro download <line> <message_id> [--out=<dir>]
32
35
  Download image attachments to disk.
33
36
  metro fetch <line> [--limit=N] Recent-message lookback (Discord only).
34
37
  metro history [--limit=N] [--line=…] [--station=…] [--kind=…] [--from=…] [--text=…] [--since=…]
35
38
  Read the universal message log (newest first).
39
+ metro tail [--as=<user-uri>] [--follow] [--strict | --unclaimed | --all] [--include-webhooks]
40
+ [--chat=<line>] [--station=…] [--since=<offset|tail>] [--limit=N]
41
+ Subscribe to the event log; claim-aware by default. See docs/broker.md.
42
+ Webhooks are hidden in personal modes unless --include-webhooks is set.
43
+ metro claim <line> [--as=<user-uri>] Take exclusive ownership of a line (so only you receive its events).
44
+ metro release <line> Release a line (it returns to broadcast).
45
+ metro claims Print the current claims map.
36
46
  metro webhook add <label> [--secret=…] Register an HTTP receive endpoint (GitHub, Intercom, …).
37
47
  metro webhook list | remove <id> List or remove webhook endpoints.
38
48
  metro tunnel setup <name> <hostname> Configure a Cloudflare named tunnel (run cloudflared tunnel login first).
@@ -140,7 +150,9 @@ const COMMANDS = {
140
150
  send: cmdSend, reply: cmdReply, edit: cmdEdit, react: cmdReact,
141
151
  download: cmdDownload, fetch: cmdFetch,
142
152
  webhook: cmdWebhook, tunnel: cmdTunnel,
143
- history: cmdHistory, update: cmdUpdate,
153
+ history: cmdHistory, tail: cmdTail,
154
+ claim: cmdClaim, release: cmdRelease, claims: cmdClaims,
155
+ update: cmdUpdate,
144
156
  };
145
157
  async function main() {
146
158
  const cmd = process.argv[2];
@@ -0,0 +1,161 @@
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, 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, self) {
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 self ? readCursor(self) : 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 startOffset = resolveStartOffset(f, self);
54
+ const json = isJson(f);
55
+ const includeWebhooks = f['include-webhooks'] === true;
56
+ let emitted = 0;
57
+ let offset = startOffset;
58
+ const drain = () => {
59
+ /** read claims once per drain so a burst of events shares a snapshot */
60
+ const claims = readClaims();
61
+ for (const { entry, offset: next } of readEntriesFrom(offset)) {
62
+ offset = next;
63
+ if (chatFilter && entry.line !== chatFilter)
64
+ continue;
65
+ if (stationFilter && entry.station !== stationFilter)
66
+ continue;
67
+ if (!passesMode(entry, mode, self, claims, { includeWebhooks }))
68
+ continue;
69
+ if (json)
70
+ process.stdout.write(JSON.stringify(entry) + '\n');
71
+ else
72
+ process.stdout.write(fmtRow(entry) + '\n');
73
+ if (self)
74
+ writeCursor(self, offset);
75
+ emitted++;
76
+ if (limit && emitted >= limit)
77
+ return true;
78
+ }
79
+ return false;
80
+ };
81
+ if (drain() && !follow)
82
+ return;
83
+ if (!follow)
84
+ return;
85
+ /** fs.watch on macOS sometimes coalesces or drops events — poll every 500ms as a backstop. */
86
+ await new Promise(resolve => {
87
+ let watcher = null;
88
+ const trigger = () => { if (drain())
89
+ cleanup(); };
90
+ const cleanup = () => {
91
+ if (watcher) {
92
+ try {
93
+ watcher.close();
94
+ }
95
+ catch { /* ignore */ }
96
+ watcher = null;
97
+ }
98
+ clearInterval(poll);
99
+ resolve();
100
+ };
101
+ const poll = setInterval(trigger, 500);
102
+ const startWatcher = () => {
103
+ if (!existsSync(HISTORY_FILE))
104
+ return;
105
+ try {
106
+ watcher = watch(HISTORY_FILE, () => trigger());
107
+ }
108
+ catch { /* ignore — poll will catch */ }
109
+ };
110
+ startWatcher();
111
+ process.on('SIGINT', cleanup);
112
+ process.on('SIGTERM', cleanup);
113
+ process.stdin.on('end', cleanup).on('close', cleanup);
114
+ });
115
+ }
116
+ function fmtRow(e) {
117
+ const ts = e.ts.slice(11, 19);
118
+ const body = e.text ?? (e.emoji ? `[react ${e.emoji}]` : '');
119
+ const text = body.length > 80 ? body.slice(0, 79) + '…' : body;
120
+ const who = (e.fromName ?? e.from).padEnd(28).slice(0, 28);
121
+ const where = e.line.padEnd(40).slice(0, 40);
122
+ return `${ts} ${e.id.padEnd(12)} ${e.kind.padEnd(8)} ${who} ${where} ${text}`;
123
+ }
124
+ export async function cmdClaim(p, f) {
125
+ loadMetroEnv();
126
+ need(p, 1, 'metro claim <line> [--as <user-uri>]');
127
+ const line = asLine(p[0]);
128
+ const asRaw = flagOne(f, 'as');
129
+ const owner = asRaw ? asLine(asRaw) : userSelf();
130
+ const claims = claimLine(line, owner);
131
+ emit(f, `claimed ${line} → ${owner}`, { ok: true, line, owner, claims });
132
+ }
133
+ export async function cmdRelease(p, f) {
134
+ loadMetroEnv();
135
+ need(p, 1, 'metro release <line>');
136
+ const line = asLine(p[0]);
137
+ const { released, claims } = releaseLine(line);
138
+ if (!released) {
139
+ emit(f, `${line} was not claimed`, { ok: true, released: false, line, claims });
140
+ return;
141
+ }
142
+ emit(f, `released ${line}`, { ok: true, released: true, line, claims });
143
+ }
144
+ export async function cmdClaims(_, f) {
145
+ loadMetroEnv();
146
+ const claims = readClaims();
147
+ const entries = Object.entries(claims);
148
+ if (isJson(f))
149
+ return writeJson({ claims });
150
+ if (!entries.length) {
151
+ process.stdout.write('(no claims — every tail with matching filters receives every event)\n');
152
+ process.stdout.write(`file: ${CLAIMS_FILE}${existsSync(CLAIMS_FILE) ? '' : ' (not created yet)'}\n`);
153
+ return;
154
+ }
155
+ const widest = Math.max(...entries.map(([l]) => l.length));
156
+ process.stdout.write('metro claims\n\n');
157
+ for (const [line, owner] of entries) {
158
+ process.stdout.write(` ${line.padEnd(widest)} → ${owner}\n`);
159
+ }
160
+ process.stdout.write(`\n${entries.length} claim${entries.length === 1 ? '' : 's'} · ${CLAIMS_FILE}\n`);
161
+ }
package/docs/broker.md ADDED
@@ -0,0 +1,173 @@
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-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. |
25
+
26
+ Subscribers do not register with the daemon. They tail the log; the broker semantics emerge from one filtering rule applied at read time:
27
+
28
+ > An event is delivered to a user when its `line` is **claimed by that user** *or* **claimed by no one**.
29
+
30
+ That single rule covers every case the design needs to handle:
31
+
32
+ - **Chat with one responder** — user claims the chat; other tailing users stop receiving it. No race.
33
+ - **Webhook fan-out** — nobody claims; every user tailing a matching filter sees it.
34
+ - **Operator observability** — `metro tail` with no `--as` (or `--all`) shows everything regardless of claims; doesn't take ownership.
35
+ - **Sub-user onboarding** — sub-user claims its assigned chat before reading; parent stops receiving that chat without any coordination.
36
+
37
+ 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.
38
+
39
+ ```
40
+ ┌──────────────────────────┐
41
+ inbound (Discord/TG/web) ──► │ dispatcher │ ──► history.jsonl ◄── metro tail --as claude-A
42
+ │ (writes log, no routing)│ ◄── metro tail --as codex-B
43
+ └──────────────────────────┘ ◄── metro tail --as claude-sub-1
44
+ (each holds its own cursor)
45
+ ```
46
+
47
+ ## CLI surface
48
+
49
+ ```bash
50
+ # Tail the event log. --follow streams new entries via fs.watch.
51
+ metro tail [--as <user-id>] [--follow] [--strict | --unclaimed | --all] [--include-webhooks]
52
+ [--chat <line>] [--station <name>] [--since <offset|tail>] [--limit <n>]
53
+
54
+ # Claims: assert/release exclusive ownership of a line. Updates claims.json.
55
+ metro claim <line> [--as <user-id>] # add/overwrite — last writer wins
56
+ metro release <line> # remove (line returns to broadcast)
57
+ metro claims # print current claims.json
58
+
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]
64
+ # Or disable globally: METRO_NO_AUTO_CLAIM=1
65
+
66
+ # Lease/ack — optional, v2. When enabled, an event is "in flight" with the
67
+ # claimant; if no ack in N seconds the cursor isn't advanced and the next
68
+ # `metro tail` re-emits.
69
+ metro ack <event-id> --as <user-id>
70
+ ```
71
+
72
+ `--as <user-id>` defaults to `userSelf()` ([history.ts:121](../src/history.ts#L121)) — the same stable identity already used in routing-aware code.
73
+
74
+ ### Subscription modes
75
+
76
+ 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:
77
+
78
+ | Mode | Flag | Predicate | Who uses it |
79
+ |--------------------|----------------------------|---------------------------------------------------------------------------------|------------------------------------------------------------|
80
+ | **Mine + free** | `--as <id>` (default) | `(claims[line] == <id> ∨ line ∉ claims) ∧ station ≠ 'webhook'` | Default working user. Zero-config single-user setup. |
81
+ | **Mine only** | `--as <id> --strict` | `claims[line] == <id> ∧ station ≠ 'webhook'` | Disciplined worker that won't race on unclaimed events. |
82
+ | **Unclaimed only** | `--unclaimed` | `line ∉ claims` | Router/first-responder user that finds work to claim. |
83
+ | **All** | (no `--as`) or `--all` | `true` | Operator/auditor/debugger; never takes ownership. |
84
+
85
+ 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.
86
+
87
+ Two UX defaults worth being explicit about:
88
+
89
+ 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.
90
+ 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.
91
+
92
+ `--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.
93
+
94
+ 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.
95
+
96
+ ### Auto-claim on outbound
97
+
98
+ `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
+
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.
101
+ - Opt-out per command with `--no-claim`, or globally with the env var `METRO_NO_AUTO_CLAIM=1`.
102
+ - 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
+
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.
105
+
106
+ ### `metro tail` mechanics
107
+
108
+ - Reads `history.jsonl`, applies the mode predicate + any `--chat`/`--station` filters (AND), prints one JSONL line per event to stdout.
109
+ - With `--follow`: stays open, watches the file via `fs.watch`, emits new matching lines as they're appended.
110
+ - 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.
111
+ - `--since <offset>` overrides the cursor; `--since=tail` starts from EOF, ignoring backlog. Useful for fresh-start without losing the persisted cursor.
112
+ - 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.
113
+
114
+ ### `metro claim` semantics
115
+
116
+ - Pure metadata edit on `claims.json`. Does **not** notify the daemon — claims are read by tails, not the dispatcher (see "Dispatcher changes" below).
117
+ - Re-claiming a line re-assigns it (last writer wins). `metro claims` prints the current map so a human can audit.
118
+ - Releasing a line returns it to broadcast — every matching tail picks it up again.
119
+ - Writes to `claims.json` are wrapped in an `O_EXCL` lockfile to serialize concurrent `metro claim` invocations on the same host.
120
+
121
+ ## Dispatcher changes
122
+
123
+ 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.
124
+
125
+ 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.
126
+
127
+ No new sockets. No fan-out bookkeeping. No coupling between subscriber count and daemon state.
128
+
129
+ ## What this enables
130
+
131
+ - **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.
132
+ - **Two manual Claude Code windows**: each runs `metro tail --as claude-A` / `claude-B`, claims disjoint chats. No coordination beyond `metro claim`.
133
+ - **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.
134
+ - **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).
135
+ - **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.
136
+
137
+ ## Concurrency
138
+
139
+ 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.
140
+
141
+ `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
+
143
+ ## Failure modes & guardrails
144
+
145
+ | Failure | Behavior |
146
+ |-------------------------------------|---------------------------------------------------------------------------------------------------|
147
+ | Process crashes mid-event | Cursor not advanced → event redelivered on next `metro tail`. At-least-once. |
148
+ | Two users claim same line | `claims.json` last-write-wins. `metro claims` shows current owner; humans resolve. |
149
+ | 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). |
150
+ | 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. |
151
+ | `history.jsonl` grows unboundedly | Existing concern; out of scope for this doc. (Rotate by date, prune by age.) |
152
+
153
+ ## Migration
154
+
155
+ 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:
156
+
157
+ 1. Ship `metro tail` (read-only, no daemon changes, no claim file). Users can subscribe and filter; multi-cast works for everything.
158
+ 2. Ship `metro claim`/`release`/`claims` + claim-aware filtering in `metro tail`. Exclusivity works.
159
+ 3. Optional v2: lease/ack — only if silent drops become a real problem.
160
+
161
+ Each step is independently shippable.
162
+
163
+ ## Open questions
164
+
165
+ - **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.
166
+ - **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.
167
+
168
+ ## Non-goals
169
+
170
+ - **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.
171
+ - **Exactly-once delivery**: at-least-once via cursor + redelivery. Idempotency is the subscriber's problem (the daemon already mints stable `msg_*` ids).
172
+ - **Authn between users**: any process with filesystem access to `$METRO_STATE_DIR` can tail and claim. Same trust model as today.
173
+ - **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).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.11",
3
+ "version": "0.1.0-beta.12",
4
4
  "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
5
  "license": "MIT",
6
6
  "repository": {
@@ -31,7 +31,8 @@
31
31
  "prepublishOnly": "tsc",
32
32
  "lint": "eslint src/",
33
33
  "lint:fix": "eslint src/ --fix",
34
- "typecheck": "tsc --noEmit"
34
+ "typecheck": "tsc --noEmit",
35
+ "test": "bun test test/"
35
36
  },
36
37
  "dependencies": {
37
38
  "discord.js": "^14.14.0",