@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/dist/cli.js CHANGED
@@ -1,61 +1,32 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn } from 'node:child_process';
3
- import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
4
- import { dirname, join } from 'node:path';
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, log } from './log.js';
13
- import { CONFIG_ENV_FILE, configuredPlatforms, loadMetroEnv, STATE_DIR, skillDir, } from './paths.js';
14
- // ---------------- USAGE ----------------------------------------------------
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 Inbound stream (long-running; run in the background).
19
- metro setup […] Manage tokens and the agent skill (see below).
20
- metro doctor Health check across tokens, gateways, and skills.
21
- metro reply --to=<addr> [--text=<t>] Quote-reply, threading under the original. Clears 👀.
22
- metro react --to=<addr> --emoji=<e> Set or clear ('') a reaction.
23
- metro edit --to=<addr> [--text=<t>] Edit a message the bot previously sent.
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 setup skill [--project] [--clear] Install (or remove) the agent skill.
36
-
37
- Address format:
38
- telegram:<chat_id>/<message_id> e.g. telegram:-100123456789/4567
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 success
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)
27
+ 0 success · 1 usage · 2 config (no tokens — run \`metro setup\`) · 3 upstream
55
28
  `;
56
- function exitErr(message, code) {
57
- return Object.assign(new Error(message), { code });
58
- }
29
+ function exitErr(msg, code) { return Object.assign(new Error(msg), { code }); }
59
30
  function parseArgs(argv) {
60
31
  const positional = [];
61
32
  const flags = {};
@@ -81,91 +52,17 @@ function parseArgs(argv) {
81
52
  }
82
53
  return { positional, flags };
83
54
  }
84
- const isJson = (flags) => flags.json === true;
85
- function emitResult(flags, human, structured) {
86
- process.stdout.write(isJson(flags) ? JSON.stringify(structured) + '\n' : human + '\n');
87
- }
88
- function resolveAddr(flags, requireMessage) {
89
- const addr = parseAddress(String(flags.to), requireMessage);
90
- if (!configuredPlatforms()[addr.platform]) {
91
- throw exitErr(`platform '${addr.platform}' is not configured (missing token)`, 2);
92
- }
93
- return addr;
94
- }
95
- function tgMessageId(addr) {
96
- const n = Number(addr.messageId);
97
- if (!Number.isInteger(n))
98
- throw new Error(`telegram message_id must be an integer: ${addr.messageId}`);
99
- return n;
100
- }
101
- // Tell tail.ts to stop refreshing the typing indicator (the agent has replied).
102
- function signalReplyComplete(platform, chat) {
103
- const dir = join(STATE_DIR, '.typing-stop');
104
- try {
105
- mkdirSync(dir, { recursive: true });
106
- writeFileSync(join(dir, `${platform}_${chat}`), '');
107
- }
108
- catch (err) {
109
- log.warn({ err: errMsg(err) }, 'typing stop-signal write failed');
110
- }
111
- }
112
- async function clearReaction(addr) {
113
- if (addr.platform === 'telegram') {
114
- await tg('setMessageReaction', { chat_id: addr.chat, message_id: tgMessageId(addr), reaction: [] });
115
- }
116
- else {
117
- await discord.setReaction(addr.chat, addr.messageId, '');
118
- }
119
- }
120
- async function readStdinText() {
121
- if (process.stdin.isTTY)
122
- return '';
123
- const chunks = [];
124
- for await (const c of process.stdin)
125
- chunks.push(c);
126
- return Buffer.concat(chunks).toString('utf8');
127
- }
128
- async function resolveText(flags) {
129
- if (typeof flags.text === 'string')
130
- return flags.text;
131
- const stdin = (await readStdinText()).replace(/\n$/, '');
132
- if (!stdin)
133
- throw new Error('--text is required (or pipe text on stdin)');
134
- return stdin;
135
- }
136
- function readTelegramOpts(flags) {
137
- const opts = {};
138
- if (flags['parse-mode']) {
139
- const v = String(flags['parse-mode']);
140
- if (v !== 'HTML' && v !== 'MarkdownV2')
141
- throw new Error("--parse-mode must be 'HTML' or 'MarkdownV2'");
142
- opts.parseMode = v;
143
- }
144
- if (flags['no-link-preview'])
145
- opts.disableLinkPreview = true;
146
- if (flags['buttons-json'])
147
- opts.buttons = JSON.parse(String(flags['buttons-json']));
148
- return opts;
149
- }
150
- function maskToken(token) {
151
- 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)
152
61
  return '';
153
- if (token.length <= 8)
62
+ if (t.length <= 8)
154
63
  return '••••';
155
- return `${token.slice(0, 6)}…${token.slice(-2)}`;
64
+ return `${t.slice(0, 6)}…${t.slice(-2)}`;
156
65
  }
