@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,285 +1,132 @@
1
- // Metro orchestrator long-running daemon. Owns the Discord gateway and
2
- // Telegram poller, runs both codex and claude as agent backends, streams
3
- // per-turn responses back to chat with tool-call status visible.
4
- //
5
- // Per-message agent routing: a message ending in "with claude" / "with
6
- // codex" (any casing) targets that agent. Otherwise, the scope's last-used
7
- // agent answers; for brand-new scopes, the default is Claude.
8
- //
9
- // Scopes:
10
- // Discord — one per thread (auto-created from an @-mention).
11
- // Telegram — one per DM, one per forum-topic (auto-created when a user
12
- // @-mentions in General).
13
- import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
1
+ /** Daemon: owns Discord gateway + Telegram poller; routes inbounds to codex/claude (suffix "with X" overrides scope default). */
14
2
  import { join } from 'node:path';
15
3
  import pkg from '../package.json' with { type: 'json' };
16
4
  import * as discord from './channels/discord.js';
17
5
  import * as telegram from './channels/telegram.js';
18
6
  import { CodexAgent } from './agents/codex.js';
19
7
  import { ClaudeAgent } from './agents/claude.js';
20
- import { discordChannelFromScopeKey, discordScopeKey, getAgentThread, getLastAgent, listScopes, setAgentThread, setLastAgent, setLastSeen, telegramScopeKey, } from './lib/scope-cache.js';
21
- import { StreamingMessage, StreamScheduler } from './lib/streaming.js';
8
+ import { discordChannelFromScopeKey, discordScopeKey, getAgentThread, getLastAgent, listScopes, setAgentThread, setLastAgent, setLastSeen, telegramScopeKey, } from './helpers/scope-cache.js';
9
+ import { StreamScheduler } from './helpers/streaming.js';
10
+ import { runTurn } from './helpers/turn.js';
22
11
  import { errMsg, log } from './log.js';
23
- import { configuredPlatforms, loadMetroEnv, STATE_DIR, requireConfiguredPlatform } from './paths.js';
12
+ import { acquireLock, configuredPlatforms, loadMetroEnv, STATE_DIR, requireConfiguredPlatform } from './paths.js';
24
13
  loadMetroEnv();
25
14
  const platforms = configuredPlatforms();
26
15
  requireConfiguredPlatform(platforms);
27
- // Singleton lockfile. The orchestrator owns the Discord gateway / Telegram
28
- // poller, so only one instance can run per machine.
29
- const LOCK_FILE = join(STATE_DIR, '.tail-lock');
30
- function processIsAlive(pid) {
31
- try {
32
- process.kill(pid, 0);
33
- return true;
34
- }
35
- catch {
36
- return false;
37
- }
38
- }
39
- if (existsSync(LOCK_FILE)) {
40
- const pid = Number(readFileSync(LOCK_FILE, 'utf8').trim());
41
- if (Number.isInteger(pid) && pid > 0 && processIsAlive(pid)) {
42
- log.info({ pid }, 'another `metro` instance is already running; exiting');
43
- process.exit(0);
44
- }
45
- try {
46
- unlinkSync(LOCK_FILE);
47
- }
48
- catch { /* ignore */ }
49
- }
50
- mkdirSync(STATE_DIR, { recursive: true });
51
- writeFileSync(LOCK_FILE, String(process.pid));
52
- process.on('exit', () => { try {
53
- if (readFileSync(LOCK_FILE, 'utf8').trim() === String(process.pid))
54
- unlinkSync(LOCK_FILE);
55
- }
56
- catch { /* ignore */ } });
57
- // Track which agent threads we're actively serving a turn in, so we don't
58
- // send overlapping turn/start requests on the same thread.
59
- const inFlight = new Set();
60
- const queued = new Map();
61
- // De-dupe the *same* gateway delivery (e.g. on reconnect replay).
16
+ acquireLock(join(STATE_DIR, '.tail-lock'));
62
17
  const bootstrapped = new Set();
63
- // Both agents are instantiated; either can fail start() and be left
64
- // unavailable. Routing falls back gracefully when an agent isn't usable.
65
18
  const codexAgent = new CodexAgent(pkg.version);
66
19
  const claudeAgent = new ClaudeAgent();
67
20
  const available = {};
68
- // One scheduler per bot — coalesces streamed edits across every active
69
- // thread so concurrent turns don't compound the bot's edit cadence.
70
21
  const discordScheduler = new StreamScheduler();
