@stage-labs/metro 0.1.0-beta.12 → 0.1.0-beta.14
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/README.md +76 -189
- package/dist/broker/claims.js +144 -0
- package/dist/broker/history-stream.js +147 -0
- package/dist/cli/config.js +115 -121
- package/dist/cli/index.js +20 -55
- package/dist/cli/tail.js +161 -110
- package/dist/cli/webhook.js +103 -3
- package/dist/{codex-rc.js → codex-rc/client.js} +12 -32
- package/dist/codex-rc/protocol.js +38 -0
- package/dist/dispatcher/server.js +130 -0
- package/dist/dispatcher.js +51 -82
- package/dist/history.js +50 -19
- package/dist/ipc.js +28 -10
- package/dist/lines.js +54 -0
- package/dist/local-identity.js +80 -0
- package/dist/paths.js +58 -12
- package/dist/trains/protocol.js +99 -0
- package/dist/trains/supervisor.js +210 -0
- package/dist/tunnel.js +39 -1
- package/docs/broker.md +92 -99
- package/docs/monitor.md +180 -0
- package/docs/uri-scheme.md +10 -7
- package/examples/README.md +32 -0
- package/examples/telegram.ts +121 -0
- package/package.json +8 -6
- package/skills/metro/SKILL.md +63 -215
- package/dist/broker.js +0 -166
- package/dist/cache.js +0 -69
- package/dist/cli/actions.js +0 -166
- package/dist/cli/skill.js +0 -62
- package/dist/registry.js +0 -48
- package/dist/stations/claude.js +0 -45
- package/dist/stations/codex.js +0 -68
- package/dist/stations/discord.js +0 -216
- package/dist/stations/index.js +0 -129
- package/dist/stations/telegram-md.js +0 -34
- package/dist/stations/telegram-upload.js +0 -113
- package/dist/stations/telegram.js +0 -234
- package/dist/stations/webhook.js +0 -99
- package/dist/webhooks.js +0 -41
- package/docs/users.md +0 -226
package/README.md
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
# Metro
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/@stage-labs/metro)
|
|
4
|
-
[](https://github.com/bonustrack/metro)
|
|
4
|
+
[](https://github.com/bonustrack/metro)
|
|
5
5
|
|
|
6
|
-
> **
|
|
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
|
|
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…","
|
|
17
|
-
"text":"@metro
|
|
18
|
-
"payload":{"channelId":"123…","guildId":"456…","content":"<@…>
|
|
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
|
|
22
|
-
Bash: metro
|
|
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
|
|
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
|
-
|
|
35
|
-
metro
|
|
36
|
-
metro
|
|
37
|
-
|
|
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
|
-
|
|
46
|
+
For Discord, copy the same `telegram.ts` and port it — swap 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
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
-
|
|
63
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
78
|
+
**Inbound (train → metro stdout)** — one JSON line per event (wire fields are `snake_case`):
|
|
82
79
|
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
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
|
-
|
|
86
|
+
```json
|
|
87
|
+
{"op":"call","id":"req_abc","action":"send","args":{"line":"metro://discord/123","text":"hi"}}
|
|
88
|
+
```
|
|
126
89
|
|
|
127
|
-
|
|
90
|
+
Train responds on stdout:
|
|
128
91
|
|
|
129
|
-
```
|
|
130
|
-
|
|
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
|
-
|
|
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
|
|
146
|
-
metro
|
|
147
|
-
metro
|
|
148
|
-
metro
|
|
149
|
-
metro
|
|
150
|
-
metro
|
|
151
|
-
metro
|
|
152
|
-
|
|
153
|
-
metro
|
|
154
|
-
|
|
155
|
-
metro
|
|
156
|
-
|
|
157
|
-
metro
|
|
158
|
-
metro
|
|
159
|
-
|
|
160
|
-
metro
|
|
161
|
-
metro
|
|
162
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
128
|
+
## State
|
|
188
129
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
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 on `line`. Walks `history.jsonl` from the tail; returns undefined if missing. */
|
|
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 && e.kind === 'inbound')
|
|
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
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
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';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { errMsg, log } from '../log.js';
|
|
5
|
+
import { STATE_DIR } from '../paths.js';
|
|
6
|
+
import { HISTORY_FILE, readClaims } from './claims.js';
|
|
7
|
+
const CURSORS_DIR = join(STATE_DIR, 'cursors');
|
|
8
|
+
/** Filename-safe slug for a participant URI. `metro://claude/user/9bfc…` → `claude-user-9bfc…`. */
|
|
9
|
+
export function userSlug(uri) {
|
|
10
|
+
return uri.replace(/^metro:\/+/, '').replace(/[^A-Za-z0-9_.-]/g, '-');
|
|
11
|
+
}
|
|
12
|
+
/** Cursor key for a tail invocation. Derived from the *effective mode*, NOT from `userSelf()`. */
|
|
13
|
+
/** Keeps `--all` / `--unclaimed` from trampling a `--as=<id>` cursor in a CLAUDECODE shell. */
|
|
14
|
+
/** Keys: `--as=<id>`→slug(id); `+--strict`→slug+`--strict`; `--unclaimed`→`_unclaimed`; `--all`→`_all`. */
|
|
15
|
+
/** The `_` prefix can't collide with a userSlug (always has a station prefix like `claude-user-…`). */
|
|
16
|
+
/** `--include-webhooks` adds `--with-webhooks` so toggling mid-stream doesn't re-emit/skip events. */
|
|
17
|
+
export function cursorKey(mode, self, opts = {}) {
|
|
18
|
+
if (mode === 'all')
|
|
19
|
+
return '_all';
|
|
20
|
+
if (mode === 'unclaimed')
|
|
21
|
+
return '_unclaimed';
|
|
22
|
+
if (!self)
|
|
23
|
+
return null;
|
|
24
|
+
const base = userSlug(self);
|
|
25
|
+
const suffix = mode === 'mine-only' ? '--strict' : '';
|
|
26
|
+
const webhooks = opts.includeWebhooks ? '--with-webhooks' : '';
|
|
27
|
+
return `${base}${suffix}${webhooks}`;
|
|
28
|
+
}
|
|
29
|
+
const cursorPath = (key) => join(CURSORS_DIR, key);
|
|
30
|
+
export function readCursor(key) {
|
|
31
|
+
const p = cursorPath(key);
|
|
32
|
+
if (!existsSync(p))
|
|
33
|
+
return 0;
|
|
34
|
+
const n = Number(readFileSync(p, 'utf8').trim());
|
|
35
|
+
return Number.isFinite(n) && n >= 0 ? n : 0;
|
|
36
|
+
}
|
|
37
|
+
export function writeCursor(key, offset) {
|
|
38
|
+
mkdirSync(CURSORS_DIR, { recursive: true });
|
|
39
|
+
const p = cursorPath(key);
|
|
40
|
+
const tmp = `${p}.tmp.${process.pid}`;
|
|
41
|
+
writeFileSync(tmp, String(offset));
|
|
42
|
+
renameSync(tmp, p);
|
|
43
|
+
}
|
|
44
|
+
/** Byte size of history.jsonl right now (for `--since=tail`). */
|
|
45
|
+
export function historySize() {
|
|
46
|
+
if (!existsSync(HISTORY_FILE))
|
|
47
|
+
return 0;
|
|
48
|
+
try {
|
|
49
|
+
return readFileSync(HISTORY_FILE).length;
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return 0;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** Yield each complete JSONL line from `offset` to EOF; the returned offset is the position right after the `\n`. */
|
|
56
|
+
export function* readEntriesFrom(offset) {
|
|
57
|
+
if (!existsSync(HISTORY_FILE))
|
|
58
|
+
return;
|
|
59
|
+
const fd = openSync(HISTORY_FILE, 'r');
|
|
60
|
+
try {
|
|
61
|
+
const chunk = Buffer.alloc(64 * 1024);
|
|
62
|
+
let pending = Buffer.alloc(0);
|
|
63
|
+
let pos = offset;
|
|
64
|
+
while (true) {
|
|
65
|
+
const n = readSync(fd, chunk, 0, chunk.length, pos);
|
|
66
|
+
if (n === 0)
|
|
67
|
+
break;
|
|
68
|
+
pending = Buffer.concat([pending, chunk.subarray(0, n)]);
|
|
69
|
+
pos += n;
|
|
70
|
+
let nl;
|
|
71
|
+
while ((nl = pending.indexOf(0x0a)) !== -1) {
|
|
72
|
+
const raw = pending.subarray(0, nl).toString('utf8').trim();
|
|
73
|
+
pending = pending.subarray(nl + 1);
|
|
74
|
+
if (!raw)
|
|
75
|
+
continue;
|
|
76
|
+
try {
|
|
77
|
+
const entry = JSON.parse(raw);
|
|
78
|
+
/** offsetAfter = read-cursor in file - bytes still in pending buffer */
|
|
79
|
+
yield { entry, offset: pos - pending.length };
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
log.warn({ err: errMsg(err) }, 'broker: skipped malformed history line');
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
finally {
|
|
88
|
+
closeSync(fd);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Claim-aware filter. Webhooks excluded from personal modes unless `includeWebhooks`.
|
|
93
|
+
*/
|
|
94
|
+
export function passesMode(event, mode, self, claims, opts = {}) {
|
|
95
|
+
if (self && event.to === self)
|
|
96
|
+
return true;
|
|
97
|
+
if (mode === 'all')
|
|
98
|
+
return true;
|
|
99
|
+
const isWebhook = event.station === 'webhook';
|
|
100
|
+
if (mode === 'unclaimed')
|
|
101
|
+
return !claims[event.line];
|
|
102
|
+
/** webhooks are filtered out of personal modes unless opted in */
|
|
103
|
+
if (isWebhook && !opts.includeWebhooks)
|
|
104
|
+
return false;
|
|
105
|
+
const owner = claims[event.line];
|
|
106
|
+
if (mode === 'mine-only')
|
|
107
|
+
return 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 (!passesMode(entry, opts.mode, opts.self, claims, { includeWebhooks: opts.includeWebhooks }))
|
|
121
|
+
continue;
|
|
122
|
+
if (onEntry(entry) === true)
|
|
123
|
+
return offset;
|
|
124
|
+
}
|
|
125
|
+
return offset;
|
|
126
|
+
}
|
|
127
|
+
/** Follow history.jsonl: drain on change + poll backstop (macOS fs.watch coalesces). */
|
|
128
|
+
/** Caller invokes the returned `stop()` to clean up the watcher/timer. */
|
|
129
|
+
export function followTail(startOffset, opts, onEntry, pollMs) {
|
|
130
|
+
let offset = startOffset;
|
|
131
|
+
const tick = () => { offset = drainTail(offset, opts, onEntry); };
|
|
132
|
+
let watcher = null;
|
|
133
|
+
try {
|
|
134
|
+
watcher = watch(HISTORY_FILE, () => tick());
|
|
135
|
+
}
|
|
136
|
+
catch { /* file may not exist yet */ }
|
|
137
|
+
const poll = setInterval(tick, pollMs);
|
|
138
|
+
return () => {
|
|
139
|
+
clearInterval(poll);
|
|
140
|
+
if (watcher) {
|
|
141
|
+
try {
|
|
142
|
+
watcher.close();
|
|
143
|
+
}
|
|
144
|
+
catch { /* ignore */ }
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
}
|