157
- const EXT_FROM_MIME = {
158
- 'image/png': 'png',
159
- 'image/jpeg': 'jpg',
160
- 'image/gif': 'gif',
161
- 'image/webp': 'webp',
162
- };
163
- // Path to the SKILL.md bundled with the npm package. dist/cli.js → ../skills/metro/SKILL.md.
164
- const BUNDLED_SKILL = join(dirname(fileURLToPath(import.meta.url)), '..', 'skills', 'metro', 'SKILL.md');
165
- const SKILL_RUNTIMES = ['claude-code', 'codex'];
166
- const SKILL_SCOPES = ['user', 'project'];
167
- const skillFile = (runtime, scope) => join(skillDir(runtime, scope), 'SKILL.md');
168
- // ---------------- setup ----------------------------------------------------
169
66
  const TOKEN_KEYS = { telegram: 'TELEGRAM_BOT_TOKEN', discord: 'DISCORD_BOT_TOKEN' };
170
67
  async function cmdSetup(positional, flags) {
171
68
  const [sub, value] = positional;
@@ -177,17 +74,13 @@ async function cmdSetup(positional, flags) {
177
74
  const trimmed = value.trim();
178
75
  let identity;
179
76
  if (!flags['no-validate']) {
180
- // Validate against the platform before persisting — catches typos /
181
- // wrong-token-from-portal / rotated tokens at the earliest moment.
182
77
  process.env[TOKEN_KEYS[sub]] = trimmed;
183
78
  try {
184
- identity = sub === 'telegram'
185
- ? `@${(await telegram.getMe()).username}`
186
- : (await discord.getMe()).username;
79
+ identity = sub === 'telegram' ? `@${(await telegram.getMe()).username}` : (await discord.getMe()).username;
187
80
  }
188
81
  catch (err) {
189
82
  delete process.env[TOKEN_KEYS[sub]];
190
- throw exitErr(`token rejected by ${sub}: ${errMsg(err)} (re-run with --no-validate to save anyway)`, 3);
83
+ throw exitErr(`token rejected by ${sub}: ${errMsg(err)} (use --no-validate to save anyway)`, 3);
191
84
  }
192
85
  }
193
86
  const env = readDotenv(CONFIG_ENV_FILE);
@@ -196,7 +89,7 @@ async function cmdSetup(positional, flags) {
196
89
  const human = identity
197
90
  ? `saved ${TOKEN_KEYS[sub]} (verified as ${identity}) to ${CONFIG_ENV_FILE} (chmod 0600)`
198
91
  : `saved ${TOKEN_KEYS[sub]} to ${CONFIG_ENV_FILE} (chmod 0600)`;
199
- emitResult(flags, human, { ok: true, saved: TOKEN_KEYS[sub], path: CONFIG_ENV_FILE, verified_as: identity ?? null });
92
+ emitResult(flags, human, { ok: true, saved: TOKEN_KEYS[sub], verified_as: identity ?? null });
200
93
  return;
201
94
  }
202
95
  if (sub === 'clear') {
@@ -206,30 +99,20 @@ async function cmdSetup(positional, flags) {
206
99
  delete env.TELEGRAM_BOT_TOKEN;
207
100
  delete env.DISCORD_BOT_TOKEN;
208
101
  }
209
- else if (target === 'telegram' || target === 'discord') {
102
+ else if (target === 'telegram' || target === 'discord')
210
103
  delete env[TOKEN_KEYS[target]];
211
- }
212
- else {
104
+ else
213
105
  throw new Error(`metro setup clear <telegram|discord|all> — got '${target}'`);
214
- }
215
106
  writeDotenv(CONFIG_ENV_FILE, env);
216
- const human = `cleared ${target === 'all' ? 'all metro tokens' : TOKEN_KEYS[target]} from ${CONFIG_ENV_FILE}`;
217
- 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 });
218
108
  return;
219
109
  }
