@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/docs/broker.md CHANGED
@@ -1,43 +1,36 @@
1
1
  # Metro broker
2
2
 
3
- Multi-user event routing. Turns metro from "one daemon one stdout consumer" into "one daemon → N independently-subscribed users (Claude Code, Codex, anything) with durable, replayable delivery".
4
-
5
- ## Why
6
-
7
- Today the dispatcher writes every inbound event to **its own stdout**, which only the parent process (one Claude Code, monitoring the daemon via `Monitor`) can read. Consequences:
8
-
9
- - **Throughput bottleneck**: bursts of inbound messages serialize behind whatever the single user is currently doing.
10
- - **No real sub-users**: `Agent`-tool sub-users can call `metro send` (IPC works from anywhere), but they cannot *receive* events — they have no stdout subscription.
11
- - **No multi-instance**: a second `claude` window or a separate `codex` process can't join in; the stream has one reader.
12
- - **No durability**: a user crashes mid-conversation → events emitted during the gap are lost; on restart it starts deaf.
13
-
14
- The fix is to treat metro as a tiny **durable message broker** over the event log that already exists ([history.ts](../src/history.ts), [user-registry.json](../src/registry.ts)).
3
+ Multi-user event routing on top of the event log. Turns "one daemon → one stdout consumer"
4
+ into "one daemon → N independently-subscribed users (Claude Code, Codex, anything) with
5
+ durable, replayable delivery".
15
6
 
16
7
  ## Core idea
17
8
 
18
9
  One concept — a **claim** — and three on-disk files you can `cat`:
19
10
 
20
- | Concern | File | Role |
21
- |----------------------|-----------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------|
22
- | Event log | `$METRO_STATE_DIR/history.jsonl` | Append-only JSONL — every inbound/outbound/edit/react. Already exists. The single source of truth. |
23
- | Claims | `$METRO_STATE_DIR/claims.json` | `{ <line>: <user-id> }` — flat map. A line in here is *exclusively* owned by that user. Absence = broadcast. New. |
24
- | Per-mode cursor | `$METRO_STATE_DIR/cursors/<key>` | Byte offset into `history.jsonl` — last-emitted position for one tail mode. New. Updated atomically after each emit. |
11
+ | Concern | File | Role |
12
+ |----------------------|-----------------------------------------|-------------------------------------------------------------------------------------------------------------------|
13
+ | Event log | `$METRO_STATE_DIR/history.jsonl` | Append-only JSONL — every event (chat messages, webhooks, reactions, transcripts, …). Single source of truth. |
14
+ | Claims | `$METRO_STATE_DIR/claims.json` | `{ <line>: <user-id> }` — flat map. A line in here is *exclusively* owned by that user. Absence = broadcast. |
15
+ | Per-mode cursor | `$METRO_STATE_DIR/cursors/<key>` | Byte offset into `history.jsonl` — last-emitted position for one tail mode. Updated atomically after each emit. |
25
16
 
26
- Cursor keys are derived from the *effective mode* (not from `userSelf()`), so `--all` and `--unclaimed` don't collide with a personal `--as=<id>` tail:
17
+ Cursor keys are derived from the *effective mode* (not from `userSelf()`), so `--all` and
18
+ `--unclaimed` don't collide with a personal `--as=<id>` tail:
27
19
 
28
- | Tail invocation | Cursor key |
29
- |----------------------------------|------------------------------------|
30
- | `metro tail --as=<id>` | `<userSlug(id)>` |
31
- | `metro tail --as=<id> --strict` | `<userSlug(id)>--strict` |
32
- | `metro tail --as=<id> --include-webhooks` | `<userSlug(id)>--with-webhooks` (or `…--strict--with-webhooks`) |
33
- | `metro tail --unclaimed` | `_unclaimed` |
34
- | `metro tail --all` | `_all` |
20
+ | Tail invocation | Cursor key |
21
+ |--------------------------------------------|-----------------------------------------------------------------|
22
+ | `metro tail --as=<id>` | `<userSlug(id)>` |
23
+ | `metro tail --as=<id> --strict` | `<userSlug(id)>--strict` |
24
+ | `metro tail --as=<id> --include-webhooks` | `<userSlug(id)>--with-webhooks` (or `…--strict--with-webhooks`) |
25
+ | `metro tail --unclaimed` | `_unclaimed` |
26
+ | `metro tail --all` | `_all` |
35
27
 
36
- The `_` prefix on the mode-keys can't collide with a real `userSelf()` slug (which always contains a station name like `claude-user-…`). Switching modes mid-stream keeps each cursor independent — a `tail --all` from a `CLAUDECODE=1` shell does **not** advance the personal `--as=<me>` cursor.
28
+ The `_` prefix on the mode-keys can't collide with a real `userSelf()` slug. `--chat=<line>`
29
+ and `--station=<name>` are post-filters applied **after** cursor advancement, so they don't
30
+ need their own cursor keys.
37
31
 
