@stage-labs/metro 0.1.0-beta.13 → 0.1.0-beta.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +76 -189
  2. package/dist/broker/claims.js +144 -0
  3. package/dist/{broker.js → broker/history-stream.js} +46 -99
  4. package/dist/cli/config.js +115 -121
  5. package/dist/cli/index.js +51 -64
  6. package/dist/cli/messenger-api.js +214 -0
  7. package/dist/cli/messenger-transcribe.js +43 -0
  8. package/dist/cli/messenger-uploads.js +116 -0
  9. package/dist/cli/monitor-api.js +205 -0
  10. package/dist/cli/tail.js +49 -118
  11. package/dist/cli/webhook.js +103 -3
  12. package/dist/{codex-rc.js → codex-rc/client.js} +12 -32
  13. package/dist/codex-rc/protocol.js +38 -0
  14. package/dist/dispatcher/server.js +122 -0
  15. package/dist/dispatcher.js +52 -83
  16. package/dist/history.js +49 -27
  17. package/dist/ipc.js +28 -10
  18. package/dist/lines.js +54 -0
  19. package/dist/local-identity.js +80 -0
  20. package/dist/paths.js +58 -12
  21. package/dist/trains/protocol.js +99 -0
  22. package/dist/trains/supervisor.js +210 -0
  23. package/dist/tunnel.js +39 -1
  24. package/docs/broker.md +88 -136
  25. package/docs/monitor.md +88 -10
  26. package/docs/uri-scheme.md +10 -7
  27. package/examples/README.md +32 -0
  28. package/examples/telegram.ts +121 -0
  29. package/package.json +6 -5
  30. package/skills/metro/SKILL.md +67 -213
  31. package/dist/cache.js +0 -69
  32. package/dist/cli/actions.js +0 -206
  33. package/dist/cli/skill.js +0 -62
  34. package/dist/monitor.js +0 -194
  35. package/dist/registry.js +0 -48
  36. package/dist/stations/claude.js +0 -45
  37. package/dist/stations/codex.js +0 -68
  38. package/dist/stations/discord.js +0 -216
  39. package/dist/stations/index.js +0 -129
  40. package/dist/stations/telegram-md.js +0 -34
  41. package/dist/stations/telegram-upload.js +0 -113
  42. package/dist/stations/telegram.js +0 -234
  43. package/dist/stations/webhook.js +0 -103
  44. package/dist/webhooks.js +0 -41
  45. package/docs/users.md +0 -226
package/README.md CHANGED
@@ -1,11 +1,14 @@
1
1
  # Metro
2
2
 
