@fonz/tgcc 0.0.1 → 0.3.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.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +266 -0
  3. package/dist/bridge.d.ts +32 -0
  4. package/dist/bridge.js +1193 -0
  5. package/dist/bridge.js.map +1 -0
  6. package/dist/cc-process.d.ts +75 -0
  7. package/dist/cc-process.js +426 -0
  8. package/dist/cc-process.js.map +1 -0
  9. package/dist/cc-protocol.d.ts +255 -0
  10. package/dist/cc-protocol.js +109 -0
  11. package/dist/cc-protocol.js.map +1 -0
  12. package/dist/cli.d.ts +2 -0
  13. package/dist/cli.js +668 -0
  14. package/dist/cli.js.map +1 -0
  15. package/dist/config.d.ts +75 -0
  16. package/dist/config.js +268 -0
  17. package/dist/config.js.map +1 -0
  18. package/dist/ctl-server.d.ts +57 -0
  19. package/dist/ctl-server.js +98 -0
  20. package/dist/ctl-server.js.map +1 -0
  21. package/dist/index.d.ts +4 -0
  22. package/dist/index.js +13 -0
  23. package/dist/index.js.map +1 -0
  24. package/dist/mcp-bridge.d.ts +45 -0
  25. package/dist/mcp-bridge.js +182 -0
  26. package/dist/mcp-bridge.js.map +1 -0
  27. package/dist/mcp-server.d.ts +1 -0
  28. package/dist/mcp-server.js +109 -0
  29. package/dist/mcp-server.js.map +1 -0
  30. package/dist/service.d.ts +1 -0
  31. package/dist/service.js +84 -0
  32. package/dist/service.js.map +1 -0
  33. package/dist/session.d.ts +71 -0
  34. package/dist/session.js +438 -0
  35. package/dist/session.js.map +1 -0
  36. package/dist/streaming.d.ts +178 -0
  37. package/dist/streaming.js +814 -0
  38. package/dist/streaming.js.map +1 -0
  39. package/dist/telegram-html.d.ts +5 -0
  40. package/dist/telegram-html.js +120 -0
  41. package/dist/telegram-html.js.map +1 -0
  42. package/dist/telegram.d.ts +71 -0
  43. package/dist/telegram.js +384 -0
  44. package/dist/telegram.js.map +1 -0
  45. package/package.json +95 -4
