@stage-labs/metro 0.1.0-beta.7 → 0.1.0-beta.8
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 +83 -27
- package/dist/cli/index.js +19 -1
- package/dist/cli/webhook.js +83 -0
- package/dist/codex-rc.js +14 -5
- package/dist/dispatcher.js +30 -3
- package/dist/history.js +18 -3
- package/dist/paths.js +3 -3
- package/dist/registry.js +48 -0
- package/dist/stations/claude.js +45 -0
- package/dist/stations/codex.js +68 -0
- package/dist/stations/index.js +68 -8
- package/dist/stations/webhook.js +100 -0
- package/dist/tunnel.js +49 -0
- package/dist/webhooks.js +40 -0
- package/docs/agents.md +47 -16
- package/docs/uri-scheme.md +68 -14
- package/package.json +1 -1
- package/skills/metro/SKILL.md +24 -16
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# Metro
|
|
2
2
|
|
|
3
|
-
> **A live JSON stream of Telegram
|
|
3
|
+
> **A live JSON stream of Telegram, Discord, webhooks, and cross-agent messages for your local Claude Code / Codex session.**
|
|
4
4
|
|
|
5
|
-
Metro is a small daemon you launch from inside your agent. It connects to Discord and
|
|
5
|
+
Metro is a small daemon you launch from inside your agent. 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-agent: any agent can ping any other via `metro send metro://claude/<agent-id>/<session-id>` and the daemon re-emits it on the stream.
|
|
6
6
|
|
|
7
7
|
```
|
|
8
8
|
[Claude Code session]
|
|
@@ -43,20 +43,23 @@ In **Discord**: DM the bot, or `@<bot>` in any channel. In **Telegram**: DM, or
|
|
|
43
43
|
## Architecture
|
|
44
44
|
|
|
45
45
|
```
|
|
46
|
-
Discord gateway
|
|
47
|
-
Telegram poller
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
46
|
+
Discord gateway ──┐
|
|
47
|
+
Telegram poller ──┤
|
|
48
|
+
Cloudflare tunnel ──┤ ── HTTP webhooks (GitHub, Intercom, …)
|
|
49
|
+
│
|
|
50
|
+
├─▶ metro daemon ───▶ stdout (JSON events; Claude Code's Monitor reads here)
|
|
51
|
+
│ ───▶ codex-rc WebSocket (Codex turn/start; opt-in)
|
|
52
|
+
│ ◀── IPC Unix socket (metro send to agent lines)
|
|
53
|
+
│
|
|
54
|
+
agent CLI calls ────┴── REST → Discord / Telegram (metro reply / send / edit / react / download / fetch)
|
|
54
55
|
```
|
|
55
56
|
|
|
56
57
|
- **Inversion of control.** The agent (Claude Code, Codex) launches `metro`, not the other way around. Metro never spawns an agent process.
|
|
57
58
|
- **Single daemon per machine.** Lockfile at `$METRO_STATE_DIR/.tail-lock` enforces singleton.
|
|
59
|
+
- **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.
|
|
58
60
|
- **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.
|
|
59
|
-
- **Cross-agent notification.** `metro send metro://claude/<
|
|
61
|
+
- **Cross-agent notification.** `metro send metro://claude/<agent-id>/<session-id>` (or `metro://codex/<agent-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 agent sees it. Discover reachable agents/sessions via `metro stations` or `$METRO_STATE_DIR/agent-registry.json`.
|
|
62
|
+
- **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.
|
|
60
63
|
|
|
61
64
|
---
|
|
62
65
|
|
|
@@ -64,12 +67,13 @@ agent CLI calls ──┴── REST → Discord / Telegram (metro reply / sen
|
|
|
64
67
|
|
|
65
68
|
Each endpoint is a **station** with declared capabilities:
|
|
66
69
|
|
|
67
|
-
| Station | Kind
|
|
68
|
-
|
|
69
|
-
| `discord` | chat
|
|
70
|
-
| `telegram` | chat
|
|
71
|
-
| `claude` | agent
|
|
72
|
-
| `codex` | agent
|
|
70
|
+
| Station | Kind | Modalities | Features | Config |
|
|
71
|
+
|------------|---------|---------------|-------------------------------------------------------|-----------------------------------------------------------------------------------------|
|
|
72
|
+
| `discord` | chat | text + image | reply, send, edit, react, download, fetch | `DISCORD_BOT_TOKEN` + Message Content Intent |
|
|
73
|
+
| `telegram` | chat | text + image | reply, send, edit, react, download | `TELEGRAM_BOT_TOKEN` |
|
|
74
|
+
| `claude` | agent | text | send, notify | auto-detected from `$CLAUDECODE`; identity via `claude auth status --json` |
|
|
75
|
+
| `codex` | agent | text | send, notify | auto-detected from `$METRO_CODEX_RC` / `$CODEX_HOME`; identity via `$CODEX_HOME/auth.json` |
|
|
76
|
+
| `webhook` | service | text | (receive-only; optional HMAC verify) | `metro webhook add <label>` + `metro tunnel setup` (Cloudflare named tunnel) |
|
|
73
77
|
|
|
74
78
|
Run `metro stations` to see live config status (`✓` configured, `✗` not, `·` informational).
|
|
75
79
|
|
|
@@ -79,6 +83,39 @@ Behaviors worth knowing:
|
|
|
79
83
|
- **Image attachments inbound** — `[image]` placeholders surface inline in `text`; the agent calls `metro download` to materialize them. 20 MB cap.
|
|
80
84
|
- **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.
|
|
81
85
|
- **Telegram non-forum groups are skipped.** No thread boundary to scope on.
|
|
86
|
+
- **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.
|
|
87
|
+
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
## Webhooks
|
|
91
|
+
|
|
92
|
+
Receive HTTP events from third parties (GitHub, Intercom, Fireflies, anything that POSTs) as standard metro inbound events. Each registered endpoint is one Line.
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
# One-time per machine — bring your own Cloudflare domain (free Registrar at-cost):
|
|
96
|
+
brew install cloudflared
|
|
97
|
+
cloudflared tunnel login # browser OAuth, pick your domain
|
|
98
|
+
metro tunnel setup metro webhook.example.com # creates tunnel + DNS CNAME
|
|
99
|
+
|
|
100
|
+
# Per endpoint — repeat for each provider:
|
|
101
|
+
metro webhook add github --secret=$(openssl rand -hex 32)
|
|
102
|
+
# → https://webhook.example.com/wh/<id>
|
|
103
|
+
# (without `metro tunnel setup`, falls back to http://127.0.0.1:8420/wh/<id> — local-only)
|
|
104
|
+
|
|
105
|
+
metro # daemon binds 8420 + spawns cloudflared automatically
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Paste the URL into the provider's webhook settings (for GitHub: **Content type must be `application/json`** — form-encoded won't parse). Every POST becomes an inbound event with `station: "webhook"`, `line: metro://webhook/<id>`, `payload: { headers, body }`. If you set `--secret`, metro verifies the `X-Hub-Signature-256` header (GitHub/Intercom format) and rejects mismatches with 401.
|
|
109
|
+
|
|
110
|
+
| Action | Command |
|
|
111
|
+
|---|---|
|
|
112
|
+
| Register an endpoint | `metro webhook add <label> [--secret=<shared-secret>]` |
|
|
113
|
+
| List endpoints + URLs | `metro webhook list` |
|
|
114
|
+
| Remove an endpoint | `metro webhook remove <id>` |
|
|
115
|
+
| One-time tunnel setup | `metro tunnel setup <tunnel-name> <hostname>` |
|
|
116
|
+
| Tunnel status | `metro tunnel status` |
|
|
117
|
+
|
|
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/agents.md](docs/agents.md) for the full event shape.
|
|
82
119
|
|
|
83
120
|
---
|
|
84
121
|
|
|
@@ -90,8 +127,9 @@ Every conversational scope is identified by a **Line** — a URI in the form `me
|
|
|
90
127
|
metro://discord/1234567890123456789
|
|
91
128
|
metro://telegram/-1001234567890 # main chat / DM
|
|
92
129
|
metro://telegram/-1001234567890/42 # forum topic 42
|
|
93
|
-
metro://claude/
|
|
94
|
-
metro://codex/
|
|
130
|
+
metro://claude/9bfc7af0-…/50b00d11-… # claude agent session
|
|
131
|
+
metro://codex/8119ecb1-…/01997d4b-… # codex agent session
|
|
132
|
+
metro://webhook/fwaCgTKJuLAjS2K0 # HTTP webhook endpoint
|
|
95
133
|
```
|
|
96
134
|
|
|
97
135
|
Anyone can post to a line via [`metro send`](#cli) — daemon required only for agent lines. Full grammar in [`docs/uri-scheme.md`](docs/uri-scheme.md).
|
|
@@ -119,6 +157,10 @@ metro download <line> <message_id> [--out=<dir>]
|
|
|
119
157
|
metro fetch <line> [--limit=N] Recent-message lookback (Discord only).
|
|
120
158
|
metro history [--limit=N] [--line=…] [--station=…] [--kind=…] [--from=…] [--text=…] [--since=…]
|
|
121
159
|
Universal message log (every inbound + outbound), newest first.
|
|
160
|
+
metro webhook add <label> [--secret=…] Register an HTTP receive endpoint (GitHub, Intercom, …).
|
|
161
|
+
metro webhook list | remove <id> List or remove webhook endpoints.
|
|
162
|
+
metro tunnel setup <name> <hostname> Configure a Cloudflare named tunnel for public webhook URLs.
|
|
163
|
+
metro tunnel status Show current tunnel config.
|
|
122
164
|
metro update Upgrade in place.
|
|
123
165
|
```
|
|
124
166
|
|
|
@@ -126,9 +168,13 @@ All commands accept `--json`. `reply` / `send` / `edit` read multi-line `<text>`
|
|
|
126
168
|
|
|
127
169
|
**State files** in `$METRO_STATE_DIR` (default `~/.cache/metro`):
|
|
128
170
|
- `AGENTS.md` — agent skill copied from the package on every start (so the path is stable across upgrades)
|
|
129
|
-
- `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/<
|
|
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 consuming agent 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`).
|
|
130
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).
|
|
131
173
|
- `lines.json` — line → last-seen / name cache (read by `metro lines`)
|
|
174
|
+
- `agent-registry.json` — every `(station, agent-id, sessions[])` tuple metro has seen; surfaced under each agent row in `metro stations`
|
|
175
|
+
- `stations/codex/session-id` — current codex-rc thread id (daemon writes on handshake; CLI processes read for `metro://codex/<agent-id>/<session>`)
|
|
176
|
+
- `webhooks.json` — registered HTTP receive endpoints (id, label, optional shared secret)
|
|
177
|
+
- `tunnel.json` — Cloudflare named-tunnel config (`{name, hostname}`); when present, the daemon spawns `cloudflared tunnel run`
|
|
132
178
|
- `.tail-lock` — dispatcher pid
|
|
133
179
|
- `metro.sock` — daemon IPC socket
|
|
134
180
|
- `telegram-offset.json` — last processed update id
|
|
@@ -141,8 +187,12 @@ All commands accept `--json`. `reply` / `send` / `edit` read multi-line `<text>`
|
|
|
141
187
|
|---|---|---|
|
|
142
188
|
| `TELEGRAM_BOT_TOKEN`, `DISCORD_BOT_TOKEN` | — | Bot tokens. `metro setup` writes them here. |
|
|
143
189
|
| `METRO_CODEX_RC` | — | Codex app-server URL (`ws://…`, `wss://…`, `unix:///…`). When set, the daemon pushes each event via JSON-RPC `turn/start`. |
|
|
190
|
+
| `METRO_WEBHOOK_PORT` | `8420` | Local port the HTTP webhook listener binds to (always `127.0.0.1`; expose publicly via Cloudflare tunnel). |
|
|
191
|
+
| `METRO_AGENT_ID` | — | Override the resolved agent id (orgId / accountId) used in `metro://<station>/user/<id>` and `metro://<station>/<id>/<session>`. Useful for testing. |
|
|
192
|
+
| `METRO_AGENT_SESSION_ID` | — | Override the resolved session id (Claude session / Codex thread). |
|
|
193
|
+
| `METRO_FROM` | — | Pin a custom `from` URI for all writes (overrides runtime detection). |
|
|
144
194
|
| `METRO_CONFIG_DIR` | `~/.config/metro` | Where the global `.env` lives. |
|
|
145
|
-
| `METRO_STATE_DIR` | `~/.cache/metro` | Lockfile, line cache, IPC socket, telegram offset. |
|
|
195
|
+
| `METRO_STATE_DIR` | `~/.cache/metro` | Lockfile, line cache, IPC socket, telegram offset, registries, tunnel config. |
|
|
146
196
|
| `METRO_LOG_LEVEL` | `info` | `trace` / `debug` / `info` / `warn` / `error` / `fatal`. |
|
|
147
197
|
|
|
148
198
|
Precedence: process env → `./.env` → `$METRO_CONFIG_DIR/.env`. Logs go to stderr.
|
|
@@ -163,12 +213,16 @@ bun run lint # eslint
|
|
|
163
213
|
|
|
164
214
|
Source map:
|
|
165
215
|
|
|
166
|
-
- [`src/cli/`](src/cli/) — `metro` binary entry ([`index.ts`](src/cli/index.ts)) + admin commands ([`config.ts`](src/cli/config.ts): setup/doctor/update) + shared CLI primitives ([`util.ts`](src/cli/util.ts)).
|
|
167
|
-
- [`src/dispatcher.ts`](src/dispatcher.ts) — the daemon: starts each
|
|
168
|
-
- [`src/stations/`](src/stations/) — Line URI scheme + ChatStation interface + listing ([`index.ts`](src/stations/index.ts))
|
|
169
|
-
- [`src/codex-rc.ts`](src/codex-rc.ts) — Codex app-server WebSocket push client.
|
|
216
|
+
- [`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
|
+
- [`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). Agent 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
|
+
- [`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
|
+
- [`src/tunnel.ts`](src/tunnel.ts) — Cloudflared named-tunnel supervisor.
|
|
221
|
+
- [`src/webhooks.ts`](src/webhooks.ts) — webhook endpoint store (`webhooks.json` CRUD).
|
|
222
|
+
- [`src/registry.ts`](src/registry.ts) — agent registry: `(station, agent-id, sessions[])` tracking.
|
|
223
|
+
- [`src/history.ts`](src/history.ts) — universal message log + `agentSelf()` / `selfLine()` identity helpers.
|
|
170
224
|
- [`src/ipc.ts`](src/ipc.ts) — Unix-socket IPC between the daemon and one-shot CLI commands.
|
|
171
|
-
- [`src/cache.ts`](src/cache.ts) — in-memory line cache with debounced flush to `lines.json
|
|
225
|
+
- [`src/cache.ts`](src/cache.ts) — in-memory line cache with debounced flush to `lines.json`, plus bot-id cache.
|
|
172
226
|
- [`docs/uri-scheme.md`](docs/uri-scheme.md) specs the Line format; [`docs/agents.md`](docs/agents.md) is the in-context skill for agents.
|
|
173
227
|
|
|
174
228
|
CI runs typecheck + lint + build on every PR via [`.github/workflows/ci.yml`](.github/workflows/ci.yml).
|
|
@@ -177,10 +231,12 @@ CI runs typecheck + lint + build on every PR via [`.github/workflows/ci.yml`](.g
|
|
|
177
231
|
|
|
178
232
|
## Caveats
|
|
179
233
|
|
|
180
|
-
- **No allowlist.** Anyone who can DM/`@`-mention your bot can produce events. Run against bots you own.
|
|
234
|
+
- **No allowlist on chat stations.** Anyone who can DM/`@`-mention your bot can produce events. Run against bots you own.
|
|
235
|
+
- **Webhook secrets are optional but recommended.** Without `--secret`, anyone who learns the endpoint URL can POST events. With it, metro verifies `X-Hub-Signature-256` and rejects mismatches.
|
|
181
236
|
- **Telegram bot privacy is on by default**, which can block `@`-mentions in groups. Disable via [@BotFather](https://t.me/BotFather) → Bot Settings → Group Privacy, then kick + re-invite.
|
|
182
237
|
- **Telegram non-forum groups are skipped.** No thread boundary to scope on. DMs and forum topics work normally.
|
|
183
238
|
- **Telegram fetch isn't supported** (bot API doesn't expose history); `metro fetch` returns `[]` on Telegram lines.
|
|
239
|
+
- **Cloudflared is your responsibility.** `metro tunnel setup` records the named tunnel; you still install `cloudflared` (`brew install cloudflared`) and run `cloudflared tunnel login` once.
|
|
184
240
|
|
|
185
241
|
---
|
|
186
242
|
|
package/dist/cli/index.js
CHANGED
|
@@ -4,10 +4,12 @@ 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 { listAgents } from '../registry.js';
|
|
7
8
|
import { loadMetroEnv } from '../paths.js';
|
|
8
9
|
import { readHistory } from '../history.js';
|
|
9
10
|
import { cmdDoctor, cmdSetup, cmdUpdate } from './config.js';
|
|
10
11
|
import { cmdDownload, cmdEdit, cmdFetch, cmdReact, cmdReply, cmdSend, } from './actions.js';
|
|
12
|
+
import { cmdTunnel, cmdWebhook } from './webhook.js';
|
|
11
13
|
import { flagOne, isJson, parseArgs, writeJson, } from './util.js';
|
|
12
14
|
const USAGE = `metro — Telegram + Discord stream for your Claude Code / Codex agent
|
|
13
15
|
|
|
@@ -31,6 +33,10 @@ Usage:
|
|
|
31
33
|
metro fetch <line> [--limit=N] Recent-message lookback (Discord only).
|
|
32
34
|
metro history [--limit=N] [--line=…] [--station=…] [--kind=…] [--from=…] [--text=…] [--since=…]
|
|
33
35
|
Read the universal message log (newest first).
|
|
36
|
+
metro webhook add <label> [--secret=…] Register an HTTP receive endpoint (GitHub, Intercom, …).
|
|
37
|
+
metro webhook list | remove <id> List or remove webhook endpoints.
|
|
38
|
+
metro tunnel setup <name> <hostname> Configure a Cloudflare named tunnel (run cloudflared tunnel login first).
|
|
39
|
+
metro tunnel status Show current tunnel config.
|
|
34
40
|
metro update Upgrade in place.
|
|
35
41
|
metro --version | --help
|
|
36
42
|
|
|
@@ -41,12 +47,23 @@ Exit codes: 0 success · 1 usage · 2 config · 3 upstream
|
|
|
41
47
|
async function cmdStations(_, f) {
|
|
42
48
|
loadMetroEnv();
|
|
43
49
|
const rows = listStations();
|
|
50
|
+
const agentsByStation = {
|
|
51
|
+
claude: listAgents('claude'),
|
|
52
|
+
codex: listAgents('codex'),
|
|
53
|
+
};
|
|
44
54
|
if (isJson(f))
|
|
45
|
-
return writeJson({ stations: rows });
|
|
55
|
+
return writeJson({ stations: rows, agents: agentsByStation });
|
|
46
56
|
process.stdout.write('metro stations\n\n');
|
|
47
57
|
for (const s of rows) {
|
|
48
58
|
const mark = s.configured === true ? '✓' : s.configured === false ? '✗' : '·';
|
|
49
59
|
process.stdout.write(` ${mark} ${s.name.padEnd(10)} ${s.kind.padEnd(6)} ${fmtCapabilities(s.capabilities)}\n ${s.detail}\n`);
|
|
60
|
+
if (s.kind === 'agent') {
|
|
61
|
+
const seen = agentsByStation[s.name] ?? [];
|
|
62
|
+
for (const inst of seen) {
|
|
63
|
+
const sessionsTxt = inst.sessions.length ? ` · sessions: ${inst.sessions.length}` : '';
|
|
64
|
+
process.stdout.write(` seen: ${inst.agentId}${sessionsTxt}\n`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
50
67
|
}
|
|
51
68
|
process.stdout.write('\n');
|
|
52
69
|
}
|
|
@@ -126,6 +143,7 @@ const COMMANDS = {
|
|
|
126
143
|
setup: cmdSetup, doctor: cmdDoctor, stations: cmdStations, lines: cmdLines,
|
|
127
144
|
send: cmdSend, reply: cmdReply, edit: cmdEdit, react: cmdReact,
|
|
128
145
|
download: cmdDownload, fetch: cmdFetch,
|
|
146
|
+
webhook: cmdWebhook, tunnel: cmdTunnel,
|
|
129
147
|
history: cmdHistory, update: cmdUpdate,
|
|
130
148
|
};
|
|
131
149
|
async function main() {
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/** CLI subcommands: `metro webhook add|list|remove` + `metro tunnel setup`. */
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { addEndpoint, listEndpoints, removeEndpoint } from '../webhooks.js';
|
|
4
|
+
import { loadTunnelConfig, saveTunnelConfig } from '../tunnel.js';
|
|
5
|
+
import { emit, exitErr, flagOne, isJson, need, writeJson } from './util.js';
|
|
6
|
+
const DEFAULT_PORT = 8420;
|
|
7
|
+
const port = () => Number(process.env.METRO_WEBHOOK_PORT) || DEFAULT_PORT;
|
|
8
|
+
function urlFor(endpointId) {
|
|
9
|
+
const t = loadTunnelConfig();
|
|
10
|
+
return t ? `https://${t.hostname}/wh/${endpointId}` : `http://127.0.0.1:${port()}/wh/${endpointId}`;
|
|
11
|
+
}
|
|
12
|
+
export async function cmdWebhook(p, f) {
|
|
13
|
+
const sub = p[0];
|
|
14
|
+
if (sub === 'add')
|
|
15
|
+
return cmdWebhookAdd(p.slice(1), f);
|
|
16
|
+
if (sub === 'list' || sub === undefined)
|
|
17
|
+
return cmdWebhookList(f);
|
|
18
|
+
if (sub === 'remove' || sub === 'rm')
|
|
19
|
+
return cmdWebhookRemove(p.slice(1), f);
|
|
20
|
+
throw exitErr('usage: metro webhook [add <label> [--secret=…] | list | remove <id>]', 1);
|
|
21
|
+
}
|
|
22
|
+
async function cmdWebhookAdd(p, f) {
|
|
23
|
+
need(p, 1, 'metro webhook add <label> [--secret=<shared-secret>]');
|
|
24
|
+
const ep = addEndpoint(p[0], flagOne(f, 'secret'));
|
|
25
|
+
const url = urlFor(ep.id);
|
|
26
|
+
emit(f, `webhook ${ep.id} (${ep.label}) → ${url}${ep.secret ? `\nshared secret: ${ep.secret}` : ''}`, { ok: true, endpoint: ep, url });
|
|
27
|
+
}
|
|
28
|
+
async function cmdWebhookList(f) {
|
|
29
|
+
const eps = listEndpoints().map(ep => ({ ...ep, url: urlFor(ep.id) }));
|
|
30
|
+
if (isJson(f))
|
|
31
|
+
return writeJson({ endpoints: eps });
|
|
32
|
+
if (!eps.length)
|
|
33
|
+
return void process.stdout.write('metro webhooks\n\n (none — run `metro webhook add <label>`)\n\n');
|
|
34
|
+
process.stdout.write('metro webhooks\n\n');
|
|
35
|
+
for (const ep of eps) {
|
|
36
|
+
process.stdout.write(` ${ep.id} ${ep.label}${ep.secret ? ' (signed)' : ''}\n ${ep.url}\n`);
|
|
37
|
+
}
|
|
38
|
+
process.stdout.write('\n');
|
|
39
|
+
}
|
|
40
|
+
async function cmdWebhookRemove(p, f) {
|
|
41
|
+
need(p, 1, 'metro webhook remove <id>');
|
|
42
|
+
const ok = removeEndpoint(p[0]);
|
|
43
|
+
if (!ok)
|
|
44
|
+
throw exitErr(`no webhook with id "${p[0]}"`, 1);
|
|
45
|
+
emit(f, `removed webhook ${p[0]}`, { ok: true, id: p[0] });
|
|
46
|
+
}
|
|
47
|
+
export async function cmdTunnel(p, f) {
|
|
48
|
+
const sub = p[0];
|
|
49
|
+
if (sub === 'setup')
|
|
50
|
+
return cmdTunnelSetup(p.slice(1), f);
|
|
51
|
+
if (sub === 'status' || sub === undefined)
|
|
52
|
+
return cmdTunnelStatus(f);
|
|
53
|
+
throw exitErr('usage: metro tunnel [setup <name> <hostname> | status]', 1);
|
|
54
|
+
}
|
|
55
|
+
async function cmdTunnelSetup(p, f) {
|
|
56
|
+
need(p, 2, 'metro tunnel setup <tunnel-name> <hostname> (e.g. `metro tunnel setup metro webhook.example.com`)');
|
|
57
|
+
const [name, hostname] = p;
|
|
58
|
+
if (!hasCloudflared()) {
|
|
59
|
+
throw exitErr('cloudflared not on PATH — install with `brew install cloudflared` (or see https://developers.cloudflare.com/cloudflared/)', 2);
|
|
60
|
+
}
|
|
61
|
+
/** Idempotent: if tunnel exists, `tunnel create` errors with "already exists" — that's fine, continue. */
|
|
62
|
+
run('cloudflared', ['tunnel', 'create', name], { allowFail: true });
|
|
63
|
+
/** DNS route is also idempotent in newer cloudflared; older versions error if the CNAME exists. Same handling. */
|
|
64
|
+
run('cloudflared', ['tunnel', 'route', 'dns', name, hostname], { allowFail: true });
|
|
65
|
+
saveTunnelConfig({ name, hostname });
|
|
66
|
+
emit(f, `tunnel saved: ${name} → ${hostname}\n` +
|
|
67
|
+
'first run: `cloudflared tunnel login` if you haven\'t (browser OAuth).\n' +
|
|
68
|
+
'then start metro — the daemon will spawn `cloudflared tunnel run` for you.', { ok: true, name, hostname });
|
|
69
|
+
}
|
|
70
|
+
async function cmdTunnelStatus(f) {
|
|
71
|
+
const cfg = loadTunnelConfig();
|
|
72
|
+
if (isJson(f))
|
|
73
|
+
return writeJson({ configured: !!cfg, tunnel: cfg });
|
|
74
|
+
if (!cfg)
|
|
75
|
+
return void process.stdout.write('metro tunnel\n\n (not configured — run `metro tunnel setup <name> <hostname>`)\n\n');
|
|
76
|
+
process.stdout.write(`metro tunnel\n\n name: ${cfg.name}\n hostname: ${cfg.hostname}\n\n`);
|
|
77
|
+
}
|
|
78
|
+
const hasCloudflared = () => spawnSync('cloudflared', ['--version'], { stdio: 'ignore' }).status === 0;
|
|
79
|
+
function run(cmd, args, opts = {}) {
|
|
80
|
+
const r = spawnSync(cmd, args, { stdio: 'inherit' });
|
|
81
|
+
if (r.status !== 0 && !opts.allowFail)
|
|
82
|
+
throw exitErr(`${cmd} ${args.join(' ')} exited ${r.status}`, 2);
|
|
83
|
+
}
|
package/dist/codex-rc.js
CHANGED
|
@@ -32,6 +32,7 @@ export class CodexRC {
|
|
|
32
32
|
nextId = 1;
|
|
33
33
|
pending = new Map();
|
|
34
34
|
threadId = null;
|
|
35
|
+
threadListener = null;
|
|
35
36
|
queue = [];
|
|
36
37
|
connected = false;
|
|
37
38
|
connecting = false;
|
|
@@ -45,6 +46,14 @@ export class CodexRC {
|
|
|
45
46
|
this.endpoint = parseUrl(url);
|
|
46
47
|
}
|
|
47
48
|
start() { void this.connect(); }
|
|
49
|
+
getThreadId() { return this.threadId; }
|
|
50
|
+
onThread(listener) { this.threadListener = listener; }
|
|
51
|
+
setThreadId(id) {
|
|
52
|
+
if (this.threadId === id)
|
|
53
|
+
return;
|
|
54
|
+
this.threadId = id;
|
|
55
|
+
this.threadListener?.(id);
|
|
56
|
+
}
|
|
48
57
|
stop() {
|
|
49
58
|
this.closed = true;
|
|
50
59
|
this.clearTurnTimeout();
|
|
@@ -75,7 +84,7 @@ export class CodexRC {
|
|
|
75
84
|
ws.on('close', () => this.onClose());
|
|
76
85
|
const clientInfo = { name: 'metro', version: this.clientVersion, title: null };
|
|
77
86
|
await this.call('initialize', { clientInfo });
|
|
78
|
-
this.
|
|
87
|
+
this.setThreadId(await this.pickOrCreateThread());
|
|
79
88
|
this.connected = true;
|
|
80
89
|
log.info({ url: this.url, thread: this.threadId ?? '(none yet)' }, 'codex-rc connected');
|
|
81
90
|
void this.drainQueue();
|
|
@@ -110,7 +119,7 @@ export class CodexRC {
|
|
|
110
119
|
case 'thread/started': {
|
|
111
120
|
const id = msg.params?.thread?.id;
|
|
112
121
|
if (id) {
|
|
113
|
-
this.
|
|
122
|
+
this.setThreadId(id);
|
|
114
123
|
log.info({ thread: id }, 'codex-rc thread started');
|
|
115
124
|
void this.drainQueue();
|
|
116
125
|
}
|
|
@@ -142,7 +151,7 @@ export class CodexRC {
|
|
|
142
151
|
case 'thread/closed':
|
|
143
152
|
case 'thread/archived':
|
|
144
153
|
log.warn({ method: msg.method }, 'codex-rc thread closed/archived');
|
|
145
|
-
this.
|
|
154
|
+
this.setThreadId(null);
|
|
146
155
|
break;
|
|
147
156
|
}
|
|
148
157
|
}
|
|
@@ -186,7 +195,7 @@ export class CodexRC {
|
|
|
186
195
|
if (!this.connected || this.turnInFlight || !this.queue.length)
|
|
187
196
|
return;
|
|
188
197
|
if (!this.threadId) {
|
|
189
|
-
this.
|
|
198
|
+
this.setThreadId(await this.pickOrCreateThread());
|
|
190
199
|
if (!this.threadId)
|
|
191
200
|
return;
|
|
192
201
|
}
|
|
@@ -204,7 +213,7 @@ export class CodexRC {
|
|
|
204
213
|
this.clearTurnTimeout();
|
|
205
214
|
this.turnInFlight = false;
|
|
206
215
|
if (dead)
|
|
207
|
-
this.
|
|
216
|
+
this.setThreadId(null);
|
|
208
217
|
setTimeout(() => void this.drainQueue(), 1_000);
|
|
209
218
|
}
|
|
210
219
|
}
|
package/dist/dispatcher.js
CHANGED
|
@@ -7,17 +7,29 @@ import { fileURLToPath } from 'node:url';
|
|
|
7
7
|
import pkg from '../package.json' with { type: 'json' };
|
|
8
8
|
import { DiscordStation } from './stations/discord.js';
|
|
9
9
|
import { TelegramStation } from './stations/telegram.js';
|
|
10
|
+
import { WebhookStation } from './stations/webhook.js';
|
|
10
11
|
import { asLine, Line } from './stations/index.js';
|
|
11
12
|
import { CodexRC } from './codex-rc.js';
|
|
12
13
|
import { startIpcServer, stopIpcServer } from './ipc.js';
|
|
13
|
-
import { agentSelf, appendHistory, mintId } from './history.js';
|
|
14
|
+
import { agentSelf, appendHistory, mintId, selfLine } from './history.js';
|
|
14
15
|
import { noteSeen, saveBotId } from './cache.js';
|
|
15
16
|
import { errMsg, log } from './log.js';
|
|
16
17
|
import { acquireLock, configuredPlatforms, loadMetroEnv, STATE_DIR, requireConfiguredPlatform } from './paths.js';
|
|
18
|
+
import { setCodexSessionId } from './stations/codex.js';
|
|
19
|
+
import { noteAgentFromLine } from './registry.js';
|
|
20
|
+
import { listEndpoints } from './webhooks.js';
|
|
21
|
+
import { loadTunnelConfig, Tunnel } from './tunnel.js';
|
|
17
22
|
loadMetroEnv();
|
|
18
23
|
const platforms = configuredPlatforms();
|
|
19
|
-
|
|
24
|
+
const endpoints = listEndpoints();
|
|
25
|
+
requireConfiguredPlatform(platforms, endpoints.length > 0);
|
|
20
26
|
acquireLock(join(STATE_DIR, '.tail-lock'));
|
|
27
|
+
// Fail fast if launched from Claude Code without a logged-in account.
|
|
28
|
+
const self = agentSelf();
|
|
29
|
+
log.info({ self, line: selfLine() }, 'agent identity');
|
|
30
|
+
const seedSelf = () => { const l = selfLine(); if (l)
|
|
31
|
+
noteAgentFromLine(l); };
|
|
32
|
+
seedSelf();
|
|
21
33
|
const AGENTS_MD = join(STATE_DIR, 'AGENTS.md');
|
|
22
34
|
try {
|
|
23
35
|
copyFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'docs', 'agents.md'), AGENTS_MD);
|
|
@@ -31,14 +43,21 @@ process.stdout.on('error', err => {
|
|
|
31
43
|
log.warn({ err: errMsg(err) }, 'stdout error');
|
|
32
44
|
});
|
|
33
45
|
const codexRc = process.env.METRO_CODEX_RC ? new CodexRC(process.env.METRO_CODEX_RC, pkg.version) : null;
|
|
46
|
+
codexRc?.onThread(id => { setCodexSessionId(id); seedSelf(); });
|
|
34
47
|
codexRc?.start();
|
|
35
48
|
const discord = new DiscordStation();
|
|
36
49
|
const telegram = new TelegramStation();
|
|
50
|
+
const webhook = new WebhookStation();
|
|
51
|
+
const tunnelCfg = loadTunnelConfig();
|
|
52
|
+
const tunnel = tunnelCfg ? new Tunnel(tunnelCfg, webhook.port()) : null;
|
|
37
53
|
function emit(entry) {
|
|
38
54
|
const json = JSON.stringify(entry);
|
|
39
55
|
process.stdout.write(json + '\n');
|
|
40
56
|
codexRc?.push(json);
|
|
41
57
|
noteSeen(entry.line, entry.lineName);
|
|
58
|
+
for (const l of [entry.line, entry.from, entry.to])
|
|
59
|
+
if (l)
|
|
60
|
+
noteAgentFromLine(l);
|
|
42
61
|
appendHistory(entry);
|
|
43
62
|
}
|
|
44
63
|
const onInbound = (m) => emit({ ...m, kind: 'inbound', to: agentSelf() });
|
|
@@ -73,7 +92,13 @@ async function main() {
|
|
|
73
92
|
saveBotId('telegram', String(me.id));
|
|
74
93
|
log.info({ bot: `@${me.username}` }, 'telegram ready');
|
|
75
94
|
}
|
|
76
|
-
|
|
95
|
+
/** Start the HTTP receiver only when ≥1 endpoint is registered — no point binding a port nobody listens to. */
|
|
96
|
+
if (endpoints.length) {
|
|
97
|
+
webhook.onMessage(onInbound);
|
|
98
|
+
await webhook.start();
|
|
99
|
+
tunnel?.start();
|
|
100
|
+
}
|
|
101
|
+
log.info({ codexRc: !!codexRc, tunnel: !!tunnel }, 'dispatcher ready');
|
|
77
102
|
}
|
|
78
103
|
let shuttingDown = false;
|
|
79
104
|
async function shutdown() {
|
|
@@ -82,7 +107,9 @@ async function shutdown() {
|
|
|
82
107
|
shuttingDown = true;
|
|
83
108
|
log.info('dispatcher shutting down');
|
|
84
109
|
codexRc?.stop();
|
|
110
|
+
tunnel?.stop();
|
|
85
111
|
await stopIpcServer(ipc).catch(() => { });
|
|
112
|
+
await webhook.stop().catch(() => { });
|
|
86
113
|
if (platforms.discord)
|
|
87
114
|
await discord.stop().catch(() => { });
|
|
88
115
|
if (platforms.telegram)
|
package/dist/history.js
CHANGED
|
@@ -4,6 +4,9 @@ import { appendFileSync, existsSync, readFileSync } from 'node:fs';
|
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { errMsg, log } from './log.js';
|
|
6
6
|
import { STATE_DIR } from './paths.js';
|
|
7
|
+
import { Line } from './stations/index.js';
|
|
8
|
+
import { claudeAgentId, claudeSessionId } from './stations/claude.js';
|
|
9
|
+
import { codexAgentId, codexSessionId } from './stations/codex.js';
|
|
7
10
|
const FILE = join(STATE_DIR, 'history.jsonl');
|
|
8
11
|
/** Mint a universal metro message ID. Short, prefixed, URL-safe. */
|
|
9
12
|
export const mintId = () => `msg_${randomBytes(6).toString('base64url')}`;
|
|
@@ -71,14 +74,26 @@ export function resolvePlatformId(id) {
|
|
|
71
74
|
return hit.messageId;
|
|
72
75
|
throw new Error(`unknown universal id: ${id} (run \`metro history --limit=50\` to see recent ids)`);
|
|
73
76
|
}
|
|
74
|
-
/**
|
|
77
|
+
/** The current agent's **participant** URI for `from`/`to`. Precedence: METRO_FROM > runtime env > generic. */
|
|
75
78
|
export function agentSelf() {
|
|
76
79
|
const explicit = process.env.METRO_FROM;
|
|
77
80
|
if (explicit)
|
|
78
81
|
return explicit;
|
|
79
82
|
if (process.env.CLAUDECODE)
|
|
80
|
-
return '
|
|
83
|
+
return Line.user('claude', claudeAgentId());
|
|
81
84
|
if (process.env.METRO_CODEX_RC || process.env.CODEX_HOME)
|
|
82
|
-
return '
|
|
85
|
+
return Line.user('codex', codexAgentId());
|
|
83
86
|
return 'metro://agent';
|
|
84
87
|
}
|
|
88
|
+
/** The current agent's **line** URI `<agent-id>/<session>`. Null until session is known (rc thread pending). */
|
|
89
|
+
export function selfLine() {
|
|
90
|
+
if (process.env.CLAUDECODE) {
|
|
91
|
+
const s = claudeSessionId();
|
|
92
|
+
return s ? Line.claude(claudeAgentId(), s) : null;
|
|
93
|
+
}
|
|
94
|
+
if (process.env.METRO_CODEX_RC || process.env.CODEX_HOME) {
|
|
95
|
+
const s = codexSessionId();
|
|
96
|
+
return s ? Line.codex(codexAgentId(), s) : null;
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
package/dist/paths.js
CHANGED
|
@@ -36,10 +36,10 @@ export function loadMetroEnv() {
|
|
|
36
36
|
export function configuredPlatforms() {
|
|
37
37
|
return { telegram: !!process.env.TELEGRAM_BOT_TOKEN, discord: !!process.env.DISCORD_BOT_TOKEN };
|
|
38
38
|
}
|
|
39
|
-
export function requireConfiguredPlatform(p) {
|
|
40
|
-
if (p.telegram || p.discord)
|
|
39
|
+
export function requireConfiguredPlatform(p, hasWebhooks) {
|
|
40
|
+
if (p.telegram || p.discord || hasWebhooks)
|
|
41
41
|
return;
|
|
42
|
-
log.fatal('no
|
|
42
|
+
log.fatal('no inputs configured — run `metro setup telegram <token>`, `metro setup discord <token>`, or `metro webhook add <label>`');
|
|
43
43
|
process.exit(2);
|
|
44
44
|
}
|
|
45
45
|
/** Singleton pidfile. Exits if another instance owns it; reclaims stale locks. */
|
package/dist/registry.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/** Append-only registry of `(station, agent-id, sessions[])` tuples metro has seen. */
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { STATE_DIR } from './paths.js';
|
|
5
|
+
import { Line } from './stations/index.js';
|
|
6
|
+
import { errMsg, log } from './log.js';
|
|
7
|
+
const REGISTRY_FILE = join(STATE_DIR, 'agent-registry.json');
|
|
8
|
+
function readRegistry() {
|
|
9
|
+
if (!existsSync(REGISTRY_FILE))
|
|
10
|
+
return {};
|
|
11
|
+
try {
|
|
12
|
+
return JSON.parse(readFileSync(REGISTRY_FILE, 'utf8'));
|
|
13
|
+
}
|
|
14
|
+
catch (err) {
|
|
15
|
+
log.warn({ err: errMsg(err) }, 'agent-registry: malformed, resetting');
|
|
16
|
+
return {};
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function record(station, agentId, sessionId) {
|
|
20
|
+
const reg = readRegistry();
|
|
21
|
+
const rows = (reg[station] ??= []);
|
|
22
|
+
let row = rows.find(r => r.agentId === agentId);
|
|
23
|
+
if (!row) {
|
|
24
|
+
row = { agentId, sessions: [], lastSeen: '' };
|
|
25
|
+
rows.push(row);
|
|
26
|
+
}
|
|
27
|
+
if (sessionId && !row.sessions.includes(sessionId))
|
|
28
|
+
row.sessions.push(sessionId);
|
|
29
|
+
row.lastSeen = new Date().toISOString();
|
|
30
|
+
try {
|
|
31
|
+
writeFileSync(REGISTRY_FILE, JSON.stringify(reg, null, 2));
|
|
32
|
+
}
|
|
33
|
+
catch (err) {
|
|
34
|
+
log.warn({ err: errMsg(err) }, 'agent-registry: write failed');
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** Scan a line URI for `(station, agentId, sessionId)` and record it. No-op on non-agent or participant URIs. */
|
|
38
|
+
export function noteAgentFromLine(line) {
|
|
39
|
+
const station = Line.station(line);
|
|
40
|
+
if (station !== 'claude' && station !== 'codex')
|
|
41
|
+
return;
|
|
42
|
+
const p = station === 'claude' ? Line.parseClaude(line) : Line.parseCodex(line);
|
|
43
|
+
if (p)
|
|
44
|
+
record(station, p.agentId, p.sessionId);
|
|
45
|
+
}
|
|
46
|
+
export function listAgents(station) {
|
|
47
|
+
return readRegistry()[station] ?? [];
|
|
48
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/** Resolve the Claude Code agent identity (account id + session id). */
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
/** Short TTL so account switches via `claude auth login` propagate to the daemon within seconds. */
|
|
4
|
+
const TTL_MS = 5_000;
|
|
5
|
+
let cache = null;
|
|
6
|
+
/** Stable per-Anthropic-account UUID. Same across devices for the same login. */
|
|
7
|
+
export function claudeAccountId() {
|
|
8
|
+
if (cache && Date.now() - cache.at < TTL_MS)
|
|
9
|
+
return cache.id;
|
|
10
|
+
let raw;
|
|
11
|
+
try {
|
|
12
|
+
raw = execFileSync('claude', ['auth', 'status', '--json'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
13
|
+
}
|
|
14
|
+
catch (e) {
|
|
15
|
+
throw new Error(`metro: failed to run 'claude auth status --json' — is Claude Code installed and on PATH? (${e.message})`);
|
|
16
|
+
}
|
|
17
|
+
let parsed;
|
|
18
|
+
try {
|
|
19
|
+
parsed = JSON.parse(raw);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
throw new Error(`metro: 'claude auth status --json' returned non-JSON: ${raw.slice(0, 200)}`);
|
|
23
|
+
}
|
|
24
|
+
if (!parsed.loggedIn || !parsed.orgId) {
|
|
25
|
+
throw new Error('metro: Claude Code is not logged in — run \'claude auth login\'');
|
|
26
|
+
}
|
|
27
|
+
cache = { id: parsed.orgId, at: Date.now() };
|
|
28
|
+
return parsed.orgId;
|
|
29
|
+
}
|
|
30
|
+
export function tryClaudeAccountId() {
|
|
31
|
+
try {
|
|
32
|
+
return claudeAccountId();
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** Agent-id for the line URI: `METRO_AGENT_ID` override, else the account id. */
|
|
39
|
+
export function claudeAgentId() {
|
|
40
|
+
return process.env.METRO_AGENT_ID || claudeAccountId();
|
|
41
|
+
}
|
|
42
|
+
/** Session: `CLAUDE_CODE_SESSION_ID` (stable across `--resume`). Override: `METRO_AGENT_SESSION_ID`. */
|
|
43
|
+
export function claudeSessionId() {
|
|
44
|
+
return process.env.METRO_AGENT_SESSION_ID || process.env.CLAUDE_CODE_SESSION_ID || null;
|
|
45
|
+
}
|