3
3
  [![npm](https://img.shields.io/npm/v/@stage-labs/metro/beta?label=npm&color=cb3837)](https://www.npmjs.com/package/@stage-labs/metro)
4
- [![lines of code](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.codetabs.com%2Fv1%2Floc%2F%3Fgithub%3Dbonustrack%2Fmetro&query=%24%5B0%5D.linesOfCode&label=lines%20of%20TypeScript&color=blue)](https://github.com/bonustrack/metro)
4
+ [![lines of code](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.codetabs.com%2Fv1%2Floc%2F%3Fgithub%3Dbonustrack%2Fmetro%26ignored%3Dapps%2Ctest&query=%24%5B0%5D.linesOfCode&label=lines%20of%20TypeScript&color=blue)](https://github.com/bonustrack/metro)
5
5
 
6
- > **A live JSON stream of Telegram, Discord, webhooks, and cross-user messages for your local Claude Code / Codex session.**
6
+ > **Event-interception wire. Supervises train subprocesses, multiplexes their stdout into one
7
+ > JSON event stream, routes outbound action calls back via stdin. Per-platform code lives in
8
+ > train scripts under `~/.metro/trains/` — outside this repo — written by the user (or agent)
9
+ > on demand.**
7
10
 
8
- Metro is a small daemon you launch from inside your session. It connects to Discord, Telegram, and any third-party service that can POST a webhook (GitHub, Intercom, Fireflies, …), emits each inbound as one JSON line on stdout (which Claude Code's `Monitor` consumes natively, and Codex picks up via an app-server WebSocket push), and exposes a tiny CLI — `metro reply`, `metro send`, `metro edit`, `metro react`, `metro download`, `metro fetch` — for posting back. Cross-user: any user can ping any other via `metro send metro://claude/<user-id>/<session-id>` and the daemon re-emits it on the stream.
11
+ Metro is not a framework with platform connectors. Metro is the wire.
9
12
 
10
13
  ```
11
14
  [Claude Code session]
@@ -13,16 +16,16 @@ Metro is a small daemon you launch from inside your session. It connects to Disc
13
16
  $ metro & # backgrounded
14
17
  $ Monitor( … metro's stdout … )
15
18
 
16
- >>> {"kind":"inbound","station":"discord","line":"metro://discord/123…","messageId":"9876",
17
- "text":"@metro we got a 5xx spike from /v1/sync. Look?",
18
- "payload":{"channelId":"123…","guildId":"456…","content":"<@…> we got a 5xx spike…",
19
+ >>> {"kind":"inbound","station":"discord","line":"metro://discord/123…","message_id":"9876",
20
+ "text":"@metro 5xx spike on /v1/sync — look?",
21
+ "payload":{"channelId":"123…","guildId":"456…","content":"<@…> 5xx spike…",
19
22
  "mentions":{"users":["<bot-id>"],"roles":[],"everyone":false},…}}
20
23
 
21
- [I'd run git log + read services/sync.ts, then…]
22
- Bash: metro reply metro://discord/123… 9876 "three deploys in the last 24h…"
24
+ [I'd grep services/sync.ts, then…]
25
+ Bash: metro call discord send '{"line":"metro://discord/123…","text":"three deploys in the last 24h…","replyTo":"9876"}'
23
26
  ```
24
27
 
25
- You own your own streaming, tool calls, and reply timing. Metro is the wire.
28
+ You own streaming, tool calls, and reply timing. Metro is the wire.
26
29
 
27
30
  ---
28
31
 
@@ -31,222 +34,106 @@ You own your own streaming, tool calls, and reply timing. Metro is the wire.
31
34
  ```bash
32
35
  npm install -g @stage-labs/metro@beta # or: bun add -g @stage-labs/metro@beta
33
36
 
34
- metro setup discord <token> # https://discord.com/developers/applications
35
- metro setup telegram <token> # https://t.me/BotFather
36
- metro doctor # verify
37
- metro # run the daemon
37
+ # One-time train setup (Telegram no npm deps needed; uses native fetch)
38
+ mkdir -p ~/.metro && cd ~/.metro && bun init -y
39
+ cp $(npm root -g)/@stage-labs/metro/examples/telegram.ts ~/.metro/trains/
40
+ echo 'TELEGRAM_BOT_TOKEN=your-token' >> ~/.metro/.env
41
+
42
+ metro doctor # verify
43
+ metro # run the daemon
38
44
  ```
39
45
 
40
- Requires **Node 22 or Bun ≥ 1.3**. Metro doesn't launch Claude or Codex you do, and the user launches metro. See [`docs/users.md`](docs/users.md).
46
+ For Discord, copy the same `telegram.ts` and port itswap the API base for
47
+ `https://discord.com/api/v10` with `Authorization: Bot $TOKEN`, install
48
+ `discord.js` for the gateway (`cd ~/.metro && bun add discord.js`), and keep
49
+ the same envelope + `op:"call"` ↔ `op:"response"` protocol. See
50
+ [`examples/README.md`](./examples/README.md) for the wire-format reference.
41
51
 
42
- In **Discord**: DM the bot, or `@<bot>` in any channel. In **Telegram**: DM, or `@<bot>` in a forum supergroup. Every inbound becomes one JSON line on `metro`'s stdout.
52
+ Requires **Bun 1.3** (trains run under `bun run`). Metro core itself works under Node 22.
43
53
 
44
54
  ---
45
55
 
46
56
  ## Architecture
47
57
 
48
58
  ```
49
- Discord gateway ──┐
50
- Telegram poller ──┤
51
- Cloudflare tunnel ──┤ ── HTTP webhooks (GitHub, Intercom, )
52
-
53
- ├─▶ metro daemon ───▶ stdout (JSON events; Claude Code's Monitor reads here)
54
- │ ───▶ codex-rc WebSocket (Codex turn/start; opt-in)
55
- │ ◀── IPC Unix socket (metro send to Claude / Codex lines)
56
-
57
- local CLI calls ────┴── REST → Discord / Telegram (metro reply / send / edit / react / download / fetch)
59
+ ~/.metro/trains/discord.ts ──> stdout JSON ──┐
60
+ ~/.metro/trains/telegram.ts ─> stdout JSON ──┤
61
+ ~/.metro/trains/<anything>.ts ─> stdout ─────┼──> metro daemon ──> stdout (Monitor / Codex push)
62
+ history.jsonl
63
+ HTTP /wh/<id> (builtin webhook receiver) ───┤
64
+ IPC `notify` (builtin cross-user channel) ─┘
65
+
66
+ metro call discord send {…} ──> IPC ──> daemon ──> train stdin ──> response ──> CLI stdout
58
67
  ```
59
68
 
60
- - **Inversion of control.** Claude Code / Codex launches `metro`, not the other way around. Metro never spawns a Claude / Codex process.
61
- - **Single daemon per machine.** Lockfile at `$METRO_STATE_DIR/.tail-lock` enforces singleton.
62
- - **Account-tied identity.** `to` on inbound and `from` on outbound resolve to a stable account-scoped URI per runtime: `metro://claude/user/<orgId>` (from `claude auth status --json`) or `metro://codex/user/<accountId>` (from `$CODEX_HOME/auth.json`). Same on any device for the same logged-in account.
63
- - **Codex push (opt-in).** Set `METRO_CODEX_RC=ws://127.0.0.1:8421` and metro pushes each event via JSON-RPC `turn/start` to the Codex app-server. Codex's TUI must be attached with `--remote` to the same URL.
64
- - **Cross-user notification.** `metro send metro://claude/<user-id>/<session-id>` (or `metro://codex/<user-id>/<session-id>`) routes through the daemon's IPC socket; the daemon re-emits on its stdout (and pushes to codex-rc), so the peer sees it. Discover reachable users/sessions via `metro stations` or `$METRO_STATE_DIR/user-registry.json`.
65
- - **Webhooks (opt-in).** `metro webhook add <label>` registers an HTTP receive endpoint; the daemon binds `127.0.0.1:8420` (override with `$METRO_WEBHOOK_PORT`). If you've run `metro tunnel setup`, a Cloudflare named tunnel exposes it publicly. Each POST is re-emitted on stdout as an inbound event.
69
+ Every event metro emits is a `HistoryEntry`. Trains produce the full envelope; metro
70
+ enriches `id`/`display` and appends to `history.jsonl`. Outbound action calls are
71
+ train-defined metro core knows the protocol (`{op:"call", id, action, args}` `{op:"response", id, result|error}`),
72
+ not what any specific action does.
66
73
 
67
74
  ---
68
75
 
69
- ## Stations
70
-
71
- Each endpoint is a **station** with declared capabilities:
72
-
73
- | Station | Modalities | Features | Config |
74
- |------------|---------------|-------------------------------------------------------|-----------------------------------------------------------------------------------------|
75
- | `discord` | text + image | reply, send, edit, react, download, fetch | `DISCORD_BOT_TOKEN` + Message Content Intent |
76
- | `telegram` | text + image | reply, send, edit, react, download | `TELEGRAM_BOT_TOKEN` |
77
- | `claude` | text | send | auto-detected from `$CLAUDECODE`; identity via `claude auth status --json` |
78
- | `codex` | text | send | auto-detected from `$METRO_CODEX_RC` / `$CODEX_HOME`; identity via `$CODEX_HOME/auth.json` |
79
- | `webhook` | text | (receive-only; optional HMAC verify) | `metro webhook add <label>` + `metro tunnel setup` (Cloudflare named tunnel) |
76
+ ## Train protocol
80
77
 
81
- Run `metro stations` to see live config status (`✓` configured, `✗` not, `·` informational).
78
+ **Inbound (train → metro stdout)** one JSON line per event (wire fields are `snake_case`):
82
79
 
83
- Behaviors worth knowing:
84
- - **No streaming / no edit machinery in metro.** The local CLI runs the show; metro is one-shot REST.
85
- - **No link previews.** Outgoing messages set `link_preview_options.is_disabled` on Telegram and `SUPPRESS_EMBEDS` on Discord.
86
- - **Image attachments inbound** — `[image]` placeholders surface inline in `text`; the user calls `metro download` to materialize them. 20 MB cap.
87
- - **Rich content outbound.** `metro send` / `reply` accept `--image=<path>` (repeatable: albums of up to 10), `--document=<path>` (repeatable), `--voice=<path>` (single voice message — Telegram renders the voice bubble), and `--buttons='[[{"text":"…","url":"…"}]]'` for inline URL-button keyboards. `metro edit` accepts `--buttons` (pass `'[]'` to clear). 20 MB / file. URL buttons only for now — no callback/interactive components.
88
- - **Telegram non-forum groups are skipped.** No thread boundary to scope on.
89
- - **Webhook signature verification.** Pass `--secret=<shared-secret>` to `metro webhook add` and the daemon verifies `X-Hub-Signature-256` (GitHub/Intercom format) on every POST. Mismatches return 401 and never reach the stream.
90
-
91
- ---
92
-
93
- ## Webhooks
94
-
95
- Receive HTTP events from third parties (GitHub, Intercom, Fireflies, anything that POSTs) as standard metro inbound events. Each registered endpoint is one Line.
96
-
97
- ```bash
98
- # One-time per machine — bring your own Cloudflare domain (free Registrar at-cost):
99
- brew install cloudflared
100
- cloudflared tunnel login # browser OAuth, pick your domain
101
- metro tunnel setup metro webhook.example.com # creates tunnel + DNS CNAME
102
-
103
- # Per endpoint — repeat for each provider:
104
- metro webhook add github --secret=$(openssl rand -hex 32)
105
- # → https://webhook.example.com/wh/<id>
106
- # (without `metro tunnel setup`, falls back to http://127.0.0.1:8420/wh/<id> — local-only)
107
-
108
- metro # daemon binds 8420 + spawns cloudflared automatically
80
+ ```json
81
+ {"kind":"inbound","station":"discord","line":"metro://discord/123","from":"metro://discord/user/456","from_name":"alice","message_id":"789","text":"hi","is_private":false,"ts":"2026-05-17T18:00:00Z","payload":{...}}
109
82
  ```
110
83
 
111
- Paste the URL into the provider's webhook settings (for GitHub: **Content type must be `application/json`** form-encoded won't parse). Every POST becomes an inbound event with `station: "webhook"`, `line: metro://webhook/<id>`, `payload: { headers, body }`. If you set `--secret`, metro verifies the `X-Hub-Signature-256` header (GitHub/Intercom format) and rejects mismatches with 401.
112
-
113
- | Action | Command |
114
- |---|---|
115
- | Register an endpoint | `metro webhook add <label> [--secret=<shared-secret>]` |
116
- | List endpoints + URLs | `metro webhook list` |
117
- | Remove an endpoint | `metro webhook remove <id>` |
118
- | One-time tunnel setup | `metro tunnel setup <tunnel-name> <hostname>` |
119
- | Tunnel status | `metro tunnel status` |
120
-
121
- The tunnel is optional — without it the listener binds `127.0.0.1:8420` only (good for local testing or your own loopback tools). With Cloudflare named tunnels, the URL stays stable across daemon restarts and machines. See [docs/uri-scheme.md](docs/uri-scheme.md) and [docs/users.md](docs/users.md) for the full event shape.
84
+ **Outbound (metro train stdin)** — one JSON line per action call:
122
85
 
123
- ---
124
-
125
- ## Lines
86
+ ```json
87
+ {"op":"call","id":"req_abc","action":"send","args":{"line":"metro://discord/123","text":"hi"}}
88
+ ```
126
89
 
127
- Every conversational scope is identified by a **Line** — a URI in the form `metro://<station>/<path>`:
90
+ Train responds on stdout:
128
91
 
129
- ```
130
- metro://discord/1234567890123456789
131
- metro://telegram/-1001234567890 # main chat / DM
132
- metro://telegram/-1001234567890/42 # forum topic 42
133
- metro://claude/9bfc7af0-…/50b00d11-… # claude user session
134
- metro://codex/8119ecb1-…/01997d4b-… # codex user session
135
- metro://webhook/fwaCgTKJuLAjS2K0 # HTTP webhook endpoint
92
+ ```json
93
+ {"op":"response","id":"req_abc","result":{"messageId":"999"}}
136
94
  ```
137
95
 
138
- Anyone can post to a line via [`metro send`](#cli) daemon required only for Claude / Codex lines. Full grammar in [`docs/uri-scheme.md`](docs/uri-scheme.md).
96
+ See [`examples/telegram.ts`](./examples/telegram.ts) (a self-contained ~110 LOC reference train) and [`examples/README.md`](./examples/README.md) for the full protocol + Discord port notes.
139
97
 
140
98
  ---
141
99
 
142
100
  ## CLI
143
101
 
144
102
  ```
145
- metro Run the daemon (emits JSON events on stdout).
146
- metro setup [telegram|discord <token>] Save token, or show status.
147
- metro setup clear [telegram|discord|all] Remove tokens.
148
- metro doctor Health check.
149
- metro stations List stations + capabilities.
150
- metro lines List recently-seen conversations.
151
- metro send <line> <text> [--image=…] [--document=…] [--voice=…] [--buttons=…]
152
- Post a fresh message; --image/--document repeat for albums.
153
- metro reply <line> <message_id> <text> [--image|--document|--voice|--buttons]
154
- Threaded reply (same flags as send).
155
- metro edit <line> <message_id> <text> [--buttons=<json>]
156
- Edit a previously-sent message (text + URL-button keyboard).
157
- metro react <line> <message_id> <emoji> Set or clear ('') a reaction.
158
- metro download <line> <message_id> [--out=<dir>]
159
- Download image attachments to disk.
160
- metro fetch <line> [--limit=N] Recent-message lookback (Discord only).
161
- metro history [--limit=N] [--line=…] [--station=…] [--kind=…] [--from=…] [--text=…] [--since=…]
162
- Universal message log (every inbound + outbound), newest first.
163
- metro webhook add <label> [--secret=…] Register an HTTP receive endpoint (GitHub, Intercom, …).
164
- metro webhook list | remove <id> List or remove webhook endpoints.
165
- metro tunnel setup <name> <hostname> Configure a Cloudflare named tunnel for public webhook URLs.
166
- metro tunnel status Show current tunnel config.
167
- metro update Upgrade in place.
103
+ metro # start the daemon (foreground)
104
+ metro trains list # supervised trains + state
105
+ metro trains new <name> # scaffold ~/.metro/trains/<name>.ts from the example
106
+ metro trains restart <name> # kill + respawn a train (resets backoff)
107
+ metro call <train> <action> <args> # forward an action call; args = JSON / @file / - / string
108
+ metro tail [--as=<user-uri>] [--follow] # subscribe to the event log; claim-aware
109
+ metro history [--limit=50] [--line=…] # recent history (newest first), filterable
110
+ metro lines # recently-seen conversations
111
+ metro claim <line> # take exclusive ownership of a line
112
+ metro release <line> # release
113
+ metro claims # print the claims map
114
+ metro webhook add <label> [--secret=…] # add an HTTP receive endpoint
115
+ metro webhook list | remove <id> # manage endpoints
116
+ metro tunnel setup <name> <hostname> # configure a Cloudflare named tunnel
117
+ metro tunnel status # show current tunnel config
118
+ metro setup [skill [clear]] # status; or install/remove the skill into ~/.claude / ~/.codex
119
+ metro doctor # health check (trains, deps, tunnel, webhooks, env vars)
120
+ metro update # upgrade in place
168
121
  ```
169
122
 
170
- All commands accept `--json`. `reply` / `send` / `edit` read multi-line `<text>` from stdin if no positional is given.
171
-
172
- **State files** in `$METRO_STATE_DIR` (default `~/.cache/metro`):
173
- - `USERS.md` — user skill copied from the package on every start (so the path is stable across upgrades)
174
- - `history.jsonl` — universal message log (one JSON object per line; append-only). Read with `metro history`. Each entry carries `from` and `to` as universal participant URIs (`metro://<station>/user/<id>`, `metro://claude/user/<orgId>`, `metro://codex/user/<accountId>`) plus a `fromName` display field. The dispatcher auto-detects the local user for `to` on inbound (`$CLAUDECODE` → `metro://claude/user/<orgId>` from `claude auth status --json`; `$METRO_CODEX_RC`/`$CODEX_HOME` → `metro://codex/user/<accountId>` from `$CODEX_HOME/auth.json`).
175
- - `bot-ids.json` — `{discord: "<botUserId>", telegram: "<botUserId>"}` written by the daemon on startup (cached for the few historical lookups that still need a platform-side bot identity).
176
- - `lines.json` — line → last-seen / name cache (read by `metro lines`)
177
- - `user-registry.json` — every `(station, user-id, sessions[])` tuple metro has seen; surfaced under each Claude / Codex row in `metro stations`
178
- - `stations/codex/session-id` — current codex-rc thread id (daemon writes on handshake; CLI processes read for `metro://codex/<user-id>/<session>`)
179
- - `webhooks.json` — registered HTTP receive endpoints (id, label, optional shared secret)
180
- - `tunnel.json` — Cloudflare named-tunnel config (`{name, hostname}`); when present, the daemon spawns `cloudflared tunnel run`. The token is resolved via `cloudflared tunnel token <name>` and passed through as `TUNNEL_TOKEN`, so the per-tunnel credentials JSON at `~/.cloudflared/<id>.json` is not required (the named-form spawn is the fallback when the token call fails)
181
- - `.tail-lock` — dispatcher pid
182
- - `metro.sock` — daemon IPC socket
183
- - `telegram-offset.json` — last processed update id
123
+ No more `metro send / reply / edit / react / download / fetch` outbound is always
124
+ `metro call <train> <action> <args>`, with action names defined by the train.
184
125
 
185
126
  ---
186
127
 
187
- ## Configuration
128
+ ## State
188
129
 
189
- | Variable | Default | Description |
190
- |---|---|---|
191
- | `TELEGRAM_BOT_TOKEN`, `DISCORD_BOT_TOKEN` | | Bot tokens. `metro setup` writes them here. |
192
- | `METRO_CODEX_RC` | — | Codex app-server URL (`ws://…`, `wss://…`, `unix:///…`). When set, the daemon pushes each event via JSON-RPC `turn/start`. |
193
- | `METRO_WEBHOOK_PORT` | `8420` | Local port the HTTP webhook listener binds to (always `127.0.0.1`; expose publicly via Cloudflare tunnel). |
194
- | `METRO_USER_ID` | — | Override the resolved user id (orgId / accountId) used in `metro://<station>/user/<id>` and `metro://<station>/<id>/<session>`. Useful for testing. |
195
- | `METRO_USER_SESSION_ID` | — | Override the resolved session id (Claude session / Codex thread). |
196
- | `METRO_FROM` | — | Pin a custom `from` URI for all writes (overrides runtime detection). |
197
- | `METRO_CONFIG_DIR` | `~/.config/metro` | Where the global `.env` lives. |
198
- | `METRO_STATE_DIR` | `~/.cache/metro` | Lockfile, line cache, IPC socket, telegram offset, registries, tunnel config. |
199
- | `METRO_LOG_LEVEL` | `info` | `trace` / `debug` / `info` / `warn` / `error` / `fatal`. |
200
-
201
- Precedence: process env → `./.env` → `$METRO_CONFIG_DIR/.env`. Logs go to stderr.
130
+ - `~/.metro/trains/` your train scripts
131
+ - `~/.metro/.env` — your credentials (trains read these)
132
+ - `~/.metro/package.json` — `bun add` here for train deps
133
+ - `$METRO_STATE_DIR` (default `~/.cache/metro/`) history, claims, cursors, monitor data
202
134
 
203
135
  ---
204
136
 
205
- ## Develop
206
-
207
- ```bash
208
- git clone https://github.com/bonustrack/metro && cd metro
209
- bun install && bun run build
210
- bun link # makes `metro` resolve to this checkout
211
- METRO_LOG_LEVEL=debug metro
212
-
213
- bun run typecheck # ts
214
- bun run lint # eslint
215
- ```
137
+ ## License
216
138
 
217
- Source map:
218
-
219
- - [`src/cli/`](src/cli/) — `metro` binary entry ([`index.ts`](src/cli/index.ts)) + admin commands ([`config.ts`](src/cli/config.ts): setup/doctor/update), action handlers ([`actions.ts`](src/cli/actions.ts): send/reply/edit/react/download/fetch), webhook + tunnel commands ([`webhook.ts`](src/cli/webhook.ts)), and shared CLI primitives ([`util.ts`](src/cli/util.ts)).
220
- - [`src/dispatcher.ts`](src/dispatcher.ts) — the daemon: starts each station, emits events on stdout, listens on the IPC socket, optionally pushes to codex-rc, supervises the Cloudflare tunnel.
221
- - [`src/stations/`](src/stations/) — Line URI scheme + ChatStation interface + listing ([`index.ts`](src/stations/index.ts)). Chat impls: [`discord.ts`](src/stations/discord.ts), [`telegram.ts`](src/stations/telegram.ts) (+ [`telegram-md.ts`](src/stations/telegram-md.ts) markdown helper). User identity resolvers: [`claude.ts`](src/stations/claude.ts) (orgId via `claude auth status --json`), [`codex.ts`](src/stations/codex.ts) (account_id via `auth.json`). HTTP receive: [`webhook.ts`](src/stations/webhook.ts).
222
- - [`src/codex-rc.ts`](src/codex-rc.ts) — Codex app-server WebSocket push client (also exposes the rc thread id used as Codex session-id).
223
- - [`src/tunnel.ts`](src/tunnel.ts) — Cloudflared named-tunnel supervisor.
224
- - [`src/webhooks.ts`](src/webhooks.ts) — webhook endpoint store (`webhooks.json` CRUD).
225
- - [`src/registry.ts`](src/registry.ts) — user registry: `(station, user-id, sessions[])` tracking.
226
- - [`src/history.ts`](src/history.ts) — universal message log + `userSelf()` / `selfLine()` identity helpers.
227
- - [`src/ipc.ts`](src/ipc.ts) — Unix-socket IPC between the daemon and one-shot CLI commands.
228
- - [`src/cache.ts`](src/cache.ts) — in-memory line cache with debounced flush to `lines.json`, plus bot-id cache.
229
- - [`docs/uri-scheme.md`](docs/uri-scheme.md) specs the Line format; [`docs/users.md`](docs/users.md) is the in-context skill for users.
230
-
231
- CI runs typecheck + lint + build on every PR via [`.github/workflows/ci.yml`](.github/workflows/ci.yml).
232
-
233
- ---
234
-
235
- ## Caveats
236
-
237
- - **No allowlist on chat stations.** Anyone who can DM/`@`-mention your bot can produce events. Run against bots you own.
238
- - **Webhook secrets are optional but recommended.** Without `--secret`, anyone who learns the endpoint URL can POST events. With it, metro verifies `X-Hub-Signature-256` and rejects mismatches.
239
- - **Telegram bot privacy is on by default**, which can block `@`-mentions in groups. Disable via [@BotFather](https://t.me/BotFather) → Bot Settings → Group Privacy, then kick + re-invite.
240
- - **Telegram non-forum groups are skipped.** No thread boundary to scope on. DMs and forum topics work normally.
241
- - **Telegram fetch isn't supported** (bot API doesn't expose history); `metro fetch` returns `[]` on Telegram lines.
242
- - **Cloudflared is your responsibility.** `metro tunnel setup` records the named tunnel; you still install `cloudflared` (`brew install cloudflared`) and run `cloudflared tunnel login` once.
243
-
244
- ---
245
-
246
- ## Uninstall
247
-
248
- ```bash
249
- metro setup clear
250
- rm -rf ~/.cache/metro
251
- npm uninstall -g @stage-labs/metro
252
- ```
139
+ MIT
@@ -0,0 +1,144 @@
1
+ /** Claims map: per-line owner registry under an O_EXCL lockfile. */
2
+ import { closeSync, existsSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { log } from '../log.js';
5
+ import { STATE_DIR } from '../paths.js';
6
+ import { Line } from '../lines.js';
7
+ export const CLAIMS_FILE = join(STATE_DIR, 'claims.json');
8
+ const CLAIMS_LOCK = join(STATE_DIR, 'claims.json.lock');
9
+ export const HISTORY_FILE = join(STATE_DIR, 'history.jsonl');
10
+ /** Read claims.json. Returns empty map if missing or malformed (retries once on race). */
11
+ export function readClaims() {
12
+ if (!existsSync(CLAIMS_FILE))
13
+ return {};
14
+ for (let attempt = 0; attempt < 2; attempt++) {
15
+ try {
16
+ return JSON.parse(readFileSync(CLAIMS_FILE, 'utf8'));
17
+ }
18
+ catch { /* race with writer — retry once */ }
19
+ }
20
+ log.warn({ path: CLAIMS_FILE }, 'claims: malformed, treating as empty');
21
+ return {};
22
+ }
23
+ /** Mutate claims under an O_EXCL lockfile. Throws if another writer holds the lock past timeout. */
24
+ function withClaimsLock(fn) {
25
+ const deadline = Date.now() + 2_000;
26
+ while (true) {
27
+ try {
28
+ closeSync(openSync(CLAIMS_LOCK, 'wx'));
29
+ break;
30
+ }
31
+ catch (err) {
32
+ if (err.code !== 'EEXIST')
33
+ throw err;
34
+ if (Date.now() > deadline)
35
+ throw new Error('claims.json: lock contention (held >2s)');
36
+ }
37
+ }
38
+ try {
39
+ const next = readClaims();
40
+ const result = fn(next);
41
+ /** atomic publish: tmpfile + rename so readers never see a half-written file */
42
+ const tmp = `${CLAIMS_FILE}.tmp.${process.pid}`;
43
+ writeFileSync(tmp, JSON.stringify(next, null, 2) + '\n');
44
+ renameSync(tmp, CLAIMS_FILE);
45
+ return result;
46
+ }
47
+ finally {
48
+ try {
49
+ unlinkSync(CLAIMS_LOCK);
50
+ }
51
+ catch { /* ignore */ }
52
+ }
53
+ }
54
+ export function claimLine(line, owner) {
55
+ return withClaimsLock(m => { m[line] = owner; return m; });
56
+ }
57
+ export function releaseLine(line) {
58
+ return withClaimsLock(m => {
59
+ const released = line in m;
60
+ delete m[line];
61
+ return { released, claims: m };
62
+ });
63
+ }
64
+ /** Classify a chat line as DM/group/unknown for the auto-claim group-skip rule. */
65
+ /** TG: chatId<0⇒group. Discord: peek payload.guildId on most-recent inbound. Claude/Codex⇒dm. */
66
+ export function classifyLine(line) {
67
+ const station = Line.station(line);
68
+ if (station === 'telegram') {
69
+ const parsed = Line.parseTelegram(line);
70
+ if (!parsed)
71
+ return 'unknown';
72
+ return parsed.chatId < 0 ? 'group' : 'dm';
73
+ }
74
+ if (station === 'claude' || station === 'codex')
75
+ return 'dm';
76
+ if (station === 'webhook')
77
+ return 'group';
78
+ if (station === 'discord') {
79
+ /** Lazy tail-scan of history.jsonl to avoid a static dep on the history filter helpers. */
80
+ const recent = readRecentInbound(line);
81
+ if (!recent)
82
+ return 'unknown';
83
+ const payload = recent.payload;
84
+ if (!payload || !('guildId' in payload)) {
85
+ /** Older entries may not have a guildId — fall back to the `to` field: DMs route to a user URI. */
86
+ if (recent.to && recent.to !== recent.line)
87
+ return 'dm';
88
+ return 'unknown';
89
+ }
90
+ return payload.guildId == null ? 'dm' : 'group';
91
+ }
92
+ return 'unknown';
93
+ }
94
+ /** Most-recent inbound (from-someone-else) on `line`. Walks `history.jsonl` from the tail. */
95
+ function readRecentInbound(line) {
96
+ if (!existsSync(HISTORY_FILE))
97
+ return undefined;
98
+ const lines = readFileSync(HISTORY_FILE, 'utf8').split('\n');
99
+ for (let i = lines.length - 1; i >= 0; i--) {
100
+ if (!lines[i].trim())
101
+ continue;
102
+ try {
103
+ const e = JSON.parse(lines[i]);
104
+ if (e.line === line && !Line.isLocal(e.from))
105
+ return e;
106
+ }
107
+ catch { /* skip */ }
108
+ }
109
+ return undefined;
110
+ }
111
+ /** Per-line decision: should auto-claim run on a successful outbound? */
112
+ function shouldAutoClaim(line, kind) {
113
+ const station = Line.station(line);
114
+ /** Webhook lines are a broadcast stream — claiming one is a footgun. */
115
+ if (station === 'webhook')
116
+ return { ok: false, reason: 'webhook' };
117
+ /** Claude/Codex cross-user lines are 1:1 by construction — always safe. */
118
+ if (station === 'claude' || station === 'codex')
119
+ return { ok: true };
120
+ if (kind === 'group')
121
+ return { ok: false, reason: 'group' };
122
+ return { ok: true };
123
+ }
124
+ export function tryAutoClaim(line, owner, opts = {}) {
125
+ if (!opts.force) {
126
+ const decision = shouldAutoClaim(line, opts.lineKind ?? 'unknown');
127
+ if (!decision.ok)
128
+ return { status: decision.reason, line };
129
+ }
130
+ try {
131
+ return withClaimsLock(m => {
132
+ const existing = m[line];
133
+ if (existing && existing !== owner)
134
+ return { status: 'skipped', existing };
135
+ if (existing === owner)
136
+ return { status: 'kept', owner };
137
+ m[line] = owner;
138
+ return { status: 'claimed', owner };
139
+ });
140
+ }
141
+ catch (err) {
142
+ return { status: 'error', error: err.message };
143
+ }
144
+ }
@@ -1,102 +1,10 @@
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';
1
+ /** Per-user byte-offset cursors over history.jsonl + claim-aware mode filter. */
2
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, renameSync, watch, writeFileSync, } from 'node:fs';
4
3
  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');
4
+ import { errMsg, log } from '../log.js';
5
+ import { STATE_DIR } from '../paths.js';
6
+ import { HISTORY_FILE, readClaims } from './claims.js';
10
7
  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
8
  /** Filename-safe slug for a participant URI. `metro://claude/user/9bfc…` → `claude-user-9bfc…`. */
101
9
  export function userSlug(uri) {
102
10
  return uri.replace(/^metro:\/+/, '').replace(/[^A-Za-z0-9_.-]/g, '-');
@@ -197,6 +105,45 @@ export function passesMode(event, mode, self, claims, opts = {}) {
197
105
  const owner = claims[event.line];
198
106
  if (mode === 'mine-only')
199
107
  return owner === self;
200
- /** mode === 'mine-or-unclaimed' */
201
- return !owner || owner === self;
108
+ return !owner || owner === self; /** mode === 'mine-or-unclaimed' */
109
+ }
110
+ /** Drain matching entries from `offset` to EOF, returning the new offset. */
111
+ /** Caller `onEntry` may return `true` to stop draining early (e.g. tail --limit). */
112
+ export function drainTail(offset, opts, onEntry) {
113
+ const claims = readClaims();
114
+ for (const { entry, offset: next } of readEntriesFrom(offset)) {
115
+ offset = next;
116
+ if (opts.chatFilter && entry.line !== opts.chatFilter)
117
+ continue;
118
+ if (opts.stationFilter && entry.station !== opts.stationFilter)
119
+ continue;
120
+ if (opts.excludeFrom && opts.excludeFrom.includes(entry.from))
121
+ continue;
122
+ if (!passesMode(entry, opts.mode, opts.self, claims, { includeWebhooks: opts.includeWebhooks }))
123
+ continue;
124
+ if (onEntry(entry) === true)
125
+ return offset;
126
+ }
127
+ return offset;
128
+ }
129
+ /** Follow history.jsonl: drain on change + poll backstop (macOS fs.watch coalesces). */
130
+ /** Caller invokes the returned `stop()` to clean up the watcher/timer. */
131
+ export function followTail(startOffset, opts, onEntry, pollMs) {
132
+ let offset = startOffset;
133
+ const tick = () => { offset = drainTail(offset, opts, onEntry); };
134
+ let watcher = null;
135
+ try {
136
+ watcher = watch(HISTORY_FILE, () => tick());
137
+ }
138
+ catch { /* file may not exist yet */ }
139
+ const poll = setInterval(tick, pollMs);
140
+ return () => {
141
+ clearInterval(poll);
142
+ if (watcher) {
143
+ try {
144
+ watcher.close();
145
+ }
146
+ catch { /* ignore */ }
147
+ }
148
+ };
202
149
  }