@stage-labs/metro 0.1.0-beta.3 → 0.1.0-beta.4

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.
@@ -1,8 +1,6 @@
1
1
  import { Client, Events, GatewayIntentBits, Partials } from 'discord.js';
2
2
  import { errMsg, log } from '../log.js';
3
- // Receive path: discord.js gateway, owned by the orchestrator.
4
- // Send path: raw REST against discord.com/api — no gateway login required,
5
- // so callers outside the orchestrator (e.g. cli.ts validation) stay one-shot.
3
+ /** Receive: discord.js gateway. Send: raw REST so non-orchestrator callers stay one-shot. */
6
4
  const API_BASE = 'https://discord.com/api/v10';
7
5
  function token() {
8
6
  const t = process.env.DISCORD_BOT_TOKEN;
@@ -21,9 +19,7 @@ async function rest(method, path, body, timeoutMs = 30_000, retriesLeft = 2) {
21
19
  body: body !== undefined ? JSON.stringify(body) : undefined,
22
20
  signal: AbortSignal.timeout(timeoutMs),
23
21
  });
24
- // 429: honor Discord's retry_after (seconds) and try again, up to a few
25
- // hops. Common during streaming edits when the agent emits deltas faster
26
- // than the per-channel edit cap.
22
+ /** 429: honor `retry_after` (seconds) and retry, up to a few hops. */
27
23
  if (res.status === 429 && retriesLeft > 0) {
28
24
  const retryAfter = Number(res.headers.get('retry-after')) || 1;
29
25
  log.debug({ path, retryAfter }, 'discord 429; backing off');
@@ -34,32 +30,23 @@ async function rest(method, path, body, timeoutMs = 30_000, retriesLeft = 2) {
34
30
  const text = await res.text().catch(() => '');
35
31
  throw new Error(`discord ${method} ${path}: ${res.status} ${text}`);
36
32
  }
37
- // 204 No Content for typing/reactions/clear.
38
33
  if (res.status === 204)
39
34
  return undefined;
40
35
  return (await res.json());
41
36
  }
42
- // ---------- Receive path (gateway, discord.js) -----------------------------
43
37
  let client = null;
44
38
  function getClient() {
45
39
  if (client)
46
40
  return client;
47
41
  client = new Client({
48
- intents: [
49
- GatewayIntentBits.DirectMessages,
50
- GatewayIntentBits.Guilds,
51
- GatewayIntentBits.GuildMessages,
52
- GatewayIntentBits.MessageContent,
53
- ],
54
- // DM channels arrive partial; without this messageCreate never fires.
42
+ intents: [GatewayIntentBits.DirectMessages, GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent],
43
+ /** DM channels arrive partial; without this messageCreate never fires. */
55
44
  partials: [Partials.Channel],
56
45
  });
57
46
  return client;
58
47
  }
59
48
  let onInboundHandler = () => { };
