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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,207 @@
1
+ // Accumulates streaming response deltas + tool-call status lines from a
2
+ // running agent turn and pushes them to a chat platform (Discord / Telegram)
3
+ // via debounced message edits. Smooth visible progress without hammering
4
+ // rate limits.
5
+ //
6
+ // The debounce is owned by a per-bot `StreamScheduler`, not by individual
7
+ // streams. One tick (e.g. every 1500ms) flushes every dirty stream the bot
8
+ // has accumulated, so two concurrent threads don't compound into 2× the
9
+ // edit rate on the same bot token.
10
+ //
11
+ // When the agent's response grows past the platform's per-message content
12
+ // cap, the body is split across multiple messages: the prior segment is
13
+ // frozen at its final text, and a fresh message holds the continuation
14
+ // (with the live status line, which always anchors to the latest segment).
15
+ //
16
+ // On agent turn completion, call finalize() to flush a final edit with the
17
+ // status cleared.
18
+ import { errMsg, log } from '../log.js';
19
+ // Steady-state cadence: 1500ms keeps us comfortably under Discord's ~5/5s
20
+ // per-channel edit cap even after the transport adds its own retry-on-429
21
+ // jitter. After a quiet period, the next flush is leading-edge (LEADING_MS)
22
+ // so short responses don't appear as one final dump.
23
+ const DEFAULT_DEBOUNCE_MS = 1500;
24
+ const LEADING_MS = 500;
25
+ // Discord's bot content cap is 2000 by default (4000 for boosted/Nitro).
26
+ // 1900 is universally safe and leaves headroom for the status suffix.
27
+ const MAX_BODY_LEN = 1900;
28
+ // Reserve for "\n\n_<status>_" + a continuation hint.
29
+ const STATUS_RESERVE = 80;
30
+ /**
31
+ * One scheduler per bot. Coalesces edits across every active stream the
32
+ * bot is serving — Discord's per-channel rate limit doesn't compound when
33
+ * we run multiple threads concurrently this way.
34
+ */
35
+ export class StreamScheduler {
36
+ debounceMs;
37
+ leadingMs;
38
+ dirty = new Set();
39
+ timer = null;
40
+ lastFlushAt = 0;
41
+ constructor(debounceMs = DEFAULT_DEBOUNCE_MS, leadingMs = LEADING_MS) {
42
+ this.debounceMs = debounceMs;
43
+ this.leadingMs = leadingMs;
44
+ }
45
+ request(stream) {
46
+ this.dirty.add(stream);
47
+ if (this.timer)
48
+ return;
49
+ // Leading-edge: if we haven't flushed recently, fire fast so the first
50
+ // visible content lands within `leadingMs` of the agent's first delta.
51
+ // Otherwise stay at the steady-state cadence to respect rate limits.
52
+ const sinceLast = Date.now() - this.lastFlushAt;
53
+ const delay = sinceLast >= this.debounceMs ? this.leadingMs : this.debounceMs - sinceLast;
54
+ this.timer = setTimeout(() => {
55
+ this.timer = null;
56
+ this.lastFlushAt = Date.now();
57
+ const batch = [...this.dirty];
58
+ this.dirty.clear();
59
+ // Fire all in parallel — distinct channels are in distinct rate-limit
60
+ // buckets, so they don't queue behind each other.
61
+ for (const s of batch)
62
+ void s._flushFromScheduler();
63
+ }, delay);
64
+ }
65
+ /** Drop a stream from the queue (called when it finalizes). */
66
+ forget(stream) {
67
+ this.dirty.delete(stream);
68
+ }
69
+ }
70
+ export class StreamingMessage {
71
+ adapter;
72
+ scheduler;
73
+ segments = [{ id: null, text: '', dirty: false }];
74
+ statusLine = null;
75
+ flushing = false;
76
+ flushAgain = false;
77
+ finalized = false;
78
+ constructor(adapter, scheduler) {
79
+ this.adapter = adapter;
80
+ this.scheduler = scheduler;
81
+ }
82
+ appendDelta(delta) {
83
+ if (this.finalized || !delta)
84
+ return;
85
+ this.appendToLast(delta);
86
+ this.scheduler.request(this);
87
+ }
88
+ setStatus(status) {
89
+ if (this.finalized)
90
+ return;
91
+ this.statusLine = status;
92
+ this.markLastDirty();
93
+ this.scheduler.request(this);
94
+ }
95
+ /**
96
+ * Append an error notice to the visible message. Renders as `⚠️ <msg>`
97
+ * either on its own (no prior text) or after a blank line (preserves
98
+ * whatever streamed before the failure). Clears any pending status
99
+ * line since 'Thinking…' is meaningless after an error.
100
+ */
101
+ appendError(message) {
102
+ if (this.finalized)
103
+ return;
104
+ const last = this.segments[this.segments.length - 1];
105
+ const sep = last.text ? '\n\n' : '';
106
+ this.statusLine = null;
107
+ this.appendToLast(`${sep}⚠️ ${message}`);
108
+ this.scheduler.request(this);
109
+ }
110
+ async finalize() {
111
+ if (this.finalized)
112
+ return;
113
+ this.finalized = true;
114
+ this.scheduler.forget(this);
115
+ this.statusLine = null;
116
+ this.markLastDirty();
117
+ await this.flush();
118
+ }
119
+ /** Internal — called by the scheduler tick. */
120
+ async _flushFromScheduler() {
121
+ await this.flush();
122
+ }
123
+ appendToLast(delta) {
124
+ const cap = MAX_BODY_LEN - STATUS_RESERVE;
125
+ let remaining = delta;
126
+ while (remaining) {
127
+ let last = this.segments[this.segments.length - 1];
128
+ const room = cap - last.text.length;
129
+ if (room <= 0) {
130
+ // Previous last loses status anchor — re-edit without it.
131
+ last.dirty = true;
132
+ last = { id: null, text: '', dirty: false };
133
+ this.segments.push(last);
134
+ continue;
135
+ }
136
+ const take = this.sliceAtBoundary(remaining, room);
137
+ last.text += take;
138
+ last.dirty = true;
139
+ remaining = remaining.slice(take.length);
140
+ }
141
+ }
142
+ // Prefer splitting at the last newline / space / sentence end within the
143
+ // allowed slice, so continuation messages don't cut words in half. Falls
144
+ // back to a hard slice if no boundary is in reach.
145
+ sliceAtBoundary(s, room) {
146
+ if (s.length <= room)
147
+ return s;
148
+ const candidate = s.slice(0, room);
149
+ const breakers = ['\n\n', '\n', '. ', ' '];
150
+ for (const b of breakers) {
151
+ const i = candidate.lastIndexOf(b);
152
+ if (i > room * 0.5)
153
+ return candidate.slice(0, i + b.length);
154
+ }
155
+ return candidate;
156
+ }
157
+ markLastDirty() {
158
+ this.segments[this.segments.length - 1].dirty = true;
159
+ }
160
+ async flush() {
161
+ if (this.flushing) {
162
+ this.flushAgain = true;
163
+ return;
164
+ }
165
+ this.flushing = true;
166
+ try {
167
+ do {
168
+ this.flushAgain = false;
169
+ for (let i = 0; i < this.segments.length; i++) {
170
+ const s = this.segments[i];
171
+ const isLast = i === this.segments.length - 1;
172
+ const body = this.render(s, isLast);
173
+ if (!body)
174
+ continue;
175
+ try {
176
+ if (s.id === null) {
177
+ s.id = await this.adapter.send(body);
178
+ s.dirty = false;
179
+ }
180
+ else if (s.dirty) {
181
+ await this.adapter.edit(s.id, body);
182
+ s.dirty = false;
183
+ }
184
+ }
185
+ catch (err) {
186
+ log.warn({ err: errMsg(err) }, 'streaming edit failed');
187
+ // Leave dirty=true so the next tick retries.
188
+ }
189
+ }
190
+ } while (this.flushAgain);
191
+ }
192
+ finally {
193
+ this.flushing = false;
194
+ }
195
+ }
196
+ render(s, isLast) {
197
+ const body = s.text;
198
+ const showStatus = isLast && !!this.statusLine;
199
+ if (!body && !showStatus)
200
+ return ''; // nothing to show yet — skip the flush
201
+ if (!body)
202
+ return this.statusLine;
203
+ if (showStatus)
204
+ return `${body}\n\n${this.statusLine}`;
205
+ return body;
206
+ }
207
+ }
@@ -0,0 +1,399 @@
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';
14
+ import { join } from 'node:path';
15
+ import pkg from '../package.json' with { type: 'json' };
16
+ import * as discord from './channels/discord.js';
17
+ import * as telegram from './channels/telegram.js';
18
+ import { CodexAgent } from './agents/codex.js';
19
+ 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';
22
+ import { errMsg, log } from './log.js';
23
+ import { configuredPlatforms, loadMetroEnv, STATE_DIR, requireConfiguredPlatform } from './paths.js';
24
+ loadMetroEnv();
25
+ const platforms = configuredPlatforms();
26
+ 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).
62
+ 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
+ const codexAgent = new CodexAgent(pkg.version);
66
+ const claudeAgent = new ClaudeAgent();
67
+ 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
+ const discordScheduler = new StreamScheduler();
71
+ const telegramScheduler = new StreamScheduler();
72
+ async function startAgents() {
73
+ 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")),
78
+ ]);
79
+ if (Object.keys(available).length === 0) {
80
+ log.fatal('no agents available — install codex or claude and authenticate');
81
+ process.exit(2);
82
+ }
83
+ log.info({ agents: Object.keys(available) }, 'orchestrator: agents ready');
84
+ }
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
+ const SUFFIX_RE = /(?:^|\s)with\s+(claude|codex)\s*$/i;
134
+ 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() };
141
+ }
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
+ }
151
+ const last = scopeKey ? getLastAgent(scopeKey) : undefined;
152
+ if (last && available[last])
153
+ return { kind: last, explicit: false };
154
+ if (available.claude)
155
+ return { kind: 'claude', explicit: false };
156
+ if (available.codex)
157
+ return { kind: 'codex', explicit: false };
158
+ return { error: 'no agents available' };
159
+ }
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;
189
+ }
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');
193
+ return;
194
+ }
195
+ if (bootstrapped.has(m.message_id))
196
+ return;
197
+ 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);
212
+ }
213
+ 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);
225
+ 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');
233
+ 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);
252
+ }
253
+ 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)))
259
+ return;
260
+ 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;
269
+ 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');
272
+ }
273
+ 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);
283
+ }
284
+ async function postTelegramError(chatId, threadId, message) {
285
+ try {
286
+ await telegram.sendMessage(chatId, threadId, `⚠️ ${message}`);
287
+ }
288
+ catch (err) {
289
+ log.warn({ err: errMsg(err) }, 'failed to post telegram error');
290
+ }
291
+ }
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.
304
+ 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();
313
+ if (!cleaned)
314
+ return fallback.slice(0, 100);
315
+ return cleaned.length <= 100 ? cleaned : cleaned.slice(0, 99) + '…';
316
+ }
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
+ function discordAdapter(channelId) {
368
+ return {
369
+ send: t => discord.sendMessage(channelId, t),
370
+ edit: async (id, t) => { await discord.editMessage(channelId, id, t); },
371
+ };
372
+ }
373
+ function telegramAdapter(chatId, topicId) {
374
+ return {
375
+ send: async (t) => String(await telegram.sendMessage(chatId, topicId, t)),
376
+ edit: async (id, t) => { await telegram.editMessageText(chatId, Number(id), t); },
377
+ };
378
+ }
379
+ let shuttingDown = false;
380
+ async function shutdown() {
381
+ if (shuttingDown)
382
+ return;
383
+ shuttingDown = true;
384
+ 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
+ ]);
389
+ if (platforms.discord)
390
+ await discord.shutdownGateway().catch(err => log.warn({ err: errMsg(err) }, 'discord shutdown failed'));
391
+ if (platforms.telegram)
392
+ await telegram.shutdownPolling().catch(err => log.warn({ err: errMsg(err) }, 'telegram shutdown failed'));
393
+ process.exit(0);
394
+ }
395
+ process.stdin.on('end', shutdown);
396
+ process.stdin.on('close', shutdown);
397
+ process.on('SIGINT', shutdown);
398
+ process.on('SIGTERM', shutdown);
399
+ await main();
package/dist/paths.js CHANGED
@@ -3,7 +3,8 @@ import { homedir } from 'node:os';
3
3
  import { join } from 'node:path';