package/dist/bridge.js ADDED
@@ -0,0 +1,1193 @@
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
+ // Skip if background sub-agents are running — their results grow the JSONL
238
+ // and would cause false-positive staleness detection
239
+ const accKey2 = `${userId}:${chatId}`;
240
+ const activeTracker = agent.subAgentTrackers.get(accKey2);
241
+ const hasBackgroundAgents = activeTracker?.hasDispatchedAgents ?? false;
242
+ if (proc && proc.state !== 'idle' && !hasBackgroundAgents) {
243
+ const staleInfo = this.checkSessionStaleness(agentId, userId);
244
+ if (staleInfo) {
245
+ // Session was modified externally — silently reconnect for roaming support
246
+ this.logger.info({ agentId, userId }, 'Session modified externally — reconnecting for roaming');
247
+ proc.destroy();
248
+ agent.processes.delete(userId);
249
+ proc = undefined;
250
+ }
251
+ }
252
+ if (!proc || proc.state === 'idle') {
253
+ // Save first message text as pending session title
254
+ if (data.text) {
255
+ agent.pendingTitles.set(userId, data.text);
256
+ }
257
+ proc = this.spawnCCProcess(agentId, userId, chatId);
258
+ agent.processes.set(userId, proc);
259
+ }
260
+ // Show typing indicator
261
+ agent.tgBot.sendTyping(chatId);
262
+ proc.sendMessage(ccMsg);
263
+ this.sessionStore.updateSessionActivity(agentId, userId);
264
+ }
265
+ /** Check if the session JSONL was modified externally since we last tracked it. */
266
+ checkSessionStaleness(agentId, userId) {
267
+ const userState = this.sessionStore.getUser(agentId, userId);
268
+ const sessionId = userState.currentSessionId;
269
+ if (!sessionId)
270
+ return null;
271
+ const repo = userState.repo || resolveUserConfig(this.agents.get(agentId).config, userId).repo;
272
+ const jsonlPath = getSessionJsonlPath(sessionId, repo);
273
+ const tracking = this.sessionStore.getJsonlTracking(agentId, userId);
274
+ // No tracking yet (first message or new session) — not stale
275
+ if (!tracking)
276
+ return null;
277
+ try {
278
+ const stat = statSync(jsonlPath);
279
+ // File grew or was modified since we last tracked
280
+ if (stat.size <= tracking.size && stat.mtimeMs <= tracking.mtimeMs) {
281
+ return null;
282
+ }
283
+ // Session is stale — build a summary of what happened
284
+ const summary = summarizeJsonlDelta(jsonlPath, tracking.size)
285
+ ?? '<i>ℹ️ Session was updated from another client. Reconnecting...</i>';
286
+ return { summary };
287
+ }
288
+ catch {
289
+ // File doesn't exist or stat failed — skip check
290
+ return null;
291
+ }
292
+ }
293
+ /** Update JSONL tracking from the current file state. */
294
+ updateJsonlTracking(agentId, userId) {
295
+ const userState = this.sessionStore.getUser(agentId, userId);
296
+ const sessionId = userState.currentSessionId;
297
+ if (!sessionId)
298
+ return;
299
+ const repo = userState.repo || resolveUserConfig(this.agents.get(agentId).config, userId).repo;
300
+ const jsonlPath = getSessionJsonlPath(sessionId, repo);
301
+ try {
302
+ const stat = statSync(jsonlPath);
303
+ this.sessionStore.updateJsonlTracking(agentId, userId, stat.size, stat.mtimeMs);
304
+ }
305
+ catch {
306
+ // File doesn't exist yet — clear tracking
307
+ this.sessionStore.clearJsonlTracking(agentId, userId);
308
+ }
309
+ }
310
+ spawnCCProcess(agentId, userId, chatId) {
311
+ const agent = this.agents.get(agentId);
312
+ const userConfig = resolveUserConfig(agent.config, userId);
313
+ // Check session store for model/repo/permission overrides
314
+ const userState = this.sessionStore.getUser(agentId, userId);
315
+ if (userState.model)
316
+ userConfig.model = userState.model;
317
+ if (userState.repo)
318
+ userConfig.repo = userState.repo;
319
+ if (userState.permissionMode) {
320
+ userConfig.permissionMode = userState.permissionMode;
321
+ }
322
+ // Generate MCP config
323
+ const mcpServerPath = resolveMcpServerPath();
324
+ const mcpConfigPath = generateMcpConfig(agentId, userId, this.config.global.socketDir, mcpServerPath);
325
+ // Start MCP socket listener for this agent-user pair
326
+ const socketPath = join(this.config.global.socketDir, `${agentId}-${userId}.sock`);
327
+ this.mcpServer.listen(socketPath);
328
+ const proc = new CCProcess({
329
+ agentId,
330
+ userId,
331
+ ccBinaryPath: this.config.global.ccBinaryPath,
332
+ userConfig,
333
+ mcpConfigPath,
334
+ sessionId: userState.currentSessionId ?? undefined,
335
+ continueSession: !!userState.currentSessionId,
336
+ logger: this.logger,
337
+ });
338
+ // ── Wire up event handlers ──
339
+ proc.on('init', (event) => {
340
+ this.sessionStore.setCurrentSession(agentId, userId, event.session_id);
341
+ // Set session title from the first user message
342
+ const pendingTitle = agent.pendingTitles.get(userId);
343
+ if (pendingTitle) {
344
+ this.sessionStore.setSessionTitle(agentId, userId, event.session_id, pendingTitle);
345
+ agent.pendingTitles.delete(userId);
346
+ }
347
+ // Initialize JSONL tracking for staleness detection
348
+ this.updateJsonlTracking(agentId, userId);
349
+ });
350
+ proc.on('stream_event', (event) => {
351
+ this.handleStreamEvent(agentId, userId, chatId, event);
352
+ });
353
+ proc.on('tool_result', (event) => {
354
+ const accKey = `${userId}:${chatId}`;
355
+ const tracker = agent.subAgentTrackers.get(accKey);
356
+ if (!tracker)
357
+ return;
358
+ const resultText = typeof event.content === 'string' ? event.content : JSON.stringify(event.content);
359
+ const meta = event.tool_use_result;
360
+ // Log warning if structured metadata is missing
361
+ if (!meta && /agent_id:\s*\S+@\S+/.test(resultText)) {
362
+ this.logger.warn({ agentId, toolUseId: event.tool_use_id }, 'Spawn detected in text but no structured tool_use_result metadata - skipping');
363
+ }
364
+ const spawnMeta = meta?.status === 'teammate_spawned' ? meta : undefined;
365
+ if (spawnMeta?.status === 'teammate_spawned' && spawnMeta.team_name) {
366
+ if (!tracker.currentTeamName) {
367
+ this.logger.info({ agentId, teamName: spawnMeta.team_name, agentName: spawnMeta.name, agentType: spawnMeta.agent_type }, 'Spawn detected');
368
+ tracker.setTeamName(spawnMeta.team_name);
369
+ // Wire the "all agents reported" callback to send follow-up to CC
370
+ tracker.setOnAllReported(() => {
371
+ const ccProc = agent.processes.get(userId);
372
+ if (ccProc && ccProc.state === 'active') {
373
+ ccProc.sendMessage(createTextMessage('All background agents have reported back. Please read their results and provide a synthesis.'));
374
+ }
375
+ });
376
+ }
377
+ // Set agent metadata from structured data or text fallback
378
+ if (event.tool_use_id && spawnMeta.name) {
379
+ tracker.setAgentMetadata(event.tool_use_id, {
380
+ agentName: spawnMeta.name,
381
+ agentType: spawnMeta.agent_type,
382
+ color: spawnMeta.color,
383
+ });
384
+ }
385
+ }
386
+ // Handle tool result (sets status, edits TG message)
387
+ if (event.tool_use_id) {
388
+ tracker.handleToolResult(event.tool_use_id, resultText);
389
+ }
390
+ // Start mailbox watch AFTER handleToolResult has set agent names
391
+ if (tracker.currentTeamName && tracker.hasDispatchedAgents && !tracker.isMailboxWatching) {
392
+ tracker.startMailboxWatch();
393
+ }
394
+ });
395
+ proc.on('assistant', (event) => {
396
+ // Non-streaming fallback — only used if stream_events don't fire
397
+ // In practice, stream_events handle the display
398
+ });
399
+ proc.on('result', (event) => {
400
+ this.handleResult(agentId, userId, chatId, event);
401
+ });
402
+ proc.on('permission_request', (event) => {
403
+ const req = event.request;
404
+ const requestId = event.request_id;
405
+ // Store pending permission
406
+ agent.pendingPermissions.set(requestId, {
407
+ requestId,
408
+ userId,
409
+ toolName: req.tool_name,
410
+ input: req.input,
411
+ });
412
+ // Build description of what CC wants to do
413
+ const toolName = escapeHtml(req.tool_name);
414
+ const inputPreview = req.input
415
+ ? escapeHtml(JSON.stringify(req.input).slice(0, 200))
416
+ : '';
417
+ const text = inputPreview
418
+ ? `🔐 CC wants to use <code>${toolName}</code>\n<pre>${inputPreview}</pre>`
419
+ : `🔐 CC wants to use <code>${toolName}</code>`;
420
+ const keyboard = new InlineKeyboard()
421
+ .text('✅ Allow', `perm_allow:${requestId}`)
422
+ .text('❌ Deny', `perm_deny:${requestId}`)
423
+ .text('✅ Allow All', `perm_allow_all:${userId}`);
424
+ agent.tgBot.sendTextWithKeyboard(chatId, text, keyboard, 'HTML');
425
+ });
426
+ proc.on('api_error', (event) => {
427
+ const errMsg = event.error?.message || 'Unknown API error';
428
+ const status = event.error?.status;
429
+ const isOverloaded = status === 529 || errMsg.includes('overloaded');
430
+ const retryInfo = event.retryAttempt != null && event.maxRetries != null
431
+ ? ` (retry ${event.retryAttempt}/${event.maxRetries})`
432
+ : '';
433
+ const text = isOverloaded
434
+ ? `<blockquote>⚠️ API overloaded, retrying...${retryInfo}</blockquote>`
435
+ : `<blockquote>⚠️ ${escapeHtml(errMsg)}${retryInfo}</blockquote>`;
436
+ agent.tgBot.sendText(chatId, text, 'HTML');
437
+ });
438
+ proc.on('hang', () => {
439
+ agent.tgBot.sendText(chatId, '<i>CC session paused. Send a message to continue.</i>', 'HTML');
440
+ });
441
+ proc.on('takeover', () => {
442
+ this.logger.warn({ agentId, userId }, 'Session takeover detected — keeping session for roaming');
443
+ // Don't clear session — allow roaming between clients.
444
+ // Just kill the current process; next message will --resume the same session.
445
+ proc.destroy();
446
+ agent.processes.delete(userId);
447
+ });
448
+ proc.on('exit', () => {
449
+ // Finalize any active accumulator
450
+ const accKey = `${userId}:${chatId}`;
451
+ const acc = agent.accumulators.get(accKey);
452
+ if (acc) {
453
+ acc.finalize();
454
+ agent.accumulators.delete(accKey);
455
+ }
456
+ // Clean up sub-agent tracker (stops mailbox watch)
457
+ const exitTracker = agent.subAgentTrackers.get(accKey);
458
+ if (exitTracker) {
459
+ exitTracker.stopMailboxWatch();
460
+ }
461
+ agent.subAgentTrackers.delete(accKey);
462
+ });
463
+ proc.on('error', (err) => {
464
+ agent.tgBot.sendText(chatId, `<i>CC error: ${escapeHtml(String(err.message))}</i>`, 'HTML');
465
+ });
466
+ return proc;
467
+ }
468
+ // ── Stream event handling ──
469
+ handleStreamEvent(agentId, userId, chatId, event) {
470
+ const agent = this.agents.get(agentId);
471
+ if (!agent)
472
+ return;
473
+ const accKey = `${userId}:${chatId}`;
474
+ let acc = agent.accumulators.get(accKey);
475
+ if (!acc) {
476
+ const sender = {
477
+ sendMessage: (cid, text, parseMode) => agent.tgBot.sendText(cid, text, parseMode),
478
+ editMessage: (cid, msgId, text, parseMode) => agent.tgBot.editText(cid, msgId, text, parseMode),
479
+ sendPhoto: (cid, buffer, caption) => agent.tgBot.sendPhotoBuffer(cid, buffer, caption),
480
+ };
481
+ acc = new StreamAccumulator({ chatId, sender });
482
+ agent.accumulators.set(accKey, acc);
483
+ }
484
+ // Sub-agent tracker — create lazily alongside the accumulator
485
+ let tracker = agent.subAgentTrackers.get(accKey);
486
+ if (!tracker) {
487
+ const subAgentSender = {
488
+ sendMessage: (cid, text, parseMode) => agent.tgBot.sendText(cid, text, parseMode),
489
+ editMessage: (cid, msgId, text, parseMode) => agent.tgBot.editText(cid, msgId, text, parseMode),
490
+ setReaction: (cid, msgId, emoji) => agent.tgBot.setReaction(cid, msgId, emoji),
491
+ };
492
+ tracker = new SubAgentTracker({
493
+ chatId,
494
+ sender: subAgentSender,
495
+ });
496
+ agent.subAgentTrackers.set(accKey, tracker);
497
+ }
498
+ // On message_start: always create new message on new CC turn for deterministic behavior
499
+ if (event.type === 'message_start') {
500
+ // Soft reset: clear buffer but keep tgMessageId so tool indicators
501
+ // ("Using Bash...") get overwritten by the actual response text.
502
+ // Only do a full reset (new TG message) when sub-agents were active,
503
+ // since their results should appear in a separate message.
504
+ if (tracker.hadSubAgents) {
505
+ acc.reset();
506
+ }
507
+ else {
508
+ acc.softReset();
509
+ }
510
+ // Only reset tracker if no agents still dispatched
511
+ if (!tracker.hasDispatchedAgents) {
512
+ tracker.reset();
513
+ }
514
+ }
515
+ acc.handleEvent(event);
516
+ tracker.handleEvent(event);
517
+ }
518
+ handleResult(agentId, userId, chatId, event) {
519
+ const agent = this.agents.get(agentId);
520
+ if (!agent)
521
+ return;
522
+ // Set usage stats on the accumulator before finalizing
523
+ const accKey = `${userId}:${chatId}`;
524
+ const acc = agent.accumulators.get(accKey);
525
+ if (acc) {
526
+ // Extract usage from result event
527
+ if (event.usage) {
528
+ acc.setTurnUsage({
529
+ inputTokens: event.usage.input_tokens ?? 0,
530
+ outputTokens: event.usage.output_tokens ?? 0,
531
+ cacheReadTokens: event.usage.cache_read_input_tokens ?? 0,
532
+ cacheCreationTokens: event.usage.cache_creation_input_tokens ?? 0,
533
+ costUsd: event.total_cost_usd ?? null,
534
+ });
535
+ }
536
+ acc.finalize();
537
+ // Don't delete — next turn will softReset via message_start and edit the same message
538
+ }
539
+ // Update session store with cost
540
+ if (event.total_cost_usd) {
541
+ this.sessionStore.updateSessionActivity(agentId, userId, event.total_cost_usd);
542
+ }
543
+ // Update JSONL tracking after our own CC turn completes
544
+ // This prevents false-positive staleness on our own writes
545
+ this.updateJsonlTracking(agentId, userId);
546
+ // Handle errors
547
+ if (event.is_error && event.result) {
548
+ agent.tgBot.sendText(chatId, `<i>Error: ${escapeHtml(String(event.result))}</i>`, 'HTML');
549
+ }
550
+ // If background sub-agents are still running, mailbox watcher handles them.
551
+ // Ensure mailbox watch is started if we have a team name and dispatched agents.
552
+ const tracker = agent.subAgentTrackers.get(accKey);
553
+ if (tracker?.hasDispatchedAgents && tracker.currentTeamName) {
554
+ this.logger.info({ agentId }, 'Turn ended with background sub-agents still running');
555
+ // Clear idle timer — don't kill CC while background agents are working
556
+ const ccProcess = agent.processes.get(userId);
557
+ if (ccProcess)
558
+ ccProcess.clearIdleTimer();
559
+ // Start mailbox watcher (works for general-purpose agents that have SendMessage)
560
+ tracker.startMailboxWatch();
561
+ // Fallback: send ONE follow-up after 60s if mailbox hasn't resolved all agents
562
+ // This handles bash-type agents that can't write to mailbox
563
+ if (!tracker.hasPendingFollowUp) {
564
+ tracker.hasPendingFollowUp = true;
565
+ setTimeout(() => {
566
+ if (!tracker.hasDispatchedAgents)
567
+ return; // already resolved via mailbox
568
+ const proc = agent.processes.get(userId);
569
+ if (!proc)
570
+ return;
571
+ this.logger.info({ agentId }, 'Mailbox timeout — sending single follow-up for remaining agents');
572
+ // Mark all remaining dispatched agents as completed (CC already has the results)
573
+ for (const info of tracker.activeAgents) {
574
+ if (info.status === 'dispatched') {
575
+ tracker.markCompleted(info.toolUseId, '(results delivered in CC response)');
576
+ }
577
+ }
578
+ proc.sendMessage(createTextMessage('The background agents should be done by now. Please report their results.'));
579
+ }, 60_000);
580
+ }
581
+ }
582
+ }
583
+ // ── Slash commands ──
584
+ async handleSlashCommand(agentId, cmd) {
585
+ const agent = this.agents.get(agentId);
586
+ if (!agent)
587
+ return;
588
+ this.logger.debug({ agentId, command: cmd.command, args: cmd.args }, 'Slash command');
589
+ switch (cmd.command) {
590
+ case 'start': {
591
+ 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.');
592
+ // Re-register commands with BotFather to ensure menu is up to date
593
+ try {
594
+ const { COMMANDS } = await import('./telegram.js');
595
+ await agent.tgBot.bot.api.setMyCommands(COMMANDS);
596
+ }
597
+ catch { }
598
+ break;
599
+ }
600
+ case 'help':
601
+ await agent.tgBot.sendText(cmd.chatId, HELP_TEXT, 'HTML');
602
+ break;
603
+ case 'ping': {
604
+ const proc = agent.processes.get(cmd.userId);
605
+ const state = proc?.state ?? 'idle';
606
+ await agent.tgBot.sendText(cmd.chatId, `pong — process: ${state.toUpperCase()}`);
607
+ break;
608
+ }
609
+ case 'status': {
610
+ const proc = agent.processes.get(cmd.userId);
611
+ const userState = this.sessionStore.getUser(agentId, cmd.userId);
612
+ const uptime = proc?.spawnedAt
613
+ ? formatDuration(Date.now() - proc.spawnedAt.getTime())
614
+ : 'N/A';
615
+ const status = [
616
+ `<b>Agent:</b> ${escapeHtml(agentId)}`,
617
+ `<b>Process:</b> ${(proc?.state ?? 'idle').toUpperCase()} (uptime: ${uptime})`,
618
+ `<b>Session:</b> <code>${escapeHtml(proc?.sessionId?.slice(0, 8) ?? 'none')}</code>`,
619
+ `<b>Model:</b> ${escapeHtml(userState.model || resolveUserConfig(agent.config, cmd.userId).model)}`,
620
+ `<b>Repo:</b> ${escapeHtml(userState.repo || resolveUserConfig(agent.config, cmd.userId).repo)}`,
621
+ `<b>Cost:</b> $${(proc?.totalCostUsd ?? 0).toFixed(4)}`,
622
+ ].join('\n');
623
+ await agent.tgBot.sendText(cmd.chatId, status, 'HTML');
624
+ break;
625
+ }
626
+ case 'cost': {
627
+ const proc = agent.processes.get(cmd.userId);
628
+ await agent.tgBot.sendText(cmd.chatId, `Session cost: $${(proc?.totalCostUsd ?? 0).toFixed(4)}`);
629
+ break;
630
+ }
631
+ case 'new': {
632
+ const proc = agent.processes.get(cmd.userId);
633
+ if (proc) {
634
+ proc.destroy();
635
+ agent.processes.delete(cmd.userId);
636
+ }
637
+ this.sessionStore.clearSession(agentId, cmd.userId);
638
+ await agent.tgBot.sendText(cmd.chatId, 'Session cleared. Next message starts fresh.');
639
+ break;
640
+ }
641
+ case 'sessions': {
642
+ const sessions = this.sessionStore.getRecentSessions(agentId, cmd.userId);
643
+ if (sessions.length === 0) {
644
+ await agent.tgBot.sendText(cmd.chatId, 'No recent sessions.');
645
+ break;
646
+ }
647
+ const currentSessionId = this.sessionStore.getUser(agentId, cmd.userId).currentSessionId;
648
+ const lines = [];
649
+ const keyboard = new InlineKeyboard();
650
+ sessions.forEach((s, i) => {
651
+ const rawTitle = s.title || s.id.slice(0, 8);
652
+ const displayTitle = s.title ? escapeHtml(s.title) : `<code>${escapeHtml(s.id.slice(0, 8))}</code>`;
653
+ const age = formatAge(new Date(s.lastActivity));
654
+ const isCurrent = s.id === currentSessionId;
655
+ const current = isCurrent ? ' ✓' : '';
656
+ lines.push(`<b>${i + 1}.</b> ${displayTitle}${current}\n ${s.messageCount} msgs · $${s.totalCostUsd.toFixed(2)} · ${age}`);
657
+ // Button: full title (TG allows up to ~40 chars visible), no ellipsis
658
+ if (!isCurrent) {
659
+ const btnLabel = rawTitle.length > 35 ? rawTitle.slice(0, 35) : rawTitle;
660
+ keyboard.text(`${i + 1}. ${btnLabel}`, `resume:${s.id}`).row();
661
+ }
662
+ });
663
+ await agent.tgBot.sendTextWithKeyboard(cmd.chatId, `📋 <b>Sessions</b>\n\n${lines.join('\n\n')}`, keyboard, 'HTML');
664
+ break;
665
+ }
666
+ case 'resume': {
667
+ if (!cmd.args) {
668
+ await agent.tgBot.sendText(cmd.chatId, 'Usage: /resume <session-id>');
669
+ break;
670
+ }
671
+ const proc = agent.processes.get(cmd.userId);
672
+ if (proc) {
673
+ proc.destroy();
674
+ agent.processes.delete(cmd.userId);
675
+ }
676
+ this.sessionStore.setCurrentSession(agentId, cmd.userId, cmd.args.trim());
677
+ await agent.tgBot.sendText(cmd.chatId, `Will resume session <code>${escapeHtml(cmd.args.trim().slice(0, 8))}</code> on next message.`, 'HTML');
678
+ break;
679
+ }
680
+ case 'session': {
681
+ const userState = this.sessionStore.getUser(agentId, cmd.userId);
682
+ const session = userState.sessions.find(s => s.id === userState.currentSessionId);
683
+ if (!session) {
684
+ await agent.tgBot.sendText(cmd.chatId, 'No active session.');
685
+ break;
686
+ }
687
+ 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');
688
+ break;
689
+ }
690
+ case 'model': {
691
+ if (!cmd.args) {
692
+ const current = this.sessionStore.getUser(agentId, cmd.userId).model
693
+ || resolveUserConfig(agent.config, cmd.userId).model;
694
+ await agent.tgBot.sendText(cmd.chatId, `Current model: ${current}\n\nUsage: /model <model-name>`);
695
+ break;
696
+ }
697
+ this.sessionStore.setModel(agentId, cmd.userId, cmd.args.trim());
698
+ await agent.tgBot.sendText(cmd.chatId, `Model set to <code>${escapeHtml(cmd.args.trim())}</code>. Takes effect on next spawn.`, 'HTML');
699
+ break;
700
+ }
701
+ case 'repo': {
702
+ const repoArgs = cmd.args?.trim().split(/\s+/) ?? [];
703
+ const repoSub = repoArgs[0];
704
+ this.logger.debug({ repoSub, repoArgs, repos: Object.keys(this.config.repos), hasArgs: !!cmd.args, argsRaw: cmd.args }, '/repo command debug');
705
+ if (repoSub === 'add') {
706
+ // /repo add <name> <path>
707
+ const repoName = repoArgs[1];
708
+ const repoAddPath = repoArgs[2];
709
+ if (!repoName || !repoAddPath) {
710
+ await agent.tgBot.sendText(cmd.chatId, 'Usage: /repo add <name> <path>');
711
+ break;
712
+ }
713
+ if (!isValidRepoName(repoName)) {
714
+ await agent.tgBot.sendText(cmd.chatId, 'Invalid repo name. Use alphanumeric + hyphens only.');
715
+ break;
716
+ }
717
+ if (!existsSync(repoAddPath)) {
718
+ await agent.tgBot.sendText(cmd.chatId, `Path not found: ${repoAddPath}`);
719
+ break;
720
+ }
721
+ if (this.config.repos[repoName]) {
722
+ await agent.tgBot.sendText(cmd.chatId, `Repo "${repoName}" already exists.`);
723
+ break;
724
+ }
725
+ updateConfig((cfg) => {
726
+ const repos = (cfg.repos ?? {});
727
+ repos[repoName] = repoAddPath;
728
+ cfg.repos = repos;
729
+ });
730
+ await agent.tgBot.sendText(cmd.chatId, `Repo <code>${escapeHtml(repoName)}</code> added → ${escapeHtml(repoAddPath)}`, 'HTML');
731
+ break;
732
+ }
733
+ if (repoSub === 'remove') {
734
+ // /repo remove <name>
735
+ const repoName = repoArgs[1];
736
+ if (!repoName) {
737
+ await agent.tgBot.sendText(cmd.chatId, 'Usage: /repo remove <name>');
738
+ break;
739
+ }
740
+ if (!this.config.repos[repoName]) {
741
+ await agent.tgBot.sendText(cmd.chatId, `Repo "${repoName}" not found.`);
742
+ break;
743
+ }
744
+ // Check if any agent has it assigned
745
+ const rawCfg = JSON.parse(readFileSync(join(homedir(), '.tgcc', 'config.json'), 'utf-8'));
746
+ const owner = findRepoOwner(rawCfg, repoName);
747
+ if (owner) {
748
+ await agent.tgBot.sendText(cmd.chatId, `Can't remove: repo "${repoName}" is assigned to agent "${owner}". Use /repo clear on that agent first.`);
749
+ break;
750
+ }
751
+ updateConfig((cfg) => {
752
+ const repos = (cfg.repos ?? {});
753
+ delete repos[repoName];
754
+ cfg.repos = repos;
755
+ });
756
+ await agent.tgBot.sendText(cmd.chatId, `Repo <code>${escapeHtml(repoName)}</code> removed.`, 'HTML');
757
+ break;
758
+ }
759
+ if (repoSub === 'assign') {
760
+ // /repo assign <name> — assign to THIS agent
761
+ const repoName = repoArgs[1];
762
+ if (!repoName) {
763
+ await agent.tgBot.sendText(cmd.chatId, 'Usage: /repo assign <name>');
764
+ break;
765
+ }
766
+ if (!this.config.repos[repoName]) {
767
+ await agent.tgBot.sendText(cmd.chatId, `Repo "${repoName}" not found in registry.`);
768
+ break;
769
+ }
770
+ const rawCfg2 = JSON.parse(readFileSync(join(homedir(), '.tgcc', 'config.json'), 'utf-8'));
771
+ const existingOwner = findRepoOwner(rawCfg2, repoName);
772
+ if (existingOwner && existingOwner !== agentId) {
773
+ await agent.tgBot.sendText(cmd.chatId, `Repo "${repoName}" is already assigned to agent "${existingOwner}".`);
774
+ break;
775
+ }
776
+ updateConfig((cfg) => {
777
+ const agents = (cfg.agents ?? {});
778
+ const agentCfg = agents[agentId];
779
+ if (agentCfg) {
780
+ const defaults = (agentCfg.defaults ?? {});
781
+ defaults.repo = repoName;
782
+ agentCfg.defaults = defaults;
783
+ }
784
+ });
785
+ await agent.tgBot.sendText(cmd.chatId, `Repo <code>${escapeHtml(repoName)}</code> assigned to agent <code>${escapeHtml(agentId)}</code>.`, 'HTML');
786
+ break;
787
+ }
788
+ if (repoSub === 'help') {
789
+ const helpText = [
790
+ '<b>Repo Management</b>',
791
+ '',
792
+ '/repo — List repos (buttons)',
793
+ '/repo help — This help text',
794
+ '/repo add &lt;name&gt; &lt;path&gt; — Register a repo',
795
+ '/repo remove &lt;name&gt; — Unregister a repo',
796
+ '/repo assign &lt;name&gt; — Set as this agent\'s default',
797
+ '/repo clear — Clear this agent\'s default',
798
+ ].join('\n');
799
+ await agent.tgBot.sendText(cmd.chatId, helpText, 'HTML');
800
+ break;
801
+ }
802
+ if (repoSub === 'clear') {
803
+ // /repo clear — clear THIS agent's default repo
804
+ updateConfig((cfg) => {
805
+ const agents = (cfg.agents ?? {});
806
+ const agentCfg = agents[agentId];
807
+ if (agentCfg) {
808
+ const defaults = (agentCfg.defaults ?? {});
809
+ delete defaults.repo;
810
+ agentCfg.defaults = defaults;
811
+ }
812
+ });
813
+ await agent.tgBot.sendText(cmd.chatId, `Default repo cleared for agent <code>${escapeHtml(agentId)}</code>.`, 'HTML');
814
+ break;
815
+ }
816
+ if (!cmd.args) {
817
+ const current = this.sessionStore.getUser(agentId, cmd.userId).repo
818
+ || resolveUserConfig(agent.config, cmd.userId).repo;
819
+ // Show available repos as inline keyboard buttons
820
+ const repoEntries = Object.entries(this.config.repos);
821
+ if (repoEntries.length > 0) {
822
+ const keyboard = new InlineKeyboard();
823
+ for (const [name] of repoEntries) {
824
+ keyboard.text(name, `repo:${name}`).row();
825
+ }
826
+ keyboard.text('➕ Add', 'repo_add:prompt').text('❓ Help', 'repo_help:show').row();
827
+ 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');
828
+ }
829
+ else {
830
+ await agent.tgBot.sendText(cmd.chatId, `Current repo: ${current}\n\nUsage: /repo <path>`);
831
+ }
832
+ break;
833
+ }
834
+ // Fallback: /repo <path-or-name> — switch working directory for session
835
+ const repoPath = resolveRepoPath(this.config.repos, cmd.args.trim());
836
+ if (!existsSync(repoPath)) {
837
+ await agent.tgBot.sendText(cmd.chatId, `Path not found: ${repoPath}`);
838
+ break;
839
+ }
840
+ // Kill current process (different CWD needs new process)
841
+ const proc = agent.processes.get(cmd.userId);
842
+ if (proc) {
843
+ proc.destroy();
844
+ agent.processes.delete(cmd.userId);
845
+ }
846
+ this.sessionStore.setRepo(agentId, cmd.userId, repoPath);
847
+ const userState = this.sessionStore.getUser(agentId, cmd.userId);
848
+ const lastActive = new Date(userState.lastActivity).getTime();
849
+ const staleMs = 24 * 60 * 60 * 1000;
850
+ if (Date.now() - lastActive > staleMs || !userState.currentSessionId) {
851
+ this.sessionStore.clearSession(agentId, cmd.userId);
852
+ await agent.tgBot.sendText(cmd.chatId, `Repo set to <code>${escapeHtml(repoPath)}</code>. Session cleared (stale).`, 'HTML');
853
+ }
854
+ else {
855
+ await agent.tgBot.sendText(cmd.chatId, `Repo set to <code>${escapeHtml(repoPath)}</code>. Session kept (active &lt;24h).`, 'HTML');
856
+ }
857
+ break;
858
+ }
859
+ case 'cancel': {
860
+ const proc = agent.processes.get(cmd.userId);
861
+ if (proc && proc.state === 'active') {
862
+ proc.cancel();
863
+ await agent.tgBot.sendText(cmd.chatId, 'Cancelled.');
864
+ }
865
+ else {
866
+ await agent.tgBot.sendText(cmd.chatId, 'No active turn to cancel.');
867
+ }
868
+ break;
869
+ }
870
+ case 'catchup': {
871
+ const userState = this.sessionStore.getUser(agentId, cmd.userId);
872
+ const repo = userState.repo || resolveUserConfig(agent.config, cmd.userId).repo;
873
+ const lastActivity = new Date(userState.lastActivity || 0);
874
+ const missed = findMissedSessions(repo, userState.knownSessionIds, lastActivity);
875
+ const message = formatCatchupMessage(repo, missed);
876
+ await agent.tgBot.sendText(cmd.chatId, message, 'HTML');
877
+ break;
878
+ }
879
+ case 'permissions': {
880
+ const validModes = ['dangerously-skip', 'acceptEdits', 'default', 'plan'];
881
+ const userState = this.sessionStore.getUser(agentId, cmd.userId);
882
+ const agentDefault = agent.config.defaults.permissionMode;
883
+ const currentMode = userState.permissionMode || agentDefault;
884
+ if (cmd.args) {
885
+ const mode = cmd.args.trim();
886
+ if (!validModes.includes(mode)) {
887
+ await agent.tgBot.sendText(cmd.chatId, `Invalid mode. Valid: ${validModes.join(', ')}`);
888
+ break;
889
+ }
890
+ this.sessionStore.setPermissionMode(agentId, cmd.userId, mode);
891
+ // Kill current process so new mode takes effect on next spawn
892
+ const proc = agent.processes.get(cmd.userId);
893
+ if (proc) {
894
+ proc.destroy();
895
+ agent.processes.delete(cmd.userId);
896
+ }
897
+ await agent.tgBot.sendText(cmd.chatId, `Permission mode set to <code>${escapeHtml(mode)}</code>. Takes effect on next message.`, 'HTML');
898
+ break;
899
+ }
900
+ // No args — show current mode + inline keyboard
901
+ const keyboard = new InlineKeyboard();
902
+ keyboard.text('🔓 Bypass', 'permissions:dangerously-skip').text('✏️ Accept Edits', 'permissions:acceptEdits').row();
903
+ keyboard.text('🔒 Default', 'permissions:default').text('📋 Plan', 'permissions:plan').row();
904
+ 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');
905
+ break;
906
+ }
907
+ }
908
+ }
909
+ // ── Callback query handling (inline buttons) ──
910
+ async handleCallbackQuery(agentId, query) {
911
+ const agent = this.agents.get(agentId);
912
+ if (!agent)
913
+ return;
914
+ this.logger.debug({ agentId, action: query.action, data: query.data }, 'Callback query');
915
+ switch (query.action) {
916
+ case 'resume': {
917
+ const sessionId = query.data;
918
+ const proc = agent.processes.get(query.userId);
919
+ if (proc) {
920
+ proc.destroy();
921
+ agent.processes.delete(query.userId);
922
+ }
923
+ this.sessionStore.setCurrentSession(agentId, query.userId, sessionId);
924
+ await agent.tgBot.answerCallbackQuery(query.callbackQueryId, 'Session set');
925
+ await agent.tgBot.sendText(query.chatId, `Will resume session <code>${escapeHtml(sessionId.slice(0, 8))}</code> on next message.`, 'HTML');
926
+ break;
927
+ }
928
+ case 'delete': {
929
+ const sessionId = query.data;
930
+ const deleted = this.sessionStore.deleteSession(agentId, query.userId, sessionId);
931
+ if (deleted) {
932
+ await agent.tgBot.answerCallbackQuery(query.callbackQueryId, 'Session deleted');
933
+ await agent.tgBot.sendText(query.chatId, `Session <code>${escapeHtml(sessionId.slice(0, 8))}</code> deleted.`, 'HTML');
934
+ }
935
+ else {
936
+ await agent.tgBot.answerCallbackQuery(query.callbackQueryId, 'Session not found');
937
+ }
938
+ break;
939
+ }
940
+ case 'repo': {
941
+ const repoName = query.data;
942
+ const repoPath = resolveRepoPath(this.config.repos, repoName);
943
+ if (!existsSync(repoPath)) {
944
+ await agent.tgBot.answerCallbackQuery(query.callbackQueryId, 'Path not found');
945
+ break;
946
+ }
947
+ // Kill current process (different CWD needs new process)
948
+ const proc = agent.processes.get(query.userId);
949
+ if (proc) {
950
+ proc.destroy();
951
+ agent.processes.delete(query.userId);
952
+ }
953
+ this.sessionStore.setRepo(agentId, query.userId, repoPath);
954
+ const userState2 = this.sessionStore.getUser(agentId, query.userId);
955
+ const lastActive2 = new Date(userState2.lastActivity).getTime();
956
+ const staleMs2 = 24 * 60 * 60 * 1000;
957
+ await agent.tgBot.answerCallbackQuery(query.callbackQueryId, `Repo: ${repoName}`);
958
+ if (Date.now() - lastActive2 > staleMs2 || !userState2.currentSessionId) {
959
+ this.sessionStore.clearSession(agentId, query.userId);
960
+ await agent.tgBot.sendText(query.chatId, `Repo set to <code>${escapeHtml(repoPath)}</code>. Session cleared (stale).`, 'HTML');
961
+ }
962
+ else {
963
+ await agent.tgBot.sendText(query.chatId, `Repo set to <code>${escapeHtml(repoPath)}</code>. Session kept (active &lt;24h).`, 'HTML');
964
+ }
965
+ break;
966
+ }
967
+ case 'repo_add': {
968
+ await agent.tgBot.answerCallbackQuery(query.callbackQueryId, 'Usage below');
969
+ await agent.tgBot.sendText(query.chatId, 'Send: <code>/repo add &lt;name&gt; &lt;path&gt;</code>', 'HTML');
970
+ break;
971
+ }
972
+ case 'permissions': {
973
+ const validModes = ['dangerously-skip', 'acceptEdits', 'default', 'plan'];
974
+ const mode = query.data;
975
+ if (!validModes.includes(mode)) {
976
+ await agent.tgBot.answerCallbackQuery(query.callbackQueryId, 'Invalid mode');
977
+ break;
978
+ }
979
+ this.sessionStore.setPermissionMode(agentId, query.userId, mode);
980
+ // Kill current process so new mode takes effect on next spawn
981
+ const proc = agent.processes.get(query.userId);
982
+ if (proc) {
983
+ proc.destroy();
984
+ agent.processes.delete(query.userId);
985
+ }
986
+ await agent.tgBot.answerCallbackQuery(query.callbackQueryId, `Mode: ${mode}`);
987
+ await agent.tgBot.sendText(query.chatId, `Permission mode set to <code>${escapeHtml(mode)}</code>. Takes effect on next message.`, 'HTML');
988
+ break;
989
+ }
990
+ case 'repo_help': {
991
+ await agent.tgBot.answerCallbackQuery(query.callbackQueryId);
992
+ const helpText = [
993
+ '<b>Repo Management</b>',
994
+ '',
995
+ '/repo — List repos (buttons)',
996
+ '/repo help — This help text',
997
+ '/repo add &lt;name&gt; &lt;path&gt; — Register a repo',
998
+ '/repo remove &lt;name&gt; — Unregister a repo',
999
+ '/repo assign &lt;name&gt; — Set as this agent\'s default',
1000
+ '/repo clear — Clear this agent\'s default',
1001
+ ].join('\n');
1002
+ await agent.tgBot.sendText(query.chatId, helpText, 'HTML');
1003
+ break;
1004
+ }
1005
+ case 'perm_allow': {
1006
+ const requestId = query.data;
1007
+ const pending = agent.pendingPermissions.get(requestId);
1008
+ if (!pending) {
1009
+ await agent.tgBot.answerCallbackQuery(query.callbackQueryId, 'Permission expired');
1010
+ break;
1011
+ }
1012
+ const proc = agent.processes.get(pending.userId);
1013
+ if (proc) {
1014
+ proc.respondToPermission(requestId, true);
1015
+ }
1016
+ agent.pendingPermissions.delete(requestId);
1017
+ await agent.tgBot.answerCallbackQuery(query.callbackQueryId, '✅ Allowed');
1018
+ break;
1019
+ }
1020
+ case 'perm_deny': {
1021
+ const requestId = query.data;
1022
+ const pending = agent.pendingPermissions.get(requestId);
1023
+ if (!pending) {
1024
+ await agent.tgBot.answerCallbackQuery(query.callbackQueryId, 'Permission expired');
1025
+ break;
1026
+ }
1027
+ const proc = agent.processes.get(pending.userId);
1028
+ if (proc) {
1029
+ proc.respondToPermission(requestId, false);
1030
+ }
1031
+ agent.pendingPermissions.delete(requestId);
1032
+ await agent.tgBot.answerCallbackQuery(query.callbackQueryId, '❌ Denied');
1033
+ break;
1034
+ }
1035
+ case 'perm_allow_all': {
1036
+ // Allow all pending permissions for this user
1037
+ const targetUserId = query.data;
1038
+ const toAllow = [];
1039
+ for (const [reqId, pending] of agent.pendingPermissions) {
1040
+ if (pending.userId === targetUserId) {
1041
+ toAllow.push(reqId);
1042
+ }
1043
+ }
1044
+ const proc = agent.processes.get(targetUserId);
1045
+ for (const reqId of toAllow) {
1046
+ if (proc)
1047
+ proc.respondToPermission(reqId, true);
1048
+ agent.pendingPermissions.delete(reqId);
1049
+ }
1050
+ await agent.tgBot.answerCallbackQuery(query.callbackQueryId, `✅ Allowed ${toAllow.length} permission(s)`);
1051
+ break;
1052
+ }
1053
+ default:
1054
+ await agent.tgBot.answerCallbackQuery(query.callbackQueryId);
1055
+ }
1056
+ }
1057
+ // ── Control socket handlers (CLI interface) ──
1058
+ handleCtlMessage(agentId, text, sessionId) {
1059
+ const agent = this.agents.get(agentId);
1060
+ if (!agent) {
1061
+ // Return error via the CtlAckResponse shape won't work — but the ctl-server
1062
+ // protocol handles errors separately. We'll throw and let it catch.
1063
+ throw new Error(`Unknown agent: ${agentId}`);
1064
+ }
1065
+ // Use the first allowed user as the "CLI user" identity
1066
+ const userId = agent.config.allowedUsers[0];
1067
+ const chatId = Number(userId);
1068
+ // If explicit session requested, set it
1069
+ if (sessionId) {
1070
+ this.sessionStore.setCurrentSession(agentId, userId, sessionId);
1071
+ }
1072
+ // Route through the same sendToCC path as Telegram
1073
+ this.sendToCC(agentId, userId, chatId, { text });
1074
+ const proc = agent.processes.get(userId);
1075
+ return {
1076
+ type: 'ack',
1077
+ sessionId: proc?.sessionId ?? this.sessionStore.getUser(agentId, userId).currentSessionId,
1078
+ state: proc?.state ?? 'idle',
1079
+ };
1080
+ }
1081
+ handleCtlStatus(agentId) {
1082
+ const agents = [];
1083
+ const sessions = [];
1084
+ const agentIds = agentId ? [agentId] : [...this.agents.keys()];
1085
+ for (const id of agentIds) {
1086
+ const agent = this.agents.get(id);
1087
+ if (!agent)
1088
+ continue;
1089
+ // Aggregate process state across users
1090
+ let state = 'idle';
1091
+ for (const [, proc] of agent.processes) {
1092
+ if (proc.state === 'active') {
1093
+ state = 'active';
1094
+ break;
1095
+ }
1096
+ if (proc.state === 'spawning')
1097
+ state = 'spawning';
1098
+ }
1099
+ const userId = agent.config.allowedUsers[0];
1100
+ const proc = agent.processes.get(userId);
1101
+ const userConfig = resolveUserConfig(agent.config, userId);
1102
+ agents.push({
1103
+ id,
1104
+ state,
1105
+ sessionId: proc?.sessionId ?? null,
1106
+ repo: this.sessionStore.getUser(id, userId).repo || userConfig.repo,
1107
+ });
1108
+ // List sessions for this agent
1109
+ const userState = this.sessionStore.getUser(id, userId);
1110
+ for (const sess of userState.sessions.slice(-5).reverse()) {
1111
+ sessions.push({
1112
+ id: sess.id,
1113
+ agentId: id,
1114
+ messageCount: sess.messageCount,
1115
+ totalCostUsd: sess.totalCostUsd,
1116
+ });
1117
+ }
1118
+ }
1119
+ return { type: 'status', agents, sessions };
1120
+ }
1121
+ // ── MCP tool handling ──
1122
+ async handleMcpToolRequest(request) {
1123
+ const agent = this.agents.get(request.agentId);
1124
+ if (!agent) {
1125
+ return { id: request.id, success: false, error: `Unknown agent: ${request.agentId}` };
1126
+ }
1127
+ // Find the chat ID for this user (from the most recent message)
1128
+ // We use the userId to find which chat to send to
1129
+ const chatId = Number(request.userId); // In TG, private chat ID === user ID
1130
+ try {
1131
+ switch (request.tool) {
1132
+ case 'send_file':
1133
+ await agent.tgBot.sendFile(chatId, request.params.path, request.params.caption);
1134
+ return { id: request.id, success: true };
1135
+ case 'send_image':
1136
+ await agent.tgBot.sendImage(chatId, request.params.path, request.params.caption);
1137
+ return { id: request.id, success: true };
1138
+ case 'send_voice':
1139
+ await agent.tgBot.sendVoice(chatId, request.params.path, request.params.caption);
1140
+ return { id: request.id, success: true };
1141
+ default:
1142
+ return { id: request.id, success: false, error: `Unknown tool: ${request.tool}` };
1143
+ }
1144
+ }
1145
+ catch (err) {
1146
+ return { id: request.id, success: false, error: err instanceof Error ? err.message : 'Unknown error' };
1147
+ }
1148
+ }
1149
+ // ── Shutdown ──
1150
+ async stop() {
1151
+ this.logger.info('Stopping bridge');
1152
+ for (const agentId of [...this.agents.keys()]) {
1153
+ await this.stopAgent(agentId);
1154
+ }
1155
+ this.mcpServer.closeAll();
1156
+ this.ctlServer.closeAll();
1157
+ this.logger.info('Bridge stopped');
1158
+ }
1159
+ }
1160
+ // ── Helpers ──
1161
+ function formatDuration(ms) {
1162
+ const secs = Math.floor(ms / 1000);
1163
+ if (secs < 60)
1164
+ return `${secs}s`;
1165
+ const mins = Math.floor(secs / 60);
1166
+ if (mins < 60)
1167
+ return `${mins}m`;
1168
+ const hours = Math.floor(mins / 60);
1169
+ return `${hours}h ${mins % 60}m`;
1170
+ }
1171
+ function formatAge(date) {
1172
+ const diffMs = Date.now() - date.getTime();
1173
+ const mins = Math.floor(diffMs / 60000);
1174
+ if (mins < 60)
1175
+ return `${mins}m ago`;
1176
+ const hours = Math.floor(mins / 60);
1177
+ if (hours < 24)
1178
+ return `${hours}h ago`;
1179
+ const days = Math.floor(hours / 24);
1180
+ return `${days}d ago`;
1181
+ }
1182
+ /** Environment-aware MCP server path resolution */
1183
+ function resolveMcpServerPath() {
1184
+ const baseDir = import.meta.dirname ?? '.';
1185
+ // Check for compiled JS first (production/tsx runtime)
1186
+ const jsPath = join(baseDir, 'mcp-server.js');
1187
+ if (existsSync(jsPath)) {
1188
+ return jsPath;
1189
+ }
1190
+ // Fallback to TS source (development)
1191
+ return join(baseDir, 'mcp-server.ts');
1192
+ }
1193
+ //# sourceMappingURL=bridge.js.map