@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.
- package/README.md +7 -4
- package/dist/agents/claude.js +72 -94
- package/dist/agents/codex.js +35 -110
- package/dist/agents/types.js +1 -2
- package/dist/channels/discord.js +18 -56
- package/dist/channels/telegram.js +54 -74
- package/dist/cli.js +49 -94
- package/dist/{lib → helpers}/scope-cache.js +4 -24
- package/dist/helpers/streaming.js +209 -0
- package/dist/helpers/telegram-format.js +39 -0
- package/dist/helpers/turn.js +40 -0
- package/dist/log.js +1 -3
- package/dist/orchestrator.js +120 -311
- package/dist/paths.js +53 -18
- package/package.json +1 -1
- package/dist/lib/dotenv.js +0 -31
- package/dist/lib/streaming.js +0 -207
package/dist/orchestrator.js
CHANGED
|
@@ -1,285 +1,132 @@
|
|
|
1
|
-
|
|
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 './
|
|
21
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
80
|
-
log.fatal('no agents available
|
|
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) }, '
|
|
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
|
|
136
|
-
const m = SUFFIX_RE.exec(
|
|
137
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
|
45
|
+
return { kind: last };
|
|
154
46
|
if (available.claude)
|
|
155
|
-
return { kind: 'claude'
|
|
47
|
+
return { kind: 'claude' };
|
|
156
48
|
if (available.codex)
|
|
157
|
-
return { kind: 'codex'
|
|
49
|
+
return { kind: 'codex' };
|
|
158
50
|
return { error: 'no agents available' };
|
|
159
51
|
}
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
const
|
|
230
|
-
|
|
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
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
|
262
|
-
const choice = pickAgent(null,
|
|
263
|
-
if ('error' in choice)
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
const
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
await
|
|
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
|
-
|
|
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(
|
|
199
|
+
await discord.shutdownGateway().catch(() => { });
|
|
391
200
|
if (platforms.telegram)
|
|
392
|
-
await telegram.shutdownPolling().catch(
|
|
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
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
20
|
-
|
|
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
|
+
"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": {
|
package/dist/lib/dotenv.js
DELETED
|
@@ -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
|
-
}
|