60
- export function onInbound(handler) {
61
- onInboundHandler = handler;
62
- }
49
+ export const onInbound = (handler) => { onInboundHandler = handler; };
63
50
  export async function shutdownGateway() {
64
51
  if (!client)
65
52
  return;
@@ -71,19 +58,10 @@ export async function startGateway() {
71
58
  c.on(Events.MessageCreate, m => {
72
59
  if (m.author.bot)
73
60
  return;
74
- // Forward every human message; the orchestrator decides what to route
75
- // where (scoped thread vs new bootstrap mention vs ignore). The bot's
76
- // own @-mention is preserved in `m.content` so the orchestrator can
77
- // make that decision from the same payload.
78
- const tags = [...m.attachments.values()]
79
- .map(a => {
80
- if (a.contentType?.startsWith('image/'))
81
- return '[image]';
82
- if (a.contentType?.startsWith('audio/'))
83
- return `[audio: ${a.name}]`;
84
- return `[file: ${a.name}]`;
85
- })
86
- .join(' ');
61
+ /** Forward every human message; orchestrator decides routing. @-mention stays in content. */
62
+ const tags = [...m.attachments.values()].map(a => a.contentType?.startsWith('image/') ? '[image]'
63
+ : a.contentType?.startsWith('audio/') ? `[audio: ${a.name}]`
64
+ : `[file: ${a.name}]`).join(' ');
87
65
  const text = [m.content, tags].filter(Boolean).join(' ').trim();
88
66
  if (!text)
89
67
  return;
@@ -103,40 +81,24 @@ export async function getMe() {
103
81
  const me = await rest('GET', '/users/@me');
104
82
  return { username: me.username };
105
83
  }
84
+ /** SUPPRESS_EMBEDS (1 << 2) — strip link unfurls from every bot message. */
85
+ const SUPPRESS_EMBEDS = 1 << 2;
106
86
  export async function sendMessage(channelId, text) {
107
- const sent = await rest('POST', `/channels/${channelId}/messages`, { content: text });
87
+ const sent = await rest('POST', `/channels/${channelId}/messages`, { content: text, flags: SUPPRESS_EMBEDS });
108
88
  return sent.id;
109
89
  }
110
- /**
111
- * Create a public thread anchored to an existing message. The user's
112
- * @-mention becomes the thread's starter, so the thread is visually
113
- * tied to the request that opened it. Returns the new thread's channel id.
114
- */
90
+ /** Public thread anchored on `messageId`. Returns the new thread channel id. */
115
91
  export async function createThreadFromMessage(channelId, messageId, name) {
116
- const created = await rest('POST', `/channels/${channelId}/messages/${messageId}/threads`, {
117
- name,
118
- auto_archive_duration: 1440,
119
- });
92
+ const created = await rest('POST', `/channels/${channelId}/messages/${messageId}/threads`, { name, auto_archive_duration: 1440 });
120
93
  return created.id;
121
94
  }
122
95
  export async function editMessage(channelId, messageId, text) {
123
- const sent = await rest('PATCH', `/channels/${channelId}/messages/${messageId}`, { content: text });
96
+ const sent = await rest('PATCH', `/channels/${channelId}/messages/${messageId}`, { content: text, flags: SUPPRESS_EMBEDS });
124
97
  return sent.id;
125
98
  }
126
- /**
127
- * Fetch all messages newer than `afterMessageId` in a channel. Used for
128
- * catchup-on-restart so messages sent while metro was down still get a
129
- * reply once it comes back. Caps at 100 messages — anything older than
130
- * that during downtime is dropped on the floor.
131
- */
99
+ /** Catchup-on-restart: fetch messages newer than `afterMessageId` (cap 100). */
132
100
  export async function fetchMessagesSince(channelId, afterMessageId) {
133
101
  const msgs = await rest('GET', `/channels/${channelId}/messages?after=${afterMessageId}&limit=100`);
134
- // Discord returns newest-first; flip to chronological so replay order
135
- // matches what the user actually typed.
136
- return [...msgs].reverse().map(m => ({
137
- message_id: m.id,
138
- text: m.content,
139
- author_id: m.author.id,
140
- author_is_bot: !!m.author.bot,
141
- }));
102
+ /** Discord returns newest-first; flip to chronological for replay. */
103
+ return [...msgs].reverse().map(m => ({ message_id: m.id, text: m.content, author_id: m.author.id, author_is_bot: !!m.author.bot }));
142
104
  }
@@ -2,6 +2,7 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { STATE_DIR } from '../paths.js';
4
4
  import { errMsg, log } from '../log.js';
5
+ import { mdToTelegramHtml } from '../helpers/telegram-format.js';
5
6
  const API_BASE = 'https://api.telegram.org';
6
7
  function token() {
7
8
  const t = process.env.TELEGRAM_BOT_TOKEN;
@@ -10,23 +11,20 @@ function token() {
10
11
  return t;
11
12
  }
12
13
  async function tg(method, body, opts = {}) {
13
- const timeout = opts.timeoutMs ?? 30_000;
14
- const signals = [AbortSignal.timeout(timeout)];
14
+ const signals = [AbortSignal.timeout(opts.timeoutMs ?? 30_000)];
15
15
  if (opts.signal)
16
16
  signals.push(opts.signal);
17
- const signal = AbortSignal.any(signals);
18
17
  const res = await fetch(`${API_BASE}/bot${token()}/${method}`, {
19
18
  method: 'POST',
20
19
  headers: { 'Content-Type': 'application/json' },
21
20
  body: JSON.stringify(body),
22
- signal,
21
+ signal: AbortSignal.any(signals),
23
22
  });
24
23
  const json = (await res.json());
25
24
  if (!json.ok)
26
25
  throw new Error(`telegram ${method}: ${json.description ?? 'unknown error'}`);
27
26
  return json.result;
28
27
  }
29
- // ---------- Identity (cached after getMe) ----------------------------------
30
28
  let botUsername = null;
31
29
  let botUserId = null;
32
30
  export async function getMe() {
@@ -36,17 +34,13 @@ export async function getMe() {
36
34
  return me;
37
35
  }
38
36
  let onInboundHandler = () => { };
39
- export function onInbound(handler) {
40
- onInboundHandler = handler;
41
- }
37
+ export const onInbound = (handler) => { onInboundHandler = handler; };
42
38
  const offsetFile = join(STATE_DIR, 'telegram-offset.json');
43
39
  let pollOffset = 0;
44
40
  let pollAbort = null;
45
41
  function loadOffset() {
46
42
  try {
47
- if (!existsSync(offsetFile))
48
- return 0;
49
- return Number(readFileSync(offsetFile, 'utf8').trim()) || 0;
43
+ return existsSync(offsetFile) ? Number(readFileSync(offsetFile, 'utf8').trim()) || 0 : 0;
50
44
  }
51
45
  catch {
52
46
  return 0;
@@ -61,18 +55,14 @@ function saveOffset(o) {
61
55
  }
62
56
  }
63
57
  export async function startPolling() {
64
- // A registered webhook short-circuits getUpdates — clear it defensively.
65
58
  await tg('deleteWebhook', { drop_pending_updates: false }).catch(() => { });
66
59
  const persisted = loadOffset();
67
60
  if (persisted > 0) {
68
- // Resume from where we left off — telegram queues updates for ~24h, so
69
- // any messages sent while metro was down come back through this poll.
70
61
  pollOffset = persisted;
71
62
  log.info({ offset: pollOffset }, 'telegram polling: resuming from persisted offset');
72
63
  }
73
64
  else {
74
- // First-ever run: skip historical backlog by anchoring on the latest
75
- // update id (-1 returns just the most recent one without consuming it).
65
+ /** First run: anchor on latest update id (-1 returns most recent without consuming). */
76
66
  const initial = await tg('getUpdates', { offset: -1, timeout: 0 });
77
67
  pollOffset = initial.length ? initial[0].update_id + 1 : 0;
78
68
  saveOffset(pollOffset);
@@ -102,26 +92,15 @@ async function pollLoop() {
102
92
  }
103
93
  log.info('telegram polling stopped');
104
94
  }
105
- export async function shutdownPolling() {
106
- pollAbort?.abort();
107
- pollAbort = null;
108
- }
95
+ export async function shutdownPolling() { pollAbort?.abort(); pollAbort = null; }
109
96
  async function dispatchUpdate(u) {
110
97
  const m = u.message;
111
- if (!m?.chat?.id || typeof m.message_id !== 'number') {
112
- log.trace({ update_id: u.update_id }, 'telegram: non-message update');
98
+ if (!m?.chat?.id || typeof m.message_id !== 'number' || m.from?.is_bot)
113
99
  return;
114
- }
115
- if (m.from?.is_bot) {
116
- log.trace({ chat: m.chat.id }, 'telegram: skipping bot author');
117
- return;
118
- }
119
- const text = await messageToText(m);
120
- if (!text) {
121
- log.trace({ chat: m.chat.id }, 'telegram: no text/caption');
100
+ const text = messageToText(m);
101
+ if (!text)
122
102
  return;
123
- }
124
- const msg = {
103
+ onInboundHandler({
125
104
  chat_id: m.chat.id,
126
105
  message_id: m.message_id,
127
106
  message_thread_id: m.is_topic_message ? m.message_thread_id : undefined,
@@ -130,42 +109,27 @@ async function dispatchUpdate(u) {
130
109
  is_forum_topic: !!m.is_topic_message,
131
110
  in_forum: !!m.chat.is_forum,
132
111
  mentions_bot: detectMentionsBot(m),
133
- };
134
- log.debug({
135
- chat: msg.chat_id,
136
- topic: msg.message_thread_id,
137
- is_private: msg.is_private,
138
- is_forum_topic: msg.is_forum_topic,
139
- in_forum: msg.in_forum,
140
- mentions_bot: msg.mentions_bot,
141
- }, 'telegram: inbound');
142
- onInboundHandler(msg);
112
+ });
143
113
  }
144
114
  function detectMentionsBot(m) {
145
- // In DMs, every message to the bot is implicitly addressed to it.
146
115
  if (m.chat?.type === 'private')
147
116
  return true;
148
117
  const text = m.text ?? m.caption ?? '';
149
- const entities = m.entities ?? m.caption_entities ?? [];
150
- for (const e of entities) {
118
+ for (const e of m.entities ?? m.caption_entities ?? []) {
151
119
  if (e.type === 'mention' && botUsername) {
152
- const slice = text.substring(e.offset, e.offset + e.length);
153
- if (slice.toLowerCase() === `@${botUsername.toLowerCase()}`)
120
+ if (text.substring(e.offset, e.offset + e.length).toLowerCase() === `@${botUsername.toLowerCase()}`)
154
121
  return true;
155
122
  }
156
- else if (e.type === 'text_mention' && e.user?.id === botUserId) {
123
+ else if (e.type === 'text_mention' && e.user?.id === botUserId)
157
124
  return true;
158
- }
159
125
  }
160
126
  return false;
161
127
  }
162
- async function messageToText(m) {
128
+ function messageToText(m) {
163
129
  if (m.text)
164
130
  return m.text;
165
131
  const caption = m.caption ?? '';
166
- if (m.photo?.length)
167
- return [caption, '[image]'].filter(Boolean).join(' ');
168
- if (m.document?.mime_type?.startsWith('image/'))
132
+ if (m.photo?.length || m.document?.mime_type?.startsWith('image/'))
169
133
  return [caption, '[image]'].filter(Boolean).join(' ');
170
134
  if (m.voice)
171
135
  return [caption, '[voice]'].filter(Boolean).join(' ');
@@ -173,37 +137,53 @@ async function messageToText(m) {
173
137
  return [caption, '[audio]'].filter(Boolean).join(' ');
174
138
  return caption || null;
175
139
  }
176
- // ---------- Outbound (REST) ------------------------------------------------
177
- /**
178
- * Create a new forum topic in a supergroup. Returns the topic's
179
- * message_thread_id. Requires the bot to be an admin with the
180
- * `can_manage_topics` privilege.
181
- */
140
+ /** Create a forum topic; requires `can_manage_topics` admin permission. */
182
141
  export async function createForumTopic(chatId, name) {
183
- // Telegram caps topic names at 128 chars.
184
- const trimmedName = name.slice(0, 128) || 'metro';
185
- const r = await tg('createForumTopic', {
186
- chat_id: chatId,
187
- name: trimmedName,
188
- });
142
+ const r = await tg('createForumTopic', { chat_id: chatId, name: name.slice(0, 128) || 'metro' });
189
143
  return r.message_thread_id;
190
144
  }
191
- export async function sendMessage(chatId, threadId, text) {
192
- const body = { chat_id: chatId, text };
145
+ /** Deep link `t.me/c/<id>/<topic>`; strips supergroup's `-100` prefix from chat id. */
146
+ export function topicLink(chatId, topicId) {
147
+ const id = String(Math.abs(chatId)).replace(/^100/, '');
148
+ return `https://t.me/c/${id}/${topicId}`;
149
+ }
150
+ const isParseError = (err) => errMsg(err).includes("can't parse entities");
151
+ const NO_PREVIEW = { link_preview_options: { is_disabled: true } };
152
+ /** Send agent-style markdown as Telegram HTML, falling back to plain text on parse errors. */
153
+ export async function sendMessage(chatId, threadId, text, replyToMessageId) {
154
+ const base = { chat_id: chatId, ...NO_PREVIEW };
193
155
  if (threadId !== undefined)
194
- body.message_thread_id = threadId;
195
- const r = await tg('sendMessage', body);
196
- return r.message_id;
156
+ base.message_thread_id = threadId;
157
+ if (replyToMessageId !== undefined)
158
+ base.reply_parameters = { message_id: replyToMessageId };
159
+ try {
160
+ return (await tg('sendMessage', { ...base, text: mdToTelegramHtml(text), parse_mode: 'HTML' })).message_id;
161
+ }
162
+ catch (err) {
163
+ if (!isParseError(err))
164
+ throw err;
165
+ log.warn({ err: errMsg(err) }, 'telegram: HTML rejected, sending plain');
166
+ return (await tg('sendMessage', { ...base, text })).message_id;
167
+ }
197
168
  }
198
169
  export async function editMessageText(chatId, messageId, text) {
170
+ const base = { chat_id: chatId, message_id: messageId, ...NO_PREVIEW };
171
+ const swallow = (err) => { if (!errMsg(err).includes('message is not modified'))
172
+ throw err; };
199
173
  try {
200
- await tg('editMessageText', { chat_id: chatId, message_id: messageId, text });
174
+ await tg('editMessageText', { ...base, text: mdToTelegramHtml(text), parse_mode: 'HTML' });
201
175
  }
202
176
  catch (err) {
203
- // Telegram rejects edits that match the existing content — ignore that
204
- // specific case so debounced no-op flushes don't surface as errors.
205
177
  if (errMsg(err).includes('message is not modified'))
206
178
  return;
207
- throw err;
179
+ if (!isParseError(err))
180
+ throw err;
181
+ log.warn({ err: errMsg(err) }, 'telegram: HTML edit rejected, retrying plain');
182
+ try {
183
+ await tg('editMessageText', { ...base, text });
184
+ }
185
+ catch (e) {
186
+ swallow(e);
187
+ }
208
188
  }
209
189
  }
package/dist/cli.js CHANGED
@@ -5,28 +5,27 @@ import { join } from 'node:path';
5
5
  import pkg from '../package.json' with { type: 'json' };
6
6
  import * as discord from './channels/discord.js';
7
7
  import * as telegram from './channels/telegram.js';
8
- import { readDotenv, writeDotenv } from './lib/dotenv.js';
9
8
  import { errMsg } from './log.js';
10
- import { CONFIG_ENV_FILE, configuredPlatforms, loadMetroEnv, STATE_DIR } from './paths.js';
9
+ import { CONFIG_ENV_FILE, configuredPlatforms, loadMetroEnv, readDotenv, STATE_DIR, writeDotenv } from './paths.js';
11
10
  const USAGE = `metro — Telegram + Discord bridge for your Claude Code / Codex agent
12
11
 
13
12
  Usage:
14
13
  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).
14
+ metro setup [telegram|discord <token>] Save token, or show status with no args.
20
15
  metro setup clear [telegram|discord|all] Remove tokens.
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.
16
+ metro doctor Health check.
17
+ metro update Upgrade in place.
18
+ metro --version | --help
25
19
 
26
- Exit codes:
27
- 0 success · 1 usage · 2 config (no tokens — run \`metro setup\`) · 3 upstream
20
+ Exit codes: 0 success · 1 usage · 2 config · 3 upstream
28
21
  `;
29
- function exitErr(msg, code) { return Object.assign(new Error(msg), { code }); }
22
+ const exitErr = (msg, code) => Object.assign(new Error(msg), { code });
23
+ const isJson = (f) => f.json === true;
24
+ const emit = (f, human, structured) => {
25
+ process.stdout.write(isJson(f) ? JSON.stringify(structured) + '\n' : human + '\n');
26
+ };
27
+ const maskToken = (t) => !t ? '' : t.length <= 8 ? '••••' : `${t.slice(0, 6)}…${t.slice(-2)}`;
28
+ const TOKEN_KEYS = { telegram: 'TELEGRAM_BOT_TOKEN', discord: 'DISCORD_BOT_TOKEN' };
30
29
  function parseArgs(argv) {
31
30
  const positional = [];
32
31
  const flags = {};
@@ -52,18 +51,6 @@ function parseArgs(argv) {
52
51
  }
53
52
  return { positional, flags };
54
53
  }
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)
61
- return '';
62
- if (t.length <= 8)
63
- return '••••';
64
- return `${t.slice(0, 6)}…${t.slice(-2)}`;
65
- }
66
- const TOKEN_KEYS = { telegram: 'TELEGRAM_BOT_TOKEN', discord: 'DISCORD_BOT_TOKEN' };
67
54
  async function cmdSetup(positional, flags) {
68
55
  const [sub, value] = positional;
69
56
  if (!sub)
@@ -86,10 +73,8 @@ async function cmdSetup(positional, flags) {
86
73
  const env = readDotenv(CONFIG_ENV_FILE);
87
74
  env[TOKEN_KEYS[sub]] = trimmed;
88
75
  writeDotenv(CONFIG_ENV_FILE, env);
89
- const human = identity
90
- ? `saved ${TOKEN_KEYS[sub]} (verified as ${identity}) to ${CONFIG_ENV_FILE} (chmod 0600)`
91
- : `saved ${TOKEN_KEYS[sub]} to ${CONFIG_ENV_FILE} (chmod 0600)`;
92
- emitResult(flags, human, { ok: true, saved: TOKEN_KEYS[sub], verified_as: identity ?? null });
76
+ const human = `saved ${TOKEN_KEYS[sub]}${identity ? ` (verified as ${identity})` : ''} to ${CONFIG_ENV_FILE} (chmod 0600)`;
77
+ emit(flags, human, { ok: true, saved: TOKEN_KEYS[sub], verified_as: identity ?? null });
93
78
  return;
94
79
  }
95
80
  if (sub === 'clear') {
@@ -104,7 +89,7 @@ async function cmdSetup(positional, flags) {
104
89
  else
105
90
  throw new Error(`metro setup clear <telegram|discord|all> — got '${target}'`);
106
91
  writeDotenv(CONFIG_ENV_FILE, env);
107
- emitResult(flags, `cleared ${target === 'all' ? 'all metro tokens' : TOKEN_KEYS[target]}`, { ok: true, cleared: target });
92
+ emit(flags, `cleared ${target === 'all' ? 'all metro tokens' : TOKEN_KEYS[target]}`, { ok: true, cleared: target });
108
93
  return;
109
94
  }
110
95
  throw new Error(`unknown setup subcommand '${sub}' (try: telegram, discord, clear)`);
@@ -117,57 +102,43 @@ async function cmdSetupStatus(flags) {
117
102
  process.stdout.write(JSON.stringify({
118
103
  version: pkg.version,
119
104
  config_env_file: CONFIG_ENV_FILE,
120
- tokens: {
121
- telegram: { set: !!tg, masked: maskToken(tg) },
122
- discord: { set: !!dc, masked: maskToken(dc) },
123
- },
105
+ tokens: { telegram: { set: !!tg, masked: maskToken(tg) }, discord: { set: !!dc, masked: maskToken(dc) } },
124
106
  }) + '\n');
125
107
  return;
126
108
  }
127
- process.stdout.write(`metro ${pkg.version}\n\n` +
128
- `config: ${CONFIG_ENV_FILE}${existsSync(CONFIG_ENV_FILE) ? '' : ' (not yet written)'}\n\n` +
109
+ const cfgState = existsSync(CONFIG_ENV_FILE) ? '' : ' (not yet written)';
110
+ process.stdout.write(`metro ${pkg.version}\n\nconfig: ${CONFIG_ENV_FILE}${cfgState}\n\n` +
129
111
  ` TELEGRAM_BOT_TOKEN ${tg ? `set (${maskToken(tg)})` : 'not set'}\n` +
130
112
  ` DISCORD_BOT_TOKEN ${dc ? `set (${maskToken(dc)})` : 'not set'}\n\n`);
131
- if (!tg && !dc) {
132
- process.stdout.write('Get started:\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');
137
- }
138
- else {
139
- process.stdout.write('Run `metro` to start the orchestrator, or `metro doctor` to verify.\n');
140
- }
113
+ process.stdout.write(!tg && !dc
114
+ ? 'Get started:\n 1. metro setup telegram <token> # https://t.me/BotFather\n metro setup discord <token> # https://discord.com/developers/applications\n 2. metro doctor\n 3. metro\n'
115
+ : 'Run `metro` to start the orchestrator, or `metro doctor` to verify.\n');
141
116
  }
142
117
  async function cmdDoctor(flags) {
143
118
  loadMetroEnv();
144
- const checks = [];
145
119
  const cfg = configuredPlatforms();
146
- checks.push({
147
- name: 'tokens',
148
- ok: cfg.telegram || cfg.discord,
149
- detail: cfg.telegram || cfg.discord
150
- ? `loaded from ${existsSync(CONFIG_ENV_FILE) ? CONFIG_ENV_FILE : 'process env'}`
151
- : 'no platform configured run `metro setup telegram|discord <token>`',
152
- });
153
- for (const [platform, getMe] of [['telegram', telegram.getMe], ['discord', discord.getMe]]) {
154
- if (!cfg[platform]) {
155
- checks.push({ name: platform, ok: null, detail: 'not configured' });
120
+ const checks = [{
121
+ name: 'tokens',
122
+ ok: cfg.telegram || cfg.discord,
123
+ detail: cfg.telegram || cfg.discord ? `loaded from ${existsSync(CONFIG_ENV_FILE) ? CONFIG_ENV_FILE : 'process env'}` : 'no platform configured — run `metro setup telegram|discord <token>`',
124
+ }];
125
+ for (const [p, getMe] of [['telegram', telegram.getMe], ['discord', discord.getMe]]) {
126
+ if (!cfg[p]) {
127
+ checks.push({ name: p, ok: null, detail: 'not configured' });
156
128
  continue;
157
129
  }
158
130
  try {
159
131
  const me = await getMe();
160
- checks.push({ name: platform, ok: true, detail: `getMe → ${platform === 'telegram' ? '@' : ''}${me.username}` });
132
+ checks.push({ name: p, ok: true, detail: `getMe → ${p === 'telegram' ? '@' : ''}${me.username}` });
161
133
  }
162
134
  catch (err) {
163
- checks.push({ name: platform, ok: false, detail: errMsg(err) });
135
+ checks.push({ name: p, ok: false, detail: errMsg(err) });
164
136
  }
165
137
  }
166
138
  const lockFile = join(STATE_DIR, '.tail-lock');
167
- if (!existsSync(lockFile)) {
139
+ if (!existsSync(lockFile))
168
140
  checks.push({ name: 'orchestrator', ok: null, detail: 'not running' });
169
- }
170
- else {
141
+ else
171
142
  try {
172
143
  const pid = Number(readFileSync(lockFile, 'utf8').trim());
173
144
  if (!Number.isInteger(pid) || pid <= 0)
@@ -178,16 +149,12 @@ async function cmdDoctor(flags) {
178
149
  catch {
179
150
  checks.push({ name: 'orchestrator', ok: null, detail: 'stale lockfile (will auto-reclaim on next start)' });
180
151
  }
181
- }
182
- if (isJson(flags)) {
152
+ if (isJson(flags))
183
153
  process.stdout.write(JSON.stringify({ checks }) + '\n');
184
- }
185
154
  else {
186
155
  process.stdout.write('metro doctor\n\n');
187
- for (const c of checks) {
188
- const icon = c.ok === true ? '✓' : c.ok === false ? '✗' : '–';
189
- process.stdout.write(` ${icon} ${c.name.padEnd(15)} ${c.detail}\n`);
190
- }
156
+ for (const c of checks)
157
+ process.stdout.write(` ${c.ok === true ? '✓' : c.ok === false ? '✗' : '–'} ${c.name.padEnd(15)} ${c.detail}\n`);
191
158
  process.stdout.write('\n');
192
159
  }
193
160
  if (checks.some(c => c.ok === false))
@@ -198,28 +165,23 @@ async function cmdUpdate(flags) {
198
165
  const res = await fetch('https://registry.npmjs.org/@stage-labs/metro', { signal: AbortSignal.timeout(15_000) });
199
166
  if (!res.ok)
200
167
  throw new Error(`npm registry: ${res.status}`);
201
- const data = (await res.json());
202
- const latest = data['dist-tags']?.[tag];
168
+ const latest = (await res.json())['dist-tags']?.[tag];
203
169
  if (!latest)
204
170
  throw new Error(`no '${tag}' dist-tag for @stage-labs/metro`);
205
- if (latest === pkg.version) {
206
- emitResult(flags, `already on ${pkg.version} (latest ${tag})`, { ok: true, current: pkg.version, latest, upgraded: false });
207
- return;
208
- }
171
+ if (latest === pkg.version)
172
+ return emit(flags, `already on ${pkg.version} (latest ${tag})`, { ok: true, current: pkg.version, latest, upgraded: false });
209
173
  const argv1 = process.argv[1] ?? '';
210
174
  const spec = `@stage-labs/metro@${tag}`;
211
175
  const argv = argv1.includes('/.bun/') || argv1.includes('\\bun\\') ? ['bun', 'add', '-g', spec]
212
176
  : argv1.includes('/pnpm/') || argv1.includes('\\pnpm\\') ? ['pnpm', 'add', '-g', spec]
213
177
  : ['npm', 'install', '-g', spec];
214
- if (isJson(flags)) {
178
+ if (isJson(flags))
215
179
  process.stdout.write(JSON.stringify({ ok: true, current: pkg.version, latest, command: argv.join(' ') }) + '\n');
216
- }
217
- else {
180
+ else
218
181
  process.stdout.write(`metro ${pkg.version} → ${latest}\n$ ${argv.join(' ')}\n`);
219
- }
220
182
  await new Promise((resolve, reject) => {
221
183
  const child = spawn(argv[0], argv.slice(1), { stdio: isJson(flags) ? 'ignore' : 'inherit' });
222
- child.on('exit', code => (code === 0 ? resolve() : reject(new Error(`${argv[0]} exited ${code}`))));
184
+ child.on('exit', code => code === 0 ? resolve() : reject(new Error(`${argv[0]} exited ${code}`)));
223
185
  child.on('error', reject);
224
186
  });
225
187
  }
@@ -230,15 +192,10 @@ const COMMANDS = {
230
192
  };
231
193
  async function main() {
232
194
  const cmd = process.argv[2];
233
- if (cmd === '--version' || cmd === '-v') {
234
- process.stdout.write(`${pkg.version}\n`);
235
- return;
236
- }
237
- if (cmd === '--help' || cmd === '-h') {
238
- process.stdout.write(USAGE);
239
- return;
240
- }
241
- // Bare `metro` runs the orchestrator (the daemon's the default).
195
+ if (cmd === '--version' || cmd === '-v')
196
+ return void process.stdout.write(`${pkg.version}\n`);
197
+ if (cmd === '--help' || cmd === '-h')
198
+ return void process.stdout.write(USAGE);
242
199
  if (!cmd) {
243
200
  await import('./orchestrator.js');
244
201
  return;
@@ -254,12 +211,10 @@ async function main() {
254
211
  }
255
212
  catch (err) {
256
213
  const code = err.code;
257
- if (isJson(flags)) {
214
+ if (isJson(flags))
258
215
  process.stdout.write(JSON.stringify({ ok: false, error: errMsg(err), code: code ?? 1 }) + '\n');
259
- }
260
- else {
216
+ else
261
217
  process.stderr.write(`error: ${errMsg(err)}\n`);
262
- }
263
218
  process.exit(typeof code === 'number' ? code : 1);
264
219
  }
265
220
  }
@@ -1,16 +1,4 @@
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>
1
+ /** Per-machine scope→{thread ids,last-used} cache. Keys: `discord:<id>` / `telegram:<chat>:<topic>`. */
14
2
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
15
3
  import { join } from 'node:path';
16
4
  import { errMsg, log } from '../log.js';
@@ -72,14 +60,6 @@ export function setLastSeen(scopeKey, messageId) {
72
60
  export function listScopes() {
73
61
  return Object.entries(read()).map(([scopeKey, entry]) => ({ scopeKey, entry }));
74
62
  }
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
- }
63
+ export const discordScopeKey = (threadChannelId) => `discord:${threadChannelId}`;
64
+ export const discordChannelFromScopeKey = (scopeKey) => scopeKey.startsWith('discord:') ? scopeKey.slice('discord:'.length) : null;
65
+ export const telegramScopeKey = (chatId, topicId) => `telegram:${chatId}:${topicId ?? 'main'}`;