38
- `--chat=<line>` and `--station=<name>` are post-filters applied **after** cursor advancement, so they don't need their own cursor keys.
39
-
40
- Subscribers do not register with the daemon. They tail the log; the broker semantics emerge from one filtering rule applied at read time:
32
+ Subscribers do not register with the daemon. They tail the log; the broker semantics emerge
33
+ from one filtering rule applied at read time:
41
34
 
42
35
  > An event is delivered to a user when its `line` is **claimed by that user** *or* **claimed by no one**.
43
36
 
@@ -48,8 +41,6 @@ That single rule covers every case the design needs to handle:
48
41
  - **Operator observability** — `metro tail` with no `--as` (or `--all`) shows everything regardless of claims; doesn't take ownership.
49
42
  - **Sub-user onboarding** — sub-user claims its assigned chat before reading; parent stops receiving that chat without any coordination.
50
43
 
51
- There is no separate concept for "subscription" or "fan-out mode" — claims and their absence cover both. The dispatcher writes; tails filter; claims gate exclusivity. Three primitives, one rule.
52
-
53
44
  ```
54
45
  ┌──────────────────────────┐
55
46
  inbound (Discord/TG/web) ──► │ dispatcher │ ──► history.jsonl ◄── metro tail --as claude-A
@@ -69,146 +60,107 @@ metro tail [--as <user-id>] [--follow] [--strict | --unclaimed | --all] [--inclu
69
60
  metro claim <line> [--as <user-id>] # add/overwrite — last writer wins
70
61
  metro release <line> # remove (line returns to broadcast)
71
62
  metro claims # print current claims.json
72
-
73
- # Outbound actions auto-claim the line on first contact when topology is 1:1 (DM, claude/codex line).
74
- # Group / public / webhook lines are skipped by default — pass --claim to force.
75
- metro send <line> <text> [--no-claim] [--claim]
76
- metro reply <line> <msg-id> <text> [--no-claim] [--claim]
77
- metro edit <line> <msg-id> <text> [--no-claim] [--claim]
78
- metro react <line> <msg-id> <emoji> [--no-claim] [--claim]
79
- # Or disable globally: METRO_NO_AUTO_CLAIM=1
80
-
81
- # Lease/ack — optional, v2. When enabled, an event is "in flight" with the
82
- # claimant; if no ack in N seconds the cursor isn't advanced and the next
83
- # `metro tail` re-emits.
84
- metro ack <event-id> --as <user-id>
85
63
  ```
86
64
 