220
- if (sub === 'skill')
221
- return cmdSetupSkill(flags);
222
- throw new Error(`unknown setup subcommand '${sub}' (try: telegram, discord, clear, skill)`);
223
- }
224
- function readSkillState() {
225
- 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)`);
226
111
  }
227
112
  async function cmdSetupStatus(flags) {
228
113
  loadMetroEnv();
229
114
  const tg = process.env.TELEGRAM_BOT_TOKEN ?? '';
230
115
  const dc = process.env.DISCORD_BOT_TOKEN ?? '';
231
- const skills = readSkillState();
232
- const anySkill = SKILL_RUNTIMES.some(r => SKILL_SCOPES.some(s => skills[r][s]));
233
116
  if (isJson(flags)) {
234
117
  process.stdout.write(JSON.stringify({
235
118
  version: pkg.version,
@@ -238,64 +121,27 @@ async function cmdSetupStatus(flags) {
238
121
  telegram: { set: !!tg, masked: maskToken(tg) },
239
122
  discord: { set: !!dc, masked: maskToken(dc) },
240
123
  },
241
- skills,
242
124
  }) + '\n');
243
125
  return;
244
126
  }
245
- const fmtSkill = (s) => {
246
- const set = SKILL_SCOPES.filter(k => s[k]);
247
- return set.length ? `installed (${set.join(' + ')})` : 'not installed';
248
- };
249
127
  process.stdout.write(`metro ${pkg.version}\n\n` +
250
128
  `config: ${CONFIG_ENV_FILE}${existsSync(CONFIG_ENV_FILE) ? '' : ' (not yet written)'}\n\n` +
251
129
  ` TELEGRAM_BOT_TOKEN ${tg ? `set (${maskToken(tg)})` : 'not set'}\n` +
252
- ` DISCORD_BOT_TOKEN ${dc ? `set (${maskToken(dc)})` : 'not set'}\n\n` +
253
- 'Skills:\n' +
254
- ` claude-code ${fmtSkill(skills['claude-code'])}\n` +
255
- ` codex ${fmtSkill(skills.codex)}\n\n`);
130
+ ` DISCORD_BOT_TOKEN ${dc ? `set (${maskToken(dc)})` : 'not set'}\n\n`);
256
131
  if (!tg && !dc) {
257
132
  process.stdout.write('Get started:\n' +
258
- ' 1. metro setup telegram <token> # https://t.me/BotFather\n' +
259
- ' metro setup discord <token> # https://discord.com/developers/applications\n' +
260
- ' 2. metro setup skill # auto-onboard your agent (writes to both runtimes)\n' +
261
- ' 3. metro doctor # verify everything works\n' +
262
- ' 4. metro # start the inbound stream\n');
263
- }
264
- else if (!anySkill) {
265
- process.stdout.write('Next: `metro setup skill` to auto-onboard your agent. Then `metro doctor`, then `metro`.\n');
133
+ ' 1. metro setup telegram <token> # https://t.me/BotFather\n' +
134
+ ' metro setup discord <token> # https://discord.com/developers/applications\n' +
135
+ ' 2. metro doctor # verify\n' +
136
+ ' 3. metro # run the orchestrator\n');
266
137
  }
267
138
  else {
268
- process.stdout.write('Run `metro` to start the inbound stream, or `metro doctor` to verify.\n');
139
+ process.stdout.write('Run `metro` to start the orchestrator, or `metro doctor` to verify.\n');
269
140
  }
270
141
  }
271
- async function cmdSetupSkill(flags) {
272
- const scope = flags.project ? 'project' : 'user';
273
- if (flags.clear) {
274
- const removed = [];
275
- for (const runtime of SKILL_RUNTIMES) {
276
- const dir = skillDir(runtime, scope);
277
- if (existsSync(dir)) {
278
- rmSync(dir, { recursive: true, force: true });
279
- removed.push(dir);
280
- }
281
- }
282
- emitResult(flags, removed.length ? `removed:\n ${removed.join('\n ')}` : '(no skills installed at this scope)', { ok: true, cleared: removed });
283
- return;
284
- }
285
- if (!existsSync(BUNDLED_SKILL))
286
- throw new Error(`bundled SKILL.md missing at ${BUNDLED_SKILL} (broken install?)`);
287
- const written = SKILL_RUNTIMES.map(r => {
288
- const dest = skillFile(r, scope);
289
- mkdirSync(dirname(dest), { recursive: true });
290
- copyFileSync(BUNDLED_SKILL, dest);
291
- return dest;
292
- });
293
- 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 });
294
- }
295
142
  async function cmdDoctor(flags) {
296
143
  loadMetroEnv();
297
144
  const checks = [];
298
- // tokens
299
145
  const cfg = configuredPlatforms();
300
146
  checks.push({
301
147
  name: 'tokens',
@@ -304,7 +150,6 @@ async function cmdDoctor(flags) {
304
150
  ? `loaded from ${existsSync(CONFIG_ENV_FILE) ? CONFIG_ENV_FILE : 'process env'}`
305
151
  : 'no platform configured — run `metro setup telegram|discord <token>`',
306
152
  });
307
- // gateway / API healthcheck per configured platform
308
153
  for (const [platform, getMe] of [['telegram', telegram.getMe], ['discord', discord.getMe]]) {
309
154
  if (!cfg[platform]) {
310
155
  checks.push({ name: platform, ok: null, detail: 'not configured' });
@@ -318,11 +163,9 @@ async function cmdDoctor(flags) {
318
163
  checks.push({ name: platform, ok: false, detail: errMsg(err) });
319
164
  }
320
165
  }
321
- // tail process state. A stale lockfile (process gone) is informational —
322
- // the next `metro` start auto-reclaims it, so we don't fail the doctor on it.
323
166
  const lockFile = join(STATE_DIR, '.tail-lock');
324
167
  if (!existsSync(lockFile)) {
325
- checks.push({ name: 'stream', ok: null, detail: 'not running' });
168
+ checks.push({ name: 'orchestrator', ok: null, detail: 'not running' });
326
169
  }
327
170
  else {
328
171
  try {
@@ -330,27 +173,12 @@ async function cmdDoctor(flags) {
330
173
  if (!Number.isInteger(pid) || pid <= 0)
331
174
  throw new Error('invalid pid');
332
175
  process.kill(pid, 0);
333
- checks.push({ name: 'stream', ok: true, detail: `running (pid ${pid})` });
176
+ checks.push({ name: 'orchestrator', ok: true, detail: `running (pid ${pid})` });
334
177
  }
335
178
  catch {
336
- checks.push({ name: 'stream', ok: null, detail: 'stale lockfile (will auto-reclaim on next start)' });
179
+ checks.push({ name: 'orchestrator', ok: null, detail: 'stale lockfile (will auto-reclaim on next start)' });
337
180
  }
338
181
  }
339
- // skill install + freshness vs bundled
340
- for (const runtime of SKILL_RUNTIMES) {
341
- const present = SKILL_SCOPES.filter(s => existsSync(skillFile(runtime, s)));
342
- if (present.length === 0) {
343
- checks.push({ name: `skill: ${runtime}`, ok: null, detail: 'not installed — run `metro setup skill` (writes both runtimes)' });
344
- continue;
345
- }
346
- const bundled = existsSync(BUNDLED_SKILL) ? readFileSync(BUNDLED_SKILL) : null;
347
- const stale = bundled
348
- ? present.filter(s => !readFileSync(skillFile(runtime, s)).equals(bundled))
349
- : [];
350
- checks.push(stale.length === 0
351
- ? { name: `skill: ${runtime}`, ok: true, detail: present.join(' + ') }
352
- : { name: `skill: ${runtime}`, ok: false, detail: `stale at ${stale.join(' + ')} — re-run \`metro setup skill${stale.includes('project') ? ' --project' : ''}\`` });
353
- }
354
182
  if (isJson(flags)) {
355
183
  process.stdout.write(JSON.stringify({ checks }) + '\n');
356
184
  }