71
22
  const telegramScheduler = new StreamScheduler();
72
23
  async function startAgents() {
73
24
  await Promise.allSettled([
74
- codexAgent.start().then(() => { available.codex = codexAgent; })
75
- .catch(err => log.warn({ err: errMsg(err) }, "codex unavailable'with codex' will fail")),
76
- claudeAgent.start().then(() => { available.claude = claudeAgent; })
77
- .catch(err => log.warn({ err: errMsg(err) }, "claude unavailable — 'with claude' will fail")),
25
+ codexAgent.start().then(() => { available.codex = codexAgent; }).catch(err => log.warn({ err: errMsg(err) }, 'codex unavailable')),
26
+ claudeAgent.start().then(() => { available.claude = claudeAgent; }).catch(err => log.warn({ err: errMsg(err) }, 'claude unavailable')),
78
27
  ]);
79
- if (Object.keys(available).length === 0) {
80
- log.fatal('no agents available — install codex or claude and authenticate');
28
+ if (!Object.keys(available).length) {
29
+ log.fatal('no agents available');
81
30
  process.exit(2);
82
31
  }
83
- log.info({ agents: Object.keys(available) }, 'orchestrator: agents ready');
32
+ log.info({ agents: Object.keys(available) }, 'agents ready');
84
33
  }
85
- async function main() {
86
- await startAgents();
87
- if (platforms.discord) {
88
- await discord.startGateway();
89
- const me = await discord.getMe();
90
- log.info({ bot: me.username }, 'discord ready');
91
- discord.onInbound(m => void onDiscordInbound(m).catch(err => log.warn({ err: errMsg(err) }, 'discord inbound failed')));
92
- void catchupDiscord().catch(err => log.warn({ err: errMsg(err) }, 'discord catchup failed'));
93
- }
94
- if (platforms.telegram) {
95
- // Identity needed for @-mention detection in groups; populated as a
96
- // side-effect on the channel module.
97
- const me = await telegram.getMe();
98
- log.info({ bot: `@${me.username}` }, 'telegram ready');
99
- telegram.onInbound(m => void onTelegramInbound(m).catch(err => log.warn({ err: errMsg(err) }, 'telegram inbound failed')));
100
- await telegram.startPolling();
101
- }
102
- log.info('orchestrator ready');
103
- }
104
- async function catchupDiscord() {
105
- const scopes = listScopes();
106
- for (const { scopeKey, entry } of scopes) {
107
- const channelId = discordChannelFromScopeKey(scopeKey);
108
- if (!channelId || !entry.lastSeenMessageId)
109
- continue;
110
- try {
111
- const missed = await discord.fetchMessagesSince(channelId, entry.lastSeenMessageId);
112
- const humanMissed = missed.filter(m => !m.author_is_bot && m.text);
113
- if (humanMissed.length === 0)
114
- continue;
115
- log.info({ channel: channelId, count: humanMissed.length }, 'discord catchup: replaying missed messages');
116
- for (const m of humanMissed) {
117
- await onDiscordInbound({
118
- channel_id: channelId,
119
- message_id: m.message_id,
120
- text: m.text,
121
- in_guild: true,
122
- mentions_bot: false,
123
- });
124
- }
125
- }
126
- catch (err) {
127
- log.warn({ err: errMsg(err), channel: channelId }, 'discord catchup: channel skipped');
128
- }
129
- }
130
- }
131
- // "with claude" / "with codex" suffix (any casing). Captures the kind and
132
- // returns the message without the suffix. If no suffix, returns null.
133
34
  const SUFFIX_RE = /(?:^|\s)with\s+(claude|codex)\s*$/i;
134
35
  function parseAgentSuffix(text) {
135
- const trimmed = text.trimEnd();
136
- const m = SUFFIX_RE.exec(trimmed);
137
- if (!m)
138
- return { kind: null, cleanText: text };
139
- const kind = m[1].toLowerCase();
140
- return { kind, cleanText: trimmed.slice(0, m.index).trimEnd() };
36
+ const t = text.trimEnd();
37
+ const m = SUFFIX_RE.exec(t);
38
+ return m ? { kind: m[1].toLowerCase(), cleanText: t.slice(0, m.index).trimEnd() } : { kind: null, cleanText: text };
141
39
  }
142
- // Effective agent for this turn: explicit suffix beats lastAgent beats the
143
- // default (Claude). If the requested kind is unavailable, returns null so
144
- // the caller can surface an error to the user.
145
- function pickAgent(scopeKey, requestedKind) {
146
- if (requestedKind) {
147
- if (!available[requestedKind])
148
- return { error: `${requestedKind} is not available on this metro instance` };
149
- return { kind: requestedKind, explicit: true };
150
- }
40
+ function pickAgent(scopeKey, req) {
41
+ if (req)
42
+ return available[req] ? { kind: req } : { error: `${req} is not available on this metro instance` };
151
43
  const last = scopeKey ? getLastAgent(scopeKey) : undefined;
152
44
  if (last && available[last])
153
- return { kind: last, explicit: false };
45
+ return { kind: last };
154
46
  if (available.claude)
155
- return { kind: 'claude', explicit: false };
47
+ return { kind: 'claude' };
156
48
  if (available.codex)
157
- return { kind: 'codex', explicit: false };
49
+ return { kind: 'codex' };
158
50
  return { error: 'no agents available' };
159
51
  }
160
- async function onDiscordInbound(m) {
161
- if (!m.in_guild) {
162
- log.debug({ channel: m.channel_id }, 'discord DM ignored (not supported yet)');
163
- return;
164
- }
165
- const cachedScope = discordScopeKey(m.channel_id);
166
- const cachedHasAnyAgent = !!(getAgentThread(cachedScope, 'codex') ?? getAgentThread(cachedScope, 'claude'));
167
- // Existing scope (in-thread message): route to the requested or last-used agent.
168
- if (cachedHasAnyAgent) {
169
- const parsed = parseAgentSuffix(m.text);
170
- const choice = pickAgent(cachedScope, parsed.kind);
171
- if ('error' in choice) {
172
- await postErrorMessage(m.channel_id, choice.error);
173
- return;
174
- }
175
- setLastSeen(cachedScope, m.message_id);
176
- let agentThreadId = getAgentThread(cachedScope, choice.kind);
177
- if (!agentThreadId) {
178
- // First time using this agent in this scope — fresh session, no prior
179
- // history shared with the other agent.
180
- agentThreadId = await available[choice.kind].createThread();
181
- setAgentThread(cachedScope, choice.kind, agentThreadId);
182
- log.info({ scope: cachedScope, agent: choice.kind, thread: agentThreadId }, 'allocated new agent session for existing scope');
183
- }
184
- else {
185
- setLastAgent(cachedScope, choice.kind);
186
- }
187
- await handleTurn(parsed.cleanText, choice.kind, agentThreadId, discordAdapter(m.channel_id), discordScheduler);
188
- return;
52
+ /** Resolve agent session for `scopeKey`, allocating if new, then run the turn. */
53
+ async function dispatch(scopeKey, text, kind, messageId, adapter, scheduler) {
54
+ setLastSeen(scopeKey, messageId);
55
+ let threadId = getAgentThread(scopeKey, kind);
56
+ if (!threadId) {
57
+ threadId = await available[kind].createThread();
58
+ setAgentThread(scopeKey, kind, threadId);
59
+ log.info({ scope: scopeKey, agent: kind, thread: threadId }, 'allocated agent session');
189
60
  }
190
- // No scope yet — only @-mentions bootstrap a new thread.
191
- if (!m.mentions_bot) {
192
- log.debug({ channel: m.channel_id }, 'discord guild msg dropped: no scope, no @-mention');
61
+ else
62
+ setLastAgent(scopeKey, kind);
63
+ await runTurn(available[kind], threadId, text, adapter, scheduler);
64
+ }
65
+ async function onDiscordInbound(m) {
66
+ if (!m.in_guild)
193
67
  return;
68
+ const scope = discordScopeKey(m.channel_id);
69
+ const hasAgent = !!(getAgentThread(scope, 'codex') ?? getAgentThread(scope, 'claude'));
70
+ const { kind: req, cleanText } = parseAgentSuffix(m.text);
71
+ const postErr = (msg) => discord.sendMessage(m.channel_id, `⚠️ ${msg}`).then(() => { }).catch(err => log.warn({ err: errMsg(err) }, 'discord error post failed'));
72
+ if (hasAgent) {
73
+ const choice = pickAgent(scope, req);
74
+ if ('error' in choice)
75
+ return postErr(choice.error);
76
+ return dispatch(scope, cleanText, choice.kind, m.message_id, discordAdapter(m.channel_id), discordScheduler);
194
77
  }
195
- if (bootstrapped.has(m.message_id))
78
+ if (!m.mentions_bot || bootstrapped.has(m.message_id))
196
79
  return;
197
80
  bootstrapped.add(m.message_id);
198
- const parsed = parseAgentSuffix(m.text);
199
- const choice = pickAgent(null, parsed.kind);
200
- if ('error' in choice) {
201
- await postErrorMessage(m.channel_id, choice.error);
202
- return;
203
- }
204
- const agentForBootstrap = available[choice.kind];
205
- const agentThreadId = await agentForBootstrap.createThread();
206
- const threadName = makeThreadName(parsed.cleanText, agentThreadId);
207
- log.info({ parent: m.channel_id, agent: choice.kind, thread: agentThreadId, threadName }, 'discord: bootstrapping new scope from @-mention');
208
- const threadId = await discord.createThreadFromMessage(m.channel_id, m.message_id, threadName);
209
- setAgentThread(discordScopeKey(threadId), choice.kind, agentThreadId);
210
- log.info({ discord: threadId, agent: choice.kind, thread: agentThreadId }, 'scope created');
211
- await handleTurn(parsed.cleanText, choice.kind, agentThreadId, discordAdapter(threadId), discordScheduler);
81
+ const choice = pickAgent(null, req);
82
+ if ('error' in choice)
83
+ return postErr(choice.error);
84
+ const threadId = await available[choice.kind].createThread();
85
+ const ch = await discord.createThreadFromMessage(m.channel_id, m.message_id, makeThreadName(cleanText, threadId));
86
+ setAgentThread(discordScopeKey(ch), choice.kind, threadId);
87
+ log.info({ discord: ch, agent: choice.kind, thread: threadId }, 'scope created');
88
+ await runTurn(available[choice.kind], threadId, cleanText, discordAdapter(ch), discordScheduler);
212
89
  }
213
90
  async function onTelegramInbound(m) {
214
- // Allow DMs and any chat in a forum supergroup (custom topics + General).
215
- // Plain (non-forum) groups are skipped — no thread boundary.
216
- if (!m.is_private && !m.in_forum) {
217
- log.debug({ chat: m.chat_id }, 'telegram: dropped — non-private, non-forum chat');
218
- return;
219
- }
220
- // General topic of a forum is a *launcher*, never a session. Every
221
- // @-mention spawns a fresh topic + scope. Any stale scope previously
222
- // bound to General is ignored deliberately.
223
- if (m.in_forum && !m.is_forum_topic) {
224
- await bootstrapForumTopic(m);
91
+ if (!m.is_private && !m.in_forum)
225
92
  return;
226
- }
227
- // DM or custom topic: route via the existing scope, allocating fresh
228
- // agent sessions per-kind on first use.
229
- const scopeKey = telegramScopeKey(m.chat_id, m.message_thread_id);
230
- const cachedHasAnyAgent = !!(getAgentThread(scopeKey, 'codex') ?? getAgentThread(scopeKey, 'claude'));
231
- if (!cachedHasAnyAgent && !m.is_private && !m.mentions_bot) {
232
- log.debug({ chat: m.chat_id, topic: m.message_thread_id }, 'telegram: dropped — no scope, no @-mention');
93
+ if (m.in_forum && !m.is_forum_topic)
94
+ return bootstrapForumTopic(m);
95
+ const scope = telegramScopeKey(m.chat_id, m.message_thread_id);
96
+ const hasAgent = !!(getAgentThread(scope, 'codex') ?? getAgentThread(scope, 'claude'));
97
+ if (!hasAgent && !m.is_private && !m.mentions_bot)
233
98
  return;
234
- }
235
- const parsed = parseAgentSuffix(m.text);
236
- const choice = pickAgent(cachedHasAnyAgent ? scopeKey : null, parsed.kind);
237
- if ('error' in choice) {
238
- await postTelegramError(m.chat_id, m.message_thread_id, choice.error);
239
- return;
240
- }
241
- setLastSeen(scopeKey, String(m.message_id));
242
- let agentThreadId = getAgentThread(scopeKey, choice.kind);
243
- if (!agentThreadId) {
244
- agentThreadId = await available[choice.kind].createThread();
245
- setAgentThread(scopeKey, choice.kind, agentThreadId);
246
- log.info({ scope: scopeKey, agent: choice.kind, thread: agentThreadId }, 'telegram: allocated agent session');
247
- }
248
- else {
249
- setLastAgent(scopeKey, choice.kind);
250
- }
251
- await handleTurn(parsed.cleanText, choice.kind, agentThreadId, telegramAdapter(m.chat_id, m.message_thread_id), telegramScheduler);
99
+ const { kind: req, cleanText } = parseAgentSuffix(m.text);
100
+ const choice = pickAgent(hasAgent ? scope : null, req);
101
+ if ('error' in choice)
102
+ return postTelegramError(m.chat_id, m.message_thread_id, choice.error);
103
+ await dispatch(scope, cleanText, choice.kind, String(m.message_id), telegramAdapter(m.chat_id, m.message_thread_id), telegramScheduler);
252
104
  }
253
105
  async function bootstrapForumTopic(m) {
254
- if (!m.mentions_bot) {
255
- log.debug({ chat: m.chat_id }, 'telegram: General msg ignored — no @-mention');
256
- return;
257
- }
258
- if (bootstrapped.has(String(m.message_id)))
106
+ if (!m.mentions_bot || bootstrapped.has(String(m.message_id)))
259
107
  return;
260
108
  bootstrapped.add(String(m.message_id));
261
- const parsed = parseAgentSuffix(m.text);
262
- const choice = pickAgent(null, parsed.kind);
263
- if ('error' in choice) {
264
- await postTelegramError(m.chat_id, undefined, choice.error);
265
- return;
266
- }
267
- const topicName = makeThreadName(parsed.cleanText, 'metro');
268
- let newTopicId;
109
+ const { kind: req, cleanText } = parseAgentSuffix(m.text);
110
+ const choice = pickAgent(null, req);
111
+ if ('error' in choice)
112
+ return postTelegramError(m.chat_id, undefined, choice.error);
113
+ let topicId;
114
+ const topicName = makeThreadName(cleanText, 'metro');
269
115
  try {
270
- newTopicId = await telegram.createForumTopic(m.chat_id, topicName);
271
- log.info({ chat: m.chat_id, topic: newTopicId, name: topicName }, 'telegram: created topic from @-mention');
116
+ topicId = await telegram.createForumTopic(m.chat_id, topicName);
272
117
  }
273
118
  catch (err) {
274
- await postTelegramError(m.chat_id, undefined, `couldn't create a new topic — make the bot a forum admin with "Manage Topics" permission. (${errMsg(err)})`);
275
- return;
276
- }
277
- const agentThreadId = await available[choice.kind].createThread();
278
- const newScopeKey = telegramScopeKey(m.chat_id, newTopicId);
279
- setAgentThread(newScopeKey, choice.kind, agentThreadId);
280
- setLastSeen(newScopeKey, String(m.message_id));
281
- log.info({ scope: newScopeKey, agent: choice.kind, thread: agentThreadId }, 'telegram: scope created');
282
- await handleTurn(parsed.cleanText, choice.kind, agentThreadId, telegramAdapter(m.chat_id, newTopicId), telegramScheduler);
119
+ return postTelegramError(m.chat_id, undefined, `couldn't create topic — bot needs Manage Topics admin permission. (${errMsg(err)})`);
120
+ }
121
+ const threadId = await available[choice.kind].createThread();
122
+ const scope = telegramScopeKey(m.chat_id, topicId);
123
+ setAgentThread(scope, choice.kind, threadId);
124
+ setLastSeen(scope, String(m.message_id));
125
+ log.info({ scope, agent: choice.kind, thread: threadId }, 'telegram: scope created');
126
+ /** Post a deep link back in General as a reply to the @-mention so it threads visually. */
127
+ await telegram.sendMessage(m.chat_id, undefined, `→ [${topicName}](${telegram.topicLink(m.chat_id, topicId)})`, m.message_id)
128
+ .catch(err => log.warn({ err: errMsg(err) }, 'telegram: failed to post topic link in General'));
129
+ await runTurn(available[choice.kind], threadId, cleanText, telegramAdapter(m.chat_id, topicId), telegramScheduler);
283
130
  }
284
131
  async function postTelegramError(chatId, threadId, message) {
285
132
  try {
@@ -289,81 +136,13 @@ async function postTelegramError(chatId, threadId, message) {
289
136
  log.warn({ err: errMsg(err) }, 'failed to post telegram error');
290
137
  }
291
138
  }
292
- async function postErrorMessage(channelId, message) {
293
- try {
294
- await discord.sendMessage(channelId, `⚠️ ${message}`);
295
- }
296
- catch (err) {
297
- log.warn({ err: errMsg(err) }, 'failed to post error message');
298
- }
299
- }
300
- // Thread/topic names. Strip platform mention syntax (discord <@id>, custom
301
- // emoji; telegram @username), normalize whitespace, fall back to the given
302
- // default if nothing usable is left. Telegram caps topic names at 128;
303
- // Discord at 100 — use 100 so the same value works for both.
139
+ /** Strip mention syntax + normalize whitespace; cap at 100 chars (Discord limit). */
304
140
  function makeThreadName(rawText, fallback) {
305
- const cleaned = rawText
306
- .replace(/<@!?\d+>/g, '')
307
- .replace(/<@&\d+>/g, '')
308
- .replace(/<#\d+>/g, '')
309
- .replace(/<a?:[^:]+:\d+>/g, '')
310
- .replace(/@\w+/g, '') // telegram @username
311
- .replace(/\s+/g, ' ')
312
- .trim();
141
+ const cleaned = rawText.replace(/<@!?\d+>|<@&\d+>|<#\d+>|<a?:[^:]+:\d+>|@\w+/g, '').replace(/\s+/g, ' ').trim();
313
142
  if (!cleaned)
314
143
  return fallback.slice(0, 100);
315
144
  return cleaned.length <= 100 ? cleaned : cleaned.slice(0, 99) + '…';
316
145
  }
317
- /**
318
- * Run one agent turn against a known agent thread, streaming the response
319
- * through the provided platform adapter. If a turn is already in flight
320
- * for this thread, append to the per-thread queue and let the current
321
- * turn's drain pick it up.
322
- */
323
- async function handleTurn(text, kind, agentThreadId, adapter, scheduler) {
324
- const agent = available[kind];
325
- if (!agent) {
326
- // Shouldn't happen — the caller's pickAgent() already filters this —
327
- // but log defensively so it's not silent.
328
- log.warn({ kind, agent: agentThreadId }, 'handleTurn called for unavailable agent');
329
- return;
330
- }
331
- const dispatch = (t) => handleTurn(t, kind, agentThreadId, adapter, scheduler);
332
- if (inFlight.has(agentThreadId)) {
333
- const q = queued.get(agentThreadId);
334
- if (q)
335
- q.texts.push(text);
336
- else
337
- queued.set(agentThreadId, { texts: [text], dispatch });
338
- log.debug({ agent: agentThreadId, queueDepth: queued.get(agentThreadId).texts.length }, 'queued follow-up turn');
339
- return;
340
- }
341
- inFlight.add(agentThreadId);
342
- const stream = new StreamingMessage(adapter, scheduler);
343
- const finishAndDrain = async () => {
344
- await stream.finalize();
345
- inFlight.delete(agentThreadId);
346
- const q = queued.get(agentThreadId);
347
- if (!q || q.texts.length === 0)
348
- return;
349
- queued.delete(agentThreadId);
350
- const combined = q.texts.join('\n\n');
351
- log.debug({ agent: agentThreadId, batched: q.texts.length }, 'draining queued follow-ups');
352
- await q.dispatch(combined).catch(err => log.warn({ err: errMsg(err) }, 'queued turn failed'));
353
- };
354
- const callbacks = {
355
- onDelta: d => stream.appendDelta(d),
356
- onToolStart: (_kind, summary) => stream.setStatus(summary),
357
- onToolEnd: () => stream.setStatus(null),
358
- onComplete: () => { void finishAndDrain(); },
359
- onError: err => {
360
- log.warn({ err: errMsg(err) }, 'agent turn failed');
361
- stream.appendError(errMsg(err) || 'agent turn failed');
362
- void finishAndDrain();
363
- },
364
- };
365
- await agent.sendTurn(agentThreadId, text, callbacks);
366
- }
367
146
  function discordAdapter(channelId) {
368
147
  return {
369
148
  send: t => discord.sendMessage(channelId, t),
@@ -376,20 +155,50 @@ function telegramAdapter(chatId, topicId) {
376
155
  edit: async (id, t) => { await telegram.editMessageText(chatId, Number(id), t); },
377
156
  };
378
157
  }
158
+ async function catchupDiscord() {
159
+ for (const { scopeKey, entry } of listScopes()) {
160
+ const channelId = discordChannelFromScopeKey(scopeKey);
161
+ if (!channelId || !entry.lastSeenMessageId)
162
+ continue;
163
+ try {
164
+ const missed = (await discord.fetchMessagesSince(channelId, entry.lastSeenMessageId)).filter(m => !m.author_is_bot && m.text);
165
+ if (!missed.length)
166
+ continue;
167
+ log.info({ channel: channelId, count: missed.length }, 'discord catchup');
168
+ for (const m of missed)
169
+ await onDiscordInbound({ channel_id: channelId, message_id: m.message_id, text: m.text, in_guild: true, mentions_bot: false });
170
+ }
171
+ catch (err) {
172
+ log.warn({ err: errMsg(err), channel: channelId }, 'discord catchup skipped');
173
+ }
174
+ }
175
+ }
176
+ async function main() {
177
+ await startAgents();
178
+ if (platforms.discord) {
179
+ await discord.startGateway();
180
+ log.info({ bot: (await discord.getMe()).username }, 'discord ready');
181
+ discord.onInbound(m => void onDiscordInbound(m).catch(err => log.warn({ err: errMsg(err) }, 'discord inbound failed')));
182
+ void catchupDiscord().catch(err => log.warn({ err: errMsg(err) }, 'discord catchup failed'));
183
+ }
184
+ if (platforms.telegram) {
185
+ log.info({ bot: `@${(await telegram.getMe()).username}` }, 'telegram ready');
186
+ telegram.onInbound(m => void onTelegramInbound(m).catch(err => log.warn({ err: errMsg(err) }, 'telegram inbound failed')));
187
+ await telegram.startPolling();
188
+ }
189
+ log.info('orchestrator ready');
190
+ }
379
191
  let shuttingDown = false;
380
192
  async function shutdown() {
381
193
  if (shuttingDown)
382
194
  return;
383
195
  shuttingDown = true;
384
196
  log.info('orchestrator shutting down');
385
- await Promise.allSettled([
386
- available.codex?.stop().catch(err => log.warn({ err: errMsg(err) }, 'codex shutdown failed')),
387
- available.claude?.stop().catch(err => log.warn({ err: errMsg(err) }, 'claude shutdown failed')),
388
- ]);
197
+ await Promise.allSettled([available.codex?.stop(), available.claude?.stop()]);
389
198
  if (platforms.discord)
390
- await discord.shutdownGateway().catch(err => log.warn({ err: errMsg(err) }, 'discord shutdown failed'));
199
+ await discord.shutdownGateway().catch(() => { });
391
200
  if (platforms.telegram)
392
- await telegram.shutdownPolling().catch(err => log.warn({ err: errMsg(err) }, 'telegram shutdown failed'));
201
+ await telegram.shutdownPolling().catch(() => { });
393
202
  process.exit(0);
394
203
  }
395
204
  process.stdin.on('end', shutdown);
package/dist/paths.js CHANGED
@@ -1,29 +1,40 @@
1
- import { mkdirSync } from 'node:fs';
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
- import { join } from 'node:path';
4
- import { loadDotenvIntoProcess } from './lib/dotenv.js';
3
+ import { dirname, join } from 'node:path';
5
4
  import { log } from './log.js';
6
- // Lockfile, scope cache, codex app-server socket, telegram poll offset,
7
- // claude session set. Override with METRO_STATE_DIR.
8
5
  export const STATE_DIR = process.env.METRO_STATE_DIR ?? join(homedir(), '.cache', 'metro');
9
6
  mkdirSync(STATE_DIR, { recursive: true });
10
- // Where `metro setup` writes the global .env. Override with METRO_CONFIG_DIR
11
- // or the standard $XDG_CONFIG_HOME.
12
- const CONFIG_DIR = process.env.METRO_CONFIG_DIR ??
13
- join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'metro');
7
+ const CONFIG_DIR = process.env.METRO_CONFIG_DIR ?? join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'metro');
14
8
  export const CONFIG_ENV_FILE = join(CONFIG_DIR, '.env');
15
- // Precedence: process.env (already set) > cwd/.env > <CONFIG_DIR>/.env.
16
- // loadDotenvIntoProcess only fills vars that aren't already populated, so the
17
- // first call that defines a key wins.
9
+ const LINE_RE = /^\s*([A-Za-z_]\w*)\s*=\s*(.*?)\s*$/;
10
+ const QUOTED_RE = /^(['"])(.*)\1$/;
11
+ export function readDotenv(path) {
12
+ if (!existsSync(path))
13
+ return {};
14
+ const out = {};
15
+ for (const line of readFileSync(path, 'utf8').split('\n')) {
16
+ const m = line.match(LINE_RE);
17
+ if (m)
18
+ out[m[1]] = m[2].replace(QUOTED_RE, '$2');
19
+ }
20
+ return out;
21
+ }
22
+ export function writeDotenv(path, env) {
23
+ mkdirSync(dirname(path), { recursive: true });
24
+ writeFileSync(path, Object.entries(env).map(([k, v]) => `${k}=${v}`).join('\n') + '\n');
25
+ chmodSync(path, 0o600);
26
+ }
27
+ /** Precedence: process.env > cwd/.env > $METRO_CONFIG_DIR/.env. First-set wins. */
18
28
  export function loadMetroEnv() {
19
- loadDotenvIntoProcess(join(process.cwd(), '.env'));
20
- loadDotenvIntoProcess(CONFIG_ENV_FILE);
29
+ for (const path of [join(process.cwd(), '.env'), CONFIG_ENV_FILE]) {
30
+ for (const [k, v] of Object.entries(readDotenv(path))) {
31
+ if (process.env[k] === undefined)
32
+ process.env[k] = v;
33
+ }
34
+ }
21
35
  }
22
36
  export function configuredPlatforms() {
23
- return {
24
- telegram: !!process.env.TELEGRAM_BOT_TOKEN,
25
- discord: !!process.env.DISCORD_BOT_TOKEN,
26
- };
37
+ return { telegram: !!process.env.TELEGRAM_BOT_TOKEN, discord: !!process.env.DISCORD_BOT_TOKEN };
27
38
  }
28
39
  export function requireConfiguredPlatform(p) {
29
40
  if (p.telegram || p.discord)
@@ -31,3 +42,27 @@ export function requireConfiguredPlatform(p) {
31
42
  log.fatal('no platforms configured — run `metro setup telegram <token>` or `metro setup discord <token>`');
32
43
  process.exit(2);
33
44
  }
45
+ /** Singleton pidfile. Exits if another instance owns it; reclaims stale locks. */
46
+ export function acquireLock(lockFile) {
47
+ if (existsSync(lockFile)) {
48
+ const pid = Number(readFileSync(lockFile, 'utf8').trim());
49
+ try {
50
+ if (Number.isInteger(pid) && pid > 0) {
51
+ process.kill(pid, 0);
52
+ log.info({ pid }, 'another `metro` is running; exiting');
53
+ process.exit(0);
54
+ }
55
+ }
56
+ catch { /* stale */ }
57
+ try {
58
+ unlinkSync(lockFile);
59
+ }
60
+ catch { /* ignore */ }
61
+ }
62
+ writeFileSync(lockFile, String(process.pid));
63
+ process.on('exit', () => { try {
64
+ if (readFileSync(lockFile, 'utf8').trim() === String(process.pid))
65
+ unlinkSync(lockFile);
66
+ }
67
+ catch { /* ignore */ } });
68
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.3",
3
+ "version": "0.1.0-beta.4",
4
4
  "description": "Orchestrator daemon that bridges Codex / Claude Code agent sessions with Telegram and Discord. Each chat thread gets its own agent session, with streaming responses and tool-call visibility.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,31 +0,0 @@
1
- // Tiny .env reader/writer. Used by `metro setup` (read/write the global
2
- // config file) and by paths.ts (load env vars into process.env at startup).
3
- import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
4
- import { dirname } from 'node:path';
5
- const LINE_RE = /^\s*([A-Za-z_]\w*)\s*=\s*(.*?)\s*$/;
6
- const QUOTED_RE = /^(['"])(.*)\1$/;
7
- export function readDotenv(path) {
8
- if (!existsSync(path))
9
- return {};
10
- const out = {};
11
- for (const line of readFileSync(path, 'utf8').split('\n')) {
12
- const m = line.match(LINE_RE);
13
- if (m)
14
- out[m[1]] = m[2].replace(QUOTED_RE, '$2');
15
- }
16
- return out;
17
- }
18
- export function writeDotenv(path, env) {
19
- mkdirSync(dirname(path), { recursive: true });
20
- writeFileSync(path, Object.entries(env).map(([k, v]) => `${k}=${v}`).join('\n') + '\n');
21
- chmodSync(path, 0o600);
22
- }
23
- // Load .env at `path` into process.env, but only for keys that aren't already
24
- // set — first definer wins, so callers control precedence by the order they
25
- // invoke this.
26
- export function loadDotenvIntoProcess(path) {
27
- for (const [k, v] of Object.entries(readDotenv(path))) {
28
- if (process.env[k] === undefined)
29
- process.env[k] = v;
30
- }
31
- }