@stage-labs/metro 0.1.0-beta.10 → 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/README.md +36 -33
- package/dist/broker.js +166 -0
- package/dist/cache.js +0 -6
- package/dist/cli/actions.js +36 -9
- package/dist/cli/index.js +30 -22
- package/dist/cli/skill.js +2 -2
- package/dist/cli/tail.js +161 -0
- package/dist/dispatcher.js +19 -15
- package/dist/history.js +14 -16
- package/dist/registry.js +11 -11
- package/dist/stations/claude.js +6 -6
- package/dist/stations/codex.js +7 -7
- package/dist/stations/discord.js +23 -7
- package/dist/stations/index.js +23 -27
- package/dist/stations/telegram-md.js +1 -1
- package/dist/stations/telegram.js +28 -7
- package/docs/broker.md +173 -0
- package/docs/uri-scheme.md +21 -22
- package/docs/{agents.md → users.md} +26 -24
- package/package.json +4 -3
- package/skills/metro/SKILL.md +21 -22
package/README.md
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
# Metro
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/@stage-labs/metro)
|
|
4
|
+
[](https://github.com/bonustrack/metro)
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
> **A live JSON stream of Telegram, Discord, webhooks, and cross-user messages for your local Claude Code / Codex session.**
|
|
7
|
+
|
|
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.
|
|
6
9
|
|
|
7
10
|
```
|
|
8
11
|
[Claude Code session]
|
|
@@ -11,7 +14,7 @@ $ metro & # backgrounded
|
|
|
11
14
|
$ Monitor( … metro's stdout … )
|
|
12
15
|
|
|
13
16
|
>>> {"kind":"inbound","station":"discord","line":"metro://discord/123…","messageId":"9876",
|
|
14
|
-
"text":"@
|
|
17
|
+
"text":"@metro we got a 5xx spike from /v1/sync. Look?",
|
|
15
18
|
"payload":{"channelId":"123…","guildId":"456…","content":"<@…> we got a 5xx spike…",
|
|
16
19
|
"mentions":{"users":["<bot-id>"],"roles":[],"everyone":false},…}}
|
|
17
20
|
|
|
@@ -19,7 +22,7 @@ $ Monitor( … metro's stdout … )
|
|
|
19
22
|
Bash: metro reply metro://discord/123… 9876 "three deploys in the last 24h…"
|
|
20
23
|
```
|
|
21
24
|
|
|
22
|
-
|
|
25
|
+
You own your own streaming, tool calls, and reply timing. Metro is the wire.
|
|
23
26
|
|
|
24
27
|
---
|
|
25
28
|
|
|
@@ -34,7 +37,7 @@ metro doctor # verify
|
|
|
34
37
|
metro # run the daemon
|
|
35
38
|
```
|
|
36
39
|
|
|
37
|
-
Requires **Node ≥ 22 or Bun ≥ 1.3**. Metro doesn't launch Claude or Codex — you do, and the
|
|
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).
|
|
38
41
|
|
|
39
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.
|
|
40
43
|
|
|
@@ -49,16 +52,16 @@ Cloudflare tunnel ──┤ ── HTTP webhooks (GitHub, Intercom, …)
|
|
|
49
52
|
│
|
|
50
53
|
├─▶ metro daemon ───▶ stdout (JSON events; Claude Code's Monitor reads here)
|
|
51
54
|
│ ───▶ codex-rc WebSocket (Codex turn/start; opt-in)
|
|
52
|
-
│ ◀── IPC Unix socket (metro send to
|
|
55
|
+
│ ◀── IPC Unix socket (metro send to Claude / Codex lines)
|
|
53
56
|
│
|
|
54
|
-
|
|
57
|
+
local CLI calls ────┴── REST → Discord / Telegram (metro reply / send / edit / react / download / fetch)
|
|
55
58
|
```
|
|
56
59
|
|
|
57
|
-
- **Inversion of control.**
|
|
60
|
+
- **Inversion of control.** Claude Code / Codex launches `metro`, not the other way around. Metro never spawns a Claude / Codex process.
|
|
58
61
|
- **Single daemon per machine.** Lockfile at `$METRO_STATE_DIR/.tail-lock` enforces singleton.
|
|
59
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.
|
|
60
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.
|
|
61
|
-
- **Cross-
|
|
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`.
|
|
62
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.
|
|
63
66
|
|
|
64
67
|
---
|
|
@@ -67,20 +70,20 @@ agent CLI calls ────┴── REST → Discord / Telegram (metro rep
|
|
|
67
70
|
|
|
68
71
|
Each endpoint is a **station** with declared capabilities:
|
|
69
72
|
|
|
70
|
-
| Station |
|
|
71
|
-
|
|
72
|
-
| `discord` |
|
|
73
|
-
| `telegram` |
|
|
74
|
-
| `claude` |
|
|
75
|
-
| `codex` |
|
|
76
|
-
| `webhook` |
|
|
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) |
|
|
77
80
|
|
|
78
81
|
Run `metro stations` to see live config status (`✓` configured, `✗` not, `·` informational).
|
|
79
82
|
|
|
80
83
|
Behaviors worth knowing:
|
|
81
|
-
- **No streaming / no edit machinery in metro.** The
|
|
84
|
+
- **No streaming / no edit machinery in metro.** The local CLI runs the show; metro is one-shot REST.
|
|
82
85
|
- **No link previews.** Outgoing messages set `link_preview_options.is_disabled` on Telegram and `SUPPRESS_EMBEDS` on Discord.
|
|
83
|
-
- **Image attachments inbound** — `[image]` placeholders surface inline in `text`; the
|
|
86
|
+
- **Image attachments inbound** — `[image]` placeholders surface inline in `text`; the user calls `metro download` to materialize them. 20 MB cap.
|
|
84
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.
|
|
85
88
|
- **Telegram non-forum groups are skipped.** No thread boundary to scope on.
|
|
86
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.
|
|
@@ -115,7 +118,7 @@ Paste the URL into the provider's webhook settings (for GitHub: **Content type m
|
|
|
115
118
|
| One-time tunnel setup | `metro tunnel setup <tunnel-name> <hostname>` |
|
|
116
119
|
| Tunnel status | `metro tunnel status` |
|
|
117
120
|
|
|
118
|
-
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/
|
|
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.
|
|
119
122
|
|
|
120
123
|
---
|
|
121
124
|
|
|
@@ -127,12 +130,12 @@ Every conversational scope is identified by a **Line** — a URI in the form `me
|
|
|
127
130
|
metro://discord/1234567890123456789
|
|
128
131
|
metro://telegram/-1001234567890 # main chat / DM
|
|
129
132
|
metro://telegram/-1001234567890/42 # forum topic 42
|
|
130
|
-
metro://claude/9bfc7af0-…/50b00d11-… # claude
|
|
131
|
-
metro://codex/8119ecb1-…/01997d4b-… # codex
|
|
133
|
+
metro://claude/9bfc7af0-…/50b00d11-… # claude user session
|
|
134
|
+
metro://codex/8119ecb1-…/01997d4b-… # codex user session
|
|
132
135
|
metro://webhook/fwaCgTKJuLAjS2K0 # HTTP webhook endpoint
|
|
133
136
|
```
|
|
134
137
|
|
|
135
|
-
Anyone can post to a line via [`metro send`](#cli) — daemon required only for
|
|
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).
|
|
136
139
|
|
|
137
140
|
---
|
|
138
141
|
|
|
@@ -167,12 +170,12 @@ metro update Upgrade in place.
|
|
|
167
170
|
All commands accept `--json`. `reply` / `send` / `edit` read multi-line `<text>` from stdin if no positional is given.
|
|
168
171
|
|
|
169
172
|
**State files** in `$METRO_STATE_DIR` (default `~/.cache/metro`):
|
|
170
|
-
- `
|
|
171
|
-
- `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
|
|
172
|
-
- `bot-ids.json` — `{discord: "<botUserId>", telegram: "<botUserId>"}` written by the daemon on startup (cached for the few historical lookups that still need a bot identity).
|
|
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).
|
|
173
176
|
- `lines.json` — line → last-seen / name cache (read by `metro lines`)
|
|
174
|
-
- `
|
|
175
|
-
- `stations/codex/session-id` — current codex-rc thread id (daemon writes on handshake; CLI processes read for `metro://codex/<
|
|
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>`)
|
|
176
179
|
- `webhooks.json` — registered HTTP receive endpoints (id, label, optional shared secret)
|
|
177
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)
|
|
178
181
|
- `.tail-lock` — dispatcher pid
|
|
@@ -188,8 +191,8 @@ All commands accept `--json`. `reply` / `send` / `edit` read multi-line `<text>`
|
|
|
188
191
|
| `TELEGRAM_BOT_TOKEN`, `DISCORD_BOT_TOKEN` | — | Bot tokens. `metro setup` writes them here. |
|
|
189
192
|
| `METRO_CODEX_RC` | — | Codex app-server URL (`ws://…`, `wss://…`, `unix:///…`). When set, the daemon pushes each event via JSON-RPC `turn/start`. |
|
|
190
193
|
| `METRO_WEBHOOK_PORT` | `8420` | Local port the HTTP webhook listener binds to (always `127.0.0.1`; expose publicly via Cloudflare tunnel). |
|
|
191
|
-
| `
|
|
192
|
-
| `
|
|
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). |
|
|
193
196
|
| `METRO_FROM` | — | Pin a custom `from` URI for all writes (overrides runtime detection). |
|
|
194
197
|
| `METRO_CONFIG_DIR` | `~/.config/metro` | Where the global `.env` lives. |
|
|
195
198
|
| `METRO_STATE_DIR` | `~/.cache/metro` | Lockfile, line cache, IPC socket, telegram offset, registries, tunnel config. |
|
|
@@ -215,15 +218,15 @@ Source map:
|
|
|
215
218
|
|
|
216
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)).
|
|
217
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.
|
|
218
|
-
- [`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).
|
|
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).
|
|
219
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).
|
|
220
223
|
- [`src/tunnel.ts`](src/tunnel.ts) — Cloudflared named-tunnel supervisor.
|
|
221
224
|
- [`src/webhooks.ts`](src/webhooks.ts) — webhook endpoint store (`webhooks.json` CRUD).
|
|
222
|
-
- [`src/registry.ts`](src/registry.ts) —
|
|
223
|
-
- [`src/history.ts`](src/history.ts) — universal message log + `
|
|
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.
|
|
224
227
|
- [`src/ipc.ts`](src/ipc.ts) — Unix-socket IPC between the daemon and one-shot CLI commands.
|
|
225
228
|
- [`src/cache.ts`](src/cache.ts) — in-memory line cache with debounced flush to `lines.json`, plus bot-id cache.
|
|
226
|
-
- [`docs/uri-scheme.md`](docs/uri-scheme.md) specs the Line format; [`docs/
|
|
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.
|
|
227
230
|
|
|
228
231
|
CI runs typecheck + lint + build on every PR via [`.github/workflows/ci.yml`](.github/workflows/ci.yml).
|
|
229
232
|
|
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
|
+
}
|
package/dist/cache.js
CHANGED
|
@@ -3,7 +3,6 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { errMsg, log } from './log.js';
|
|
5
5
|
import { STATE_DIR } from './paths.js';
|
|
6
|
-
import { Line } from './stations/index.js';
|
|
7
6
|
const cacheFile = join(STATE_DIR, 'lines.json');
|
|
8
7
|
const FLUSH_DELAY_MS = 5_000;
|
|
9
8
|
let cache = null;
|
|
@@ -68,8 +67,3 @@ export function saveBotId(station, id) {
|
|
|
68
67
|
log.warn({ err: errMsg(err) }, 'bot-ids cache write failed');
|
|
69
68
|
}
|
|
70
69
|
}
|
|
71
|
-
/** Resolve the bot's URI for a station. Returns `metro://<station>/bot/<id>` or the placeholder. */
|
|
72
|
-
export function botLine(station) {
|
|
73
|
-
const id = readBotIds()[station];
|
|
74
|
-
return id ? Line.bot(station, id) : `metro://${station}/bot`;
|
|
75
|
-
}
|
package/dist/cli/actions.js
CHANGED
|
@@ -6,9 +6,10 @@ import { errMsg } from '../log.js';
|
|
|
6
6
|
import { DiscordStation } from '../stations/discord.js';
|
|
7
7
|
import { TelegramStation } from '../stations/telegram.js';
|
|
8
8
|
import { ipcCall } from '../ipc.js';
|
|
9
|
-
import {
|
|
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);
|
|
@@ -45,29 +46,55 @@ function richOpts(f) {
|
|
|
45
46
|
opts.buttons = buttons;
|
|
46
47
|
return opts;
|
|
47
48
|
}
|
|
48
|
-
/**
|
|
49
|
+
/** Mirror the original entry's destination: group → `line`; DM → the other-party user URI. */
|
|
50
|
+
function destinationFor(orig, line) {
|
|
51
|
+
if (!orig || !orig.to || orig.to === orig.line)
|
|
52
|
+
return line;
|
|
53
|
+
return orig.from;
|
|
54
|
+
}
|
|
55
|
+
/** Append an outbound action to history.jsonl; `to` mirrors the destination per `destinationFor`. */
|
|
49
56
|
function logOutbound(f, e) {
|
|
50
57
|
const id = mintId();
|
|
51
58
|
const fromOverride = flagOne(f, 'from');
|
|
59
|
+
const from = fromOverride ? asLine(fromOverride) : userSelf();
|
|
52
60
|
appendHistory({
|
|
53
61
|
id, ts: new Date().toISOString(), station: Line.station(e.line) ?? '?',
|
|
54
|
-
from
|
|
62
|
+
from, to: e.to ?? e.line, ...e,
|
|
55
63
|
});
|
|
64
|
+
maybeAutoClaim(f, e.line, from);
|
|
56
65
|
return id;
|
|
57
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
|
+
}
|
|
58
81
|
export async function cmdSend(p, f) {
|
|
59
82
|
need(p, 1, 'metro send <line> <text> [--image=<path>]… [--document=<path>]… [--voice=<path>] [--buttons=<json>]');
|
|
60
83
|
loadMetroEnv();
|
|
61
84
|
const text = await resolveText(p, 1), line = asLine(p[0]);
|
|
62
|
-
if (Line.
|
|
63
|
-
const
|
|
64
|
-
const resp = await ipcCall({ op: 'notify', line, from, text });
|
|
85
|
+
if (Line.isLocal(line)) {
|
|
86
|
+
const fromFlag = flagOne(f, 'from');
|
|
87
|
+
const resp = await ipcCall({ op: 'notify', line, from: fromFlag, text });
|
|
65
88
|
if (!resp.ok)
|
|
66
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());
|
|
67
92
|
return emit(f, `notified ${line}`, { ok: true, line, id: null, messageId: null });
|
|
68
93
|
}
|
|
69
94
|
const messageId = await chatStationOf(line).send(line, text, richOpts(f));
|
|
70
|
-
|
|
95
|
+
/** Inherit destination from the most recent inbound on this line so DM sends address the user. */
|
|
96
|
+
const to = destinationFor(readHistory({ line, kind: 'inbound', limit: 1 })[0], line);
|
|
97
|
+
const id = logOutbound(f, { kind: 'outbound', line, text, messageId, to });
|
|
71
98
|
emit(f, `sent ${id} (${messageId}) to ${line}`, { ok: true, line, id, messageId });
|
|
72
99
|
}
|
|
73
100
|
export async function cmdReply(p, f) {
|
|
@@ -76,7 +103,7 @@ export async function cmdReply(p, f) {
|
|
|
76
103
|
const [to, replyToArg] = p, text = await resolveText(p, 2), line = asLine(to);
|
|
77
104
|
const replyTo = resolvePlatformId(replyToArg);
|
|
78
105
|
const messageId = await chatStationOf(line).send(line, text, { ...richOpts(f), replyTo });
|
|
79
|
-
const id = logOutbound(f, { kind: 'outbound', line, text, messageId, replyTo: replyToArg, to: lookupEntry(replyToArg)
|
|
106
|
+
const id = logOutbound(f, { kind: 'outbound', line, text, messageId, replyTo: replyToArg, to: destinationFor(lookupEntry(replyToArg), line) });
|
|
80
107
|
emit(f, `replied ${id} (${messageId}) to ${line}#${replyTo}`, { ok: true, line, id, replyTo: replyToArg, messageId });
|
|
81
108
|
}
|
|
82
109
|
export async function cmdEdit(p, f) {
|
|
@@ -96,7 +123,7 @@ export async function cmdReact(p, f) {
|
|
|
96
123
|
const [to, msgArg, emoji = ''] = p, line = asLine(to);
|
|
97
124
|
const platformId = resolvePlatformId(msgArg);
|
|
98
125
|
await chatStationOf(line).react(line, platformId, emoji);
|
|
99
|
-
const id = logOutbound(f, { kind: 'react', line, messageId: platformId, emoji, to: lookupEntry(msgArg)
|
|
126
|
+
const id = logOutbound(f, { kind: 'react', line, messageId: platformId, emoji, to: destinationFor(lookupEntry(msgArg), line) });
|
|
100
127
|
const human = emoji ? `reacted ${emoji} on ${line}#${platformId}` : `cleared reaction on ${line}#${platformId}`;
|
|
101
128
|
emit(f, human, { ok: true, line, id, messageId: platformId, emoji });
|
|
102
129
|
}
|
package/dist/cli/index.js
CHANGED
|
@@ -4,14 +4,15 @@ import pkg from '../../package.json' with { type: 'json' };
|
|
|
4
4
|
import { errMsg } from '../log.js';
|
|
5
5
|
import { listLines } from '../cache.js';
|
|
6
6
|
import { fmtCapabilities, listStations } from '../stations/index.js';
|
|
7
|
-
import {
|
|
7
|
+
import { listUsers } from '../registry.js';
|
|
8
8
|
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
|
-
const USAGE = `metro — Telegram + Discord stream for your Claude Code / Codex
|
|
15
|
+
const USAGE = `metro — Telegram + Discord stream for your Claude Code / Codex user
|
|
15
16
|
|
|
16
17
|
Usage:
|
|
17
18
|
metro Run the dispatcher (emits JSON events on stdout).
|
|
@@ -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
|
-
|
|
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>
|
|
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).
|
|
@@ -47,22 +57,20 @@ Exit codes: 0 success · 1 usage · 2 config · 3 upstream
|
|
|
47
57
|
async function cmdStations(_, f) {
|
|
48
58
|
loadMetroEnv();
|
|
49
59
|
const rows = listStations();
|
|
50
|
-
const
|
|
51
|
-
claude:
|
|
52
|
-
codex:
|
|
60
|
+
const usersByStation = {
|
|
61
|
+
claude: listUsers('claude'),
|
|
62
|
+
codex: listUsers('codex'),
|
|
53
63
|
};
|
|
54
64
|
if (isJson(f))
|
|
55
|
-
return writeJson({ stations: rows,
|
|
65
|
+
return writeJson({ stations: rows, users: usersByStation });
|
|
56
66
|
process.stdout.write('metro stations\n\n');
|
|
57
67
|
for (const s of rows) {
|
|
58
68
|
const mark = s.configured === true ? '✓' : s.configured === false ? '✗' : '·';
|
|
59
|
-
process.stdout.write(` ${mark} ${s.name.padEnd(10)} ${
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
process.stdout.write(` seen: ${inst.agentId}${sessionsTxt}\n`);
|
|
65
|
-
}
|
|
69
|
+
process.stdout.write(` ${mark} ${s.name.padEnd(10)} ${fmtCapabilities(s.capabilities)}\n ${s.detail}\n`);
|
|
70
|
+
const seen = usersByStation[s.name] ?? [];
|
|
71
|
+
for (const inst of seen) {
|
|
72
|
+
const sessionsTxt = inst.sessions.length ? ` · sessions: ${inst.sessions.length}` : '';
|
|
73
|
+
process.stdout.write(` seen: ${inst.userId}${sessionsTxt}\n`);
|
|
66
74
|
}
|
|
67
75
|
}
|
|
68
76
|
process.stdout.write('\n');
|
|
@@ -123,16 +131,14 @@ async function cmdHistory(_, f) {
|
|
|
123
131
|
process.stdout.write(`${ts} ${e.id.padEnd(12)} ${e.kind.padEnd(12)} ${from} → ${to} ${text}\n`);
|
|
124
132
|
}
|
|
125
133
|
}
|
|
126
|
-
/** Compact display: fromName if known; else `station:@<id>` (user)
|
|
134
|
+
/** Compact display: fromName if known; else `station:@<id>` (user) or `station:<id>`. */
|
|
127
135
|
function fmtActor(uri, name) {
|
|
128
136
|
if (name)
|
|
129
137
|
return name;
|
|
130
|
-
const m = uri.match(/^metro:\/\/([^/]+)(?:\/(?:(user
|
|
138
|
+
const m = uri.match(/^metro:\/\/([^/]+)(?:\/(?:(user)\/)?(.*))?$/);
|
|
131
139
|
if (!m)
|
|
132
140
|
return uri;
|
|
133
141
|
const [, station, kind, rest] = m;
|
|
134
|
-
if (kind === 'bot')
|
|
135
|
-
return `${station}:bot`;
|
|
136
142
|
if (kind === 'user')
|
|
137
143
|
return `${station}:@${shortId(rest ?? '')}`;
|
|
138
144
|
return rest ? `${station}:${shortId(rest)}` : station;
|
|
@@ -144,7 +150,9 @@ const COMMANDS = {
|
|
|
144
150
|
send: cmdSend, reply: cmdReply, edit: cmdEdit, react: cmdReact,
|
|
145
151
|
download: cmdDownload, fetch: cmdFetch,
|
|
146
152
|
webhook: cmdWebhook, tunnel: cmdTunnel,
|
|
147
|
-
history: cmdHistory,
|
|
153
|
+
history: cmdHistory, tail: cmdTail,
|
|
154
|
+
claim: cmdClaim, release: cmdRelease, claims: cmdClaims,
|
|
155
|
+
update: cmdUpdate,
|
|
148
156
|
};
|
|
149
157
|
async function main() {
|
|
150
158
|
const cmd = process.argv[2];
|
package/dist/cli/skill.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/** `metro setup skill` — install the bundled SKILL.md into each detected
|
|
1
|
+
/** `metro setup skill` — install the bundled SKILL.md into each detected user runtime. */
|
|
2
2
|
import { copyFileSync, existsSync, mkdirSync, unlinkSync } from 'node:fs';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { dirname, join } from 'node:path';
|
|
@@ -42,7 +42,7 @@ function install(f) {
|
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
44
|
if (!installed.length) {
|
|
45
|
-
throw exitErr('no
|
|
45
|
+
throw exitErr('no user runtime detected (~/.claude or ~/.codex). Install one and rerun.', 2);
|
|
46
46
|
}
|
|
47
47
|
emit(f, `installed metro skill → ${installed.join(', ')}`, { ok: true, installed });
|
|
48
48
|
}
|