@stage-labs/metro 0.1.0-beta.10 → 0.1.0-beta.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -33
- package/dist/cache.js +0 -6
- package/dist/cli/actions.js +15 -7
- package/dist/cli/index.js +13 -17
- package/dist/cli/skill.js +2 -2
- package/dist/dispatcher.js +19 -15
- package/dist/history.js +14 -16
- package/dist/registry.js +11 -11
- package/dist/stations/claude.js +6 -6
- package/dist/stations/codex.js +7 -7
- package/dist/stations/discord.js +23 -7
- package/dist/stations/index.js +23 -27
- package/dist/stations/telegram-md.js +1 -1
- package/dist/stations/telegram.js +28 -7
- package/docs/uri-scheme.md +21 -22
- package/docs/{agents.md → users.md} +26 -24
- package/package.json +2 -2
- package/skills/metro/SKILL.md +21 -22
package/README.md
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
# Metro
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/@stage-labs/metro)
|
|
4
|
+
[](https://github.com/bonustrack/metro)
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
> **A live JSON stream of Telegram, Discord, webhooks, and cross-user messages for your local Claude Code / Codex session.**
|
|
7
|
+
|
|
8
|
+
Metro is a small daemon you launch from inside your session. It connects to Discord, Telegram, and any third-party service that can POST a webhook (GitHub, Intercom, Fireflies, …), emits each inbound as one JSON line on stdout (which Claude Code's `Monitor` consumes natively, and Codex picks up via an app-server WebSocket push), and exposes a tiny CLI — `metro reply`, `metro send`, `metro edit`, `metro react`, `metro download`, `metro fetch` — for posting back. Cross-user: any user can ping any other via `metro send metro://claude/<user-id>/<session-id>` and the daemon re-emits it on the stream.
|
|
6
9
|
|
|
7
10
|
```
|
|
8
11
|
[Claude Code session]
|
|
@@ -11,7 +14,7 @@ $ metro & # backgrounded
|
|
|
11
14
|
$ Monitor( … metro's stdout … )
|
|
12
15
|
|
|
13
16
|
>>> {"kind":"inbound","station":"discord","line":"metro://discord/123…","messageId":"9876",
|
|
14
|
-
"text":"@
|
|
17
|
+
"text":"@metro we got a 5xx spike from /v1/sync. Look?",
|
|
15
18
|
"payload":{"channelId":"123…","guildId":"456…","content":"<@…> we got a 5xx spike…",
|
|
16
19
|
"mentions":{"users":["<bot-id>"],"roles":[],"everyone":false},…}}
|
|
17
20
|
|
|
@@ -19,7 +22,7 @@ $ Monitor( … metro's stdout … )
|
|
|
19
22
|
Bash: metro reply metro://discord/123… 9876 "three deploys in the last 24h…"
|
|
20
23
|
```
|
|
21
24
|
|
|
22
|
-
|
|
25
|
+
You own your own streaming, tool calls, and reply timing. Metro is the wire.
|
|
23
26
|
|
|
24
27
|
---
|
|
25
28
|
|
|
@@ -34,7 +37,7 @@ metro doctor # verify
|
|
|
34
37
|
metro # run the daemon
|
|
35
38
|
```
|
|
36
39
|
|
|
37
|
-
Requires **Node ≥ 22 or Bun ≥ 1.3**. Metro doesn't launch Claude or Codex — you do, and the
|
|
40
|
+
Requires **Node ≥ 22 or Bun ≥ 1.3**. Metro doesn't launch Claude or Codex — you do, and the user launches metro. See [`docs/users.md`](docs/users.md).
|
|
38
41
|
|
|
39
42
|
In **Discord**: DM the bot, or `@<bot>` in any channel. In **Telegram**: DM, or `@<bot>` in a forum supergroup. Every inbound becomes one JSON line on `metro`'s stdout.
|
|
40
43
|
|
|
@@ -49,16 +52,16 @@ Cloudflare tunnel ──┤ ── HTTP webhooks (GitHub, Intercom, …)
|
|
|
49
52
|
│
|
|
50
53
|
├─▶ metro daemon ───▶ stdout (JSON events; Claude Code's Monitor reads here)
|
|
51
54
|
│ ───▶ codex-rc WebSocket (Codex turn/start; opt-in)
|
|
52
|
-
│ ◀── IPC Unix socket (metro send to
|
|
55
|
+
│ ◀── IPC Unix socket (metro send to Claude / Codex lines)
|
|
53
56
|
│
|
|
54
|
-
|
|
57
|
+
local CLI calls ────┴── REST → Discord / Telegram (metro reply / send / edit / react / download / fetch)
|
|
55
58
|
```
|
|
56
59
|
|
|
57
|
-
- **Inversion of control.**
|
|
60
|
+
- **Inversion of control.** Claude Code / Codex launches `metro`, not the other way around. Metro never spawns a Claude / Codex process.
|
|
58
61
|
- **Single daemon per machine.** Lockfile at `$METRO_STATE_DIR/.tail-lock` enforces singleton.
|
|
59
62
|
- **Account-tied identity.** `to` on inbound and `from` on outbound resolve to a stable account-scoped URI per runtime: `metro://claude/user/<orgId>` (from `claude auth status --json`) or `metro://codex/user/<accountId>` (from `$CODEX_HOME/auth.json`). Same on any device for the same logged-in account.
|
|
60
63
|
- **Codex push (opt-in).** Set `METRO_CODEX_RC=ws://127.0.0.1:8421` and metro pushes each event via JSON-RPC `turn/start` to the Codex app-server. Codex's TUI must be attached with `--remote` to the same URL.
|
|
61
|
-
- **Cross-
|
|
64
|
+
- **Cross-user notification.** `metro send metro://claude/<user-id>/<session-id>` (or `metro://codex/<user-id>/<session-id>`) routes through the daemon's IPC socket; the daemon re-emits on its stdout (and pushes to codex-rc), so the peer sees it. Discover reachable users/sessions via `metro stations` or `$METRO_STATE_DIR/user-registry.json`.
|
|
62
65
|
- **Webhooks (opt-in).** `metro webhook add <label>` registers an HTTP receive endpoint; the daemon binds `127.0.0.1:8420` (override with `$METRO_WEBHOOK_PORT`). If you've run `metro tunnel setup`, a Cloudflare named tunnel exposes it publicly. Each POST is re-emitted on stdout as an inbound event.
|
|
63
66
|
|
|
64
67
|
---
|
|
@@ -67,20 +70,20 @@ agent CLI calls ────┴── REST → Discord / Telegram (metro rep
|
|
|
67
70
|
|
|
68
71
|
Each endpoint is a **station** with declared capabilities:
|
|
69
72
|
|
|
70
|
-
| Station |
|
|
71
|
-
|
|
72
|
-
| `discord` |
|
|
73
|
-
| `telegram` |
|
|
74
|
-
| `claude` |
|
|
75
|
-
| `codex` |
|
|
76
|
-
| `webhook` |
|
|
73
|
+
| Station | Modalities | Features | Config |
|
|
74
|
+
|------------|---------------|-------------------------------------------------------|-----------------------------------------------------------------------------------------|
|
|
75
|
+
| `discord` | text + image | reply, send, edit, react, download, fetch | `DISCORD_BOT_TOKEN` + Message Content Intent |
|
|
76
|
+
| `telegram` | text + image | reply, send, edit, react, download | `TELEGRAM_BOT_TOKEN` |
|
|
77
|
+
| `claude` | text | send | auto-detected from `$CLAUDECODE`; identity via `claude auth status --json` |
|
|
78
|
+
| `codex` | text | send | auto-detected from `$METRO_CODEX_RC` / `$CODEX_HOME`; identity via `$CODEX_HOME/auth.json` |
|
|
79
|
+
| `webhook` | text | (receive-only; optional HMAC verify) | `metro webhook add <label>` + `metro tunnel setup` (Cloudflare named tunnel) |
|
|
77
80
|
|
|
78
81
|
Run `metro stations` to see live config status (`✓` configured, `✗` not, `·` informational).
|
|
79
82
|
|
|
80
83
|
Behaviors worth knowing:
|
|
81
|
-
- **No streaming / no edit machinery in metro.** The
|
|
84
|
+
- **No streaming / no edit machinery in metro.** The local CLI runs the show; metro is one-shot REST.
|
|
82
85
|
- **No link previews.** Outgoing messages set `link_preview_options.is_disabled` on Telegram and `SUPPRESS_EMBEDS` on Discord.
|
|
83
|
-
- **Image attachments inbound** — `[image]` placeholders surface inline in `text`; the
|
|
86
|
+
- **Image attachments inbound** — `[image]` placeholders surface inline in `text`; the user calls `metro download` to materialize them. 20 MB cap.
|
|
84
87
|
- **Rich content outbound.** `metro send` / `reply` accept `--image=<path>` (repeatable: albums of up to 10), `--document=<path>` (repeatable), `--voice=<path>` (single voice message — Telegram renders the voice bubble), and `--buttons='[[{"text":"…","url":"…"}]]'` for inline URL-button keyboards. `metro edit` accepts `--buttons` (pass `'[]'` to clear). 20 MB / file. URL buttons only for now — no callback/interactive components.
|
|
85
88
|
- **Telegram non-forum groups are skipped.** No thread boundary to scope on.
|
|
86
89
|
- **Webhook signature verification.** Pass `--secret=<shared-secret>` to `metro webhook add` and the daemon verifies `X-Hub-Signature-256` (GitHub/Intercom format) on every POST. Mismatches return 401 and never reach the stream.
|
|
@@ -115,7 +118,7 @@ Paste the URL into the provider's webhook settings (for GitHub: **Content type m
|
|
|
115
118
|
| One-time tunnel setup | `metro tunnel setup <tunnel-name> <hostname>` |
|
|
116
119
|
| Tunnel status | `metro tunnel status` |
|
|
117
120
|
|
|
118
|
-
The tunnel is optional — without it the listener binds `127.0.0.1:8420` only (good for local testing or your own loopback tools). With Cloudflare named tunnels, the URL stays stable across daemon restarts and machines. See [docs/uri-scheme.md](docs/uri-scheme.md) and [docs/
|
|
121
|
+
The tunnel is optional — without it the listener binds `127.0.0.1:8420` only (good for local testing or your own loopback tools). With Cloudflare named tunnels, the URL stays stable across daemon restarts and machines. See [docs/uri-scheme.md](docs/uri-scheme.md) and [docs/users.md](docs/users.md) for the full event shape.
|
|
119
122
|
|
|
120
123
|
---
|
|
121
124
|
|
|
@@ -127,12 +130,12 @@ Every conversational scope is identified by a **Line** — a URI in the form `me
|
|
|
127
130
|
metro://discord/1234567890123456789
|
|
128
131
|
metro://telegram/-1001234567890 # main chat / DM
|
|
129
132
|
metro://telegram/-1001234567890/42 # forum topic 42
|
|
130
|
-
metro://claude/9bfc7af0-…/50b00d11-… # claude
|
|
131
|
-
metro://codex/8119ecb1-…/01997d4b-… # codex
|
|
133
|
+
metro://claude/9bfc7af0-…/50b00d11-… # claude user session
|
|
134
|
+
metro://codex/8119ecb1-…/01997d4b-… # codex user session
|
|
132
135
|
metro://webhook/fwaCgTKJuLAjS2K0 # HTTP webhook endpoint
|
|
133
136
|
```
|
|
134
137
|
|
|
135
|
-
Anyone can post to a line via [`metro send`](#cli) — daemon required only for
|
|
138
|
+
Anyone can post to a line via [`metro send`](#cli) — daemon required only for Claude / Codex lines. Full grammar in [`docs/uri-scheme.md`](docs/uri-scheme.md).
|
|
136
139
|
|
|
137
140
|
---
|
|
138
141
|
|
|
@@ -167,12 +170,12 @@ metro update Upgrade in place.
|
|
|
167
170
|
All commands accept `--json`. `reply` / `send` / `edit` read multi-line `<text>` from stdin if no positional is given.
|
|
168
171
|
|
|
169
172
|
**State files** in `$METRO_STATE_DIR` (default `~/.cache/metro`):
|
|
170
|
-
- `
|
|
171
|
-
- `history.jsonl` — universal message log (one JSON object per line; append-only). Read with `metro history`. Each entry carries `from` and `to` as universal participant URIs (`metro://<station>/user/<id>`, `metro://claude/user/<orgId>`, `metro://codex/user/<accountId>`) plus a `fromName` display field. The dispatcher auto-detects the
|
|
172
|
-
- `bot-ids.json` — `{discord: "<botUserId>", telegram: "<botUserId>"}` written by the daemon on startup (cached for the few historical lookups that still need a bot identity).
|
|
173
|
+
- `USERS.md` — user skill copied from the package on every start (so the path is stable across upgrades)
|
|
174
|
+
- `history.jsonl` — universal message log (one JSON object per line; append-only). Read with `metro history`. Each entry carries `from` and `to` as universal participant URIs (`metro://<station>/user/<id>`, `metro://claude/user/<orgId>`, `metro://codex/user/<accountId>`) plus a `fromName` display field. The dispatcher auto-detects the local user for `to` on inbound (`$CLAUDECODE` → `metro://claude/user/<orgId>` from `claude auth status --json`; `$METRO_CODEX_RC`/`$CODEX_HOME` → `metro://codex/user/<accountId>` from `$CODEX_HOME/auth.json`).
|
|
175
|
+
- `bot-ids.json` — `{discord: "<botUserId>", telegram: "<botUserId>"}` written by the daemon on startup (cached for the few historical lookups that still need a platform-side bot identity).
|
|
173
176
|
- `lines.json` — line → last-seen / name cache (read by `metro lines`)
|
|
174
|
-
- `
|
|
175
|
-
- `stations/codex/session-id` — current codex-rc thread id (daemon writes on handshake; CLI processes read for `metro://codex/<
|
|
177
|
+
- `user-registry.json` — every `(station, user-id, sessions[])` tuple metro has seen; surfaced under each Claude / Codex row in `metro stations`
|
|
178
|
+
- `stations/codex/session-id` — current codex-rc thread id (daemon writes on handshake; CLI processes read for `metro://codex/<user-id>/<session>`)
|
|
176
179
|
- `webhooks.json` — registered HTTP receive endpoints (id, label, optional shared secret)
|
|
177
180
|
- `tunnel.json` — Cloudflare named-tunnel config (`{name, hostname}`); when present, the daemon spawns `cloudflared tunnel run`. The token is resolved via `cloudflared tunnel token <name>` and passed through as `TUNNEL_TOKEN`, so the per-tunnel credentials JSON at `~/.cloudflared/<id>.json` is not required (the named-form spawn is the fallback when the token call fails)
|
|
178
181
|
- `.tail-lock` — dispatcher pid
|
|
@@ -188,8 +191,8 @@ All commands accept `--json`. `reply` / `send` / `edit` read multi-line `<text>`
|
|
|
188
191
|
| `TELEGRAM_BOT_TOKEN`, `DISCORD_BOT_TOKEN` | — | Bot tokens. `metro setup` writes them here. |
|
|
189
192
|
| `METRO_CODEX_RC` | — | Codex app-server URL (`ws://…`, `wss://…`, `unix:///…`). When set, the daemon pushes each event via JSON-RPC `turn/start`. |
|
|
190
193
|
| `METRO_WEBHOOK_PORT` | `8420` | Local port the HTTP webhook listener binds to (always `127.0.0.1`; expose publicly via Cloudflare tunnel). |
|
|
191
|
-
| `
|
|
192
|
-
| `
|
|
194
|
+
| `METRO_USER_ID` | — | Override the resolved user id (orgId / accountId) used in `metro://<station>/user/<id>` and `metro://<station>/<id>/<session>`. Useful for testing. |
|
|
195
|
+
| `METRO_USER_SESSION_ID` | — | Override the resolved session id (Claude session / Codex thread). |
|
|
193
196
|
| `METRO_FROM` | — | Pin a custom `from` URI for all writes (overrides runtime detection). |
|
|
194
197
|
| `METRO_CONFIG_DIR` | `~/.config/metro` | Where the global `.env` lives. |
|
|
195
198
|
| `METRO_STATE_DIR` | `~/.cache/metro` | Lockfile, line cache, IPC socket, telegram offset, registries, tunnel config. |
|
|
@@ -215,15 +218,15 @@ Source map:
|
|
|
215
218
|
|
|
216
219
|
- [`src/cli/`](src/cli/) — `metro` binary entry ([`index.ts`](src/cli/index.ts)) + admin commands ([`config.ts`](src/cli/config.ts): setup/doctor/update), action handlers ([`actions.ts`](src/cli/actions.ts): send/reply/edit/react/download/fetch), webhook + tunnel commands ([`webhook.ts`](src/cli/webhook.ts)), and shared CLI primitives ([`util.ts`](src/cli/util.ts)).
|
|
217
220
|
- [`src/dispatcher.ts`](src/dispatcher.ts) — the daemon: starts each station, emits events on stdout, listens on the IPC socket, optionally pushes to codex-rc, supervises the Cloudflare tunnel.
|
|
218
|
-
- [`src/stations/`](src/stations/) — Line URI scheme + ChatStation interface + listing ([`index.ts`](src/stations/index.ts)). Chat impls: [`discord.ts`](src/stations/discord.ts), [`telegram.ts`](src/stations/telegram.ts) (+ [`telegram-md.ts`](src/stations/telegram-md.ts) markdown helper).
|
|
221
|
+
- [`src/stations/`](src/stations/) — Line URI scheme + ChatStation interface + listing ([`index.ts`](src/stations/index.ts)). Chat impls: [`discord.ts`](src/stations/discord.ts), [`telegram.ts`](src/stations/telegram.ts) (+ [`telegram-md.ts`](src/stations/telegram-md.ts) markdown helper). User identity resolvers: [`claude.ts`](src/stations/claude.ts) (orgId via `claude auth status --json`), [`codex.ts`](src/stations/codex.ts) (account_id via `auth.json`). HTTP receive: [`webhook.ts`](src/stations/webhook.ts).
|
|
219
222
|
- [`src/codex-rc.ts`](src/codex-rc.ts) — Codex app-server WebSocket push client (also exposes the rc thread id used as Codex session-id).
|
|
220
223
|
- [`src/tunnel.ts`](src/tunnel.ts) — Cloudflared named-tunnel supervisor.
|
|
221
224
|
- [`src/webhooks.ts`](src/webhooks.ts) — webhook endpoint store (`webhooks.json` CRUD).
|
|
222
|
-
- [`src/registry.ts`](src/registry.ts) —
|
|
223
|
-
- [`src/history.ts`](src/history.ts) — universal message log + `
|
|
225
|
+
- [`src/registry.ts`](src/registry.ts) — user registry: `(station, user-id, sessions[])` tracking.
|
|
226
|
+
- [`src/history.ts`](src/history.ts) — universal message log + `userSelf()` / `selfLine()` identity helpers.
|
|
224
227
|
- [`src/ipc.ts`](src/ipc.ts) — Unix-socket IPC between the daemon and one-shot CLI commands.
|
|
225
228
|
- [`src/cache.ts`](src/cache.ts) — in-memory line cache with debounced flush to `lines.json`, plus bot-id cache.
|
|
226
|
-
- [`docs/uri-scheme.md`](docs/uri-scheme.md) specs the Line format; [`docs/
|
|
229
|
+
- [`docs/uri-scheme.md`](docs/uri-scheme.md) specs the Line format; [`docs/users.md`](docs/users.md) is the in-context skill for users.
|
|
227
230
|
|
|
228
231
|
CI runs typecheck + lint + build on every PR via [`.github/workflows/ci.yml`](.github/workflows/ci.yml).
|
|
229
232
|
|
package/dist/cache.js
CHANGED
|
@@ -3,7 +3,6 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { errMsg, log } from './log.js';
|
|
5
5
|
import { STATE_DIR } from './paths.js';
|
|
6
|
-
import { Line } from './stations/index.js';
|
|
7
6
|
const cacheFile = join(STATE_DIR, 'lines.json');
|
|
8
7
|
const FLUSH_DELAY_MS = 5_000;
|
|
9
8
|
let cache = null;
|
|
@@ -68,8 +67,3 @@ export function saveBotId(station, id) {
|
|
|
68
67
|
log.warn({ err: errMsg(err) }, 'bot-ids cache write failed');
|
|
69
68
|
}
|
|
70
69
|
}
|
|
71
|
-
/** Resolve the bot's URI for a station. Returns `metro://<station>/bot/<id>` or the placeholder. */
|
|
72
|
-
export function botLine(station) {
|
|
73
|
-
const id = readBotIds()[station];
|
|
74
|
-
return id ? Line.bot(station, id) : `metro://${station}/bot`;
|
|
75
|
-
}
|
package/dist/cli/actions.js
CHANGED
|
@@ -6,7 +6,7 @@ import { errMsg } from '../log.js';
|
|
|
6
6
|
import { DiscordStation } from '../stations/discord.js';
|
|
7
7
|
import { TelegramStation } from '../stations/telegram.js';
|
|
8
8
|
import { ipcCall } from '../ipc.js';
|
|
9
|
-
import {
|
|
9
|
+
import { userSelf, appendHistory, lookupEntry, mintId, readHistory, resolvePlatformId, } from '../history.js';
|
|
10
10
|
import { asLine, Line } from '../stations/index.js';
|
|
11
11
|
import { loadMetroEnv } from '../paths.js';
|
|
12
12
|
import { emit, flagList, flagOne, isJson, need, resolveText, writeJson, } from './util.js';
|
|
@@ -45,13 +45,19 @@ function richOpts(f) {
|
|
|
45
45
|
opts.buttons = buttons;
|
|
46
46
|
return opts;
|
|
47
47
|
}
|
|
48
|
-
/**
|
|
48
|
+
/** Mirror the original entry's destination: group → `line`; DM → the other-party user URI. */
|
|
49
|
+
function destinationFor(orig, line) {
|
|
50
|
+
if (!orig || !orig.to || orig.to === orig.line)
|
|
51
|
+
return line;
|
|
52
|
+
return orig.from;
|
|
53
|
+
}
|
|
54
|
+
/** Append an outbound action to history.jsonl; `to` mirrors the destination per `destinationFor`. */
|
|
49
55
|
function logOutbound(f, e) {
|
|
50
56
|
const id = mintId();
|
|
51
57
|
const fromOverride = flagOne(f, 'from');
|
|
52
58
|
appendHistory({
|
|
53
59
|
id, ts: new Date().toISOString(), station: Line.station(e.line) ?? '?',
|
|
54
|
-
from: fromOverride ? asLine(fromOverride) :
|
|
60
|
+
from: fromOverride ? asLine(fromOverride) : userSelf(), to: e.to ?? e.line, ...e,
|
|
55
61
|
});
|
|
56
62
|
return id;
|
|
57
63
|
}
|
|
@@ -59,7 +65,7 @@ export async function cmdSend(p, f) {
|
|
|
59
65
|
need(p, 1, 'metro send <line> <text> [--image=<path>]… [--document=<path>]… [--voice=<path>] [--buttons=<json>]');
|
|
60
66
|
loadMetroEnv();
|
|
61
67
|
const text = await resolveText(p, 1), line = asLine(p[0]);
|
|
62
|
-
if (Line.
|
|
68
|
+
if (Line.isLocal(line)) {
|
|
63
69
|
const from = flagOne(f, 'from');
|
|
64
70
|
const resp = await ipcCall({ op: 'notify', line, from, text });
|
|
65
71
|
if (!resp.ok)
|
|
@@ -67,7 +73,9 @@ export async function cmdSend(p, f) {
|
|
|
67
73
|
return emit(f, `notified ${line}`, { ok: true, line, id: null, messageId: null });
|
|
68
74
|
}
|
|
69
75
|
const messageId = await chatStationOf(line).send(line, text, richOpts(f));
|
|
70
|
-
|
|
76
|
+
/** Inherit destination from the most recent inbound on this line so DM sends address the user. */
|
|
77
|
+
const to = destinationFor(readHistory({ line, kind: 'inbound', limit: 1 })[0], line);
|
|
78
|
+
const id = logOutbound(f, { kind: 'outbound', line, text, messageId, to });
|
|
71
79
|
emit(f, `sent ${id} (${messageId}) to ${line}`, { ok: true, line, id, messageId });
|
|
72
80
|
}
|
|
73
81
|
export async function cmdReply(p, f) {
|
|
@@ -76,7 +84,7 @@ export async function cmdReply(p, f) {
|
|
|
76
84
|
const [to, replyToArg] = p, text = await resolveText(p, 2), line = asLine(to);
|
|
77
85
|
const replyTo = resolvePlatformId(replyToArg);
|
|
78
86
|
const messageId = await chatStationOf(line).send(line, text, { ...richOpts(f), replyTo });
|
|
79
|
-
const id = logOutbound(f, { kind: 'outbound', line, text, messageId, replyTo: replyToArg, to: lookupEntry(replyToArg)
|
|
87
|
+
const id = logOutbound(f, { kind: 'outbound', line, text, messageId, replyTo: replyToArg, to: destinationFor(lookupEntry(replyToArg), line) });
|
|
80
88
|
emit(f, `replied ${id} (${messageId}) to ${line}#${replyTo}`, { ok: true, line, id, replyTo: replyToArg, messageId });
|
|
81
89
|
}
|
|
82
90
|
export async function cmdEdit(p, f) {
|
|
@@ -96,7 +104,7 @@ export async function cmdReact(p, f) {
|
|
|
96
104
|
const [to, msgArg, emoji = ''] = p, line = asLine(to);
|
|
97
105
|
const platformId = resolvePlatformId(msgArg);
|
|
98
106
|
await chatStationOf(line).react(line, platformId, emoji);
|
|
99
|
-
const id = logOutbound(f, { kind: 'react', line, messageId: platformId, emoji, to: lookupEntry(msgArg)
|
|
107
|
+
const id = logOutbound(f, { kind: 'react', line, messageId: platformId, emoji, to: destinationFor(lookupEntry(msgArg), line) });
|
|
100
108
|
const human = emoji ? `reacted ${emoji} on ${line}#${platformId}` : `cleared reaction on ${line}#${platformId}`;
|
|
101
109
|
emit(f, human, { ok: true, line, id, messageId: platformId, emoji });
|
|
102
110
|
}
|
package/dist/cli/index.js
CHANGED
|
@@ -4,14 +4,14 @@ import pkg from '../../package.json' with { type: 'json' };
|
|
|
4
4
|
import { errMsg } from '../log.js';
|
|
5
5
|
import { listLines } from '../cache.js';
|
|
6
6
|
import { fmtCapabilities, listStations } from '../stations/index.js';
|
|
7
|
-
import {
|
|
7
|
+
import { listUsers } from '../registry.js';
|
|
8
8
|
import { loadMetroEnv } from '../paths.js';
|
|
9
9
|
import { readHistory } from '../history.js';
|
|
10
10
|
import { cmdDoctor, cmdSetup, cmdUpdate } from './config.js';
|
|
11
11
|
import { cmdDownload, cmdEdit, cmdFetch, cmdReact, cmdReply, cmdSend, } from './actions.js';
|
|
12
12
|
import { cmdTunnel, cmdWebhook } from './webhook.js';
|
|
13
13
|
import { flagOne, isJson, parseArgs, writeJson, } from './util.js';
|
|
14
|
-
const USAGE = `metro — Telegram + Discord stream for your Claude Code / Codex
|
|
14
|
+
const USAGE = `metro — Telegram + Discord stream for your Claude Code / Codex user
|
|
15
15
|
|
|
16
16
|
Usage:
|
|
17
17
|
metro Run the dispatcher (emits JSON events on stdout).
|
|
@@ -47,22 +47,20 @@ Exit codes: 0 success · 1 usage · 2 config · 3 upstream
|
|
|
47
47
|
async function cmdStations(_, f) {
|
|
48
48
|
loadMetroEnv();
|
|
49
49
|
const rows = listStations();
|
|
50
|
-
const
|
|
51
|
-
claude:
|
|
52
|
-
codex:
|
|
50
|
+
const usersByStation = {
|
|
51
|
+
claude: listUsers('claude'),
|
|
52
|
+
codex: listUsers('codex'),
|
|
53
53
|
};
|
|
54
54
|
if (isJson(f))
|
|
55
|
-
return writeJson({ stations: rows,
|
|
55
|
+
return writeJson({ stations: rows, users: usersByStation });
|
|
56
56
|
process.stdout.write('metro stations\n\n');
|
|
57
57
|
for (const s of rows) {
|
|
58
58
|
const mark = s.configured === true ? '✓' : s.configured === false ? '✗' : '·';
|
|
59
|
-
process.stdout.write(` ${mark} ${s.name.padEnd(10)} ${
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
process.stdout.write(` seen: ${inst.agentId}${sessionsTxt}\n`);
|
|
65
|
-
}
|
|
59
|
+
process.stdout.write(` ${mark} ${s.name.padEnd(10)} ${fmtCapabilities(s.capabilities)}\n ${s.detail}\n`);
|
|
60
|
+
const seen = usersByStation[s.name] ?? [];
|
|
61
|
+
for (const inst of seen) {
|
|
62
|
+
const sessionsTxt = inst.sessions.length ? ` · sessions: ${inst.sessions.length}` : '';
|
|
63
|
+
process.stdout.write(` seen: ${inst.userId}${sessionsTxt}\n`);
|
|
66
64
|
}
|
|
67
65
|
}
|
|
68
66
|
process.stdout.write('\n');
|
|
@@ -123,16 +121,14 @@ async function cmdHistory(_, f) {
|
|
|
123
121
|
process.stdout.write(`${ts} ${e.id.padEnd(12)} ${e.kind.padEnd(12)} ${from} → ${to} ${text}\n`);
|
|
124
122
|
}
|
|
125
123
|
}
|
|
126
|
-
/** Compact display: fromName if known; else `station:@<id>` (user)
|
|
124
|
+
/** Compact display: fromName if known; else `station:@<id>` (user) or `station:<id>`. */
|
|
127
125
|
function fmtActor(uri, name) {
|
|
128
126
|
if (name)
|
|
129
127
|
return name;
|
|
130
|
-
const m = uri.match(/^metro:\/\/([^/]+)(?:\/(?:(user
|
|
128
|
+
const m = uri.match(/^metro:\/\/([^/]+)(?:\/(?:(user)\/)?(.*))?$/);
|
|
131
129
|
if (!m)
|
|
132
130
|
return uri;
|
|
133
131
|
const [, station, kind, rest] = m;
|
|
134
|
-
if (kind === 'bot')
|
|
135
|
-
return `${station}:bot`;
|
|
136
132
|
if (kind === 'user')
|
|
137
133
|
return `${station}:@${shortId(rest ?? '')}`;
|
|
138
134
|
return rest ? `${station}:${shortId(rest)}` : station;
|
package/dist/cli/skill.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/** `metro setup skill` — install the bundled SKILL.md into each detected
|
|
1
|
+
/** `metro setup skill` — install the bundled SKILL.md into each detected user runtime. */
|
|
2
2
|
import { copyFileSync, existsSync, mkdirSync, unlinkSync } from 'node:fs';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { dirname, join } from 'node:path';
|
|
@@ -42,7 +42,7 @@ function install(f) {
|
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
44
|
if (!installed.length) {
|
|
45
|
-
throw exitErr('no
|
|
45
|
+
throw exitErr('no user runtime detected (~/.claude or ~/.codex). Install one and rerun.', 2);
|
|
46
46
|
}
|
|
47
47
|
emit(f, `installed metro skill → ${installed.join(', ')}`, { ok: true, installed });
|
|
48
48
|
}
|
package/dist/dispatcher.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Daemon: chat inbound → JSON on stdout; optional codex-rc push; cross-
|
|
2
|
+
* Daemon: chat inbound → JSON on stdout; optional codex-rc push; cross-user `notify` over Unix socket.
|
|
3
3
|
*/
|
|
4
4
|
import { copyFileSync } from 'node:fs';
|
|
5
5
|
import { dirname, join } from 'node:path';
|
|
@@ -11,12 +11,12 @@ import { WebhookStation } from './stations/webhook.js';
|
|
|
11
11
|
import { asLine, Line } from './stations/index.js';
|
|
12
12
|
import { CodexRC } from './codex-rc.js';
|
|
13
13
|
import { startIpcServer, stopIpcServer } from './ipc.js';
|
|
14
|
-
import {
|
|
14
|
+
import { userSelf, appendHistory, formatDisplay, mintId, selfLine } from './history.js';
|
|
15
15
|
import { noteSeen, saveBotId } from './cache.js';
|
|
16
16
|
import { errMsg, log } from './log.js';
|
|
17
17
|
import { acquireLock, configuredPlatforms, loadMetroEnv, STATE_DIR, requireConfiguredPlatform } from './paths.js';
|
|
18
18
|
import { setCodexSessionId } from './stations/codex.js';
|
|
19
|
-
import {
|
|
19
|
+
import { noteUserFromLine } from './registry.js';
|
|
20
20
|
import { listEndpoints, webhookPort } from './webhooks.js';
|
|
21
21
|
import { loadTunnelConfig, Tunnel } from './tunnel.js';
|
|
22
22
|
loadMetroEnv();
|
|
@@ -25,19 +25,19 @@ const endpoints = listEndpoints();
|
|
|
25
25
|
requireConfiguredPlatform(platforms, endpoints.length > 0);
|
|
26
26
|
acquireLock(join(STATE_DIR, '.tail-lock'));
|
|
27
27
|
// Fail fast if launched from Claude Code without a logged-in account.
|
|
28
|
-
const self =
|
|
29
|
-
log.info({ self, line: selfLine() }, '
|
|
28
|
+
const self = userSelf();
|
|
29
|
+
log.info({ self, line: selfLine() }, 'user identity');
|
|
30
30
|
const seedSelf = () => { const l = selfLine(); if (l)
|
|
31
|
-
|
|
31
|
+
noteUserFromLine(l); };
|
|
32
32
|
seedSelf();
|
|
33
|
-
const
|
|
33
|
+
const USERS_MD = join(STATE_DIR, 'USERS.md');
|
|
34
34
|
try {
|
|
35
|
-
copyFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'docs', '
|
|
35
|
+
copyFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'docs', 'users.md'), USERS_MD);
|
|
36
36
|
}
|
|
37
37
|
catch (err) {
|
|
38
|
-
log.warn({ err: errMsg(err), path:
|
|
38
|
+
log.warn({ err: errMsg(err), path: USERS_MD }, 'failed to install user skill');
|
|
39
39
|
}
|
|
40
|
-
/** Suppress EPIPE so the daemon survives the
|
|
40
|
+
/** Suppress EPIPE so the daemon survives the user (Monitor reader) restarting / dying. */
|
|
41
41
|
process.stdout.on('error', err => {
|
|
42
42
|
if (err.code !== 'EPIPE')
|
|
43
43
|
log.warn({ err: errMsg(err) }, 'stdout error');
|
|
@@ -51,7 +51,7 @@ const webhook = new WebhookStation();
|
|
|
51
51
|
const tunnelCfg = loadTunnelConfig();
|
|
52
52
|
const tunnel = tunnelCfg ? new Tunnel(tunnelCfg, webhookPort()) : null;
|
|
53
53
|
function emit(entry) {
|
|
54
|
-
/** `display` first so it survives Monitor's ~500-char body truncation — the
|
|
54
|
+
/** `display` first so it survives Monitor's ~500-char body truncation — the user must see it to echo it. */
|
|
55
55
|
const enriched = { display: formatDisplay(entry), ...entry };
|
|
56
56
|
const json = JSON.stringify(enriched);
|
|
57
57
|
process.stdout.write(json + '\n');
|
|
@@ -59,17 +59,19 @@ function emit(entry) {
|
|
|
59
59
|
noteSeen(entry.line, entry.lineName);
|
|
60
60
|
for (const l of [entry.line, entry.from, entry.to])
|
|
61
61
|
if (l)
|
|
62
|
-
|
|
62
|
+
noteUserFromLine(l);
|
|
63
63
|
appendHistory(enriched);
|
|
64
64
|
}
|
|
65
|
-
const
|
|
65
|
+
const destinationFor = (m) => m.isPrivate ? userSelf() : m.line;
|
|
66
|
+
const onInbound = (m) => emit({ ...m, kind: 'inbound', to: destinationFor(m) });
|
|
67
|
+
const onReaction = (r) => emit({ ...r, kind: 'react', to: destinationFor(r) });
|
|
66
68
|
const ipc = startIpcServer(async (req) => {
|
|
67
69
|
if (req.op === 'notify') {
|
|
68
70
|
const line = asLine(req.line);
|
|
69
71
|
emit({
|
|
70
|
-
id: mintId(), ts: new Date().toISOString(), kind: '
|
|
72
|
+
id: mintId(), ts: new Date().toISOString(), kind: 'inbound',
|
|
71
73
|
station: Line.station(line) ?? '?', line,
|
|
72
|
-
from: req.from ? asLine(req.from) :
|
|
74
|
+
from: req.from ? asLine(req.from) : userSelf(), to: line, text: req.text,
|
|
73
75
|
});
|
|
74
76
|
return { ok: true };
|
|
75
77
|
}
|
|
@@ -84,12 +86,14 @@ const ipc = startIpcServer(async (req) => {
|
|
|
84
86
|
async function main() {
|
|
85
87
|
if (platforms.discord) {
|
|
86
88
|
discord.onMessage(onInbound);
|
|
89
|
+
discord.onReaction(onReaction);
|
|
87
90
|
const [, me] = await Promise.all([discord.start(), discord.getMe()]);
|
|
88
91
|
saveBotId('discord', me.id);
|
|
89
92
|
log.info({ bot: me.username }, 'discord ready');
|
|
90
93
|
}
|
|
91
94
|
if (platforms.telegram) {
|
|
92
95
|
telegram.onMessage(onInbound);
|
|
96
|
+
telegram.onReaction(onReaction);
|
|
93
97
|
const [me] = await Promise.all([telegram.getMe(), telegram.start()]);
|
|
94
98
|
saveBotId('telegram', String(me.id));
|
|
95
99
|
log.info({ bot: `@${me.username}` }, 'telegram ready');
|
package/dist/history.js
CHANGED
|
@@ -5,9 +5,9 @@ import { join } from 'node:path';
|
|
|
5
5
|
import { errMsg, log } from './log.js';
|
|
6
6
|
import { STATE_DIR } from './paths.js';
|
|
7
7
|
import { Line } from './stations/index.js';
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
/** Pre-render a chat-bubble line — the
|
|
8
|
+
import { claudeUserId, claudeSessionId } from './stations/claude.js';
|
|
9
|
+
import { codexUserId, codexSessionId } from './stations/codex.js';
|
|
10
|
+
/** Pre-render a chat-bubble line — the user echoes `event.display` verbatim instead of composing markdown itself. */
|
|
11
11
|
export function formatDisplay(e) {
|
|
12
12
|
const headerFor = (icon, parts) => `**${icon} ${parts.filter(Boolean).join(' · ')}**`;
|
|
13
13
|
const body = e.text ?? (e.emoji ? `[react ${e.emoji}]` : '');
|
|
@@ -17,11 +17,9 @@ export function formatDisplay(e) {
|
|
|
17
17
|
?.headers?.['x-intercom-topic'];
|
|
18
18
|
return `${headerFor('🪝', ['webhook', e.lineName, ev])}\n> ${body}`;
|
|
19
19
|
}
|
|
20
|
-
if (e.kind === 'inbound') {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
if (e.kind === 'notification') {
|
|
24
|
-
return `${headerFor('🔔', ['notification', e.station, e.fromName ?? e.from])}\n> ${body}`;
|
|
20
|
+
if (e.kind === 'inbound' || (e.kind === 'react' && !Line.isLocal(e.from))) {
|
|
21
|
+
const reactBody = e.kind === 'react' ? `reacted ${e.emoji ?? ''}`.trim() : body;
|
|
22
|
+
return `${headerFor('📩', [e.station, e.fromName ?? e.from, e.lineName])}\n> ${reactBody}`;
|
|
25
23
|
}
|
|
26
24
|
return `${headerFor('📤', [e.station, '→', e.fromName ?? e.to])}\n> ${body}`;
|
|
27
25
|
}
|
|
@@ -92,26 +90,26 @@ export function resolvePlatformId(id) {
|
|
|
92
90
|
return hit.messageId;
|
|
93
91
|
throw new Error(`unknown universal id: ${id} (run \`metro history --limit=50\` to see recent ids)`);
|
|
94
92
|
}
|
|
95
|
-
/** The current
|
|
96
|
-
export function
|
|
93
|
+
/** The current user's **participant** URI for `from`/`to`. Precedence: METRO_FROM > runtime env > generic. */
|
|
94
|
+
export function userSelf() {
|
|
97
95
|
const explicit = process.env.METRO_FROM;
|
|
98
96
|
if (explicit)
|
|
99
97
|
return explicit;
|
|
100
98
|
if (process.env.CLAUDECODE)
|
|
101
|
-
return Line.user('claude',
|
|
99
|
+
return Line.user('claude', claudeUserId());
|
|
102
100
|
if (process.env.METRO_CODEX_RC || process.env.CODEX_HOME)
|
|
103
|
-
return Line.user('codex',
|
|
104
|
-
return 'metro://
|
|
101
|
+
return Line.user('codex', codexUserId());
|
|
102
|
+
return 'metro://user';
|
|
105
103
|
}
|
|
106
|
-
/** The current
|
|
104
|
+
/** The current user's **line** URI `<user-id>/<session>`. Null until session is known (rc thread pending). */
|
|
107
105
|
export function selfLine() {
|
|
108
106
|
if (process.env.CLAUDECODE) {
|
|
109
107
|
const s = claudeSessionId();
|
|
110
|
-
return s ? Line.claude(
|
|
108
|
+
return s ? Line.claude(claudeUserId(), s) : null;
|
|
111
109
|
}
|
|
112
110
|
if (process.env.METRO_CODEX_RC || process.env.CODEX_HOME) {
|
|
113
111
|
const s = codexSessionId();
|
|
114
|
-
return s ? Line.codex(
|
|
112
|
+
return s ? Line.codex(codexUserId(), s) : null;
|
|
115
113
|
}
|
|
116
114
|
return null;
|
|
117
115
|
}
|
package/dist/registry.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
/** Append-only registry of `(station,
|
|
1
|
+
/** Append-only registry of `(station, user-id, sessions[])` tuples metro has seen. */
|
|
2
2
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { STATE_DIR } from './paths.js';
|
|
5
5
|
import { Line } from './stations/index.js';
|
|
6
6
|
import { errMsg, log } from './log.js';
|
|
7
|
-
const REGISTRY_FILE = join(STATE_DIR, '
|
|
7
|
+
const REGISTRY_FILE = join(STATE_DIR, 'user-registry.json');
|
|
8
8
|
function readRegistry() {
|
|
9
9
|
if (!existsSync(REGISTRY_FILE))
|
|
10
10
|
return {};
|
|
@@ -12,16 +12,16 @@ function readRegistry() {
|
|
|
12
12
|
return JSON.parse(readFileSync(REGISTRY_FILE, 'utf8'));
|
|
13
13
|
}
|
|
14
14
|
catch (err) {
|
|
15
|
-
log.warn({ err: errMsg(err) }, '
|
|
15
|
+
log.warn({ err: errMsg(err) }, 'user-registry: malformed, resetting');
|
|
16
16
|
return {};
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
|
-
function record(station,
|
|
19
|
+
function record(station, userId, sessionId) {
|
|
20
20
|
const reg = readRegistry();
|
|
21
21
|
const rows = (reg[station] ??= []);
|
|
22
|
-
let row = rows.find(r => r.
|
|
22
|
+
let row = rows.find(r => r.userId === userId);
|
|
23
23
|
if (!row) {
|
|
24
|
-
row = {
|
|
24
|
+
row = { userId, sessions: [], lastSeen: '' };
|
|
25
25
|
rows.push(row);
|
|
26
26
|
}
|
|
27
27
|
if (sessionId && !row.sessions.includes(sessionId))
|
|
@@ -31,18 +31,18 @@ function record(station, agentId, sessionId) {
|
|
|
31
31
|
writeFileSync(REGISTRY_FILE, JSON.stringify(reg, null, 2));
|
|
32
32
|
}
|
|
33
33
|
catch (err) {
|
|
34
|
-
log.warn({ err: errMsg(err) }, '
|
|
34
|
+
log.warn({ err: errMsg(err) }, 'user-registry: write failed');
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
|
-
/** Scan a line URI for `(station,
|
|
38
|
-
export function
|
|
37
|
+
/** Scan a line URI for `(station, userId, sessionId)` and record it. No-op on non-user or participant URIs. */
|
|
38
|
+
export function noteUserFromLine(line) {
|
|
39
39
|
const station = Line.station(line);
|
|
40
40
|
if (station !== 'claude' && station !== 'codex')
|
|
41
41
|
return;
|
|
42
42
|
const p = station === 'claude' ? Line.parseClaude(line) : Line.parseCodex(line);
|
|
43
43
|
if (p)
|
|
44
|
-
record(station, p.
|
|
44
|
+
record(station, p.userId, p.sessionId);
|
|
45
45
|
}
|
|
46
|
-
export function
|
|
46
|
+
export function listUsers(station) {
|
|
47
47
|
return readRegistry()[station] ?? [];
|
|
48
48
|
}
|
package/dist/stations/claude.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/** Resolve the Claude Code
|
|
1
|
+
/** Resolve the Claude Code user identity (account id + session id). */
|
|
2
2
|
import { execFileSync } from 'node:child_process';
|
|
3
3
|
/** Short TTL so account switches via `claude auth login` propagate to the daemon within seconds. */
|
|
4
4
|
const TTL_MS = 5_000;
|
|
@@ -35,11 +35,11 @@ export function tryClaudeAccountId() {
|
|
|
35
35
|
return null;
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
|
-
/**
|
|
39
|
-
export function
|
|
40
|
-
return process.env.
|
|
38
|
+
/** User-id for the line URI: `METRO_USER_ID` override, else the account id. */
|
|
39
|
+
export function claudeUserId() {
|
|
40
|
+
return process.env.METRO_USER_ID || claudeAccountId();
|
|
41
41
|
}
|
|
42
|
-
/** Session: `CLAUDE_CODE_SESSION_ID` (stable across `--resume`). Override: `
|
|
42
|
+
/** Session: `CLAUDE_CODE_SESSION_ID` (stable across `--resume`). Override: `METRO_USER_SESSION_ID`. */
|
|
43
43
|
export function claudeSessionId() {
|
|
44
|
-
return process.env.
|
|
44
|
+
return process.env.METRO_USER_SESSION_ID || process.env.CLAUDE_CODE_SESSION_ID || null;
|
|
45
45
|
}
|
package/dist/stations/codex.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/** Resolve the Codex
|
|
1
|
+
/** Resolve the Codex user identity (account id + session id). */
|
|
2
2
|
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { dirname, join } from 'node:path';
|
|
@@ -42,15 +42,15 @@ export function tryCodexAccountId() {
|
|
|
42
42
|
return null;
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
|
-
/**
|
|
46
|
-
export function
|
|
47
|
-
return process.env.
|
|
45
|
+
/** User-id for the line URI: `METRO_USER_ID` override, else the account id. */
|
|
46
|
+
export function codexUserId() {
|
|
47
|
+
return process.env.METRO_USER_ID || codexAccountId();
|
|
48
48
|
}
|
|
49
49
|
const SESSION_FILE = join(STATE_DIR, 'stations', 'codex', 'session-id');
|
|
50
|
-
/** Session: codex-rc thread id (daemon persists; CLIs read state file). Override: `
|
|
50
|
+
/** Session: codex-rc thread id (daemon persists; CLIs read state file). Override: `METRO_USER_SESSION_ID`. */
|
|
51
51
|
export function codexSessionId() {
|
|
52
|
-
if (process.env.
|
|
53
|
-
return process.env.
|
|
52
|
+
if (process.env.METRO_USER_SESSION_ID)
|
|
53
|
+
return process.env.METRO_USER_SESSION_ID;
|
|
54
54
|
try {
|
|
55
55
|
return readFileSync(SESSION_FILE, 'utf8').trim() || null;
|
|
56
56
|
}
|