87
- `--as <user-id>` defaults to `userSelf()` ([history.ts:121](../src/history.ts#L121)) — the same stable identity already used in routing-aware code.
65
+ `--as <user-id>` defaults to `userSelf()` ([history.ts](../src/history.ts)) — the same stable
66
+ identity already used in routing-aware code.
88
67
 
89
68
  ### Subscription modes
90
69
 
91
- The same `metro tail` command serves four distinct callers — a working user, a strict worker, a router, and a human observer. Each maps to one mutually-exclusive flag controlling the claim-aware filter:
70
+ The same `metro tail` command serves four distinct callers — a working user, a strict
71
+ subscriber, a router, and a human observer. Each maps to one mutually-exclusive flag:
92
72
 
93
73
  | Mode | Flag | Predicate | Who uses it |
94
74
  |--------------------|----------------------------|---------------------------------------------------------------------------------|------------------------------------------------------------|
95
- | **Mine + free** | `--as <id>` (default) | `(claims[line] == <id> ∨ line ∉ claims) ∧ station ≠ 'webhook'` | Default working user. Zero-config single-user setup. |
96
- | **Mine only** | `--as <id> --strict` | `claims[line] == <id> ∧ station ≠ 'webhook'` | Disciplined worker that won't race on unclaimed events. |
97
- | **Unclaimed only** | `--unclaimed` | `line ∉ claims` | Router/first-responder user that finds work to claim. |
98
- | **All** | (no `--as`) or `--all` | `true` | Operator/auditor/debugger; never takes ownership. |
75
+ | **Mine + free** | `--as <id>` (default) | `(claims[line] == <id> ∨ line ∉ claims) ∧ station ≠ 'webhook'` | Default working user. Zero-config single-user setup. |
76
+ | **Mine only** | `--as <id> --strict` | `claims[line] == <id> ∧ station ≠ 'webhook'` | Disciplined subscriber that won't race on unclaimed events.|
77
+ | **Unclaimed only** | `--unclaimed` | `line ∉ claims` | Router/first-responder pattern that finds work to claim. |
78
+ | **All** | (no `--as`) or `--all` | `true` | Operator/auditor/debugger; never takes ownership. |
99
79
 
100
- Webhooks (`station == 'webhook'`) are excluded from the personal modes by default — they're broadcast traffic (GitHub pushes, Intercom pings, etc.) that should flow to the *router* (`--unclaimed`) or *operator* (`--all`) feed, not firehose into every `--as <id>` tail. Opt back in with `metro tail --as <id> --include-webhooks` when you genuinely want a worker to see them.
80
+ Webhooks (`station == 'webhook'`) are excluded from the personal modes by default — they're
81
+ broadcast traffic (GitHub pushes, Intercom pings, etc.) that should flow to the *router*
82
+ (`--unclaimed`) or *operator* (`--all`) feed, not firehose into every `--as <id>` tail. Opt
83
+ back in with `metro tail --as <id> --include-webhooks`.
101
84
 
102
- Two UX defaults worth being explicit about:
103
-
104
- 1. **`--as <id>` with no mode flag = "mine + free".** Single-user setups (the common case) get zero-config metro: nothing claimed yet, so the only tail sees everything. Adding a second user means claiming first — surfaced in docs, not enforced by the daemon. `--strict` is the opt-in for setups that want stricter separation.
105
- 2. **No `--as` = "all".** Matches the unix `tail -f` mental model. Operators just want to read the log without registering an identity or accidentally taking ownership of anything.
106
-
107
- `--unclaimed` is the genuinely new primitive: it enables a "router" user pattern where one process watches for ownerless events and either responds directly or claims and delegates. It works with or without `--as` — with `--as`, outbound replies are still attributed correctly.
108
-
109
- Direct messages between users (`event.to == user-line`) always pass the filter regardless of mode — they're inherently 1:1 and can't be "claimed" by someone else.
85
+ Direct messages between users (`event.to == user-line`) always pass the filter regardless of
86
+ mode — they're inherently 1:1 and can't be "claimed" by someone else.
110
87
 
111
88
  ### Auto-claim on outbound
112
89
 
113
- `metro send`, `reply`, `edit`, and `react` claim the target `<line>` for the actor (`userSelf()`) the first time they touch it, atomically — same lockfile as `metro claim`. The intent: when a user picks up a conversation by replying, subsequent inbound events on that line route to them without any explicit `metro claim` call.
114
-
115
- Auto-claim only fires when **the line topology is 1:1** (DM, or a Claude/Codex cross-user line). Shared lines — group chats, public channels, webhook streams — would lock out other workers, so they're skipped by default:
116
-
117
- | Line | Classification | Auto-claim default? | How |
118
- |-------------------------------------------------|----------------|---------------------|--------------------------------------------------------------|
119
- | `metro://telegram/<positive-id>` (incl. topics) | DM | Yes | Telegram chat-id > 0 ⇒ private chat |
120
- | `metro://telegram/<negative-id>` / `-100…` | group | **No** | Telegram chat-id < 0 ⇒ group/supergroup |
121
- | `metro://discord/<channel-id>` (no guild) | DM | Yes | Recent inbound payload `guildId == null` |
122
- | `metro://discord/<channel-id>` (in guild) | group | **No** | Recent inbound payload `guildId != null` |
123
- | `metro://discord/<channel-id>` (no inbound) | unknown | Yes (conservative) | No metadata cached treat as DM-eligible until proven group |
124
- | `metro://claude/...` / `metro://codex/...` | 1:1 | Yes | Cross-user notify is inherently 1:1 by construction |
125
- | `metro://webhook/<id>` | broadcast | **Never** | Webhook lines are conceptually a stream, not a conversation |
126
-
127
- - If the line is already claimed by **someone else** (and topology check passed), the action still proceeds (sending doesn't require ownership) but the claim is **not overwritten**. A single-line stderr note (`auto-claim skipped: line owned by <other-id>`) signals the no-op.
128
- - On a group-line skip you'll see `auto-claim skipped: <line> is a group/public line; pass --claim to take it explicitly` on stderr.
129
- - Opt-out per command with `--no-claim`, or globally with the env var `METRO_NO_AUTO_CLAIM=1`.
130
- - Opt-IN for groups: `--claim` forces auto-claim even on a group/public line (operator explicitly takes responsibility).
131
- - Cross-user sends (`metro send metro://claude/... ...` from a different user) auto-claim the target line too — the sender is taking ownership of the conversation.
132
-
133
- This default plus the webhook-exclusion above means: a webhook or a busy group channel flowing through the daemon won't auto-claim under any worker, so the router pattern (`--unclaimed`) can still see them.
90
+ Outbound paths call `tryAutoClaim` ([broker/claims.ts](../src/broker/claims.ts)) to claim the target `<line>`
91
+ for the actor (`userSelf()`) the first time it's touched, atomically — same lockfile as
92
+ `metro claim`. Auto-claim only fires when **the line topology is 1:1** (DM, or a
93
+ Claude/Codex cross-user line). Shared lines are skipped:
94
+
95
+ | Line | Classification | Auto-claim? | How |
96
+ |-------------------------------------------------|----------------|-------------|--------------------------------------------------------------|
97
+ | `metro://telegram/<positive-id>` (incl. topics) | DM | Yes | Telegram chat-id > 0 ⇒ private chat |
98
+ | `metro://telegram/<negative-id>` / `-100…` | group | **No** | Telegram chat-id < 0 group/supergroup |
99
+ | `metro://discord/<channel-id>` (no guild) | DM | Yes | Recent inbound payload `guildId == null` |
100
+ | `metro://discord/<channel-id>` (in guild) | group | **No** | Recent inbound payload `guildId != null` |
101
+ | `metro://discord/<channel-id>` (no inbound) | unknown | Yes | No metadata cached treat as DM-eligible until proven group |
102
+ | `metro://claude/...` / `metro://codex/...` | 1:1 | Yes | Cross-user notify is inherently 1:1 by construction |
103
+ | `metro://webhook/<id>` | broadcast | **Never** | Webhook lines are a stream, not a conversation |
104
+
105
+ - If the line is already claimed by someone else (and topology check passed), the action
106
+ still proceeds but the claim is **not overwritten**.
107
+ - Auto-claim writes happen after the action succeeds, so a failed call never writes to `claims.json`.
134
108
 
135
109
  ### `metro tail` mechanics
136
110
 
137
- - Reads `history.jsonl`, applies the mode predicate + any `--chat`/`--station` filters (AND), prints one JSONL line per event to stdout.
138
- - With `--follow`: stays open, watches the file via `fs.watch`, emits new matching lines as they're appended.
139
- - Maintains a per-user cursor (byte offset) at `cursors/<user-id>`. On startup, resumes from cursor; on each emitted line, the offset is advanced *after* the write succeeds. Byte offsets give O(1) resume — no file scan.
140
- - `--since <offset>` overrides the cursor; `--since=tail` starts from EOF, ignoring backlog. Useful for fresh-start without losing the persisted cursor.
141
- - Claim lookups read `claims.json` once per emitted event. The file is small (a few KB) and OS-cached; cost is sub-microsecond per event.
111
+ - Reads `history.jsonl`, applies the mode predicate + any `--chat`/`--station` filters (AND),
112
+ prints one JSONL line per event to stdout.
113
+ - With `--follow`: stays open, watches the file via `fs.watch`, emits new matching lines.
114
+ - Maintains a per-mode cursor (byte offset) at `cursors/<key>`. On startup, resumes from cursor;
115
+ on each emitted line, the offset is advanced *after* the write succeeds. O(1) resume.
116
+ - `--since <offset>` overrides the cursor; `--since=tail` starts from EOF, ignoring backlog.
117
+ - Claim lookups read `claims.json` once per emitted event. Small (~KB), OS-cached; sub-microsecond cost.
142
118
 
143
119
  ### `metro claim` semantics
144
120
 
145
- - Pure metadata edit on `claims.json`. Does **not** notify the daemon — claims are read by tails, not the dispatcher (see "Dispatcher changes" below).
146
- - Re-claiming a line re-assigns it (last writer wins). `metro claims` prints the current map so a human can audit.
121
+ - Pure metadata edit on `claims.json`. Does **not** notify the daemon — claims are read by
122
+ tails, not the dispatcher.
123
+ - Re-claiming a line re-assigns it (last writer wins). `metro claims` prints the current map.
147
124
  - Releasing a line returns it to broadcast — every matching tail picks it up again.
148
- - Writes to `claims.json` are wrapped in an `O_EXCL` lockfile to serialize concurrent `metro claim` invocations on the same host.
125
+ - Writes to `claims.json` are wrapped in an `O_EXCL` lockfile to serialize concurrent writes
126
+ on the same host.
149
127
 
150
- ## Dispatcher changes
128
+ ## Dispatcher
151
129
 
152
- Almost none. `emit()` still appends to history, pushes to codex-rc, and writes to stdout. The broker model lives entirely on the read side — claims and cursors are consulted by `metro tail`, not by the dispatcher. The dispatcher doesn't need to know who's listening or who's claimed what.
153
-
154
- This is the design's key simplification: **the daemon stays dumb**. It's still a single-writer to a JSONL file. All the routing intelligence is in `metro tail`'s filter, which reads two small files (`claims.json` and its own cursor) on each event.
155
-
156
- No new sockets. No fan-out bookkeeping. No coupling between subscriber count and daemon state.
157
-
158
- ## What this enables
159
-
160
- - **Sub-users that actually receive events**: `Agent` spawns a sub-user whose first action is `metro tail --as <its-id> --chat <line> --follow &` — it then `Monitor`s that background process and gets *only* its assigned chat's events.
161
- - **Two manual Claude Code windows**: each runs `metro tail --as claude-A` / `claude-B`, claims disjoint chats. No coordination beyond `metro claim`.
162
- - **Codex alongside Claude**: same model — `metro tail --as codex-1 --station telegram` etc. The codex-rc push becomes optional: a Codex worker can subscribe via `metro tail` directly and bypass the rc file.
163
- - **Crash recovery**: process dies → restarts → `metro tail` resumes from cursor → backlog replays in order. No double-replies (the cursor is advanced on emit, not on reply).
164
- - **Replay for new joiners**: `metro tail --as new-user --since <offset-from-5-min-ago>` lets a freshly-spawned process backfill recent history before going live.
130
+ The dispatcher stays dumb. `emit()` appends to history, pushes to codex-rc, and writes to
131
+ stdout. All routing intelligence lives in `metro tail`'s filter, which reads two small files
132
+ (`claims.json` + its own cursor) per event. No new sockets, no fan-out bookkeeping, no
133
+ coupling between subscriber count and daemon state.
165
134
 
166
135
  ## Concurrency
167
136
 
168
- Multiple processes already write `history.jsonl` today: the daemon's `emit()` and every short-lived CLI invocation (`metro send`/`reply`/`react` — see [actions.ts](../src/cli/actions.ts)). It works because `appendFileSync` opens with `O_APPEND`, and POSIX guarantees that `O_APPEND` writes atomically seek-to-end-and-write in one operation — concurrent writers produce whole lines in some order, never interleaved halves. Node issues one `write(2)` per `appendFileSync` call, and our entries (even fat webhook payloads) stay well under per-syscall atomicity limits on both Linux (~2GB) and macOS (`INT_MAX`). The broker model adds **only readers**, so the existing safety property is preserved.
137
+ Multiple processes write `history.jsonl`: the daemon's `emit()` and short-lived auto-claim
138
+ writers. `appendFileSync` opens with `O_APPEND`; POSIX guarantees atomic seek-to-end-and-write
139
+ per `write(2)`. Concurrent writers produce whole lines in some order, never interleaved halves.
140
+ Node issues one `write(2)` per `appendFileSync` call, and history entries stay well under
141
+ per-syscall atomicity limits (~2GB on Linux, `INT_MAX` on macOS).
169
142
 
170
- `claims.json` is read on every event by every tail, but writes are infrequent (`metro claim`/`release`). An `O_EXCL` lockfile around writes is enough; tails do an unlocked read with a malformed-JSON retry (one read can race with one write; the retry resolves it).
143
+ `claims.json` is read on every event by every tail; writes are infrequent. An `O_EXCL`
144
+ lockfile around writes is enough; tails do an unlocked read with a malformed-JSON retry.
171
145
 
172
146
  ## Isolation
173
147
 
174
- `METRO_STATE_DIR` isolates state-dir-scoped artifacts (`history.jsonl`, `claims.json`, `cursors/`, `lines.json`, `bot-ids.json`, the daemon socket, the webhook port). It does **not** isolate platform credentials: `metro send`, `reply`, `edit`, and `react` always read bot tokens from `$XDG_CONFIG_HOME/metro/.env` (defaulting to `~/.config/metro/.env`) and post directly to Discord/Telegram regardless of where `METRO_STATE_DIR` points.
175
-
176
- This means a test invocation with `METRO_STATE_DIR=/tmp/metro-test metro send …` will hit the **production** Discord/Telegram bot with production tokens. To avoid leaking real messages from a test/sandbox:
177
-
178
- - Use lines whose channel/chat IDs you know don't exist (the platform will 4xx before any side-effect).
179
- - Or unset/move `~/.config/metro/.env` for the test process — `metro send` will fail fast with a missing-token error.
180
- - Or use `metro tail` + manual `history.jsonl` seeding to exercise the read path without any platform contact.
181
-
182
- The auto-claim write happens **after** platform-API success, so a failed `metro send` never writes to `claims.json`. (Tests can rely on this: a failing send leaves the test state dir unchanged apart from the `history.jsonl` line the daemon would emit, if one were running.)
148
+ `METRO_STATE_DIR` isolates state-dir-scoped artifacts (`history.jsonl`, `claims.json`,
149
+ `cursors/`, `lines.json`, `bot-ids.json`, the daemon socket, the webhook port). It does **not**
150
+ isolate platform credentials those are owned by the train and read from `~/.metro/.env`.
183
151
 
184
- ## Failure modes & guardrails
152
+ ## Failure modes
185
153
 
186
154
  | Failure | Behavior |
187
155
  |-------------------------------------|---------------------------------------------------------------------------------------------------|
188
156
  | Process crashes mid-event | Cursor not advanced → event redelivered on next `metro tail`. At-least-once. |
189
157
  | Two users claim same line | `claims.json` last-write-wins. `metro claims` shows current owner; humans resolve. |
190
- | No user claims a chat | Event broadcasts to every tail whose filters match. Two tails without filters → both reply (operator error — claim should have been set first). |
191
- | User silently slow (no ack) | v1: not detected. v2: `metro ack` + lease TTL cursor doesn't advance, next `metro tail` re-emits, can surface "X went dark on chat Y" via an inbound event from another user. |
192
- | `history.jsonl` grows unboundedly | Existing concern; out of scope for this doc. (Rotate by date, prune by age.) |
193
-
194
- ## Migration
195
-
196
- All changes are additive. With no subscribers, the dispatcher behaves exactly as today (parent reads stdout, single-user throughput, no routing). The broker model layers on top:
197
-
198
- 1. Ship `metro tail` (read-only, no daemon changes, no claim file). Users can subscribe and filter; multi-cast works for everything.
199
- 2. Ship `metro claim`/`release`/`claims` + claim-aware filtering in `metro tail`. Exclusivity works.
200
- 3. Optional v2: lease/ack — only if silent drops become a real problem.
201
-
202
- Each step is independently shippable.
203
-
204
- ## Open questions
205
-
206
- - **Routing-key granularity**: claims map to `user-id` (orgId-level — same across sessions/devices) rather than `user-line` (`<user-id>/<session-id>`). This means two Claude Code windows logged into the same account share claims. The session-scoped alternative is more flexible but requires the claimant to write its current `selfLine()` into `claims.json` and refresh it when the session changes. **Default: user-id.** Override per-claim with `metro claim <line> --as <full-line>` if needed.
207
- - **codex-rc deprecation**: today the dispatcher mirrors every event into a codex-rc file so Codex sees them. Once `metro tail` exists, Codex workers could subscribe directly. The rc-push stays for compatibility; the next major version can drop it.
158
+ | No user claims a chat | Event broadcasts to every tail whose filters match. |
159
+ | `history.jsonl` grows unboundedly | Out of scope here. (Rotate by date, prune by age.) |
208
160
 
209
161
  ## Non-goals
210
162
 
211
- - **Strict ordering across chats**: events within one `line` are ordered by JSONL append order; cross-chat ordering is best-effort. Subscribers shouldn't rely on it.
212
- - **Exactly-once delivery**: at-least-once via cursor + redelivery. Idempotency is the subscriber's problem (the daemon already mints stable `msg_*` ids).
213
- - **Authn between users**: any process with filesystem access to `$METRO_STATE_DIR` can tail and claim. Same trust model as today.
214
- - **Remote users**: broker is local-only. Cross-host fan-out is a separate problem (likely solved by running metro on each host and bridging at the chat layer).
163
+ - **Strict ordering across chats**: events within one `line` are ordered by JSONL append order; cross-chat ordering is best-effort.
164
+ - **Exactly-once delivery**: at-least-once via cursor + redelivery. Idempotency is the subscriber's problem (the daemon mints stable `msg_*` ids).
165
+ - **Authn between users**: any process with filesystem access to `$METRO_STATE_DIR` can tail and claim. Same trust model as the host.
166
+ - **Remote users**: broker is local-only. Cross-host fan-out is solved by running metro on each host and bridging at the chat layer.
package/docs/monitor.md CHANGED
@@ -5,18 +5,27 @@ dashboard, a curl one-liner) to view live daemon state without touching the JSON
5
5
  directly.
6
6
 
7
7
  These endpoints mount on the **existing** webhook HTTP server (default port `8420`).
8
- There is no separate daemon, no separate port, no extra process to launch.
8
+ There is no separate daemon, no separate port, no extra process to launch. The
9
+ implementation lives in [`src/cli/monitor-api.ts`](../src/cli/monitor-api.ts) (re-exported
10
+ by `src/cli/tail.ts` for backwards compatibility) and is wired into the HTTP server in
11
+ [`src/dispatcher/server.ts`](../src/dispatcher/server.ts) via `handleMonitorRequest`.
9
12
 
10
13
  ## Routes
11
14
 
12
- | Method | Path | Returns |
13
- |--------|--------------|------------------------------------------------------------------------------------------|
14
- | GET | `/api/state` | JSON snapshot — `{ claims, lines, recent_history (last 100), bot_ids }`. |
15
- | GET | `/api/tail` | Server-Sent Events stream — `history.jsonl` entries, claim-aware filtered. |
16
-
17
- Both routes are **read-only**. The daemon never mutates state on receipt. The handlers
18
- read the same files the broker reads (`history.jsonl`, `claims.json`, `bot-ids.json`)
19
- under whatever `METRO_STATE_DIR` resolves to.
15
+ | Method | Path | Returns |
16
+ |--------|---------------------------------|------------------------------------------------------------------------------------------|
17
+ | GET | `/api/state` | JSON snapshot — `{ claims, lines, recent_history (last 100), bot_ids }`. |
18
+ | GET | `/api/tail` | Server-Sent Events stream — `history.jsonl` entries, claim-aware filtered. |
19
+ | POST | `/api/call/<train>/<action>` | Forward an action call to a train via `forward-call` IPC; returns `{result}`. |
20
+ | POST | `/api/messenger/send` | In-daemon chat: emits a history entry on `metro://messenger/owner`. Accepts `{text?, attachments?[], as?}`. |
21
+ | POST | `/api/messenger/register` | Store an Expo push token so agent replies push to the phone. |
22
+ | POST | `/api/messenger/upload` | Raw binary upload (up to 25 MiB). Body = file bytes; headers `Content-Type`, optional `X-Filename`. Returns `{id, url, kind, mime, size, name?}`. |
23
+ | GET | `/api/messenger/files/<name>` | Stream a previously uploaded attachment. Accepts `?token=…` as an alternative to the bearer header for `<img>` / `<audio>` tags. |
24
+
25
+ `/api/state` and `/api/tail` are read-only. `/api/call/<train>/<action>` is the single
26
+ write endpoint — it never touches the on-disk history; the train running on the daemon
27
+ emits its own outbound event after delivering the message, which then flows through the
28
+ normal SSE stream like any other entry.
20
29
 
21
30
  ## Authentication
22
31
 
@@ -47,7 +56,8 @@ Returns a one-shot JSON snapshot:
47
56
  "metro://telegram/-100…"
48
57
  ],
49
58
  "recent_history": [/* most-recent-first, up to 100 HistoryEntry objects */],
50
- "bot_ids": { "discord": "1234567890", "telegram": "987654321" }
59
+ "bot_ids": { "discord": "1234567890", "telegram": "987654321" },
60
+ "version": "x.y.z"
51
61
  }
