@stage-labs/metro 0.1.0-beta.2 → 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 +67 -31
- 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 +47 -354
- package/dist/lib/scope-cache.js +85 -0
- package/dist/lib/streaming.js +207 -0
- package/dist/orchestrator.js +399 -0
- package/dist/paths.js +2 -14
- package/package.json +2 -3
- package/dist/lib/address.js +0 -21
- package/dist/lib/codex-rc.js +0 -274
- package/dist/tail.js +0 -161
- package/skills/metro/SKILL.md +0 -122
package/dist/cli.js
CHANGED
|
@@ -1,73 +1,32 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { spawn } from 'node:child_process';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
6
5
|
import pkg from '../package.json' with { type: 'json' };
|
|
7
6
|
import * as discord from './channels/discord.js';
|
|
8
7
|
import * as telegram from './channels/telegram.js';
|
|
9
|
-
import { buildSendBody, tg } from './channels/telegram.js';
|
|
10
|
-
import { parseAddress } from './lib/address.js';
|
|
11
8
|
import { readDotenv, writeDotenv } from './lib/dotenv.js';
|
|
12
|
-
import { errMsg
|
|
13
|
-
import { CONFIG_ENV_FILE, configuredPlatforms, loadMetroEnv, STATE_DIR
|
|
14
|
-
|
|
15
|
-
const USAGE = `metro — Telegram + Discord bridge for your agent
|
|
9
|
+
import { errMsg } from './log.js';
|
|
10
|
+
import { CONFIG_ENV_FILE, configuredPlatforms, loadMetroEnv, STATE_DIR } from './paths.js';
|
|
11
|
+
const USAGE = `metro — Telegram + Discord bridge for your Claude Code / Codex agent
|
|
16
12
|
|
|
17
13
|
Usage:
|
|
18
|
-
metro
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
metro
|
|
22
|
-
metro
|
|
23
|
-
metro
|
|
24
|
-
metro send --to=<addr> [--text=<t>] Send a proactive message (no reply context).
|
|
25
|
-
<addr> is channel-only: <platform>:<chat_id> (no /message_id).
|
|
26
|
-
metro download --to=<addr> [--out=<dir>] Download image attachments to disk.
|
|
27
|
-
metro fetch --to=<addr> [--limit=N] Recent-message lookback (Discord only).
|
|
28
|
-
metro update Upgrade in place (npm/bun/pnpm auto-detected).
|
|
29
|
-
|
|
30
|
-
setup verbs:
|
|
31
|
-
metro setup Status: tokens, skills, what's next.
|
|
32
|
-
metro setup telegram <token> Save TELEGRAM_BOT_TOKEN (validated via getMe; --no-validate skips).
|
|
33
|
-
metro setup discord <token> Save DISCORD_BOT_TOKEN (validated via getMe; --no-validate skips).
|
|
14
|
+
metro Run the orchestrator daemon.
|
|
15
|
+
Each chat conversation gets its own
|
|
16
|
+
codex session, with streaming responses.
|
|
17
|
+
metro setup Show config status (tokens).
|
|
18
|
+
metro setup telegram <token> Save TELEGRAM_BOT_TOKEN (validated via getMe).
|
|
19
|
+
metro setup discord <token> Save DISCORD_BOT_TOKEN (validated via getMe).
|
|
34
20
|
metro setup clear [telegram|discord|all] Remove tokens.
|
|
35
|
-
metro
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
discord:<channel_id>/<message_id> e.g. discord:1234567890/9876543210
|
|
40
|
-
discord:<channel_id> fetch only (no message id)
|
|
41
|
-
|
|
42
|
-
Common flags:
|
|
43
|
-
--json machine-parseable output (single JSON line/array)
|
|
44
|
-
--version, -v print the metro version
|
|
45
|
-
--help, -h print this help
|
|
46
|
-
|
|
47
|
-
reply / edit Telegram extras:
|
|
48
|
-
--parse-mode=HTML|MarkdownV2 --no-link-preview --buttons-json='[[{"text":"…","url":"…"}]]'
|
|
21
|
+
metro doctor Health check: tokens + gateway reachability.
|
|
22
|
+
metro update Upgrade in place (npm/bun/pnpm auto-detected).
|
|
23
|
+
metro --version, -v Print version.
|
|
24
|
+
metro --help, -h This help.
|
|
49
25
|
|
|
50
26
|
Exit codes:
|
|
51
|
-
0
|
|
52
|
-
1 usage error (bad flags, unknown subcommand)
|
|
53
|
-
2 configuration error (no tokens — run \`metro setup\`)
|
|
54
|
-
3 upstream error (rate limit, auth, network — retry once, then surface)
|
|
55
|
-
|
|
56
|
-
Codex push (opt-in):
|
|
57
|
-
Set METRO_CODEX_RC to the codex app-server URL — metro will push each
|
|
58
|
-
inbound into the agent's history via JSON-RPC \`turn/start\`, the Codex
|
|
59
|
-
equivalent of Claude Code's Monitor.
|
|
60
|
-
|
|
61
|
-
Three terminals share the same URL (codex 0.130's TUI --remote only
|
|
62
|
-
accepts ws://):
|
|
63
|
-
|
|
64
|
-
codex app-server --listen ws://127.0.0.1:8421 # daemon
|
|
65
|
-
METRO_CODEX_RC=ws://127.0.0.1:8421 metro # bridge
|
|
66
|
-
codex --remote ws://127.0.0.1:8421 # TUI
|
|
27
|
+
0 success · 1 usage · 2 config (no tokens — run \`metro setup\`) · 3 upstream
|
|
67
28
|
`;
|
|
68
|
-
function exitErr(
|
|
69
|
-
return Object.assign(new Error(message), { code });
|
|
70
|
-
}
|
|
29
|
+
function exitErr(msg, code) { return Object.assign(new Error(msg), { code }); }
|
|
71
30
|
function parseArgs(argv) {
|
|
72
31
|
const positional = [];
|
|
73
32
|
const flags = {};
|
|
@@ -93,91 +52,17 @@ function parseArgs(argv) {
|
|
|
93
52
|
}
|
|
94
53
|
return { positional, flags };
|
|
95
54
|
}
|
|
96
|
-
const isJson = (
|
|
97
|
-
|
|
98
|
-
process.stdout.write(isJson(
|
|
99
|
-
}
|
|
100
|
-
function
|
|
101
|
-
|
|
102
|
-
if (!configuredPlatforms()[addr.platform]) {
|
|
103
|
-
throw exitErr(`platform '${addr.platform}' is not configured (missing token)`, 2);
|
|
104
|
-
}
|
|
105
|
-
return addr;
|
|
106
|
-
}
|
|
107
|
-
function tgMessageId(addr) {
|
|
108
|
-
const n = Number(addr.messageId);
|
|
109
|
-
if (!Number.isInteger(n))
|
|
110
|
-
throw new Error(`telegram message_id must be an integer: ${addr.messageId}`);
|
|
111
|
-
return n;
|
|
112
|
-
}
|
|
113
|
-
// Tell tail.ts to stop refreshing the typing indicator (the agent has replied).
|
|
114
|
-
function signalReplyComplete(platform, chat) {
|
|
115
|
-
const dir = join(STATE_DIR, '.typing-stop');
|
|
116
|
-
try {
|
|
117
|
-
mkdirSync(dir, { recursive: true });
|
|
118
|
-
writeFileSync(join(dir, `${platform}_${chat}`), '');
|
|
119
|
-
}
|
|
120
|
-
catch (err) {
|
|
121
|
-
log.warn({ err: errMsg(err) }, 'typing stop-signal write failed');
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
async function clearReaction(addr) {
|
|
125
|
-
if (addr.platform === 'telegram') {
|
|
126
|
-
await tg('setMessageReaction', { chat_id: addr.chat, message_id: tgMessageId(addr), reaction: [] });
|
|
127
|
-
}
|
|
128
|
-
else {
|
|
129
|
-
await discord.setReaction(addr.chat, addr.messageId, '');
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
async function readStdinText() {
|
|
133
|
-
if (process.stdin.isTTY)
|
|
134
|
-
return '';
|
|
135
|
-
const chunks = [];
|
|
136
|
-
for await (const c of process.stdin)
|
|
137
|
-
chunks.push(c);
|
|
138
|
-
return Buffer.concat(chunks).toString('utf8');
|
|
139
|
-
}
|
|
140
|
-
async function resolveText(flags) {
|
|
141
|
-
if (typeof flags.text === 'string')
|
|
142
|
-
return flags.text;
|
|
143
|
-
const stdin = (await readStdinText()).replace(/\n$/, '');
|
|
144
|
-
if (!stdin)
|
|
145
|
-
throw new Error('--text is required (or pipe text on stdin)');
|
|
146
|
-
return stdin;
|
|
147
|
-
}
|
|
148
|
-
function readTelegramOpts(flags) {
|
|
149
|
-
const opts = {};
|
|
150
|
-
if (flags['parse-mode']) {
|
|
151
|
-
const v = String(flags['parse-mode']);
|
|
152
|
-
if (v !== 'HTML' && v !== 'MarkdownV2')
|
|
153
|
-
throw new Error("--parse-mode must be 'HTML' or 'MarkdownV2'");
|
|
154
|
-
opts.parseMode = v;
|
|
155
|
-
}
|
|
156
|
-
if (flags['no-link-preview'])
|
|
157
|
-
opts.disableLinkPreview = true;
|
|
158
|
-
if (flags['buttons-json'])
|
|
159
|
-
opts.buttons = JSON.parse(String(flags['buttons-json']));
|
|
160
|
-
return opts;
|
|
161
|
-
}
|
|
162
|
-
function maskToken(token) {
|
|
163
|
-
if (!token)
|
|
55
|
+
const isJson = (f) => f.json === true;
|
|
56
|
+
const emitResult = (f, human, structured) => {
|
|
57
|
+
process.stdout.write(isJson(f) ? JSON.stringify(structured) + '\n' : human + '\n');
|
|
58
|
+
};
|
|
59
|
+
function maskToken(t) {
|
|
60
|
+
if (!t)
|
|
164
61
|
return '';
|
|
165
|
-
if (
|
|
62
|
+
if (t.length <= 8)
|
|
166
63
|
return '••••';
|
|
167
|
-
return `${
|
|
64
|
+
return `${t.slice(0, 6)}…${t.slice(-2)}`;
|
|
168
65
|
}
|
|
169
|
-
const EXT_FROM_MIME = {
|
|
170
|
-
'image/png': 'png',
|
|
171
|
-
'image/jpeg': 'jpg',
|
|
172
|
-
'image/gif': 'gif',
|
|
173
|
-
'image/webp': 'webp',
|
|
174
|
-
};
|
|
175
|
-
// Path to the SKILL.md bundled with the npm package. dist/cli.js → ../skills/metro/SKILL.md.
|
|
176
|
-
const BUNDLED_SKILL = join(dirname(fileURLToPath(import.meta.url)), '..', 'skills', 'metro', 'SKILL.md');
|
|
177
|
-
const SKILL_RUNTIMES = ['claude-code', 'codex'];
|
|
178
|
-
const SKILL_SCOPES = ['user', 'project'];
|
|
179
|
-
const skillFile = (runtime, scope) => join(skillDir(runtime, scope), 'SKILL.md');
|
|
180
|
-
// ---------------- setup ----------------------------------------------------
|
|
181
66
|
const TOKEN_KEYS = { telegram: 'TELEGRAM_BOT_TOKEN', discord: 'DISCORD_BOT_TOKEN' };
|
|
182
67
|
async function cmdSetup(positional, flags) {
|
|
183
68
|
const [sub, value] = positional;
|
|
@@ -189,17 +74,13 @@ async function cmdSetup(positional, flags) {
|
|
|
189
74
|
const trimmed = value.trim();
|
|
190
75
|
let identity;
|
|
191
76
|
if (!flags['no-validate']) {
|
|
192
|
-
// Validate against the platform before persisting — catches typos /
|
|
193
|
-
// wrong-token-from-portal / rotated tokens at the earliest moment.
|
|
194
77
|
process.env[TOKEN_KEYS[sub]] = trimmed;
|
|
195
78
|
try {
|
|
196
|
-
identity = sub === 'telegram'
|
|
197
|
-
? `@${(await telegram.getMe()).username}`
|
|
198
|
-
: (await discord.getMe()).username;
|
|
79
|
+
identity = sub === 'telegram' ? `@${(await telegram.getMe()).username}` : (await discord.getMe()).username;
|
|
199
80
|
}
|
|
200
81
|
catch (err) {
|
|
201
82
|
delete process.env[TOKEN_KEYS[sub]];
|
|
202
|
-
throw exitErr(`token rejected by ${sub}: ${errMsg(err)} (
|
|
83
|
+
throw exitErr(`token rejected by ${sub}: ${errMsg(err)} (use --no-validate to save anyway)`, 3);
|
|
203
84
|
}
|
|
204
85
|
}
|
|
205
86
|
const env = readDotenv(CONFIG_ENV_FILE);
|
|
@@ -208,7 +89,7 @@ async function cmdSetup(positional, flags) {
|
|
|
208
89
|
const human = identity
|
|
209
90
|
? `saved ${TOKEN_KEYS[sub]} (verified as ${identity}) to ${CONFIG_ENV_FILE} (chmod 0600)`
|
|
210
91
|
: `saved ${TOKEN_KEYS[sub]} to ${CONFIG_ENV_FILE} (chmod 0600)`;
|
|
211
|
-
emitResult(flags, human, { ok: true, saved: TOKEN_KEYS[sub],
|
|
92
|
+
emitResult(flags, human, { ok: true, saved: TOKEN_KEYS[sub], verified_as: identity ?? null });
|
|
212
93
|
return;
|
|
213
94
|
}
|
|
214
95
|
if (sub === 'clear') {
|
|
@@ -218,30 +99,20 @@ async function cmdSetup(positional, flags) {
|
|
|
218
99
|
delete env.TELEGRAM_BOT_TOKEN;
|
|
219
100
|
delete env.DISCORD_BOT_TOKEN;
|
|
220
101
|
}
|
|
221
|
-
else if (target === 'telegram' || target === 'discord')
|
|
102
|
+
else if (target === 'telegram' || target === 'discord')
|
|
222
103
|
delete env[TOKEN_KEYS[target]];
|
|
223
|
-
|
|
224
|
-
else {
|
|
104
|
+
else
|
|
225
105
|
throw new Error(`metro setup clear <telegram|discord|all> — got '${target}'`);
|
|
226
|
-
}
|
|
227
106
|
writeDotenv(CONFIG_ENV_FILE, env);
|
|
228
|
-
|
|
229
|
-
emitResult(flags, human, { ok: true, cleared: target, path: CONFIG_ENV_FILE });
|
|
107
|
+
emitResult(flags, `cleared ${target === 'all' ? 'all metro tokens' : TOKEN_KEYS[target]}`, { ok: true, cleared: target });
|
|
230
108
|
return;
|
|
231
109
|
}
|
|
232
|
-
|
|
233
|
-
return cmdSetupSkill(flags);
|
|
234
|
-
throw new Error(`unknown setup subcommand '${sub}' (try: telegram, discord, clear, skill)`);
|
|
235
|
-
}
|
|
236
|
-
function readSkillState() {
|
|
237
|
-
return Object.fromEntries(SKILL_RUNTIMES.map(r => [r, Object.fromEntries(SKILL_SCOPES.map(s => [s, existsSync(skillFile(r, s))]))]));
|
|
110
|
+
throw new Error(`unknown setup subcommand '${sub}' (try: telegram, discord, clear)`);
|
|
238
111
|
}
|
|
239
112
|
async function cmdSetupStatus(flags) {
|
|
240
113
|
loadMetroEnv();
|
|
241
114
|
const tg = process.env.TELEGRAM_BOT_TOKEN ?? '';
|
|
242
115
|
const dc = process.env.DISCORD_BOT_TOKEN ?? '';
|
|
243
|
-
const skills = readSkillState();
|
|
244
|
-
const anySkill = SKILL_RUNTIMES.some(r => SKILL_SCOPES.some(s => skills[r][s]));
|
|
245
116
|
if (isJson(flags)) {
|
|
246
117
|
process.stdout.write(JSON.stringify({
|
|
247
118
|
version: pkg.version,
|
|
@@ -250,64 +121,27 @@ async function cmdSetupStatus(flags) {
|
|
|
250
121
|
telegram: { set: !!tg, masked: maskToken(tg) },
|
|
251
122
|
discord: { set: !!dc, masked: maskToken(dc) },
|
|
252
123
|
},
|
|
253
|
-
skills,
|
|
254
124
|
}) + '\n');
|
|
255
125
|
return;
|
|
256
126
|
}
|
|
257
|
-
const fmtSkill = (s) => {
|
|
258
|
-
const set = SKILL_SCOPES.filter(k => s[k]);
|
|
259
|
-
return set.length ? `installed (${set.join(' + ')})` : 'not installed';
|
|
260
|
-
};
|
|
261
127
|
process.stdout.write(`metro ${pkg.version}\n\n` +
|
|
262
128
|
`config: ${CONFIG_ENV_FILE}${existsSync(CONFIG_ENV_FILE) ? '' : ' (not yet written)'}\n\n` +
|
|
263
129
|
` TELEGRAM_BOT_TOKEN ${tg ? `set (${maskToken(tg)})` : 'not set'}\n` +
|
|
264
|
-
` DISCORD_BOT_TOKEN ${dc ? `set (${maskToken(dc)})` : 'not set'}\n\n`
|
|
265
|
-
'Skills:\n' +
|
|
266
|
-
` claude-code ${fmtSkill(skills['claude-code'])}\n` +
|
|
267
|
-
` codex ${fmtSkill(skills.codex)}\n\n`);
|
|
130
|
+
` DISCORD_BOT_TOKEN ${dc ? `set (${maskToken(dc)})` : 'not set'}\n\n`);
|
|
268
131
|
if (!tg && !dc) {
|
|
269
132
|
process.stdout.write('Get started:\n' +
|
|
270
133
|
' 1. metro setup telegram <token> # https://t.me/BotFather\n' +
|
|
271
134
|
' metro setup discord <token> # https://discord.com/developers/applications\n' +
|
|
272
|
-
' 2. metro
|
|
273
|
-
' 3. metro
|
|
274
|
-
' 4. metro # start the inbound stream\n');
|
|
275
|
-
}
|
|
276
|
-
else if (!anySkill) {
|
|
277
|
-
process.stdout.write('Next: `metro setup skill` to auto-onboard your agent. Then `metro doctor`, then `metro`.\n');
|
|
135
|
+
' 2. metro doctor # verify\n' +
|
|
136
|
+
' 3. metro # run the orchestrator\n');
|
|
278
137
|
}
|
|
279
138
|
else {
|
|
280
|
-
process.stdout.write('Run `metro` to start the
|
|
139
|
+
process.stdout.write('Run `metro` to start the orchestrator, or `metro doctor` to verify.\n');
|
|
281
140
|
}
|
|
282
141
|
}
|
|
283
|
-
async function cmdSetupSkill(flags) {
|
|
284
|
-
const scope = flags.project ? 'project' : 'user';
|
|
285
|
-
if (flags.clear) {
|
|
286
|
-
const removed = [];
|
|
287
|
-
for (const runtime of SKILL_RUNTIMES) {
|
|
288
|
-
const dir = skillDir(runtime, scope);
|
|
289
|
-
if (existsSync(dir)) {
|
|
290
|
-
rmSync(dir, { recursive: true, force: true });
|
|
291
|
-
removed.push(dir);
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
emitResult(flags, removed.length ? `removed:\n ${removed.join('\n ')}` : '(no skills installed at this scope)', { ok: true, cleared: removed });
|
|
295
|
-
return;
|
|
296
|
-
}
|
|
297
|
-
if (!existsSync(BUNDLED_SKILL))
|
|
298
|
-
throw new Error(`bundled SKILL.md missing at ${BUNDLED_SKILL} (broken install?)`);
|
|
299
|
-
const written = SKILL_RUNTIMES.map(r => {
|
|
300
|
-
const dest = skillFile(r, scope);
|
|
301
|
-
mkdirSync(dirname(dest), { recursive: true });
|
|
302
|
-
copyFileSync(BUNDLED_SKILL, dest);
|
|
303
|
-
return dest;
|
|
304
|
-
});
|
|
305
|
-
emitResult(flags, `wrote skill (${scope}) to:\n ${written.join('\n ')}\n\nThe agent will pick it up on its next session start.`, { ok: true, scope, paths: written });
|
|
306
|
-
}
|
|
307
142
|
async function cmdDoctor(flags) {
|
|
308
143
|
loadMetroEnv();
|
|
309
144
|
const checks = [];
|
|
310
|
-
// tokens
|
|
311
145
|
const cfg = configuredPlatforms();
|
|
312
146
|
checks.push({
|
|
313
147
|
name: 'tokens',
|
|
@@ -316,7 +150,6 @@ async function cmdDoctor(flags) {
|
|
|
316
150
|
? `loaded from ${existsSync(CONFIG_ENV_FILE) ? CONFIG_ENV_FILE : 'process env'}`
|
|
317
151
|
: 'no platform configured — run `metro setup telegram|discord <token>`',
|
|
318
152
|
});
|
|
319
|
-
// gateway / API healthcheck per configured platform
|
|
320
153
|
for (const [platform, getMe] of [['telegram', telegram.getMe], ['discord', discord.getMe]]) {
|
|
321
154
|
if (!cfg[platform]) {
|
|
322
155
|
checks.push({ name: platform, ok: null, detail: 'not configured' });
|
|
@@ -330,11 +163,9 @@ async function cmdDoctor(flags) {
|
|
|
330
163
|
checks.push({ name: platform, ok: false, detail: errMsg(err) });
|
|
331
164
|
}
|
|
332
165
|
}
|
|
333
|
-
// tail process state. A stale lockfile (process gone) is informational —
|
|
334
|
-
// the next `metro` start auto-reclaims it, so we don't fail the doctor on it.
|
|
335
166
|
const lockFile = join(STATE_DIR, '.tail-lock');
|
|
336
167
|
if (!existsSync(lockFile)) {
|
|
337
|
-
checks.push({ name: '
|
|
168
|
+
checks.push({ name: 'orchestrator', ok: null, detail: 'not running' });
|
|
338
169
|
}
|
|
339
170
|
else {
|
|
340
171
|
try {
|
|
@@ -342,26 +173,11 @@ async function cmdDoctor(flags) {
|
|
|
342
173
|
if (!Number.isInteger(pid) || pid <= 0)
|
|
343
174
|
throw new Error('invalid pid');
|
|
344
175
|
process.kill(pid, 0);
|
|
345
|
-
checks.push({ name: '
|
|
176
|
+
checks.push({ name: 'orchestrator', ok: true, detail: `running (pid ${pid})` });
|
|
346
177
|
}
|
|
347
178
|
catch {
|
|
348
|
-
checks.push({ name: '
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
// skill install + freshness vs bundled
|
|
352
|
-
for (const runtime of SKILL_RUNTIMES) {
|
|
353
|
-
const present = SKILL_SCOPES.filter(s => existsSync(skillFile(runtime, s)));
|
|
354
|
-
if (present.length === 0) {
|
|
355
|
-
checks.push({ name: `skill: ${runtime}`, ok: null, detail: 'not installed — run `metro setup skill` (writes both runtimes)' });
|
|
356
|
-
continue;
|
|
179
|
+
checks.push({ name: 'orchestrator', ok: null, detail: 'stale lockfile (will auto-reclaim on next start)' });
|
|
357
180
|
}
|
|
358
|
-
const bundled = existsSync(BUNDLED_SKILL) ? readFileSync(BUNDLED_SKILL) : null;
|
|
359
|
-
const stale = bundled
|
|
360
|
-
? present.filter(s => !readFileSync(skillFile(runtime, s)).equals(bundled))
|
|
361
|
-
: [];
|
|
362
|
-
checks.push(stale.length === 0
|
|
363
|
-
? { name: `skill: ${runtime}`, ok: true, detail: present.join(' + ') }
|
|
364
|
-
: { name: `skill: ${runtime}`, ok: false, detail: `stale at ${stale.join(' + ')} — re-run \`metro setup skill${stale.includes('project') ? ' --project' : ''}\`` });
|
|
365
181
|
}
|
|
366
182
|
if (isJson(flags)) {
|
|
367
183
|
process.stdout.write(JSON.stringify({ checks }) + '\n');
|
|
@@ -370,17 +186,14 @@ async function cmdDoctor(flags) {
|
|
|
370
186
|
process.stdout.write('metro doctor\n\n');
|
|
371
187
|
for (const c of checks) {
|
|
372
188
|
const icon = c.ok === true ? '✓' : c.ok === false ? '✗' : '–';
|
|
373
|
-
process.stdout.write(` ${icon} ${c.name.padEnd(
|
|
189
|
+
process.stdout.write(` ${icon} ${c.name.padEnd(15)} ${c.detail}\n`);
|
|
374
190
|
}
|
|
375
191
|
process.stdout.write('\n');
|
|
376
192
|
}
|
|
377
193
|
if (checks.some(c => c.ok === false))
|
|
378
194
|
throw exitErr('one or more checks failed', 3);
|
|
379
195
|
}
|
|
380
|
-
// ---------------- update ---------------------------------------------------
|
|
381
196
|
async function cmdUpdate(flags) {
|
|
382
|
-
// While metro is in prerelease, the @beta dist-tag is what we publish.
|
|
383
|
-
// After GA, swap to 'latest' (or auto-pick from current version's prerelease tag).
|
|
384
197
|
const tag = pkg.version.includes('-') ? 'beta' : 'latest';
|
|
385
198
|
const res = await fetch('https://registry.npmjs.org/@stage-labs/metro', { signal: AbortSignal.timeout(15_000) });
|
|
386
199
|
if (!res.ok)
|
|
@@ -399,138 +212,22 @@ async function cmdUpdate(flags) {
|
|
|
399
212
|
: argv1.includes('/pnpm/') || argv1.includes('\\pnpm\\') ? ['pnpm', 'add', '-g', spec]
|
|
400
213
|
: ['npm', 'install', '-g', spec];
|
|
401
214
|
if (isJson(flags)) {
|
|
402
|
-
process.stdout.write(JSON.stringify({ ok: true, current: pkg.version, latest, command: argv.join(' ')
|
|
215
|
+
process.stdout.write(JSON.stringify({ ok: true, current: pkg.version, latest, command: argv.join(' ') }) + '\n');
|
|
403
216
|
}
|
|
404
217
|
else {
|
|
405
218
|
process.stdout.write(`metro ${pkg.version} → ${latest}\n$ ${argv.join(' ')}\n`);
|
|
406
219
|
}
|
|
407
|
-
// Snapshot which skill destinations are installed BEFORE the package
|
|
408
|
-
// manager replaces our binary — after the spawn, BUNDLED_SKILL on disk
|
|
409
|
-
// points at the new content (npm install -g overwrites in place), so a
|
|
410
|
-
// fresh copyFileSync picks it up automatically.
|
|
411
|
-
const refreshTargets = SKILL_RUNTIMES.flatMap(r => SKILL_SCOPES.filter(s => existsSync(skillFile(r, s))).map(s => skillFile(r, s)));
|
|
412
220
|
await new Promise((resolve, reject) => {
|
|
413
221
|
const child = spawn(argv[0], argv.slice(1), { stdio: isJson(flags) ? 'ignore' : 'inherit' });
|
|
414
|
-
child.on('exit', code => (code === 0 ? resolve() : reject(new Error(`${argv[0]} exited
|
|
222
|
+
child.on('exit', code => (code === 0 ? resolve() : reject(new Error(`${argv[0]} exited ${code}`))));
|
|
415
223
|
child.on('error', reject);
|
|
416
224
|
});
|
|
417
|
-
// Refresh installed skills from the (now-updated) bundled SKILL.md.
|
|
418
|
-
if (existsSync(BUNDLED_SKILL)) {
|
|
419
|
-
for (const dest of refreshTargets) {
|
|
420
|
-
mkdirSync(dirname(dest), { recursive: true });
|
|
421
|
-
copyFileSync(BUNDLED_SKILL, dest);
|
|
422
|
-
}
|
|
423
|
-
if (refreshTargets.length > 0 && !isJson(flags)) {
|
|
424
|
-
process.stdout.write(`\nrefreshed installed skills:\n ${refreshTargets.join('\n ')}\n`);
|
|
425
|
-
}
|
|
426
|
-
}
|
|
427
|
-
}
|
|
428
|
-
// ---------------- inbound action commands ----------------------------------
|
|
429
|
-
async function cmdSend(flags) {
|
|
430
|
-
const addr = resolveAddr(flags, false);
|
|
431
|
-
const text = await resolveText(flags);
|
|
432
|
-
const sentMessageId = addr.platform === 'telegram'
|
|
433
|
-
? String((await tg('sendMessage', buildSendBody(addr.chat, text, readTelegramOpts(flags)))).message_id)
|
|
434
|
-
: await discord.sendMessage(addr.chat, text);
|
|
435
|
-
emitResult(flags, 'sent', { ok: true, platform: addr.platform, to: String(flags.to), sent_message_id: sentMessageId });
|
|
436
|
-
}
|
|
437
|
-
async function cmdReply(flags) {
|
|
438
|
-
const addr = resolveAddr(flags, true);
|
|
439
|
-
const text = await resolveText(flags);
|
|
440
|
-
let sentMessageId;
|
|
441
|
-
if (addr.platform === 'telegram') {
|
|
442
|
-
const body = buildSendBody(addr.chat, text, readTelegramOpts(flags));
|
|
443
|
-
body.reply_parameters = { message_id: tgMessageId(addr), allow_sending_without_reply: true };
|
|
444
|
-
sentMessageId = String((await tg('sendMessage', body)).message_id);
|
|
445
|
-
}
|
|
446
|
-
else {
|
|
447
|
-
sentMessageId = await discord.replyToMessage(addr.chat, addr.messageId, text);
|
|
448
|
-
}
|
|
449
|
-
signalReplyComplete(addr.platform, addr.chat);
|
|
450
|
-
await clearReaction(addr).catch(err => log.warn({ err: errMsg(err) }, 'clear-reaction failed'));
|
|
451
|
-
emitResult(flags, 'sent', { ok: true, platform: addr.platform, to: String(flags.to), sent_message_id: sentMessageId });
|
|
452
|
-
}
|
|
453
|
-
async function cmdReact(flags) {
|
|
454
|
-
const addr = resolveAddr(flags, true);
|
|
455
|
-
const emoji = typeof flags.emoji === 'string' ? flags.emoji : '';
|
|
456
|
-
if (addr.platform === 'telegram') {
|
|
457
|
-
await tg('setMessageReaction', { chat_id: addr.chat, message_id: tgMessageId(addr), reaction: emoji ? [{ type: 'emoji', emoji }] : [] });
|
|
458
|
-
}
|
|
459
|
-
else {
|
|
460
|
-
await discord.setReaction(addr.chat, addr.messageId, emoji);
|
|
461
|
-
}
|
|
462
|
-
emitResult(flags, emoji ? 'reacted' : 'cleared', { ok: true, to: String(flags.to), emoji, action: emoji ? 'reacted' : 'cleared' });
|
|
463
|
-
}
|
|
464
|
-
async function cmdEdit(flags) {
|
|
465
|
-
const addr = resolveAddr(flags, true);
|
|
466
|
-
const text = await resolveText(flags);
|
|
467
|
-
if (addr.platform === 'telegram') {
|
|
468
|
-
const body = buildSendBody(addr.chat, text, readTelegramOpts(flags));
|
|
469
|
-
body.message_id = tgMessageId(addr);
|
|
470
|
-
await tg('editMessageText', body);
|
|
471
|
-
}
|
|
472
|
-
else {
|
|
473
|
-
await discord.editMessage(addr.chat, addr.messageId, text);
|
|
474
|
-
}
|
|
475
|
-
emitResult(flags, 'edited', { ok: true, to: String(flags.to) });
|
|
476
|
-
}
|
|
477
|
-
async function cmdDownload(flags) {
|
|
478
|
-
const addr = resolveAddr(flags, true);
|
|
479
|
-
const outDir = typeof flags.out === 'string' ? flags.out : join(STATE_DIR, 'attachments');
|
|
480
|
-
mkdirSync(outDir, { recursive: true });
|
|
481
|
-
const images = addr.platform === 'telegram'
|
|
482
|
-
? await Promise.all(telegram.getCachedAttachments(addr.chat, tgMessageId(addr)).map(a => telegram.downloadAttachment(a.file_id, a.mime)))
|
|
483
|
-
: await discord.fetchAttachments(addr.chat, addr.messageId);
|
|
484
|
-
if (images.length === 0) {
|
|
485
|
-
if (isJson(flags))
|
|
486
|
-
process.stdout.write(JSON.stringify({ ok: true, images: [] }) + '\n');
|
|
487
|
-
else
|
|
488
|
-
process.stderr.write('no image attachments found\n');
|
|
489
|
-
return;
|
|
490
|
-
}
|
|
491
|
-
const safeChat = addr.chat.replace(/[^\w-]/g, '_');
|
|
492
|
-
const safeMsg = (addr.messageId ?? '').replace(/[^\w-]/g, '_');
|
|
493
|
-
const out = images.map((img, i) => {
|
|
494
|
-
const ext = EXT_FROM_MIME[img.mime] ?? 'bin';
|
|
495
|
-
const path = join(outDir, `${addr.platform}_${safeChat}_${safeMsg}_${i}.${ext}`);
|
|
496
|
-
writeFileSync(path, Buffer.from(img.data, 'base64'));
|
|
497
|
-
return { path, mime: img.mime };
|
|
498
|
-
});
|
|
499
|
-
if (isJson(flags))
|
|
500
|
-
process.stdout.write(JSON.stringify({ ok: true, images: out }) + '\n');
|
|
501
|
-
else
|
|
502
|
-
process.stdout.write(out.map(o => o.path).join('\n') + '\n');
|
|
503
|
-
}
|
|
504
|
-
async function cmdFetch(flags) {
|
|
505
|
-
const addr = resolveAddr(flags, false);
|
|
506
|
-
if (addr.platform !== 'discord') {
|
|
507
|
-
throw new Error('metro fetch is Discord-only — Telegram has no recent-messages API for bots');
|
|
508
|
-
}
|
|
509
|
-
const limit = Number(flags.limit ?? 10);
|
|
510
|
-
if (!Number.isInteger(limit) || limit < 1 || limit > 100)
|
|
511
|
-
throw new Error('--limit must be an integer 1–100');
|
|
512
|
-
const msgs = await discord.fetchRecentMessages(addr.chat, limit);
|
|
513
|
-
if (isJson(flags)) {
|
|
514
|
-
process.stdout.write(JSON.stringify(msgs) + '\n');
|
|
515
|
-
}
|
|
516
|
-
else {
|
|
517
|
-
const text = msgs.map(m => `[message_id=${m.message_id} ${m.timestamp}] ${m.author}: ${m.text}`).join('\n');
|
|
518
|
-
process.stdout.write((text || '(channel is empty)') + '\n');
|
|
519
|
-
}
|
|
520
225
|
}
|
|
521
|
-
// ---------------- main dispatcher ------------------------------------------
|
|
522
226
|
const COMMANDS = {
|
|
523
|
-
setup:
|
|
524
|
-
doctor: (_,
|
|
525
|
-
update: (_,
|
|
526
|
-
reply: (_, flags) => cmdReply(flags),
|
|
527
|
-
react: (_, flags) => cmdReact(flags),
|
|
528
|
-
edit: (_, flags) => cmdEdit(flags),
|
|
529
|
-
send: (_, flags) => cmdSend(flags),
|
|
530
|
-
download: (_, flags) => cmdDownload(flags),
|
|
531
|
-
fetch: (_, flags) => cmdFetch(flags),
|
|
227
|
+
setup: cmdSetup,
|
|
228
|
+
doctor: (_, f) => cmdDoctor(f),
|
|
229
|
+
update: (_, f) => cmdUpdate(f),
|
|
532
230
|
};
|
|
533
|
-
const NEEDS_ENV = new Set(['doctor', 'reply', 'react', 'edit', 'send', 'download', 'fetch']);
|
|
534
231
|
async function main() {
|
|
535
232
|
const cmd = process.argv[2];
|
|
536
233
|
if (cmd === '--version' || cmd === '-v') {
|
|
@@ -541,11 +238,9 @@ async function main() {
|
|
|
541
238
|
process.stdout.write(USAGE);
|
|
542
239
|
return;
|
|
543
240
|
}
|
|
544
|
-
// Bare `metro` runs the
|
|
545
|
-
// subcommands are the secondary surface (matches claude / redis-server /
|
|
546
|
-
// nginx convention where the daemon's the default).
|
|
241
|
+
// Bare `metro` runs the orchestrator (the daemon's the default).
|
|
547
242
|
if (!cmd) {
|
|
548
|
-
await import('./
|
|
243
|
+
await import('./orchestrator.js');
|
|
549
244
|
return;
|
|
550
245
|
}
|
|
551
246
|
const handler = COMMANDS[cmd];
|
|
@@ -554,8 +249,6 @@ async function main() {
|
|
|
554
249
|
process.exit(1);
|
|
555
250
|
}
|
|
556
251
|
const { positional, flags } = parseArgs(process.argv.slice(3));
|
|
557
|
-
if (NEEDS_ENV.has(cmd))
|
|
558
|
-
loadMetroEnv();
|
|
559
252
|
try {
|
|
560
253
|
await handler(positional, flags);
|
|
561
254
|
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Per-machine cache of `scope_key → { agent thread ids, last-used }`.
|
|
2
|
+
// Lets orchestrator restarts rejoin the same agent conversation in the
|
|
3
|
+
// same Discord thread instead of starting from scratch. JSON file at
|
|
4
|
+
// $STATE_DIR/scopes.json.
|
|
5
|
+
//
|
|
6
|
+
// One Discord thread can have up to one session per agent kind — when a
|
|
7
|
+
// user explicitly switches via "with claude" / "with codex", a fresh
|
|
8
|
+
// session is allocated for the new kind and stored alongside.
|
|
9
|
+
//
|
|
10
|
+
// Scope keys are platform-prefixed so the same store handles Discord and
|
|
11
|
+
// Telegram without collisions:
|
|
12
|
+
// discord:<thread_channel_id>
|
|
13
|
+
// telegram:<chat_id>:<topic_id>
|
|
14
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import { errMsg, log } from '../log.js';
|
|
17
|
+
import { STATE_DIR } from '../paths.js';
|
|
18
|
+
const cacheFile = join(STATE_DIR, 'scopes.json');
|
|
19
|
+
function read() {
|
|
20
|
+
if (!existsSync(cacheFile))
|
|
21
|
+
return {};
|
|
22
|
+
try {
|
|
23
|
+
return JSON.parse(readFileSync(cacheFile, 'utf8'));
|
|
24
|
+
}
|
|
25
|
+
catch (err) {
|
|
26
|
+
log.warn({ err: errMsg(err), path: cacheFile }, 'scope cache read failed; treating as empty');
|
|
27
|
+
return {};
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function write(cache) {
|
|
31
|
+
try {
|
|
32
|
+
writeFileSync(cacheFile, JSON.stringify(cache, null, 2));
|
|
33
|
+
}
|
|
34
|
+
catch (err) {
|
|
35
|
+
log.warn({ err: errMsg(err), path: cacheFile }, 'scope cache write failed');
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function ensure(cache, scopeKey) {
|
|
39
|
+
if (!cache[scopeKey])
|
|
40
|
+
cache[scopeKey] = { createdAt: new Date().toISOString(), agents: {} };
|
|
41
|
+
if (!cache[scopeKey].agents)
|
|
42
|
+
cache[scopeKey].agents = {};
|
|
43
|
+
return cache[scopeKey];
|
|
44
|
+
}
|
|
45
|
+
export function getAgentThread(scopeKey, kind) {
|
|
46
|
+
return read()[scopeKey]?.agents?.[kind];
|
|
47
|
+
}
|
|
48
|
+
export function setAgentThread(scopeKey, kind, threadId) {
|
|
49
|
+
const cache = read();
|
|
50
|
+
const entry = ensure(cache, scopeKey);
|
|
51
|
+
entry.agents[kind] = threadId;
|
|
52
|
+
entry.lastAgent = kind;
|
|
53
|
+
write(cache);
|
|
54
|
+
}
|
|
55
|
+
export function getLastAgent(scopeKey) {
|
|
56
|
+
return read()[scopeKey]?.lastAgent;
|
|
57
|
+
}
|
|
58
|
+
export function setLastAgent(scopeKey, kind) {
|
|
59
|
+
const cache = read();
|
|
60
|
+
if (!cache[scopeKey])
|
|
61
|
+
return;
|
|
62
|
+
cache[scopeKey].lastAgent = kind;
|
|
63
|
+
write(cache);
|
|
64
|
+
}
|
|
65
|
+
export function setLastSeen(scopeKey, messageId) {
|
|
66
|
+
const cache = read();
|
|
67
|
+
if (!cache[scopeKey])
|
|
68
|
+
return;
|
|
69
|
+
cache[scopeKey].lastSeenMessageId = messageId;
|
|
70
|
+
write(cache);
|
|
71
|
+
}
|
|
72
|
+
export function listScopes() {
|
|
73
|
+
return Object.entries(read()).map(([scopeKey, entry]) => ({ scopeKey, entry }));
|
|
74
|
+
}
|
|
75
|
+
export function discordScopeKey(threadChannelId) {
|
|
76
|
+
return `discord:${threadChannelId}`;
|
|
77
|
+
}
|
|
78
|
+
export function discordChannelFromScopeKey(scopeKey) {
|
|
79
|
+
return scopeKey.startsWith('discord:') ? scopeKey.slice('discord:'.length) : null;
|
|
80
|
+
}
|
|
81
|
+
// Telegram scope: chat + optional forum-topic id. Use a sentinel ('main')
|
|
82
|
+
// for non-topic chats so the key shape stays predictable.
|
|
83
|
+
export function telegramScopeKey(chatId, topicId) {
|
|
84
|
+
return `telegram:${chatId}:${topicId ?? 'main'}`;
|
|
85
|
+
}
|