4
4
  import { loadDotenvIntoProcess } from './lib/dotenv.js';
5
5
  import { log } from './log.js';
6
- // Lockfile, typing-stop signals, attachment cache. Override with METRO_STATE_DIR.
6
+ // Lockfile, scope cache, codex app-server socket, telegram poll offset,
7
+ // claude session set. Override with METRO_STATE_DIR.
7
8
  export const STATE_DIR = process.env.METRO_STATE_DIR ?? join(homedir(), '.cache', 'metro');
8
9
  mkdirSync(STATE_DIR, { recursive: true });
9
10
  // Where `metro setup` writes the global .env. Override with METRO_CONFIG_DIR
@@ -11,19 +12,6 @@ mkdirSync(STATE_DIR, { recursive: true });
11
12
  const CONFIG_DIR = process.env.METRO_CONFIG_DIR ??
12
13
  join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'metro');
13
14
  export const CONFIG_ENV_FILE = join(CONFIG_DIR, '.env');
14
- // Default codex app-server WebSocket. The TUI's `--remote <ADDR>` flag
15
- // only accepts ws:// (no UDS), so this is the canonical URL we recommend
16
- // users align on across the daemon, the TUI, and metro:
17
- // codex app-server --listen ws://127.0.0.1:8421
18
- // codex --remote ws://127.0.0.1:8421
19
- // METRO_CODEX_RC=ws://127.0.0.1:8421 metro
20
- // Override via METRO_CODEX_RC if a different port/host is needed.
21
- export const DEFAULT_CODEX_RC_URL = 'ws://127.0.0.1:8421';
22
- export function skillDir(runtime, scope) {
23
- const base = scope === 'user' ? homedir() : process.cwd();
24
- const root = runtime === 'claude-code' ? '.claude' : '.agents';
25
- return join(base, root, 'skills', 'metro');
26
- }
27
15
  // Precedence: process.env (already set) > cwd/.env > <CONFIG_DIR>/.env.
