@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.
- package/README.md +76 -189
- package/dist/broker/claims.js +144 -0
- package/dist/{broker.js → broker/history-stream.js} +46 -99
- package/dist/cli/config.js +115 -121
- package/dist/cli/index.js +51 -64
- package/dist/cli/messenger-api.js +214 -0
- package/dist/cli/messenger-transcribe.js +43 -0
- package/dist/cli/messenger-uploads.js +116 -0
- package/dist/cli/monitor-api.js +205 -0
- package/dist/cli/tail.js +49 -118
- 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 +122 -0
- package/dist/dispatcher.js +52 -83
- package/dist/history.js +49 -27
- 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 +88 -136
- package/docs/monitor.md +88 -10
- package/docs/uri-scheme.md +10 -7
- package/examples/README.md +32 -0
- package/examples/telegram.ts +121 -0
- package/package.json +6 -5
- package/skills/metro/SKILL.md +67 -213
- package/dist/cache.js +0 -69
- package/dist/cli/actions.js +0 -206
- package/dist/cli/skill.js +0 -62
- package/dist/monitor.js +0 -194
- 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 -103
- package/dist/webhooks.js +0 -41
- 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
|
|
4
|
-
|
|
5
|
-
|
|
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
|
|
23
|
-
| Claims | `$METRO_STATE_DIR/claims.json` | `{ <line>: <user-id> }` — flat map. A line in here is *exclusively* owned by that user. Absence = broadcast.
|
|
24
|
-
| Per-mode cursor | `$METRO_STATE_DIR/cursors/<key>` | Byte offset into `history.jsonl` — last-emitted position for one tail mode.
|
|
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
|
|
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
|
|
29
|
-
|
|
30
|
-
| `metro tail --as=<id>`
|
|
31
|
-
| `metro tail --as=<id> --strict`
|
|
32
|
-
| `metro tail --as=<id> --include-webhooks`
|
|
33
|
-
| `metro tail --unclaimed`
|
|
34
|
-
| `metro tail --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
|
|
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
|
-
|
|
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
|
|
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
|
|
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**
|
|
96
|
-
| **Mine only**
|
|
97
|
-
| **Unclaimed only**
|
|
98
|
-
| **All**
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
Auto-claim only fires when **the line topology is 1:1** (DM, or a
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
| `metro://telegram/<
|
|
121
|
-
| `metro://
|
|
122
|
-
| `metro://discord/<channel-id>` (
|
|
123
|
-
| `metro://discord/<channel-id>` (
|
|
124
|
-
| `metro://
|
|
125
|
-
| `metro://
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
-
|
|
129
|
-
|
|
130
|
-
-
|
|
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),
|
|
138
|
-
|
|
139
|
-
-
|
|
140
|
-
-
|
|
141
|
-
|
|
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
|
|
146
|
-
|
|
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
|
|
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
|
|
128
|
+
## Dispatcher
|
|
151
129
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
|
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
|
|
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`,
|
|
175
|
-
|
|
176
|
-
|
|
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
|
|
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.
|
|
191
|
-
|
|
|
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.
|
|
212
|
-
- **Exactly-once delivery**: at-least-once via cursor + redelivery. Idempotency is the subscriber's problem (the daemon
|
|
213
|
-
- **Authn between users**: any process with filesystem access to `$METRO_STATE_DIR` can tail and claim. Same trust model as
|
|
214
|
-
- **Remote users**: broker is local-only. Cross-host fan-out is
|
|
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
|
|
13
|
-
|
|
14
|
-
| GET | `/api/state`
|
|
15
|
-
| GET | `/api/tail`
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
package/docs/uri-scheme.md
CHANGED
|
@@ -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
|
|
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
|
|
91
|
-
metro edit
|
|
92
|
-
metro react
|
|
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 './
|
|
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.
|
|
124
|
-
3.
|
|
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`.
|