@stage-labs/metro 0.1.0-beta.1 → 0.1.0-beta.3
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 +74 -15
- package/dist/agents/claude.js +229 -0
- package/dist/agents/codex.js +282 -0
- package/dist/agents/types.js +3 -0
- package/dist/channels/discord.js +45 -51
- package/dist/channels/telegram.js +157 -85
- package/dist/cli.js +49 -344
- package/dist/lib/scope-cache.js +85 -0
- package/dist/lib/streaming.js +207 -0
- package/dist/log.js +7 -1
- package/dist/orchestrator.js +399 -0
- package/dist/paths.js +2 -6
- package/package.json +5 -4
- package/dist/lib/address.js +0 -21
- package/dist/tail.js +0 -145
- package/skills/metro/SKILL.md +0 -99
package/dist/lib/address.js
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
// `<platform>:<chat>[/<message_id>]` — the wire format of every metro
|
|
2
|
-
// inbound `to` field, and the only address shape any subcommand accepts.
|
|
3
|
-
export function parseAddress(to, requireMessage) {
|
|
4
|
-
const colon = to.indexOf(':');
|
|
5
|
-
if (colon === -1) {
|
|
6
|
-
throw new Error(`invalid --to (expected '<platform>:<chat>[/<message_id>]'): ${to}`);
|
|
7
|
-
}
|
|
8
|
-
const platform = to.slice(0, colon);
|
|
9
|
-
if (platform !== 'telegram' && platform !== 'discord') {
|
|
10
|
-
throw new Error(`unknown platform '${platform}' in --to (expected 'telegram' or 'discord')`);
|
|
11
|
-
}
|
|
12
|
-
const rest = to.slice(colon + 1);
|
|
13
|
-
const slash = rest.indexOf('/');
|
|
14
|
-
const chat = slash === -1 ? rest : rest.slice(0, slash);
|
|
15
|
-
const messageId = slash === -1 ? undefined : rest.slice(slash + 1);
|
|
16
|
-
if (!chat)
|
|
17
|
-
throw new Error(`empty chat/channel id in --to: ${to}`);
|
|
18
|
-
if (requireMessage && !messageId)
|
|
19
|
-
throw new Error(`--to must include /<message_id>: ${to}`);
|
|
20
|
-
return { platform, chat, messageId };
|
|
21
|
-
}
|
package/dist/tail.js
DELETED
|
@@ -1,145 +0,0 @@
|
|
|
1
|
-
// Standalone inbound stream. Polls Telegram + connects to Discord, prints
|
|
2
|
-
// one JSON line per inbound message on stdout. Designed to be launched by
|
|
3
|
-
// an agent and observed via Bash+Monitor (Claude Code) or unified_exec
|
|
4
|
-
// polling (Codex).
|
|
5
|
-
//
|
|
6
|
-
// On every inbound: fires a 👀 reaction and starts a typing indicator that
|
|
7
|
-
// refreshes until the agent replies (signaled by `metro reply` touching
|
|
8
|
-
// .typing-stop/<key>) or the 60s safety cap is hit.
|
|
9
|
-
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
10
|
-
import { join } from 'node:path';
|
|
11
|
-
import * as discord from './channels/discord.js';
|
|
12
|
-
import * as telegram from './channels/telegram.js';
|
|
13
|
-
import { tg } from './channels/telegram.js';
|
|
14
|
-
import { configuredPlatforms, loadMetroEnv, STATE_DIR, requireConfiguredPlatform } from './paths.js';
|
|
15
|
-
import { errMsg, log } from './log.js';
|
|
16
|
-
loadMetroEnv();
|
|
17
|
-
const platforms = configuredPlatforms();
|
|
18
|
-
requireConfiguredPlatform(platforms);
|
|
19
|
-
// Telegram allows only one getUpdates poller per bot token. If another
|
|
20
|
-
// `metro` instance is already running, exit cleanly instead of fighting
|
|
21
|
-
// (409 spam). Stale lockfiles (PID dead) are reclaimed.
|
|
22
|
-
const LOCK_FILE = join(STATE_DIR, '.tail-lock');
|
|
23
|
-
function processIsAlive(pid) {
|
|
24
|
-
try {
|
|
25
|
-
process.kill(pid, 0);
|
|
26
|
-
return true;
|
|
27
|
-
}
|
|
28
|
-
catch {
|
|
29
|
-
return false;
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
if (existsSync(LOCK_FILE)) {
|
|
33
|
-
const pid = Number(readFileSync(LOCK_FILE, 'utf8').trim());
|
|
34
|
-
if (Number.isInteger(pid) && pid > 0 && processIsAlive(pid)) {
|
|
35
|
-
log.info({ pid }, 'another `metro` instance is already polling; exiting');
|
|
36
|
-
process.exit(0);
|
|
37
|
-
}
|
|
38
|
-
try {
|
|
39
|
-
unlinkSync(LOCK_FILE);
|
|
40
|
-
}
|
|
41
|
-
catch { }
|
|
42
|
-
}
|
|
43
|
-
writeFileSync(LOCK_FILE, String(process.pid));
|
|
44
|
-
function releaseLock() {
|
|
45
|
-
try {
|
|
46
|
-
if (existsSync(LOCK_FILE) && readFileSync(LOCK_FILE, 'utf8').trim() === String(process.pid)) {
|
|
47
|
-
unlinkSync(LOCK_FILE);
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
catch { }
|
|
51
|
-
}
|
|
52
|
-
process.on('exit', releaseLock);
|
|
53
|
-
const TYPING_DIR = join(STATE_DIR, '.typing-stop');
|
|
54
|
-
const TYPING_REFRESH_MS = 4_000;
|
|
55
|
-
const TYPING_MAX_MS = 60_000;
|
|
56
|
-
mkdirSync(TYPING_DIR, { recursive: true });
|
|
57
|
-
const emit = (line) => process.stdout.write(`${JSON.stringify(line)}\n`);
|
|
58
|
-
const typingActive = new Map();
|
|
59
|
-
const typingKey = (platform, chat) => `${platform}_${chat}`;
|
|
60
|
-
function fireTyping(platform, chat) {
|
|
61
|
-
if (platform === 'telegram') {
|
|
62
|
-
void tg('sendChatAction', { chat_id: chat, action: 'typing' }).catch(err => log.warn({ err: errMsg(err) }, 'telegram typing failed'));
|
|
63
|
-
}
|
|
64
|
-
else {
|
|
65
|
-
void discord.sendTyping(chat).catch(err => log.warn({ err: errMsg(err) }, 'discord typing failed'));
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
function startTyping(platform, chat) {
|
|
69
|
-
const k = typingKey(platform, chat);
|
|
70
|
-
typingActive.set(k, { platform, chat, started: Date.now() });
|
|
71
|
-
// Clear any stale stop signal so the new typing actually fires.
|
|
72
|
-
const stopFile = join(TYPING_DIR, k);
|
|
73
|
-
if (existsSync(stopFile)) {
|
|
74
|
-
try {
|
|
75
|
-
unlinkSync(stopFile);
|
|
76
|
-
}
|
|
77
|
-
catch { }
|
|
78
|
-
}
|
|
79
|
-
fireTyping(platform, chat);
|
|
80
|
-
}
|
|
81
|
-
setInterval(() => {
|
|
82
|
-
const now = Date.now();
|
|
83
|
-
for (const [k, e] of typingActive) {
|
|
84
|
-
const stopFile = join(TYPING_DIR, k);
|
|
85
|
-
if (existsSync(stopFile)) {
|
|
86
|
-
try {
|
|
87
|
-
unlinkSync(stopFile);
|
|
88
|
-
}
|
|
89
|
-
catch { }
|
|
90
|
-
typingActive.delete(k);
|
|
91
|
-
continue;
|
|
92
|
-
}
|
|
93
|
-
if (now - e.started > TYPING_MAX_MS) {
|
|
94
|
-
typingActive.delete(k);
|
|
95
|
-
continue;
|
|
96
|
-
}
|
|
97
|
-
fireTyping(e.platform, e.chat);
|
|
98
|
-
}
|
|
99
|
-
}, TYPING_REFRESH_MS);
|
|
100
|
-
if (platforms.telegram) {
|
|
101
|
-
const me = await telegram.getMe();
|
|
102
|
-
log.info({ bot: `@${me.username}` }, 'telegram ready');
|
|
103
|
-
telegram.onInbound(m => {
|
|
104
|
-
void tg('setMessageReaction', {
|
|
105
|
-
chat_id: m.chat_id,
|
|
106
|
-
message_id: m.message_id,
|
|
107
|
-
reaction: [{ type: 'emoji', emoji: '👀' }],
|
|
108
|
-
}).catch(err => log.warn({ err: errMsg(err) }, 'telegram auto-react failed'));
|
|
109
|
-
const chat = String(m.chat_id);
|
|
110
|
-
startTyping('telegram', chat);
|
|
111
|
-
emit({ platform: 'telegram', to: `telegram:${chat}/${m.message_id}`, text: m.text });
|
|
112
|
-
});
|
|
113
|
-
void telegram.startPolling();
|
|
114
|
-
}
|
|
115
|
-
if (platforms.discord) {
|
|
116
|
-
await discord.startGateway();
|
|
117
|
-
const me = await discord.getMe();
|
|
118
|
-
log.info({ bot: me.username }, 'discord ready');
|
|
119
|
-
discord.onInbound(m => {
|
|
120
|
-
void discord
|
|
121
|
-
.setReaction(m.channel_id, m.message_id, '👀')
|
|
122
|
-
.catch(err => log.warn({ err: errMsg(err) }, 'discord auto-react failed'));
|
|
123
|
-
startTyping('discord', m.channel_id);
|
|
124
|
-
emit({ platform: 'discord', to: `discord:${m.channel_id}/${m.message_id}`, text: m.text });
|
|
125
|
-
});
|
|
126
|
-
}
|
|
127
|
-
// `process.on('exit', releaseLock)` above runs whenever process.exit is
|
|
128
|
-
// called. We also `await discord.shutdownGateway()` here so the bot flips
|
|
129
|
-
// offline immediately on SIGTERM / SIGINT instead of waiting ~45s for the
|
|
130
|
-
// gateway's missed-heartbeat timeout. SIGKILL bypasses this (nothing we can
|
|
131
|
-
// do); the lockfile auto-reclaims on the next start either way.
|
|
132
|
-
let shuttingDown = false;
|
|
133
|
-
async function shutdown() {
|
|
134
|
-
if (shuttingDown)
|
|
135
|
-
return;
|
|
136
|
-
shuttingDown = true;
|
|
137
|
-
if (platforms.discord) {
|
|
138
|
-
await discord.shutdownGateway().catch(err => log.warn({ err: errMsg(err) }, 'discord shutdown failed'));
|
|
139
|
-
}
|
|
140
|
-
process.exit(0);
|
|
141
|
-
}
|
|
142
|
-
process.stdin.on('end', shutdown);
|
|
143
|
-
process.stdin.on('close', shutdown);
|
|
144
|
-
process.on('SIGINT', shutdown);
|
|
145
|
-
process.on('SIGTERM', shutdown);
|
package/skills/metro/SKILL.md
DELETED
|
@@ -1,99 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: metro
|
|
3
|
-
description: Run the metro Telegram/Discord bridge in this session — launch `metro` in the background, watch its stdout for inbound JSON lines, and act on each. Use when the user asks to start/run/launch metro, when you see JSON lines on stdout shaped `{"platform":..., "to":..., "text":...}`, or when handling a chat reply/react/edit/send/download/fetch.
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# Metro — running the Telegram & Discord bridge
|
|
7
|
-
|
|
8
|
-
Metro is a CLI bridge between this agent session and Telegram/Discord. You launch `metro` once when the user asks, then act on each inbound JSON line via `metro <subcommand>`.
|
|
9
|
-
|
|
10
|
-
## Starting the bridge
|
|
11
|
-
|
|
12
|
-
When the user asks to run/start/launch metro (or "start the bridge"):
|
|
13
|
-
|
|
14
|
-
1. Launch `metro` as a backgrounded Bash command (Claude Code: `run_in_background: true`; Codex: equivalent background spawn). Don't block on it.
|
|
15
|
-
2. Attach a stdout watcher: `Monitor` on Claude Code, `unified_exec` polling on Codex. Each stdout line is one inbound JSON. Stderr is logs — don't act on it.
|
|
16
|
-
3. If `metro` exits immediately or no inbounds arrive within a minute of a known DM, run `metro doctor` to diagnose. Common causes: missing tokens (tell the user to run `metro setup telegram <token>` and/or `metro setup discord <token>` in their shell), Discord Message Content Intent not toggled, or a stale lockfile from a previous session.
|
|
17
|
-
|
|
18
|
-
## Inbound shape
|
|
19
|
-
|
|
20
|
-
Each `metro` line on stdout:
|
|
21
|
-
|
|
22
|
-
```json
|
|
23
|
-
{"platform":"telegram"|"discord","to":"<platform>:<chat>/<message_id>","text":"…"}
|
|
24
|
-
```
|
|
25
|
-
|
|
26
|
-
`text` may include placeholders for non-text content: `[image]`, `[voice]`, `[audio]`, `[file: <name>]`. Voice/audio are opaque markers — you can't download them.
|
|
27
|
-
|
|
28
|
-
Discord guild messages preserve the user's raw mention markup, including the bot's own `<@bot_id>` (the gate that made the message visible). Treat the bot's own mention as metadata; other users' mentions (`<@some_other_id>`) can be addressee context. Reply normally — the message is addressed to you regardless of where the mention sits.
|
|
29
|
-
|
|
30
|
-
## Required flow on every inbound
|
|
31
|
-
|
|
32
|
-
1. **Echo to the visible reply.** Write `[<to>] <text>` on its own line in your visible output. Both Claude Code's Monitor and Codex dim/collapse tool output, so this echo is the only way the user sees what arrived without expanding cards.
|
|
33
|
-
2. **Decide and act.** Pick the matching subcommand below.
|
|
34
|
-
|
|
35
|
-
> 👀 is already on the message — `metro` auto-reacts server-side on every inbound and clears the reaction when you reply. Don't call `metro react --emoji=👀` yourself; you'd just flicker it on/off and waste a tool call.
|
|
36
|
-
|
|
37
|
-
## Subcommands
|
|
38
|
-
|
|
39
|
-
`reply` / `react` / `edit` / `download` take `--to=<platform>:<chat>/<message_id>` copied verbatim from the inbound `to` field. `send` and `fetch` take a channel-only `--to=<platform>:<chat>` (no message id). Append `--json` to any of them for a single JSON result line you can parse.
|
|
40
|
-
|
|
41
|
-
| Action | Command |
|
|
42
|
-
|---|---|
|
|
43
|
-
| Quote-reply (threads under original; clears 👀) | `metro reply --to=<to> --text=<reply>` |
|
|
44
|
-
| Quick ack reaction | `metro react --to=<to> --emoji=👍` |
|
|
45
|
-
| Edit your previous bot message | `metro edit --to=<to> --text=<new text>` |
|
|
46
|
-
| Send a proactive message (no reply context) | `metro send --to=<platform>:<chat_id> --text=<msg>` |
|
|
47
|
-
| Download `[image]` attachments → file paths | `metro download --to=<to>` |
|
|
48
|
-
| Fetch recent channel history (Discord only) | `metro fetch --to=discord:<channel_id> --limit=20` |
|
|
49
|
-
|
|
50
|
-
`reply` / `edit` / `send` accept multi-line `--text` via stdin (heredoc).
|
|
51
|
-
|
|
52
|
-
## When to use `send` vs `reply`
|
|
53
|
-
|
|
54
|
-
- **`reply`** — responding to a specific inbound message. Threads under it. This is the default when handling a `metro` inbound line.
|
|
55
|
-
- **`send`** — initiating without a triggering message: a long task you kicked off finished, a scheduled job fired, a follow-up the user asked you to deliver later. The chat/channel id you target must be one the bot can reach (existing DM, joined guild channel).
|
|
56
|
-
|
|
57
|
-
## Address format
|
|
58
|
-
|
|
59
|
-
- `telegram:<chat_id>/<message_id>` — copied straight from inbound `to`
|
|
60
|
-
- `discord:<channel_id>/<message_id>` — same
|
|
61
|
-
- `discord:<channel_id>` — channel-only, used for `metro fetch`
|
|
62
|
-
|
|
63
|
-
## Image attachments
|
|
64
|
-
|
|
65
|
-
When `text` contains `[image]`:
|
|
66
|
-
|
|
67
|
-
1. Run `metro download --to=<to>` — writes images to disk and prints absolute paths (one per line).
|
|
68
|
-
2. `Read` each path with the Read tool — the image enters your context as a vision input.
|
|
69
|
-
3. Reply normally with `metro reply`.
|
|
70
|
-
|
|
71
|
-
## Opaque attachment markers
|
|
72
|
-
|
|
73
|
-
`[voice]`, `[audio: <name>]`, and `[file: <name>]` are opaque — `metro download` only handles images. Acknowledge in text (e.g., "got your voice note — could you type it out?") or, if your runtime accepts audio/file input directly, ask the user to resend as a regular file.
|
|
74
|
-
|
|
75
|
-
## Exit codes
|
|
76
|
-
|
|
77
|
-
- `0` success
|
|
78
|
-
- `1` usage error (bad flags, unknown subcommand)
|
|
79
|
-
- `2` configuration error (no tokens; tell the user to run `metro setup`)
|
|
80
|
-
- `3` upstream error (rate limit, auth, network) — wait a few seconds and retry once before surfacing to the user
|
|
81
|
-
|
|
82
|
-
If anything's misbehaving, run `metro doctor` to see which check fails.
|
|
83
|
-
|
|
84
|
-
## --json output
|
|
85
|
-
|
|
86
|
-
Every action command supports `--json` for stable, parseable output:
|
|
87
|
-
|
|
88
|
-
```bash
|
|
89
|
-
metro reply --to=… --text=… --json
|
|
90
|
-
# {"ok":true,"platform":"discord","to":"discord:123/456","sent_message_id":"…"}
|
|
91
|
-
|
|
92
|
-
metro fetch --to=discord:1234 --limit=10 --json
|
|
93
|
-
# [{"message_id":"…","author":"…","text":"…","timestamp":"…"}, …]
|
|
94
|
-
|
|
95
|
-
metro download --to=… --json
|
|
96
|
-
# {"images":[{"path":"/abs/…png","mime":"image/png"}]}
|
|
97
|
-
```
|
|
98
|
-
|
|
99
|
-
Use `--json` when you need to chain calls or capture the new message_id for a later edit.
|