28
16
  // loadDotenvIntoProcess only fills vars that aren't already populated, so the
29
17
  // first call that defines a key wins.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.2",
4
- "description": "Chat with your Claude Code or Codex agent over Telegram and Discord. Ultra-lightweight: ~1.2K lines of TypeScript, pure CLI, no hosted infra.",
3
+ "version": "0.1.0-beta.3",
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": {
7
7
  "type": "git",
@@ -18,7 +18,6 @@
18
18
  },
19
19
  "files": [
20
20
  "dist",
21
- "skills",
22
21
  "README.md",
23
22
  "LICENSE"
24
23
  ],
@@ -1,21 +0,0 @@
1
- // `<platform>:<chat>[/<message_id>]` — the wire format of every metro
2
- // inbound `to` field, and the only address shape any subcommand accepts.
3
- export function parseAddress(to, requireMessage) {
4
- const colon = to.indexOf(':');
5
- if (colon === -1) {
6
- throw new Error(`invalid --to (expected '<platform>:<chat>[/<message_id>]'): ${to}`);
7
- }
8
- const platform = to.slice(0, colon);
9
- if (platform !== 'telegram' && platform !== 'discord') {
10
- throw new Error(`unknown platform '${platform}' in --to (expected 'telegram' or 'discord')`);
11
- }
12
- const rest = to.slice(colon + 1);
13
- const slash = rest.indexOf('/');
14
- const chat = slash === -1 ? rest : rest.slice(0, slash);
15
- const messageId = slash === -1 ? undefined : rest.slice(slash + 1);
16
- if (!chat)
17
- throw new Error(`empty chat/channel id in --to: ${to}`);
18
- if (requireMessage && !messageId)
19
- throw new Error(`--to must include /<message_id>: ${to}`);
20
- return { platform, chat, messageId };
21
- }