52
62
  ```
53
63
 
@@ -55,6 +65,19 @@ Returns a one-shot JSON snapshot:
55
65
  - `lines` — the set of conversation URIs seen across recent history and current claims (good-enough proxy for "what lines exist right now"). Subject to refinement; not authoritative.
56
66
  - `recent_history` — same shape as `HistoryEntry` in `src/history.ts`, ordered most-recent-first, capped at 100 entries.
57
67
  - `bot_ids` — verbatim contents of `bot-ids.json`.
68
+ - `version` — the daemon's package version (handy for clients gating on capabilities).
69
+
70
+ ### Pagination
71
+
72
+ For backlog scrolling, pass `?before=<N>&limit=<M>` (both non-negative integers, `limit` clamped to 500):
73
+
74
+ ```bash
75
+ curl -H "Authorization: Bearer $METRO_MONITOR_TOKEN" \
76
+ "https://monitor.metro.box/api/state?before=200&limit=100" | jq .recent_history
77
+ ```
78
+
79
+ When `before` is set, only `recent_history` is returned (the older slice). Without
80
+ `before`, the full snapshot above is returned.
58
81
 
59
82
  ### Example
60
83
 
@@ -117,6 +140,61 @@ curl -N \
117
140
  "https://monitor.metro.box/api/tail?since=0"
118
141
  ```
