@fonz/tgcc 0.0.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bridge.js ADDED
@@ -0,0 +1,1065 @@
1
+ import { join } from 'node:path';
2
+ import { existsSync, readFileSync, statSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { EventEmitter } from 'node:events';
5
+ import pino from 'pino';
6
+ import { resolveUserConfig, resolveRepoPath, updateConfig, isValidRepoName, findRepoOwner } from './config.js';
7
+ import { CCProcess, generateMcpConfig } from './cc-process.js';
8
+ import { createTextMessage, createImageMessage, createDocumentMessage, } from './cc-protocol.js';
9
+ import { StreamAccumulator, SubAgentTracker, escapeHtml } from './streaming.js';
10
+ import { TelegramBot } from './telegram.js';
11
+ import { InlineKeyboard } from 'grammy';
12
+ import { McpBridgeServer } from './mcp-bridge.js';
13
+ import { SessionStore, findMissedSessions, formatCatchupMessage, getSessionJsonlPath, summarizeJsonlDelta, } from './session.js';
14
+ import { CtlServer, } from './ctl-server.js';
15
+ // ── Message Batcher ──
16
+ class MessageBatcher {
17
+ pending = [];
18
+ timer = null;
19
+ windowMs;
20
+ flush;
21
+ constructor(windowMs, flushFn) {
22
+ this.windowMs = windowMs;
23
+ this.flush = flushFn;
24
+ }
25
+ add(msg) {
26
+ this.pending.push(msg);
27
+ // If this is a media message, flush immediately (don't batch media)
28
+ if (msg.imageBase64 || msg.filePath) {
29
+ this.doFlush();
30
+ return;
31
+ }
32
+ if (!this.timer) {
33
+ this.timer = setTimeout(() => this.doFlush(), this.windowMs);
34
+ }
35
+ }
36
+ doFlush() {
37
+ if (this.timer) {
38
+ clearTimeout(this.timer);
39
+ this.timer = null;
40
+ }
41
+ if (this.pending.length === 0)
42
+ return;
43
+ // If there's a single message with media, send it directly
44
+ if (this.pending.length === 1) {
45
+ this.flush(this.pending[0]);
46
+ this.pending = [];
47
+ return;
48
+ }
49
+ // Combine text-only messages
50
+ const combined = this.pending.map(m => m.text).filter(Boolean).join('\n\n');
51
+ this.flush({ text: combined });
52
+ this.pending = [];
53
+ }
54
+ cancel() {
55
+ if (this.timer) {
56
+ clearTimeout(this.timer);
57
+ this.timer = null;
58
+ }
59
+ this.pending = [];
60
+ }
61
+ }
62
+ // ── Help text ──
63
+ const HELP_TEXT = `<b>TGCC Commands</b>
64
+
65
+ <b>Session</b>
66
+ /new — Start a fresh session
67
+ /sessions — List recent sessions
68
+ /resume &lt;id&gt; — Resume a session by ID
69
+ /session — Current session info
70
+
71
+ <b>Info</b>
72
+ /status — Process state, model, uptime
73
+ /cost — Show session cost
74
+ /catchup — Summarize external CC activity
75
+ /ping — Liveness check
76
+
77
+ <b>Control</b>
78
+ /cancel — Abort current CC turn
79
+ /model &lt;name&gt; — Switch model
80
+ /permissions — Set permission mode
81
+ /repo — List repos (buttons)
82
+ /repo help — Repo management commands
83
+ /repo add &lt;name&gt; &lt;path&gt; — Register a repo
84
+ /repo remove &lt;name&gt; — Unregister a repo
85
+ /repo assign &lt;name&gt; — Set as agent default
86
+ /repo clear — Clear agent default
87
+
88
+ /help — This message`;
89
+ // ── Bridge ──
90
+ export class Bridge extends EventEmitter {
91
+ config;
92
+ agents = new Map();
93
+ mcpServer;
94
+ ctlServer;
95
+ sessionStore;
96
+ logger;
97
+ constructor(config, logger) {
98
+ super();
99
+ this.config = config;
100
+ this.logger = logger ?? pino({ level: config.global.logLevel });
101
+ this.sessionStore = new SessionStore(config.global.stateFile, this.logger);
102
+ this.mcpServer = new McpBridgeServer((req) => this.handleMcpToolRequest(req), this.logger);
103
+ this.ctlServer = new CtlServer(this, this.logger);
104
+ }
105
+ // ── Startup ──
106
+ async start() {
107
+ this.logger.info('Starting bridge');
108
+ for (const [agentId, agentConfig] of Object.entries(this.config.agents)) {
109
+ await this.startAgent(agentId, agentConfig);
110
+ }
111
+ this.logger.info({ agents: Object.keys(this.config.agents) }, 'Bridge started');
112
+ }
113
+ async startAgent(agentId, agentConfig) {
114
+ this.logger.info({ agentId }, 'Starting agent');
115
+ const tgBot = new TelegramBot(agentId, agentConfig, this.config.global.mediaDir, (msg) => this.handleTelegramMessage(agentId, msg), (cmd) => this.handleSlashCommand(agentId, cmd), this.logger, (query) => this.handleCallbackQuery(agentId, query));
116
+ const instance = {
117
+ id: agentId,
118
+ config: agentConfig,
119
+ tgBot,
120
+ processes: new Map(),
121
+ accumulators: new Map(),
122
+ subAgentTrackers: new Map(),
123
+ batchers: new Map(),
124
+ pendingTitles: new Map(),
125
+ pendingPermissions: new Map(),
126
+ };
127
+ this.agents.set(agentId, instance);
128
+ await tgBot.start();
129
+ // Start control socket for CLI access
130
+ const ctlSocketPath = join('/tmp/tgcc/ctl', `${agentId}.sock`);
131
+ this.ctlServer.listen(ctlSocketPath);
132
+ }
133
+ // ── Hot reload ──
134
+ async handleConfigChange(newConfig, diff) {
135
+ this.logger.info({ diff }, 'Handling config change');
136
+ // Remove agents
137
+ for (const agentId of diff.removed) {
138
+ await this.stopAgent(agentId);
139
+ }
140
+ // Add new agents
141
+ for (const agentId of diff.added) {
142
+ await this.startAgent(agentId, newConfig.agents[agentId]);
143
+ }
144
+ // Handle changed agents
145
+ for (const agentId of diff.changed) {
146
+ const oldAgent = this.agents.get(agentId);
147
+ const newAgentConfig = newConfig.agents[agentId];
148
+ if (!oldAgent)
149
+ continue;
150
+ // If bot token changed, full restart
151
+ if (oldAgent.config.botToken !== newAgentConfig.botToken) {
152
+ await this.stopAgent(agentId);
153
+ await this.startAgent(agentId, newAgentConfig);
154
+ }
155
+ else {
156
+ // Update in-memory config — active processes keep old config
157
+ oldAgent.config = newAgentConfig;
158
+ }
159
+ }
160
+ this.config = newConfig;
161
+ }
162
+ async stopAgent(agentId) {
163
+ const agent = this.agents.get(agentId);
164
+ if (!agent)
165
+ return;
166
+ this.logger.info({ agentId }, 'Stopping agent');
167
+ // Stop bot
168
+ await agent.tgBot.stop();
169
+ // Kill all CC processes
170
+ for (const [, proc] of agent.processes) {
171
+ proc.destroy();
172
+ }
173
+ // Cancel batchers
174
+ for (const [, batcher] of agent.batchers) {
175
+ batcher.cancel();
176
+ }
177
+ // Close MCP sockets
178
+ for (const userId of agent.processes.keys()) {
179
+ const socketPath = join(this.config.global.socketDir, `${agentId}-${userId}.sock`);
180
+ this.mcpServer.close(socketPath);
181
+ }
182
+ // Close control socket
183
+ const ctlSocketPath = join('/tmp/tgcc/ctl', `${agentId}.sock`);
184
+ this.ctlServer.close(ctlSocketPath);
185
+ this.agents.delete(agentId);
186
+ }
187
+ // ── Message handling ──
188
+ handleTelegramMessage(agentId, msg) {
189
+ const agent = this.agents.get(agentId);
190
+ if (!agent)
191
+ return;
192
+ this.logger.debug({ agentId, userId: msg.userId, type: msg.type }, 'TG message received');
193
+ // Ensure batcher exists
194
+ if (!agent.batchers.has(msg.userId)) {
195
+ agent.batchers.set(msg.userId, new MessageBatcher(2000, (combined) => {
196
+ this.sendToCC(agentId, msg.userId, msg.chatId, combined);
197
+ }));
198
+ }
199
+ // Prepare text with reply context
200
+ let text = msg.text;
201
+ if (msg.replyToText) {
202
+ text = `[Replying to: '${msg.replyToText}']\n\n${text}`;
203
+ }
204
+ const batcher = agent.batchers.get(msg.userId);
205
+ batcher.add({
206
+ text,
207
+ imageBase64: msg.imageBase64,
208
+ imageMediaType: msg.imageMediaType,
209
+ filePath: msg.filePath,
210
+ fileName: msg.fileName,
211
+ });
212
+ }
213
+ sendToCC(agentId, userId, chatId, data) {
214
+ const agent = this.agents.get(agentId);
215
+ if (!agent)
216
+ return;
217
+ // Construct CC message
218
+ let ccMsg;
219
+ if (data.imageBase64) {
220
+ ccMsg = createImageMessage(data.text || 'What do you see in this image?', data.imageBase64, data.imageMediaType);
221
+ }
222
+ else if (data.filePath && data.fileName) {
223
+ ccMsg = createDocumentMessage(data.text, data.filePath, data.fileName);
224
+ }
225
+ else {
226
+ ccMsg = createTextMessage(data.text);
227
+ }
228
+ // Get or create CC process
229
+ let proc = agent.processes.get(userId);
230
+ if (proc?.takenOver) {
231
+ // Session was taken over externally — discard old process
232
+ proc.destroy();
233
+ agent.processes.delete(userId);
234
+ proc = undefined;
235
+ }
236
+ // Staleness check: detect if session was modified by another client
237
+ if (proc && proc.state !== 'idle') {
238
+ const staleInfo = this.checkSessionStaleness(agentId, userId);
239
+ if (staleInfo) {
240
+ this.logger.info({ agentId, userId }, 'Session stale — killing process for reconnect');
241
+ proc.destroy();
242
+ agent.processes.delete(userId);
243
+ proc = undefined;
244
+ agent.tgBot.sendText(chatId, staleInfo.summary, 'HTML');
245
+ }
246
+ }
247
+ if (!proc || proc.state === 'idle') {
248
+ // Save first message text as pending session title
249
+ if (data.text) {
250
+ agent.pendingTitles.set(userId, data.text);
251
+ }
252
+ proc = this.spawnCCProcess(agentId, userId, chatId);
253
+ agent.processes.set(userId, proc);
254
+ }
255
+ // Show typing indicator
256
+ agent.tgBot.sendTyping(chatId);
257
+ proc.sendMessage(ccMsg);
258
+ this.sessionStore.updateSessionActivity(agentId, userId);
259
+ }
260
+ /** Check if the session JSONL was modified externally since we last tracked it. */
261
+ checkSessionStaleness(agentId, userId) {
262
+ const userState = this.sessionStore.getUser(agentId, userId);
263
+ const sessionId = userState.currentSessionId;
264
+ if (!sessionId)
265
+ return null;
266
+ const repo = userState.repo || resolveUserConfig(this.agents.get(agentId).config, userId).repo;
267
+ const jsonlPath = getSessionJsonlPath(sessionId, repo);
268
+ const tracking = this.sessionStore.getJsonlTracking(agentId, userId);
269
+ // No tracking yet (first message or new session) — not stale
270
+ if (!tracking)
271
+ return null;
272
+ try {
273
+ const stat = statSync(jsonlPath);
274
+ // File grew or was modified since we last tracked
275
+ if (stat.size <= tracking.size && stat.mtimeMs <= tracking.mtimeMs) {
276
+ return null;
277
+ }
278
+ // Session is stale — build a summary of what happened
279
+ const summary = summarizeJsonlDelta(jsonlPath, tracking.size)
280
+ ?? '<i>ℹ️ Session was updated from another client. Reconnecting...</i>';
281
+ return { summary };
282
+ }
283
+ catch {
284
+ // File doesn't exist or stat failed — skip check
285
+ return null;
286
+ }
287
+ }
288
+ /** Update JSONL tracking from the current file state. */
289
+ updateJsonlTracking(agentId, userId) {
290
+ const userState = this.sessionStore.getUser(agentId, userId);
291
+ const sessionId = userState.currentSessionId;
292
+ if (!sessionId)
293
+ return;
294
+ const repo = userState.repo || resolveUserConfig(this.agents.get(agentId).config, userId).repo;
295
+ const jsonlPath = getSessionJsonlPath(sessionId, repo);
296
+ try {
297
+ const stat = statSync(jsonlPath);
298
+ this.sessionStore.updateJsonlTracking(agentId, userId, stat.size, stat.mtimeMs);
299
+ }
300
+ catch {
301
+ // File doesn't exist yet — clear tracking
302
+ this.sessionStore.clearJsonlTracking(agentId, userId);
303
+ }
304
+ }
305
+ spawnCCProcess(agentId, userId, chatId) {
306
+ const agent = this.agents.get(agentId);
307
+ const userConfig = resolveUserConfig(agent.config, userId);
308
+ // Check session store for model/repo/permission overrides
309
+ const userState = this.sessionStore.getUser(agentId, userId);
310
+ if (userState.model)
311
+ userConfig.model = userState.model;
312
+ if (userState.repo)
313
+ userConfig.repo = userState.repo;
314
+ if (userState.permissionMode) {
315
+ userConfig.permissionMode = userState.permissionMode;
316
+ }
317
+ // Generate MCP config
318
+ const mcpServerPath = join(import.meta.dirname ?? '.', 'mcp-server.js');
319
+ const mcpConfigPath = generateMcpConfig(agentId, userId, this.config.global.socketDir, mcpServerPath);
320
+ // Start MCP socket listener for this agent-user pair
321
+ const socketPath = join(this.config.global.socketDir, `${agentId}-${userId}.sock`);
322
+ this.mcpServer.listen(socketPath);
323
+ const proc = new CCProcess({
324
+ agentId,
325
+ userId,
326
+ ccBinaryPath: this.config.global.ccBinaryPath,
327
+ userConfig,
328
+ mcpConfigPath,
329
+ sessionId: userState.currentSessionId ?? undefined,
330
+ continueSession: !!userState.currentSessionId,
331
+ logger: this.logger,
332
+ });
333
+ // ── Wire up event handlers ──
334
+ proc.on('init', (event) => {
335
+ this.sessionStore.setCurrentSession(agentId, userId, event.session_id);
336
+ // Set session title from the first user message
337
+ const pendingTitle = agent.pendingTitles.get(userId);
338
+ if (pendingTitle) {
339
+ this.sessionStore.setSessionTitle(agentId, userId, event.session_id, pendingTitle);
340
+ agent.pendingTitles.delete(userId);
341
+ }
342
+ // Initialize JSONL tracking for staleness detection
343
+ this.updateJsonlTracking(agentId, userId);
344
+ });
345
+ proc.on('stream_event', (event) => {
346
+ this.handleStreamEvent(agentId, userId, chatId, event);
347
+ });
348
+ proc.on('assistant', (event) => {
349
+ // Non-streaming fallback — only used if stream_events don't fire
350
+ // In practice, stream_events handle the display
351
+ });
352
+ proc.on('result', (event) => {
353
+ this.handleResult(agentId, userId, chatId, event);
354
+ });
355
+ proc.on('permission_request', (event) => {
356
+ const req = event.request;
357
+ const requestId = event.request_id;
358
+ // Store pending permission
359
+ agent.pendingPermissions.set(requestId, {
360
+ requestId,
361
+ userId,
362
+ toolName: req.tool_name,
363
+ input: req.input,
364
+ });
365
+ // Build description of what CC wants to do
366
+ const toolName = escapeHtml(req.tool_name);
367
+ const inputPreview = req.input
368
+ ? escapeHtml(JSON.stringify(req.input).slice(0, 200))
369
+ : '';
370
+ const text = inputPreview
371
+ ? `🔐 CC wants to use <code>${toolName}</code>\n<pre>${inputPreview}</pre>`
372
+ : `🔐 CC wants to use <code>${toolName}</code>`;
373
+ const keyboard = new InlineKeyboard()
374
+ .text('✅ Allow', `perm_allow:${requestId}`)
375
+ .text('❌ Deny', `perm_deny:${requestId}`)
376
+ .text('✅ Allow All', `perm_allow_all:${userId}`);
377
+ agent.tgBot.sendTextWithKeyboard(chatId, text, keyboard, 'HTML');
378
+ });
379
+ proc.on('api_error', (event) => {
380
+ const errMsg = event.error?.message || 'Unknown API error';
381
+ const status = event.error?.status;
382
+ const isOverloaded = status === 529 || errMsg.includes('overloaded');
383
+ const retryInfo = event.retryAttempt != null && event.maxRetries != null
384
+ ? ` (retry ${event.retryAttempt}/${event.maxRetries})`
385
+ : '';
386
+ const text = isOverloaded
387
+ ? `<blockquote>⚠️ API overloaded, retrying...${retryInfo}</blockquote>`
388
+ : `<blockquote>⚠️ ${escapeHtml(errMsg)}${retryInfo}</blockquote>`;
389
+ agent.tgBot.sendText(chatId, text, 'HTML');
390
+ });
391
+ proc.on('hang', () => {
392
+ agent.tgBot.sendText(chatId, '<i>CC session paused. Send a message to continue.</i>', 'HTML');
393
+ });
394
+ proc.on('takeover', () => {
395
+ this.logger.warn({ agentId, userId }, 'Session takeover detected');
396
+ // Clear session so next message starts fresh instead of --resume
397
+ this.sessionStore.clearSession(agentId, userId);
398
+ agent.tgBot.sendText(chatId, '<i>Session was picked up by another client. Next message will start a fresh session.</i>', 'HTML');
399
+ });
400
+ proc.on('exit', () => {
401
+ // Finalize any active accumulator
402
+ const accKey = `${userId}:${chatId}`;
403
+ const acc = agent.accumulators.get(accKey);
404
+ if (acc) {
405
+ acc.finalize();
406
+ agent.accumulators.delete(accKey);
407
+ }
408
+ // Clean up sub-agent tracker
409
+ agent.subAgentTrackers.delete(accKey);
410
+ });
411
+ proc.on('error', (err) => {
412
+ agent.tgBot.sendText(chatId, `<i>CC error: ${escapeHtml(String(err.message))}</i>`, 'HTML');
413
+ });
414
+ return proc;
415
+ }
416
+ // ── Stream event handling ──
417
+ handleStreamEvent(agentId, userId, chatId, event) {
418
+ const agent = this.agents.get(agentId);
419
+ if (!agent)
420
+ return;
421
+ const accKey = `${userId}:${chatId}`;
422
+ let acc = agent.accumulators.get(accKey);
423
+ if (!acc) {
424
+ const sender = {
425
+ sendMessage: (cid, text, parseMode) => agent.tgBot.sendText(cid, text, parseMode),
426
+ editMessage: (cid, msgId, text, parseMode) => agent.tgBot.editText(cid, msgId, text, parseMode),
427
+ sendPhoto: (cid, buffer, caption) => agent.tgBot.sendPhotoBuffer(cid, buffer, caption),
428
+ };
429
+ acc = new StreamAccumulator({ chatId, sender });
430
+ agent.accumulators.set(accKey, acc);
431
+ }
432
+ // Sub-agent tracker — create lazily alongside the accumulator
433
+ let tracker = agent.subAgentTrackers.get(accKey);
434
+ if (!tracker) {
435
+ const subAgentSender = {
436
+ replyToMessage: (cid, text, replyTo, parseMode) => agent.tgBot.replyToMessage(cid, text, replyTo, parseMode),
437
+ editMessage: (cid, msgId, text, parseMode) => agent.tgBot.editText(cid, msgId, text, parseMode),
438
+ };
439
+ tracker = new SubAgentTracker({
440
+ chatId,
441
+ sender: subAgentSender,
442
+ getMainMessageId: () => {
443
+ const ids = acc.allMessageIds;
444
+ return ids.length > 0 ? ids[0] : null;
445
+ },
446
+ });
447
+ agent.subAgentTrackers.set(accKey, tracker);
448
+ }
449
+ acc.handleEvent(event);
450
+ tracker.handleEvent(event);
451
+ }
452
+ handleResult(agentId, userId, chatId, event) {
453
+ const agent = this.agents.get(agentId);
454
+ if (!agent)
455
+ return;
456
+ // Set usage stats on the accumulator before finalizing
457
+ const accKey = `${userId}:${chatId}`;
458
+ const acc = agent.accumulators.get(accKey);
459
+ if (acc) {
460
+ // Extract usage from result event
461
+ if (event.usage) {
462
+ acc.setTurnUsage({
463
+ inputTokens: event.usage.input_tokens ?? 0,
464
+ outputTokens: event.usage.output_tokens ?? 0,
465
+ cacheReadTokens: event.usage.cache_read_input_tokens ?? 0,
466
+ cacheCreationTokens: event.usage.cache_creation_input_tokens ?? 0,
467
+ costUsd: event.total_cost_usd ?? null,
468
+ });
469
+ }
470
+ acc.finalize();
471
+ // Don't delete — next turn will softReset via message_start and edit the same message
472
+ }
473
+ // Update session store with cost
474
+ if (event.total_cost_usd) {
475
+ this.sessionStore.updateSessionActivity(agentId, userId, event.total_cost_usd);
476
+ }
477
+ // Update JSONL tracking after our own CC turn completes
478
+ // This prevents false-positive staleness on our own writes
479
+ this.updateJsonlTracking(agentId, userId);
480
+ // Handle errors
481
+ if (event.is_error && event.result) {
482
+ agent.tgBot.sendText(chatId, `<i>Error: ${escapeHtml(String(event.result))}</i>`, 'HTML');
483
+ }
484
+ }
485
+ // ── Slash commands ──
486
+ async handleSlashCommand(agentId, cmd) {
487
+ const agent = this.agents.get(agentId);
488
+ if (!agent)
489
+ return;
490
+ this.logger.debug({ agentId, command: cmd.command, args: cmd.args }, 'Slash command');
491
+ switch (cmd.command) {
492
+ case 'start': {
493
+ await agent.tgBot.sendText(cmd.chatId, '👋 TGCC — Telegram ↔ Claude Code bridge\n\nSend me a message to start a CC session, or use /help for commands.');
494
+ // Re-register commands with BotFather to ensure menu is up to date
495
+ try {
496
+ const { COMMANDS } = await import('./telegram.js');
497
+ await agent.tgBot.bot.api.setMyCommands(COMMANDS);
498
+ }
499
+ catch { }
500
+ break;
501
+ }
502
+ case 'help':
503
+ await agent.tgBot.sendText(cmd.chatId, HELP_TEXT, 'HTML');
504
+ break;
505
+ case 'ping': {
506
+ const proc = agent.processes.get(cmd.userId);
507
+ const state = proc?.state ?? 'idle';
508
+ await agent.tgBot.sendText(cmd.chatId, `pong — process: ${state.toUpperCase()}`);
509
+ break;
510
+ }
511
+ case 'status': {
512
+ const proc = agent.processes.get(cmd.userId);
513
+ const userState = this.sessionStore.getUser(agentId, cmd.userId);
514
+ const uptime = proc?.spawnedAt
515
+ ? formatDuration(Date.now() - proc.spawnedAt.getTime())
516
+ : 'N/A';
517
+ const status = [
518
+ `<b>Agent:</b> ${escapeHtml(agentId)}`,
519
+ `<b>Process:</b> ${(proc?.state ?? 'idle').toUpperCase()} (uptime: ${uptime})`,
520
+ `<b>Session:</b> <code>${escapeHtml(proc?.sessionId?.slice(0, 8) ?? 'none')}</code>`,
521
+ `<b>Model:</b> ${escapeHtml(userState.model || resolveUserConfig(agent.config, cmd.userId).model)}`,
522
+ `<b>Repo:</b> ${escapeHtml(userState.repo || resolveUserConfig(agent.config, cmd.userId).repo)}`,
523
+ `<b>Cost:</b> $${(proc?.totalCostUsd ?? 0).toFixed(4)}`,
524
+ ].join('\n');
525
+ await agent.tgBot.sendText(cmd.chatId, status, 'HTML');
526
+ break;
527
+ }
528
+ case 'cost': {
529
+ const proc = agent.processes.get(cmd.userId);
530
+ await agent.tgBot.sendText(cmd.chatId, `Session cost: $${(proc?.totalCostUsd ?? 0).toFixed(4)}`);
531
+ break;
532
+ }
533
+ case 'new': {
534
+ const proc = agent.processes.get(cmd.userId);
535
+ if (proc) {
536
+ proc.destroy();
537
+ agent.processes.delete(cmd.userId);
538
+ }
539
+ this.sessionStore.clearSession(agentId, cmd.userId);
540
+ await agent.tgBot.sendText(cmd.chatId, 'Session cleared. Next message starts fresh.');
541
+ break;
542
+ }
543
+ case 'sessions': {
544
+ const sessions = this.sessionStore.getRecentSessions(agentId, cmd.userId);
545
+ if (sessions.length === 0) {
546
+ await agent.tgBot.sendText(cmd.chatId, 'No recent sessions.');
547
+ break;
548
+ }
549
+ const currentSessionId = this.sessionStore.getUser(agentId, cmd.userId).currentSessionId;
550
+ const lines = [];
551
+ const keyboard = new InlineKeyboard();
552
+ sessions.forEach((s, i) => {
553
+ const title = s.title ? `"${escapeHtml(s.title)}"` : `<code>${escapeHtml(s.id.slice(0, 8))}</code>`;
554
+ const age = formatAge(new Date(s.lastActivity));
555
+ const isCurrent = s.id === currentSessionId;
556
+ lines.push(`${i + 1}. ${title} — ${s.messageCount} msgs, $${s.totalCostUsd.toFixed(2)} (${age})${isCurrent ? ' ✓' : ''}`);
557
+ if (!isCurrent) {
558
+ keyboard.text('Resume', `resume:${s.id}`);
559
+ }
560
+ keyboard.text('Delete', `delete:${s.id}`).row();
561
+ });
562
+ await agent.tgBot.sendTextWithKeyboard(cmd.chatId, `📋 <b>Recent sessions:</b>\n\n${lines.join('\n')}`, keyboard, 'HTML');
563
+ break;
564
+ }
565
+ case 'resume': {
566
+ if (!cmd.args) {
567
+ await agent.tgBot.sendText(cmd.chatId, 'Usage: /resume <session-id>');
568
+ break;
569
+ }
570
+ const proc = agent.processes.get(cmd.userId);
571
+ if (proc) {
572
+ proc.destroy();
573
+ agent.processes.delete(cmd.userId);
574
+ }
575
+ this.sessionStore.setCurrentSession(agentId, cmd.userId, cmd.args.trim());
576
+ await agent.tgBot.sendText(cmd.chatId, `Will resume session <code>${escapeHtml(cmd.args.trim().slice(0, 8))}</code> on next message.`, 'HTML');
577
+ break;
578
+ }
579
+ case 'session': {
580
+ const userState = this.sessionStore.getUser(agentId, cmd.userId);
581
+ const session = userState.sessions.find(s => s.id === userState.currentSessionId);
582
+ if (!session) {
583
+ await agent.tgBot.sendText(cmd.chatId, 'No active session.');
584
+ break;
585
+ }
586
+ await agent.tgBot.sendText(cmd.chatId, `<b>Session:</b> <code>${escapeHtml(session.id.slice(0, 8))}</code>\n<b>Messages:</b> ${session.messageCount}\n<b>Cost:</b> $${session.totalCostUsd.toFixed(4)}\n<b>Started:</b> ${session.startedAt}`, 'HTML');
587
+ break;
588
+ }
589
+ case 'model': {
590
+ if (!cmd.args) {
591
+ const current = this.sessionStore.getUser(agentId, cmd.userId).model
592
+ || resolveUserConfig(agent.config, cmd.userId).model;
593
+ await agent.tgBot.sendText(cmd.chatId, `Current model: ${current}\n\nUsage: /model <model-name>`);
594
+ break;
595
+ }
596
+ this.sessionStore.setModel(agentId, cmd.userId, cmd.args.trim());
597
+ await agent.tgBot.sendText(cmd.chatId, `Model set to <code>${escapeHtml(cmd.args.trim())}</code>. Takes effect on next spawn.`, 'HTML');
598
+ break;
599
+ }
600
+ case 'repo': {
601
+ const repoArgs = cmd.args?.trim().split(/\s+/) ?? [];
602
+ const repoSub = repoArgs[0];
603
+ this.logger.debug({ repoSub, repoArgs, repos: Object.keys(this.config.repos), hasArgs: !!cmd.args, argsRaw: cmd.args }, '/repo command debug');
604
+ if (repoSub === 'add') {
605
+ // /repo add <name> <path>
606
+ const repoName = repoArgs[1];
607
+ const repoAddPath = repoArgs[2];
608
+ if (!repoName || !repoAddPath) {
609
+ await agent.tgBot.sendText(cmd.chatId, 'Usage: /repo add <name> <path>');
610
+ break;
611
+ }
612
+ if (!isValidRepoName(repoName)) {
613
+ await agent.tgBot.sendText(cmd.chatId, 'Invalid repo name. Use alphanumeric + hyphens only.');
614
+ break;
615
+ }
616
+ if (!existsSync(repoAddPath)) {
617
+ await agent.tgBot.sendText(cmd.chatId, `Path not found: ${repoAddPath}`);
618
+ break;
619
+ }
620
+ if (this.config.repos[repoName]) {
621
+ await agent.tgBot.sendText(cmd.chatId, `Repo "${repoName}" already exists.`);
622
+ break;
623
+ }
624
+ updateConfig((cfg) => {
625
+ const repos = (cfg.repos ?? {});
626
+ repos[repoName] = repoAddPath;
627
+ cfg.repos = repos;
628
+ });
629
+ await agent.tgBot.sendText(cmd.chatId, `Repo <code>${escapeHtml(repoName)}</code> added → ${escapeHtml(repoAddPath)}`, 'HTML');
630
+ break;
631
+ }
632
+ if (repoSub === 'remove') {
633
+ // /repo remove <name>
634
+ const repoName = repoArgs[1];
635
+ if (!repoName) {
636
+ await agent.tgBot.sendText(cmd.chatId, 'Usage: /repo remove <name>');
637
+ break;
638
+ }
639
+ if (!this.config.repos[repoName]) {
640
+ await agent.tgBot.sendText(cmd.chatId, `Repo "${repoName}" not found.`);
641
+ break;
642
+ }
643
+ // Check if any agent has it assigned
644
+ const rawCfg = JSON.parse(readFileSync(join(homedir(), '.tgcc', 'config.json'), 'utf-8'));
645
+ const owner = findRepoOwner(rawCfg, repoName);
646
+ if (owner) {
647
+ await agent.tgBot.sendText(cmd.chatId, `Can't remove: repo "${repoName}" is assigned to agent "${owner}". Use /repo clear on that agent first.`);
648
+ break;
649
+ }
650
+ updateConfig((cfg) => {
651
+ const repos = (cfg.repos ?? {});
652
+ delete repos[repoName];
653
+ cfg.repos = repos;
654
+ });
655
+ await agent.tgBot.sendText(cmd.chatId, `Repo <code>${escapeHtml(repoName)}</code> removed.`, 'HTML');
656
+ break;
657
+ }
658
+ if (repoSub === 'assign') {
659
+ // /repo assign <name> — assign to THIS agent
660
+ const repoName = repoArgs[1];
661
+ if (!repoName) {
662
+ await agent.tgBot.sendText(cmd.chatId, 'Usage: /repo assign <name>');
663
+ break;
664
+ }
665
+ if (!this.config.repos[repoName]) {
666
+ await agent.tgBot.sendText(cmd.chatId, `Repo "${repoName}" not found in registry.`);
667
+ break;
668
+ }
669
+ const rawCfg2 = JSON.parse(readFileSync(join(homedir(), '.tgcc', 'config.json'), 'utf-8'));
670
+ const existingOwner = findRepoOwner(rawCfg2, repoName);
671
+ if (existingOwner && existingOwner !== agentId) {
672
+ await agent.tgBot.sendText(cmd.chatId, `Repo "${repoName}" is already assigned to agent "${existingOwner}".`);
673
+ break;
674
+ }
675
+ updateConfig((cfg) => {
676
+ const agents = (cfg.agents ?? {});
677
+ const agentCfg = agents[agentId];
678
+ if (agentCfg) {
679
+ const defaults = (agentCfg.defaults ?? {});
680
+ defaults.repo = repoName;
681
+ agentCfg.defaults = defaults;
682
+ }
683
+ });
684
+ await agent.tgBot.sendText(cmd.chatId, `Repo <code>${escapeHtml(repoName)}</code> assigned to agent <code>${escapeHtml(agentId)}</code>.`, 'HTML');
685
+ break;
686
+ }
687
+ if (repoSub === 'help') {
688
+ const helpText = [
689
+ '<b>Repo Management</b>',
690
+ '',
691
+ '/repo — List repos (buttons)',
692
+ '/repo help — This help text',
693
+ '/repo add &lt;name&gt; &lt;path&gt; — Register a repo',
694
+ '/repo remove &lt;name&gt; — Unregister a repo',
695
+ '/repo assign &lt;name&gt; — Set as this agent\'s default',
696
+ '/repo clear — Clear this agent\'s default',
697
+ ].join('\n');
698
+ await agent.tgBot.sendText(cmd.chatId, helpText, 'HTML');
699
+ break;
700
+ }
701
+ if (repoSub === 'clear') {
702
+ // /repo clear — clear THIS agent's default repo
703
+ updateConfig((cfg) => {
704
+ const agents = (cfg.agents ?? {});
705
+ const agentCfg = agents[agentId];
706
+ if (agentCfg) {
707
+ const defaults = (agentCfg.defaults ?? {});
708
+ delete defaults.repo;
709
+ agentCfg.defaults = defaults;
710
+ }
711
+ });
712
+ await agent.tgBot.sendText(cmd.chatId, `Default repo cleared for agent <code>${escapeHtml(agentId)}</code>.`, 'HTML');
713
+ break;
714
+ }
715
+ if (!cmd.args) {
716
+ const current = this.sessionStore.getUser(agentId, cmd.userId).repo
717
+ || resolveUserConfig(agent.config, cmd.userId).repo;
718
+ // Show available repos as inline keyboard buttons
719
+ const repoEntries = Object.entries(this.config.repos);
720
+ if (repoEntries.length > 0) {
721
+ const keyboard = new InlineKeyboard();
722
+ for (const [name] of repoEntries) {
723
+ keyboard.text(name, `repo:${name}`).row();
724
+ }
725
+ keyboard.text('➕ Add', 'repo_add:prompt').text('❓ Help', 'repo_help:show').row();
726
+ await agent.tgBot.sendTextWithKeyboard(cmd.chatId, `Current repo: <code>${escapeHtml(current)}</code>\n\nSelect a repo:\n\n<i>Type /repo help for management commands</i>`, keyboard, 'HTML');
727
+ }
728
+ else {
729
+ await agent.tgBot.sendText(cmd.chatId, `Current repo: ${current}\n\nUsage: /repo <path>`);
730
+ }
731
+ break;
732
+ }
733
+ // Fallback: /repo <path-or-name> — switch working directory for session
734
+ const repoPath = resolveRepoPath(this.config.repos, cmd.args.trim());
735
+ if (!existsSync(repoPath)) {
736
+ await agent.tgBot.sendText(cmd.chatId, `Path not found: ${repoPath}`);
737
+ break;
738
+ }
739
+ // Kill current process (different CWD needs new process)
740
+ const proc = agent.processes.get(cmd.userId);
741
+ if (proc) {
742
+ proc.destroy();
743
+ agent.processes.delete(cmd.userId);
744
+ }
745
+ this.sessionStore.setRepo(agentId, cmd.userId, repoPath);
746
+ this.sessionStore.clearSession(agentId, cmd.userId);
747
+ await agent.tgBot.sendText(cmd.chatId, `Repo set to <code>${escapeHtml(repoPath)}</code>. Session cleared.`, 'HTML');
748
+ break;
749
+ }
750
+ case 'cancel': {
751
+ const proc = agent.processes.get(cmd.userId);
752
+ if (proc && proc.state === 'active') {
753
+ proc.cancel();
754
+ await agent.tgBot.sendText(cmd.chatId, 'Cancelled.');
755
+ }
756
+ else {
757
+ await agent.tgBot.sendText(cmd.chatId, 'No active turn to cancel.');
758
+ }
759
+ break;
760
+ }
761
+ case 'catchup': {
762
+ const userState = this.sessionStore.getUser(agentId, cmd.userId);
763
+ const repo = userState.repo || resolveUserConfig(agent.config, cmd.userId).repo;
764
+ const lastActivity = new Date(userState.lastActivity || 0);
765
+ const missed = findMissedSessions(repo, userState.knownSessionIds, lastActivity);
766
+ const message = formatCatchupMessage(repo, missed);
767
+ await agent.tgBot.sendText(cmd.chatId, message, 'HTML');
768
+ break;
769
+ }
770
+ case 'permissions': {
771
+ const validModes = ['dangerously-skip', 'acceptEdits', 'default', 'plan'];
772
+ const userState = this.sessionStore.getUser(agentId, cmd.userId);
773
+ const agentDefault = agent.config.defaults.permissionMode;
774
+ const currentMode = userState.permissionMode || agentDefault;
775
+ if (cmd.args) {
776
+ const mode = cmd.args.trim();
777
+ if (!validModes.includes(mode)) {
778
+ await agent.tgBot.sendText(cmd.chatId, `Invalid mode. Valid: ${validModes.join(', ')}`);
779
+ break;
780
+ }
781
+ this.sessionStore.setPermissionMode(agentId, cmd.userId, mode);
782
+ // Kill current process so new mode takes effect on next spawn
783
+ const proc = agent.processes.get(cmd.userId);
784
+ if (proc) {
785
+ proc.destroy();
786
+ agent.processes.delete(cmd.userId);
787
+ }
788
+ await agent.tgBot.sendText(cmd.chatId, `Permission mode set to <code>${escapeHtml(mode)}</code>. Takes effect on next message.`, 'HTML');
789
+ break;
790
+ }
791
+ // No args — show current mode + inline keyboard
792
+ const keyboard = new InlineKeyboard();
793
+ keyboard.text('🔓 Bypass', 'permissions:dangerously-skip').text('✏️ Accept Edits', 'permissions:acceptEdits').row();
794
+ keyboard.text('🔒 Default', 'permissions:default').text('📋 Plan', 'permissions:plan').row();
795
+ await agent.tgBot.sendTextWithKeyboard(cmd.chatId, `Current: <code>${escapeHtml(currentMode)}</code>\nDefault: <code>${escapeHtml(agentDefault)}</code>\n\nSelect a mode for this session:`, keyboard, 'HTML');
796
+ break;
797
+ }
798
+ }
799
+ }
800
+ // ── Callback query handling (inline buttons) ──
801
+ async handleCallbackQuery(agentId, query) {
802
+ const agent = this.agents.get(agentId);
803
+ if (!agent)
804
+ return;
805
+ this.logger.debug({ agentId, action: query.action, data: query.data }, 'Callback query');
806
+ switch (query.action) {
807
+ case 'resume': {
808
+ const sessionId = query.data;
809
+ const proc = agent.processes.get(query.userId);
810
+ if (proc) {
811
+ proc.destroy();
812
+ agent.processes.delete(query.userId);
813
+ }
814
+ this.sessionStore.setCurrentSession(agentId, query.userId, sessionId);
815
+ await agent.tgBot.answerCallbackQuery(query.callbackQueryId, 'Session set');
816
+ await agent.tgBot.sendText(query.chatId, `Will resume session <code>${escapeHtml(sessionId.slice(0, 8))}</code> on next message.`, 'HTML');
817
+ break;
818
+ }
819
+ case 'delete': {
820
+ const sessionId = query.data;
821
+ const deleted = this.sessionStore.deleteSession(agentId, query.userId, sessionId);
822
+ if (deleted) {
823
+ await agent.tgBot.answerCallbackQuery(query.callbackQueryId, 'Session deleted');
824
+ await agent.tgBot.sendText(query.chatId, `Session <code>${escapeHtml(sessionId.slice(0, 8))}</code> deleted.`, 'HTML');
825
+ }
826
+ else {
827
+ await agent.tgBot.answerCallbackQuery(query.callbackQueryId, 'Session not found');
828
+ }
829
+ break;
830
+ }
831
+ case 'repo': {
832
+ const repoName = query.data;
833
+ const repoPath = resolveRepoPath(this.config.repos, repoName);
834
+ if (!existsSync(repoPath)) {
835
+ await agent.tgBot.answerCallbackQuery(query.callbackQueryId, 'Path not found');
836
+ break;
837
+ }
838
+ // Kill current process (different CWD needs new process)
839
+ const proc = agent.processes.get(query.userId);
840
+ if (proc) {
841
+ proc.destroy();
842
+ agent.processes.delete(query.userId);
843
+ }
844
+ this.sessionStore.setRepo(agentId, query.userId, repoPath);
845
+ this.sessionStore.clearSession(agentId, query.userId);
846
+ await agent.tgBot.answerCallbackQuery(query.callbackQueryId, `Repo: ${repoName}`);
847
+ await agent.tgBot.sendText(query.chatId, `Repo set to <code>${escapeHtml(repoPath)}</code>. Session cleared.`, 'HTML');
848
+ break;
849
+ }
850
+ case 'repo_add': {
851
+ await agent.tgBot.answerCallbackQuery(query.callbackQueryId, 'Usage below');
852
+ await agent.tgBot.sendText(query.chatId, 'Send: <code>/repo add &lt;name&gt; &lt;path&gt;</code>', 'HTML');
853
+ break;
854
+ }
855
+ case 'permissions': {
856
+ const validModes = ['dangerously-skip', 'acceptEdits', 'default', 'plan'];
857
+ const mode = query.data;
858
+ if (!validModes.includes(mode)) {
859
+ await agent.tgBot.answerCallbackQuery(query.callbackQueryId, 'Invalid mode');
860
+ break;
861
+ }
862
+ this.sessionStore.setPermissionMode(agentId, query.userId, mode);
863
+ // Kill current process so new mode takes effect on next spawn
864
+ const proc = agent.processes.get(query.userId);
865
+ if (proc) {
866
+ proc.destroy();
867
+ agent.processes.delete(query.userId);
868
+ }
869
+ await agent.tgBot.answerCallbackQuery(query.callbackQueryId, `Mode: ${mode}`);
870
+ await agent.tgBot.sendText(query.chatId, `Permission mode set to <code>${escapeHtml(mode)}</code>. Takes effect on next message.`, 'HTML');
871
+ break;
872
+ }
873
+ case 'repo_help': {
874
+ await agent.tgBot.answerCallbackQuery(query.callbackQueryId);
875
+ const helpText = [
876
+ '<b>Repo Management</b>',
877
+ '',
878
+ '/repo — List repos (buttons)',
879
+ '/repo help — This help text',
880
+ '/repo add &lt;name&gt; &lt;path&gt; — Register a repo',
881
+ '/repo remove &lt;name&gt; — Unregister a repo',
882
+ '/repo assign &lt;name&gt; — Set as this agent\'s default',
883
+ '/repo clear — Clear this agent\'s default',
884
+ ].join('\n');
885
+ await agent.tgBot.sendText(query.chatId, helpText, 'HTML');
886
+ break;
887
+ }
888
+ case 'perm_allow': {
889
+ const requestId = query.data;
890
+ const pending = agent.pendingPermissions.get(requestId);
891
+ if (!pending) {
892
+ await agent.tgBot.answerCallbackQuery(query.callbackQueryId, 'Permission expired');
893
+ break;
894
+ }
895
+ const proc = agent.processes.get(pending.userId);
896
+ if (proc) {
897
+ proc.respondToPermission(requestId, true);
898
+ }
899
+ agent.pendingPermissions.delete(requestId);
900
+ await agent.tgBot.answerCallbackQuery(query.callbackQueryId, '✅ Allowed');
901
+ break;
902
+ }
903
+ case 'perm_deny': {
904
+ const requestId = query.data;
905
+ const pending = agent.pendingPermissions.get(requestId);
906
+ if (!pending) {
907
+ await agent.tgBot.answerCallbackQuery(query.callbackQueryId, 'Permission expired');
908
+ break;
909
+ }
910
+ const proc = agent.processes.get(pending.userId);
911
+ if (proc) {
912
+ proc.respondToPermission(requestId, false);
913
+ }
914
+ agent.pendingPermissions.delete(requestId);
915
+ await agent.tgBot.answerCallbackQuery(query.callbackQueryId, '❌ Denied');
916
+ break;
917
+ }
918
+ case 'perm_allow_all': {
919
+ // Allow all pending permissions for this user
920
+ const targetUserId = query.data;
921
+ const toAllow = [];
922
+ for (const [reqId, pending] of agent.pendingPermissions) {
923
+ if (pending.userId === targetUserId) {
924
+ toAllow.push(reqId);
925
+ }
926
+ }
927
+ const proc = agent.processes.get(targetUserId);
928
+ for (const reqId of toAllow) {
929
+ if (proc)
930
+ proc.respondToPermission(reqId, true);
931
+ agent.pendingPermissions.delete(reqId);
932
+ }
933
+ await agent.tgBot.answerCallbackQuery(query.callbackQueryId, `✅ Allowed ${toAllow.length} permission(s)`);
934
+ break;
935
+ }
936
+ default:
937
+ await agent.tgBot.answerCallbackQuery(query.callbackQueryId);
938
+ }
939
+ }
940
+ // ── Control socket handlers (CLI interface) ──
941
+ handleCtlMessage(agentId, text, sessionId) {
942
+ const agent = this.agents.get(agentId);
943
+ if (!agent) {
944
+ // Return error via the CtlAckResponse shape won't work — but the ctl-server
945
+ // protocol handles errors separately. We'll throw and let it catch.
946
+ throw new Error(`Unknown agent: ${agentId}`);
947
+ }
948
+ // Use the first allowed user as the "CLI user" identity
949
+ const userId = agent.config.allowedUsers[0];
950
+ const chatId = Number(userId);
951
+ // If explicit session requested, set it
952
+ if (sessionId) {
953
+ this.sessionStore.setCurrentSession(agentId, userId, sessionId);
954
+ }
955
+ // Route through the same sendToCC path as Telegram
956
+ this.sendToCC(agentId, userId, chatId, { text });
957
+ const proc = agent.processes.get(userId);
958
+ return {
959
+ type: 'ack',
960
+ sessionId: proc?.sessionId ?? this.sessionStore.getUser(agentId, userId).currentSessionId,
961
+ state: proc?.state ?? 'idle',
962
+ };
963
+ }
964
+ handleCtlStatus(agentId) {
965
+ const agents = [];
966
+ const sessions = [];
967
+ const agentIds = agentId ? [agentId] : [...this.agents.keys()];
968
+ for (const id of agentIds) {
969
+ const agent = this.agents.get(id);
970
+ if (!agent)
971
+ continue;
972
+ // Aggregate process state across users
973
+ let state = 'idle';
974
+ for (const [, proc] of agent.processes) {
975
+ if (proc.state === 'active') {
976
+ state = 'active';
977
+ break;
978
+ }
979
+ if (proc.state === 'spawning')
980
+ state = 'spawning';
981
+ }
982
+ const userId = agent.config.allowedUsers[0];
983
+ const proc = agent.processes.get(userId);
984
+ const userConfig = resolveUserConfig(agent.config, userId);
985
+ agents.push({
986
+ id,
987
+ state,
988
+ sessionId: proc?.sessionId ?? null,
989
+ repo: this.sessionStore.getUser(id, userId).repo || userConfig.repo,
990
+ });
991
+ // List sessions for this agent
992
+ const userState = this.sessionStore.getUser(id, userId);
993
+ for (const sess of userState.sessions.slice(-5).reverse()) {
994
+ sessions.push({
995
+ id: sess.id,
996
+ agentId: id,
997
+ messageCount: sess.messageCount,
998
+ totalCostUsd: sess.totalCostUsd,
999
+ });
1000
+ }
1001
+ }
1002
+ return { type: 'status', agents, sessions };
1003
+ }
1004
+ // ── MCP tool handling ──
1005
+ async handleMcpToolRequest(request) {
1006
+ const agent = this.agents.get(request.agentId);
1007
+ if (!agent) {
1008
+ return { id: request.id, success: false, error: `Unknown agent: ${request.agentId}` };
1009
+ }
1010
+ // Find the chat ID for this user (from the most recent message)
1011
+ // We use the userId to find which chat to send to
1012
+ const chatId = Number(request.userId); // In TG, private chat ID === user ID
1013
+ try {
1014
+ switch (request.tool) {
1015
+ case 'send_file':
1016
+ await agent.tgBot.sendFile(chatId, request.params.path, request.params.caption);
1017
+ return { id: request.id, success: true };
1018
+ case 'send_image':
1019
+ await agent.tgBot.sendImage(chatId, request.params.path, request.params.caption);
1020
+ return { id: request.id, success: true };
1021
+ case 'send_voice':
1022
+ await agent.tgBot.sendVoice(chatId, request.params.path, request.params.caption);
1023
+ return { id: request.id, success: true };
1024
+ default:
1025
+ return { id: request.id, success: false, error: `Unknown tool: ${request.tool}` };
1026
+ }
1027
+ }
1028
+ catch (err) {
1029
+ return { id: request.id, success: false, error: err instanceof Error ? err.message : 'Unknown error' };
1030
+ }
1031
+ }
1032
+ // ── Shutdown ──
1033
+ async stop() {
1034
+ this.logger.info('Stopping bridge');
1035
+ for (const agentId of [...this.agents.keys()]) {
1036
+ await this.stopAgent(agentId);
1037
+ }
1038
+ this.mcpServer.closeAll();
1039
+ this.ctlServer.closeAll();
1040
+ this.logger.info('Bridge stopped');
1041
+ }
1042
+ }
1043
+ // ── Helpers ──
1044
+ function formatDuration(ms) {
1045
+ const secs = Math.floor(ms / 1000);
1046
+ if (secs < 60)
1047
+ return `${secs}s`;
1048
+ const mins = Math.floor(secs / 60);
1049
+ if (mins < 60)
1050
+ return `${mins}m`;
1051
+ const hours = Math.floor(mins / 60);
1052
+ return `${hours}h ${mins % 60}m`;
1053
+ }
1054
+ function formatAge(date) {
1055
+ const diffMs = Date.now() - date.getTime();
1056
+ const mins = Math.floor(diffMs / 60000);
1057
+ if (mins < 60)
1058
+ return `${mins}m ago`;
1059
+ const hours = Math.floor(mins / 60);
1060
+ if (hours < 24)
1061
+ return `${hours}h ago`;
1062
+ const days = Math.floor(hours / 24);
1063
+ return `${days}d ago`;
1064
+ }
1065
+ //# sourceMappingURL=bridge.js.map