@@ -358,17 +186,14 @@ async function cmdDoctor(flags) {
358
186
  process.stdout.write('metro doctor\n\n');
359
187
  for (const c of checks) {
360
188
  const icon = c.ok === true ? '✓' : c.ok === false ? '✗' : '–';
361
- process.stdout.write(` ${icon} ${c.name.padEnd(20)} ${c.detail}\n`);
189
+ process.stdout.write(` ${icon} ${c.name.padEnd(15)} ${c.detail}\n`);
362
190
  }
363
191
  process.stdout.write('\n');
364
192
  }
365
193
  if (checks.some(c => c.ok === false))
366
194
  throw exitErr('one or more checks failed', 3);
367
195
  }
368
- // ---------------- update ---------------------------------------------------
369
196
  async function cmdUpdate(flags) {
370
- // While metro is in prerelease, the @beta dist-tag is what we publish.
371
- // After GA, swap to 'latest' (or auto-pick from current version's prerelease tag).
372
197
  const tag = pkg.version.includes('-') ? 'beta' : 'latest';
373
198
  const res = await fetch('https://registry.npmjs.org/@stage-labs/metro', { signal: AbortSignal.timeout(15_000) });
374
199
  if (!res.ok)
@@ -387,138 +212,22 @@ async function cmdUpdate(flags) {
387
212
  : argv1.includes('/pnpm/') || argv1.includes('\\pnpm\\') ? ['pnpm', 'add', '-g', spec]
388
213
  : ['npm', 'install', '-g', spec];
389
214
  if (isJson(flags)) {
390
- process.stdout.write(JSON.stringify({ ok: true, current: pkg.version, latest, command: argv.join(' '), upgraded: 'pending' }) + '\n');
215
+ process.stdout.write(JSON.stringify({ ok: true, current: pkg.version, latest, command: argv.join(' ') }) + '\n');
391
216
  }
392
217
  else {
393
218
  process.stdout.write(`metro ${pkg.version} → ${latest}\n$ ${argv.join(' ')}\n`);
394
219
  }
395
- // Snapshot which skill destinations are installed BEFORE the package
396
- // manager replaces our binary — after the spawn, BUNDLED_SKILL on disk
397
- // points at the new content (npm install -g overwrites in place), so a
398
- // fresh copyFileSync picks it up automatically.
399
- const refreshTargets = SKILL_RUNTIMES.flatMap(r => SKILL_SCOPES.filter(s => existsSync(skillFile(r, s))).map(s => skillFile(r, s)));
400
220
  await new Promise((resolve, reject) => {
401
221
  const child = spawn(argv[0], argv.slice(1), { stdio: isJson(flags) ? 'ignore' : 'inherit' });
402
- child.on('exit', code => (code === 0 ? resolve() : reject(new Error(`${argv[0]} exited with code ${code}`))));
222
+ child.on('exit', code => (code === 0 ? resolve() : reject(new Error(`${argv[0]} exited ${code}`))));
403
223
  child.on('error', reject);
404
224
  });
405
- // Refresh installed skills from the (now-updated) bundled SKILL.md.
406
- if (existsSync(BUNDLED_SKILL)) {
407
- for (const dest of refreshTargets) {
408
- mkdirSync(dirname(dest), { recursive: true });
409
- copyFileSync(BUNDLED_SKILL, dest);
410
- }
411
- if (refreshTargets.length > 0 && !isJson(flags)) {
412
- process.stdout.write(`\nrefreshed installed skills:\n ${refreshTargets.join('\n ')}\n`);
413
- }
414
- }
415
- }
416
- // ---------------- inbound action commands ----------------------------------
417
- async function cmdSend(flags) {
418
- const addr = resolveAddr(flags, false);
419
- const text = await resolveText(flags);
420
- const sentMessageId = addr.platform === 'telegram'
421
- ? String((await tg('sendMessage', buildSendBody(addr.chat, text, readTelegramOpts(flags)))).message_id)
422
- : await discord.sendMessage(addr.chat, text);
423
- emitResult(flags, 'sent', { ok: true, platform: addr.platform, to: String(flags.to), sent_message_id: sentMessageId });
424
- }
425
- async function cmdReply(flags) {
426
- const addr = resolveAddr(flags, true);
427
- const text = await resolveText(flags);
428
- let sentMessageId;
429
- if (addr.platform === 'telegram') {
430
- const body = buildSendBody(addr.chat, text, readTelegramOpts(flags));
431
- body.reply_parameters = { message_id: tgMessageId(addr), allow_sending_without_reply: true };
432
- sentMessageId = String((await tg('sendMessage', body)).message_id);
433
- }
434
- else {
435
- sentMessageId = await discord.replyToMessage(addr.chat, addr.messageId, text);
436
- }
437
- signalReplyComplete(addr.platform, addr.chat);
438
- await clearReaction(addr).catch(err => log.warn({ err: errMsg(err) }, 'clear-reaction failed'));
439
- emitResult(flags, 'sent', { ok: true, platform: addr.platform, to: String(flags.to), sent_message_id: sentMessageId });
440
- }
441
- async function cmdReact(flags) {
442
- const addr = resolveAddr(flags, true);
443
- const emoji = typeof flags.emoji === 'string' ? flags.emoji : '';
444
- if (addr.platform === 'telegram') {
445
- await tg('setMessageReaction', { chat_id: addr.chat, message_id: tgMessageId(addr), reaction: emoji ? [{ type: 'emoji', emoji }] : [] });
446
- }
447
- else {
448
- await discord.setReaction(addr.chat, addr.messageId, emoji);
449
- }
450
- emitResult(flags, emoji ? 'reacted' : 'cleared', { ok: true, to: String(flags.to), emoji, action: emoji ? 'reacted' : 'cleared' });
451
- }
452
- async function cmdEdit(flags) {
453
- const addr = resolveAddr(flags, true);
454
- const text = await resolveText(flags);
455
- if (addr.platform === 'telegram') {
456
- const body = buildSendBody(addr.chat, text, readTelegramOpts(flags));
457
- body.message_id = tgMessageId(addr);
458
- await tg('editMessageText', body);
459
- }
460
- else {
461
- await discord.editMessage(addr.chat, addr.messageId, text);
462
- }
463
- emitResult(flags, 'edited', { ok: true, to: String(flags.to) });
464
- }
465
- async function cmdDownload(flags) {
466
- const addr = resolveAddr(flags, true);
467
- const outDir = typeof flags.out === 'string' ? flags.out : join(STATE_DIR, 'attachments');
468
- mkdirSync(outDir, { recursive: true });
469
- const images = addr.platform === 'telegram'
470
- ? await Promise.all(telegram.getCachedAttachments(addr.chat, tgMessageId(addr)).map(a => telegram.downloadAttachment(a.file_id, a.mime)))
471
- : await discord.fetchAttachments(addr.chat, addr.messageId);
472
- if (images.length === 0) {
473
- if (isJson(flags))
474
- process.stdout.write(JSON.stringify({ ok: true, images: [] }) + '\n');
475
- else
476
- process.stderr.write('no image attachments found\n');
477
- return;
478
- }
479
- const safeChat = addr.chat.replace(/[^\w-]/g, '_');
480
- const safeMsg = (addr.messageId ?? '').replace(/[^\w-]/g, '_');
481
- const out = images.map((img, i) => {
482
- const ext = EXT_FROM_MIME[img.mime] ?? 'bin';
483
- const path = join(outDir, `${addr.platform}_${safeChat}_${safeMsg}_${i}.${ext}`);
484
- writeFileSync(path, Buffer.from(img.data, 'base64'));
485
- return { path, mime: img.mime };
486
- });
487
- if (isJson(flags))
488
- process.stdout.write(JSON.stringify({ ok: true, images: out }) + '\n');
489
- else
490
- process.stdout.write(out.map(o => o.path).join('\n') + '\n');
491
- }
492
- async function cmdFetch(flags) {
493
- const addr = resolveAddr(flags, false);
494
- if (addr.platform !== 'discord') {
495
- throw new Error('metro fetch is Discord-only — Telegram has no recent-messages API for bots');
496
- }
497
- const limit = Number(flags.limit ?? 10);
498
- if (!Number.isInteger(limit) || limit < 1 || limit > 100)
499
- throw new Error('--limit must be an integer 1–100');
500
- const msgs = await discord.fetchRecentMessages(addr.chat, limit);
501
- if (isJson(flags)) {
502
- process.stdout.write(JSON.stringify(msgs) + '\n');
503
- }
504
- else {
505
- const text = msgs.map(m => `[message_id=${m.message_id} ${m.timestamp}] ${m.author}: ${m.text}`).join('\n');
506
- process.stdout.write((text || '(channel is empty)') + '\n');
507
- }
508
225
  }
509
- // ---------------- main dispatcher ------------------------------------------
510
226
  const COMMANDS = {
511
- setup: (positional, flags) => cmdSetup(positional, flags),
512
- doctor: (_, flags) => cmdDoctor(flags),
513
- update: (_, flags) => cmdUpdate(flags),
514
- reply: (_, flags) => cmdReply(flags),
515
- react: (_, flags) => cmdReact(flags),
516
- edit: (_, flags) => cmdEdit(flags),
517
- send: (_, flags) => cmdSend(flags),
518
- download: (_, flags) => cmdDownload(flags),
519
- fetch: (_, flags) => cmdFetch(flags),
227
+ setup: cmdSetup,
228
+ doctor: (_, f) => cmdDoctor(f),
229
+ update: (_, f) => cmdUpdate(f),
520
230
  };
521
- const NEEDS_ENV = new Set(['doctor', 'reply', 'react', 'edit', 'send', 'download', 'fetch']);
522
231
  async function main() {
523
232
  const cmd = process.argv[2];
524
233
  if (cmd === '--version' || cmd === '-v') {
@@ -529,11 +238,9 @@ async function main() {
529
238
  process.stdout.write(USAGE);
530
239
  return;
531
240
  }
532
- // Bare `metro` runs the inbound stream the primary action; one-shot
533
- // subcommands are the secondary surface (matches claude / redis-server /
534
- // nginx convention where the daemon's the default).
241
+ // Bare `metro` runs the orchestrator (the daemon's the default).
535
242
  if (!cmd) {
536
- await import('./tail.js');
243
+ await import('./orchestrator.js');
537
244
  return;
538
245
  }
539
246
  const handler = COMMANDS[cmd];
@@ -542,8 +249,6 @@ async function main() {
542
249
  process.exit(1);
543
250
  }
544
251
  const { positional, flags } = parseArgs(process.argv.slice(3));
545
- if (NEEDS_ENV.has(cmd))
546
- loadMetroEnv();
547
252
  try {
548
253
  await handler(positional, flags);
549
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
+ }