119
142
 
143
+ ## `POST /api/call/<train>/<action>`
144
+
145
+ Forwards an action call to a train (same as `metro call <train> <action> <args>` on the
146
+ command line) via the daemon's existing `forward-call` IPC. Use this from the mobile or
147
+ web app to send a message, react, edit, etc. without needing shell access.
148
+
149
+ ### Request
150
+
151
+ ```http
152
+ POST /api/call/discord/send HTTP/1.1
153
+ Host: monitor.metro.box
154
+ Authorization: Bearer <METRO_MONITOR_TOKEN>
155
+ Content-Type: application/json
156
+
157
+ {"args": {"line": "metro://discord/123", "text": "hello from the web"}}
158
+ ```
159
+
160
+ The body is one of:
161
+
162
+ - `{"args": <object|array|string>}` — explicit `args` wrapper (recommended).
163
+ - `<object>` — any other JSON object is forwarded as the args verbatim (useful for terse
164
+ clients).
165
+ - Empty body — forwarded as `{}`.
166
+
167
+ `<train>` must match a train running under `~/.metro/trains/`; `<action>` is whatever
168
+ that train expects (`send`, `react`, `edit`, …).
169
+
170
+ ### Response
171
+
172
+ | Status | Body | Meaning |
173
+ |--------|-----------------------------------------------------|----------------------------------------------------------|
174
+ | 200 | `{"result": <whatever the train returned>}` | Train accepted the call and returned a result. |
175
+ | 400 | `{"error": "bad JSON body: …"}` | Body was not valid JSON. |
176
+ | 401 | `{"error": "unauthorized"}` | Missing / wrong bearer token. |
177
+ | 405 | `{"error": "method not allowed"}` | Wrong verb (only `POST` is accepted on this path). |
178
+ | 500 | `{"error": "…"}` | Daemon IPC unavailable (e.g. socket missing). |
179
+ | 502 | `{"error": "…"}` | Train returned an error or the IPC handshake malformed. |
180
+
181
+ Request bodies larger than 256 KiB are rejected with HTTP 500.
182
+
183
+ ### Example: send a message
184
+
185
+ ```bash
186
+ curl -X POST \
187
+ -H "Authorization: Bearer $METRO_MONITOR_TOKEN" \
188
+ -H "Content-Type: application/json" \
189
+ -d '{"args":{"line":"metro://discord/123","text":"hi"}}' \
190
+ https://monitor.metro.box/api/call/discord/send
191
+ ```
192
+
193
+ Because the daemon's `send` adapter writes a history entry once the message lands, an
194
+ active `/api/tail` subscriber will receive the corresponding event a moment later
195
+ (`from` = local user). UIs typically clear the input on HTTP 200 and let the SSE replay
196
+ show the sent message.
197
+
120
198
  ## Exposing publicly via Cloudflare tunnel
121
199
 
122
200
  The daemon listens on `127.0.0.1:8420` only. To reach `/api/*` from a phone or a
@@ -58,7 +58,7 @@ Override either segment with `METRO_USER_ID` / `METRO_USER_SESSION_ID` env vars.
58
58
 
59
59
  ### User registry
60
60
 
61
- The daemon persists every `(station, user-id, session)` tuple it sees to `$METRO_STATE_DIR/user-registry.json`. `metro stations` prints the count of seen users and sessions per station. Run it to discover what's reachable rather than guessing topic names.
61
+ The daemon persists every `(station, user-id, session)` tuple it sees to `$METRO_STATE_DIR/user-registry.json`. `metro lines` lists the recently-seen conversation URIs.
62
62
 
63
63
  ## Webhook station
64
64
 
@@ -87,9 +87,9 @@ After setup, `metro webhook list` prints `https://webhook.yourdomain.com/wh/<id>
87
87
  Messages on chat lines are referenced by **line + message id** (two args), not as part of the URI. So:
88
88
 
89
89
  ```bash
90
- metro reply metro://discord/123… 4567 "ack"
91
- metro edit metro://discord/1239876 "fixed typo"
92
- metro react metro://telegram/-100…/42 4567 👍
90
+ metro call discord send '{"line":"metro://discord/123","text":"ack","replyTo":"4567"}'
91
+ metro call discord edit '{"line":"metro://discord/123","messageId":"9876","text":"fixed typo"}'
92
+ metro call telegram react '{"line":"metro://telegram/-100/42","messageId":"4567","emoji":"👍"}'
93
93
  ```
94
94
 
95
95
  ## Properties
@@ -102,7 +102,7 @@ metro react metro://telegram/-100…/42 4567 👍
102
102
  ## API
103
103
 
104
104
  ```ts
105
- import { Line } from './stations/index.js'; // value namespace + type
105
+ import { Line } from './lines.js'; // value namespace + type
106
106
 
107
107
  const l: Line = Line.discord('1234567890'); // typed Line
108
108
  Line.parse(l); // { station: 'discord', path: ['1234567890'] } | null
@@ -119,6 +119,9 @@ Line.isLocal(l); // true for any metro://{claude
119
119
 
120
120
  ## Adding a new station
121
121
 
122
+ A "station" in this URI scheme is just a namespace — anything a train emits with
123
+ `metro://<name>/<path>` works. There is no required registration with core:
124
+
122
125
  1. Pick a lowercase station name (`slack`, `matrix`, …).
123
- 2. Add a `Line.<station>(...)` formatter and a parser that returns your typed payload.
124
- 3. Document the path grammar in the table above.
126
+ 2. In your train, emit envelopes with `line: "metro://<name>/<id>"`.
127
+ 3. Optionally add a `Line.<station>(...)` formatter to `src/lines.ts` for type-safe construction.
@@ -0,0 +1,32 @@
1
+ # Example train
2
+
3
+ `telegram.ts` is a starting point, not runtime code. Copy to
4
+ `~/.metro/trains/<name>.ts`, edit, save, restart the daemon:
5
+
6
+ ```
7
+ cp telegram.ts ~/.metro/trains/telegram.ts
8
+ echo 'TELEGRAM_BOT_TOKEN=…' >> ~/.metro/.env
9
+ metro
10
+ ```
11
+
12
+ For a Discord port: swap the API base + auth header (`Bot $TOKEN`), install
13
+ `discord.js` for the gateway, and emit the same envelope shape — the
14
+ stdin/stdout protocol below is platform-independent. Action names and payload
15
+ shapes are entirely up to you.
16
+
17
+ ## Protocol (JSON lines over stdio)
18
+
19
+ ```
20
+ metro ─── stdin (one JSON line per action call) ──> train
21
+ <── stdout (one JSON line per event OR response) ── train
22
+ ```
23
+
24
+ - **Inbound event** (train → metro): `{ kind, station, line, from, from_name?, message_id?, line_name?, reply_to?, text?, is_private?, payload? }` — snake_case on the wire. Metro mints `id` + `display` if absent and translates to camelCase for `history.jsonl` / the broker (`HistoryEntry` in `src/history.ts`).
25
+ - **Call** (metro → train): `{ "op": "call", "id": "req_abc", "action": "send", "args": {...} }`.
26
+ - **Response** (train → metro): `{ "op": "response", "id": "req_abc", "result": {...} }` or `{ ..., "error": "..." }`.
27
+
28
+ Anything on stdout without an `op` is treated as an inbound event.
29
+
30
+ ## Lifecycle
31
+
32
+ Metro scans `~/.metro/trains/*.{ts,js,mjs}` at boot — one subprocess per file. Crashed trains restart with backoff (1s → 5s → 30s, up to 5 consecutive failures). `metro trains list` shows state. Restart the daemon to pick up edits. `~/.metro/.env` is auto-loaded into each train's `process.env`.