@fonz/tgcc 0.6.13 → 0.6.15

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 CHANGED
@@ -1,5 +1,5 @@
1
1
  import { join } from 'node:path';
2
- import { existsSync, readFileSync, statSync } from 'node:fs';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
3
  import { homedir } from 'node:os';
4
4
  import { EventEmitter } from 'node:events';
5
5
  import pino from 'pino';
@@ -10,8 +10,9 @@ import { StreamAccumulator, SubAgentTracker, escapeHtml } from './streaming.js';
10
10
  import { TelegramBot } from './telegram.js';
11
11
  import { InlineKeyboard } from 'grammy';
12
12
  import { McpBridgeServer } from './mcp-bridge.js';
13
- import { SessionStore, findMissedSessions, formatCatchupMessage, getSessionJsonlPath, summarizeJsonlDelta, } from './session.js';
13
+ import { SessionStore, discoverCCSessions, } from './session.js';
14
14
  import { CtlServer, } from './ctl-server.js';
15
+ import { ProcessRegistry } from './process-registry.js';
15
16
  // ── Message Batcher ──
16
17
  class MessageBatcher {
17
18
  pending = [];
@@ -97,6 +98,8 @@ const HELP_TEXT = `<b>TGCC Commands</b>
97
98
  export class Bridge extends EventEmitter {
98
99
  config;
99
100
  agents = new Map();
101
+ processRegistry = new ProcessRegistry();
102
+ sessionModelOverrides = new Map(); // "agentId:userId" → model (from /model cmd, not persisted)
100
103
  mcpServer;
101
104
  ctlServer;
102
105
  sessionStore;
@@ -128,7 +131,6 @@ export class Bridge extends EventEmitter {
128
131
  accumulators: new Map(),
129
132
  subAgentTrackers: new Map(),
130
133
  batchers: new Map(),
131
- pendingTitles: new Map(),
132
134
  pendingPermissions: new Map(),
133
135
  typingIntervals: new Map(),
134
136
  };
@@ -174,6 +176,12 @@ export class Bridge extends EventEmitter {
174
176
  this.logger.info({ agentId }, 'Stopping agent');
175
177
  // Stop bot
176
178
  await agent.tgBot.stop();
179
+ // Unsubscribe all clients for this agent from the registry
180
+ for (const [userId] of agent.processes) {
181
+ const chatId = Number(userId); // In TG, private chat ID === user ID
182
+ const clientRef = { agentId, userId, chatId };
183
+ this.processRegistry.unsubscribe(clientRef);
184
+ }
177
185
  // Kill all CC processes and wait for them to exit
178
186
  const processExitPromises = [];
179
187
  for (const [, proc] of agent.processes) {
@@ -223,7 +231,6 @@ export class Bridge extends EventEmitter {
223
231
  clearInterval(interval);
224
232
  }
225
233
  agent.typingIntervals.clear();
226
- agent.pendingTitles.clear();
227
234
  agent.pendingPermissions.clear();
228
235
  agent.processes.clear();
229
236
  agent.batchers.clear();
@@ -273,13 +280,17 @@ export class Bridge extends EventEmitter {
273
280
  else {
274
281
  ccMsg = createTextMessage(data.text);
275
282
  }
276
- // Get or create CC process
277
- let proc = agent.processes.get(userId);
283
+ const clientRef = { agentId, userId, chatId };
284
+ // Check if this client already has a process via the registry
285
+ let existingEntry = this.processRegistry.findByClient(clientRef);
286
+ let proc = existingEntry?.ccProcess;
278
287
  if (proc?.takenOver) {
279
288
  // Session was taken over externally — discard old process
280
- proc.destroy();
289
+ const entry = this.processRegistry.findByClient(clientRef);
290
+ this.processRegistry.destroy(entry.repo, entry.sessionId);
281
291
  agent.processes.delete(userId);
282
292
  proc = undefined;
293
+ existingEntry = null;
283
294
  }
284
295
  // Staleness check: detect if session was modified by another client
285
296
  // Skip if background sub-agents are running — their results grow the JSONL
@@ -292,9 +303,11 @@ export class Bridge extends EventEmitter {
292
303
  if (staleInfo) {
293
304
  // Session was modified externally — silently reconnect for roaming support
294
305
  this.logger.info({ agentId, userId }, 'Session modified externally — reconnecting for roaming');
295
- proc.destroy();
306
+ const entry = this.processRegistry.findByClient(clientRef);
307
+ this.processRegistry.unsubscribe(clientRef);
296
308
  agent.processes.delete(userId);
297
309
  proc = undefined;
310
+ existingEntry = null;
298
311
  }
299
312
  }
300
313
  if (!proc || proc.state === 'idle') {
@@ -302,11 +315,27 @@ export class Bridge extends EventEmitter {
302
315
  const userState2 = this.sessionStore.getUser(agentId, userId);
303
316
  const resolvedRepo = userState2.repo || resolveUserConfig(agent.config, userId).repo;
304
317
  if (resolvedRepo === homedir()) {
305
- agent.tgBot.sendText(chatId, '<blockquote>⚠️ No project selected. Use /repo to pick one, or CC will run in your home directory.</blockquote>', 'HTML');
318
+ agent.tgBot.sendText(chatId, '<blockquote>⚠️ No project selected. Use /repo to pick one, or CC will run in your home directory.</blockquote>', 'HTML').catch(err => this.logger.error({ err }, 'Failed to send no-repo warning'));
319
+ }
320
+ // Check if another client already has a process for this repo+session
321
+ const sessionId = userState2.currentSessionId;
322
+ if (sessionId && resolvedRepo) {
323
+ const sharedEntry = this.processRegistry.get(resolvedRepo, sessionId);
324
+ if (sharedEntry && sharedEntry.ccProcess.state !== 'idle') {
325
+ // Attach to existing process as subscriber
326
+ this.processRegistry.subscribe(resolvedRepo, sessionId, clientRef);
327
+ proc = sharedEntry.ccProcess;
328
+ agent.processes.set(userId, proc);
329
+ // Notify the user they've attached
330
+ agent.tgBot.sendText(chatId, '<blockquote>📎 Attached to existing session process.</blockquote>', 'HTML').catch(err => this.logger.error({ err }, 'Failed to send attach notification'));
331
+ // Show typing indicator and forward message
332
+ this.startTypingIndicator(agent, userId, chatId);
333
+ proc.sendMessage(ccMsg);
334
+ return;
335
+ }
306
336
  }
307
337
  // Save first message text as pending session title
308
338
  if (data.text) {
309
- agent.pendingTitles.set(userId, data.text);
310
339
  }
311
340
  proc = this.spawnCCProcess(agentId, userId, chatId);
312
341
  agent.processes.set(userId, proc);
@@ -314,52 +343,33 @@ export class Bridge extends EventEmitter {
314
343
  // Show typing indicator (repeated every 4s — TG typing expires after ~5s)
315
344
  this.startTypingIndicator(agent, userId, chatId);
316
345
  proc.sendMessage(ccMsg);
317
- this.sessionStore.updateSessionActivity(agentId, userId);
318
346
  }
319
347
  /** Check if the session JSONL was modified externally since we last tracked it. */
320
- checkSessionStaleness(agentId, userId) {
321
- const userState = this.sessionStore.getUser(agentId, userId);
322
- const sessionId = userState.currentSessionId;
323
- if (!sessionId)
324
- return null;
325
- const repo = userState.repo || resolveUserConfig(this.agents.get(agentId).config, userId).repo;
326
- const jsonlPath = getSessionJsonlPath(sessionId, repo);
327
- const tracking = this.sessionStore.getJsonlTracking(agentId, userId);
328
- // No tracking yet (first message or new session) — not stale
329
- if (!tracking)
330
- return null;
331
- try {
332
- const stat = statSync(jsonlPath);
333
- // File grew or was modified since we last tracked
334
- if (stat.size <= tracking.size && stat.mtimeMs <= tracking.mtimeMs) {
335
- return null;
336
- }
337
- // Session is stale — build a summary of what happened
338
- const summary = summarizeJsonlDelta(jsonlPath, tracking.size)
339
- ?? '<i>ℹ️ Session was updated from another client. Reconnecting...</i>';
340
- return { summary };
341
- }
342
- catch {
343
- // File doesn't exist or stat failed — skip check
344
- return null;
345
- }
348
+ checkSessionStaleness(_agentId, _userId) {
349
+ // With shared process registry, staleness is handled by the registry itself
350
+ return null;
346
351
  }
347
- /** Update JSONL tracking from the current file state. */
348
- updateJsonlTracking(agentId, userId) {
349
- const userState = this.sessionStore.getUser(agentId, userId);
350
- const sessionId = userState.currentSessionId;
351
- if (!sessionId)
352
+ // ── Process cleanup helper ──
353
+ /**
354
+ * Disconnect a client from its CC process.
355
+ * If other subscribers remain, the process stays alive.
356
+ * If this was the last subscriber, the process is destroyed.
357
+ */
358
+ disconnectClient(agentId, userId, chatId) {
359
+ const agent = this.agents.get(agentId);
360
+ if (!agent)
352
361
  return;
353
- const repo = userState.repo || resolveUserConfig(this.agents.get(agentId).config, userId).repo;
354
- const jsonlPath = getSessionJsonlPath(sessionId, repo);
355
- try {
356
- const stat = statSync(jsonlPath);
357
- this.sessionStore.updateJsonlTracking(agentId, userId, stat.size, stat.mtimeMs);
362
+ const clientRef = { agentId, userId, chatId };
363
+ const proc = agent.processes.get(userId);
364
+ const destroyed = this.processRegistry.unsubscribe(clientRef);
365
+ if (!destroyed && proc) {
366
+ // Other subscribers still attached — just remove from this agent's map
358
367
  }
359
- catch {
360
- // File doesn't exist yetclear tracking
361
- this.sessionStore.clearJsonlTracking(agentId, userId);
368
+ else if (proc && !destroyed) {
369
+ // Not in registry but has a process destroy directly (legacy path)
370
+ proc.destroy();
362
371
  }
372
+ agent.processes.delete(userId);
363
373
  }
364
374
  // ── Typing indicator management ──
365
375
  startTypingIndicator(agent, userId, chatId) {
@@ -385,12 +395,32 @@ export class Bridge extends EventEmitter {
385
395
  spawnCCProcess(agentId, userId, chatId) {
386
396
  const agent = this.agents.get(agentId);
387
397
  const userConfig = resolveUserConfig(agent.config, userId);
388
- // Check session store for model/repo/permission overrides
398
+ // Check session store for repo/permission overrides
389
399
  const userState = this.sessionStore.getUser(agentId, userId);
390
- if (userState.model)
391
- userConfig.model = userState.model;
392
400
  if (userState.repo)
393
401
  userConfig.repo = userState.repo;
402
+ // Model priority: /model override > running process > session JSONL > agent config default
403
+ const modelOverride = this.sessionModelOverrides.get(`${agentId}:${userId}`);
404
+ if (modelOverride) {
405
+ userConfig.model = modelOverride;
406
+ // Don't delete yet — cleared when CC writes first assistant message
407
+ }
408
+ else {
409
+ const currentSessionId = userState.currentSessionId;
410
+ if (currentSessionId && userConfig.repo) {
411
+ const registryEntry = this.processRegistry.get(userConfig.repo, currentSessionId);
412
+ if (registryEntry?.model) {
413
+ userConfig.model = registryEntry.model;
414
+ }
415
+ else {
416
+ const sessions = discoverCCSessions(userConfig.repo, 20);
417
+ const sessionInfo = sessions.find(s => s.id === currentSessionId);
418
+ if (sessionInfo?.model) {
419
+ userConfig.model = sessionInfo.model;
420
+ }
421
+ }
422
+ }
423
+ }
394
424
  if (userState.permissionMode) {
395
425
  userConfig.permissionMode = userState.permissionMode;
396
426
  }
@@ -410,98 +440,171 @@ export class Bridge extends EventEmitter {
410
440
  continueSession: !!userState.currentSessionId,
411
441
  logger: this.logger,
412
442
  });
413
- // ── Wire up event handlers ──
443
+ // Register in the process registry
444
+ const ownerRef = { agentId, userId, chatId };
445
+ // We'll register once we know the sessionId (on init), but we need a
446
+ // temporary entry for pre-init event routing. Use a placeholder sessionId
447
+ // that gets updated on init.
448
+ const tentativeSessionId = userState.currentSessionId ?? `pending-${Date.now()}`;
449
+ const registryEntry = this.processRegistry.register(userConfig.repo, tentativeSessionId, userConfig.model || 'default', proc, ownerRef);
450
+ // ── Helper: get all subscribers for this process from the registry ──
451
+ const getEntry = () => this.processRegistry.findByProcess(proc);
452
+ // ── Wire up event handlers (broadcast to all subscribers) ──
414
453
  proc.on('init', (event) => {
415
454
  this.sessionStore.setCurrentSession(agentId, userId, event.session_id);
416
- // Set session title from the first user message
417
- const pendingTitle = agent.pendingTitles.get(userId);
418
- if (pendingTitle) {
419
- this.sessionStore.setSessionTitle(agentId, userId, event.session_id, pendingTitle);
420
- agent.pendingTitles.delete(userId);
455
+ // Update registry key if session ID changed from tentative
456
+ if (event.session_id !== tentativeSessionId) {
457
+ // Re-register with the real session ID
458
+ const entry = getEntry();
459
+ if (entry) {
460
+ // Save subscribers before removing
461
+ const savedSubs = [...entry.subscribers.entries()];
462
+ this.processRegistry.remove(userConfig.repo, tentativeSessionId);
463
+ const newEntry = this.processRegistry.register(userConfig.repo, event.session_id, userConfig.model || 'default', proc, ownerRef);
464
+ // Restore additional subscribers
465
+ for (const [, sub] of savedSubs) {
466
+ if (sub.client.agentId !== agentId || sub.client.userId !== userId || sub.client.chatId !== chatId) {
467
+ this.processRegistry.subscribe(userConfig.repo, event.session_id, sub.client);
468
+ const newSub = this.processRegistry.getSubscriber(newEntry, sub.client);
469
+ if (newSub) {
470
+ newSub.accumulator = sub.accumulator;
471
+ newSub.tracker = sub.tracker;
472
+ }
473
+ }
474
+ }
475
+ }
421
476
  }
422
- // Initialize JSONL tracking for staleness detection
423
- this.updateJsonlTracking(agentId, userId);
424
477
  });
425
478
  proc.on('stream_event', (event) => {
426
- this.handleStreamEvent(agentId, userId, chatId, event);
427
- });
428
- proc.on('tool_result', (event) => {
429
- const accKey = `${userId}:${chatId}`;
430
- const tracker = agent.subAgentTrackers.get(accKey);
431
- if (!tracker)
479
+ const entry = getEntry();
480
+ if (!entry) {
481
+ // Fallback: single subscriber mode (shouldn't happen)
482
+ this.handleStreamEvent(agentId, userId, chatId, event);
432
483
  return;
433
- const resultText = typeof event.content === 'string' ? event.content : JSON.stringify(event.content);
434
- const meta = event.tool_use_result;
435
- // Log warning if structured metadata is missing
436
- if (!meta && /agent_id:\s*\S+@\S+/.test(resultText)) {
437
- this.logger.warn({ agentId, toolUseId: event.tool_use_id }, 'Spawn detected in text but no structured tool_use_result metadata - skipping');
438
484
  }
439
- const spawnMeta = meta?.status === 'teammate_spawned' ? meta : undefined;
440
- if (spawnMeta?.status === 'teammate_spawned' && spawnMeta.team_name) {
441
- if (!tracker.currentTeamName) {
442
- this.logger.info({ agentId, teamName: spawnMeta.team_name, agentName: spawnMeta.name, agentType: spawnMeta.agent_type }, 'Spawn detected');
443
- tracker.setTeamName(spawnMeta.team_name);
444
- // Wire the "all agents reported" callback to send follow-up to CC
445
- tracker.setOnAllReported(() => {
446
- const ccProc = agent.processes.get(userId);
447
- if (ccProc && ccProc.state === 'active') {
448
- ccProc.sendMessage(createTextMessage('[System] All background agents have reported back. Please read their results from the mailbox/files and provide a synthesis to the user.'));
449
- }
450
- });
485
+ for (const sub of this.processRegistry.subscribers(entry)) {
486
+ this.handleStreamEvent(sub.client.agentId, sub.client.userId, sub.client.chatId, event);
487
+ }
488
+ });
489
+ proc.on('tool_result', (event) => {
490
+ const entry = getEntry();
491
+ const subscriberList = entry ? [...this.processRegistry.subscribers(entry)] : [{ client: ownerRef }];
492
+ for (const sub of subscriberList) {
493
+ const subAgent = this.agents.get(sub.client.agentId);
494
+ if (!subAgent)
495
+ continue;
496
+ const accKey = `${sub.client.userId}:${sub.client.chatId}`;
497
+ // Resolve tool indicator message with success/failure status
498
+ const acc2 = subAgent.accumulators.get(accKey);
499
+ if (acc2 && event.tool_use_id) {
500
+ const isError = event.is_error === true;
501
+ const contentStr = typeof event.content === 'string' ? event.content : JSON.stringify(event.content);
502
+ const errorMsg = isError ? contentStr : undefined;
503
+ acc2.resolveToolMessage(event.tool_use_id, isError, errorMsg, contentStr, event.tool_use_result);
451
504
  }
452
- // Set agent metadata from structured data or text fallback
453
- if (event.tool_use_id && spawnMeta.name) {
454
- tracker.setAgentMetadata(event.tool_use_id, {
455
- agentName: spawnMeta.name,
456
- agentType: spawnMeta.agent_type,
457
- color: spawnMeta.color,
458
- });
505
+ const tracker = subAgent.subAgentTrackers.get(accKey);
506
+ if (!tracker)
507
+ continue;
508
+ const resultText = typeof event.content === 'string' ? event.content : JSON.stringify(event.content);
509
+ const meta = event.tool_use_result;
510
+ // Log warning if structured metadata is missing
511
+ if (!meta && /agent_id:\s*\S+@\S+/.test(resultText)) {
512
+ this.logger.warn({ agentId: sub.client.agentId, toolUseId: event.tool_use_id }, 'Spawn detected in text but no structured tool_use_result metadata - skipping');
513
+ }
514
+ const spawnMeta = meta?.status === 'teammate_spawned' ? meta : undefined;
515
+ if (spawnMeta?.status === 'teammate_spawned' && spawnMeta.team_name) {
516
+ if (!tracker.currentTeamName) {
517
+ this.logger.info({ agentId: sub.client.agentId, teamName: spawnMeta.team_name, agentName: spawnMeta.name, agentType: spawnMeta.agent_type }, 'Spawn detected');
518
+ tracker.setTeamName(spawnMeta.team_name);
519
+ // Wire the "all agents reported" callback to send follow-up to CC
520
+ tracker.setOnAllReported(() => {
521
+ if (proc.state === 'active') {
522
+ proc.sendMessage(createTextMessage('[System] All background agents have reported back. Please read their results from the mailbox/files and provide a synthesis to the user.'));
523
+ }
524
+ });
525
+ }
526
+ // Set agent metadata from structured data or text fallback
527
+ if (event.tool_use_id && spawnMeta.name) {
528
+ tracker.setAgentMetadata(event.tool_use_id, {
529
+ agentName: spawnMeta.name,
530
+ agentType: spawnMeta.agent_type,
531
+ color: spawnMeta.color,
532
+ });
533
+ }
534
+ }
535
+ // Handle tool result (sets status, edits TG message)
536
+ if (event.tool_use_id) {
537
+ tracker.handleToolResult(event.tool_use_id, resultText);
538
+ }
539
+ // Start mailbox watch AFTER handleToolResult has set agent names
540
+ if (tracker.currentTeamName && tracker.hasDispatchedAgents && !tracker.isMailboxWatching) {
541
+ tracker.startMailboxWatch();
459
542
  }
460
- }
461
- // Handle tool result (sets status, edits TG message)
462
- if (event.tool_use_id) {
463
- tracker.handleToolResult(event.tool_use_id, resultText);
464
- }
465
- // Start mailbox watch AFTER handleToolResult has set agent names
466
- if (tracker.currentTeamName && tracker.hasDispatchedAgents && !tracker.isMailboxWatching) {
467
- tracker.startMailboxWatch();
468
543
  }
469
544
  });
470
- // System events for background task tracking
545
+ // System events for background task tracking — broadcast to all subscribers
471
546
  proc.on('task_started', (event) => {
472
- const accKey = `${userId}:${chatId}`;
473
- const tracker = agent.subAgentTrackers.get(accKey);
474
- if (tracker) {
475
- tracker.handleTaskStarted(event.tool_use_id, event.description, event.task_type);
547
+ const entry = getEntry();
548
+ const subscriberList = entry ? [...this.processRegistry.subscribers(entry)] : [{ client: ownerRef }];
549
+ for (const sub of subscriberList) {
550
+ const subAgent = this.agents.get(sub.client.agentId);
551
+ if (!subAgent)
552
+ continue;
553
+ const accKey = `${sub.client.userId}:${sub.client.chatId}`;
554
+ const tracker = subAgent.subAgentTrackers.get(accKey);
555
+ if (tracker) {
556
+ tracker.handleTaskStarted(event.tool_use_id, event.description, event.task_type);
557
+ }
476
558
  }
477
559
  });
478
560
  proc.on('task_progress', (event) => {
479
- const accKey = `${userId}:${chatId}`;
480
- const tracker = agent.subAgentTrackers.get(accKey);
481
- if (tracker) {
482
- tracker.handleTaskProgress(event.tool_use_id, event.description, event.last_tool_name);
561
+ const entry = getEntry();
562
+ const subscriberList = entry ? [...this.processRegistry.subscribers(entry)] : [{ client: ownerRef }];
563
+ for (const sub of subscriberList) {
564
+ const subAgent = this.agents.get(sub.client.agentId);
565
+ if (!subAgent)
566
+ continue;
567
+ const accKey = `${sub.client.userId}:${sub.client.chatId}`;
568
+ const tracker = subAgent.subAgentTrackers.get(accKey);
569
+ if (tracker) {
570
+ tracker.handleTaskProgress(event.tool_use_id, event.description, event.last_tool_name);
571
+ }
483
572
  }
484
573
  });
485
574
  proc.on('task_completed', (event) => {
486
- const accKey = `${userId}:${chatId}`;
487
- const tracker = agent.subAgentTrackers.get(accKey);
488
- if (tracker) {
489
- tracker.handleTaskCompleted(event.tool_use_id);
575
+ const entry = getEntry();
576
+ const subscriberList = entry ? [...this.processRegistry.subscribers(entry)] : [{ client: ownerRef }];
577
+ for (const sub of subscriberList) {
578
+ const subAgent = this.agents.get(sub.client.agentId);
579
+ if (!subAgent)
580
+ continue;
581
+ const accKey = `${sub.client.userId}:${sub.client.chatId}`;
582
+ const tracker = subAgent.subAgentTrackers.get(accKey);
583
+ if (tracker) {
584
+ tracker.handleTaskCompleted(event.tool_use_id);
585
+ }
490
586
  }
491
587
  });
492
- // Media from tool results (images, PDFs, etc.)
588
+ // Media from tool results (images, PDFs, etc.) — broadcast to all subscribers
493
589
  proc.on('media', (media) => {
494
590
  const buf = Buffer.from(media.data, 'base64');
495
- if (media.kind === 'image') {
496
- agent.tgBot.sendPhotoBuffer(chatId, buf).catch(err => {
497
- this.logger.error({ err, agentId, userId }, 'Failed to send tool_result image');
498
- });
499
- }
500
- else if (media.kind === 'document') {
501
- const ext = media.media_type === 'application/pdf' ? '.pdf' : '';
502
- agent.tgBot.sendDocumentBuffer(chatId, buf, `document${ext}`).catch(err => {
503
- this.logger.error({ err, agentId, userId }, 'Failed to send tool_result document');
504
- });
591
+ const entry = getEntry();
592
+ const subscriberList = entry ? [...this.processRegistry.subscribers(entry)] : [{ client: ownerRef }];
593
+ for (const sub of subscriberList) {
594
+ const subAgent = this.agents.get(sub.client.agentId);
595
+ if (!subAgent)
596
+ continue;
597
+ if (media.kind === 'image') {
598
+ subAgent.tgBot.sendPhotoBuffer(sub.client.chatId, buf).catch(err => {
599
+ this.logger.error({ err, agentId: sub.client.agentId, userId: sub.client.userId }, 'Failed to send tool_result image');
600
+ });
601
+ }
602
+ else if (media.kind === 'document') {
603
+ const ext = media.media_type === 'application/pdf' ? '.pdf' : '';
604
+ subAgent.tgBot.sendDocumentBuffer(sub.client.chatId, buf, `document${ext}`).catch(err => {
605
+ this.logger.error({ err, agentId: sub.client.agentId, userId: sub.client.userId }, 'Failed to send tool_result document');
606
+ });
607
+ }
505
608
  }
506
609
  });
507
610
  proc.on('assistant', (event) => {
@@ -509,13 +612,24 @@ export class Bridge extends EventEmitter {
509
612
  // In practice, stream_events handle the display
510
613
  });
511
614
  proc.on('result', (event) => {
512
- this.stopTypingIndicator(agent, userId, chatId);
513
- this.handleResult(agentId, userId, chatId, event);
615
+ // Model override consumed — CC has written to JSONL
616
+ this.sessionModelOverrides.delete(`${agentId}:${userId}`);
617
+ // Broadcast result to all subscribers
618
+ const entry = getEntry();
619
+ const subscriberList = entry ? [...this.processRegistry.subscribers(entry)] : [{ client: ownerRef }];
620
+ for (const sub of subscriberList) {
621
+ const subAgent = this.agents.get(sub.client.agentId);
622
+ if (!subAgent)
623
+ continue;
624
+ this.stopTypingIndicator(subAgent, sub.client.userId, sub.client.chatId);
625
+ this.handleResult(sub.client.agentId, sub.client.userId, sub.client.chatId, event);
626
+ }
514
627
  });
515
628
  proc.on('permission_request', (event) => {
629
+ // Send permission request only to the owner (first subscriber)
516
630
  const req = event.request;
517
631
  const requestId = event.request_id;
518
- // Store pending permission
632
+ // Store pending permission on the owner's agent
519
633
  agent.pendingPermissions.set(requestId, {
520
634
  requestId,
521
635
  userId,
@@ -534,7 +648,8 @@ export class Bridge extends EventEmitter {
534
648
  .text('✅ Allow', `perm_allow:${requestId}`)
535
649
  .text('❌ Deny', `perm_deny:${requestId}`)
536
650
  .text('✅ Allow All', `perm_allow_all:${userId}`);
537
- agent.tgBot.sendTextWithKeyboard(chatId, text, keyboard, 'HTML');
651
+ agent.tgBot.sendTextWithKeyboard(chatId, text, keyboard, 'HTML')
652
+ .catch(err => this.logger.error({ err }, 'Failed to send permission request'));
538
653
  });
539
654
  proc.on('api_error', (event) => {
540
655
  const errMsg = event.error?.message || 'Unknown API error';
@@ -546,39 +661,100 @@ export class Bridge extends EventEmitter {
546
661
  const text = isOverloaded
547
662
  ? `<blockquote>⚠️ API overloaded, retrying...${retryInfo}</blockquote>`
548
663
  : `<blockquote>⚠️ ${escapeHtml(errMsg)}${retryInfo}</blockquote>`;
549
- agent.tgBot.sendText(chatId, text, 'HTML');
664
+ // Broadcast API error to all subscribers
665
+ const entry = getEntry();
666
+ const subscriberList = entry ? [...this.processRegistry.subscribers(entry)] : [{ client: ownerRef }];
667
+ for (const sub of subscriberList) {
668
+ const subAgent = this.agents.get(sub.client.agentId);
669
+ if (!subAgent)
670
+ continue;
671
+ subAgent.tgBot.sendText(sub.client.chatId, text, 'HTML')
672
+ .catch(err => this.logger.error({ err }, 'Failed to send API error notification'));
673
+ }
550
674
  });
551
675
  proc.on('hang', () => {
552
- this.stopTypingIndicator(agent, userId, chatId);
553
- agent.tgBot.sendText(chatId, '<blockquote>⏸ Session paused. Send a message to continue.</blockquote>', 'HTML');
676
+ // Broadcast hang notification to all subscribers
677
+ const entry = getEntry();
678
+ const subscriberList = entry ? [...this.processRegistry.subscribers(entry)] : [{ client: ownerRef }];
679
+ for (const sub of subscriberList) {
680
+ const subAgent = this.agents.get(sub.client.agentId);
681
+ if (!subAgent)
682
+ continue;
683
+ this.stopTypingIndicator(subAgent, sub.client.userId, sub.client.chatId);
684
+ subAgent.tgBot.sendText(sub.client.chatId, '<blockquote>⏸ Session paused. Send a message to continue.</blockquote>', 'HTML')
685
+ .catch(err => this.logger.error({ err }, 'Failed to send hang notification'));
686
+ }
554
687
  });
555
688
  proc.on('takeover', () => {
556
- this.stopTypingIndicator(agent, userId, chatId);
557
689
  this.logger.warn({ agentId, userId }, 'Session takeover detected — keeping session for roaming');
690
+ // Notify and clean up all subscribers
691
+ const entry = getEntry();
692
+ if (entry) {
693
+ for (const sub of this.processRegistry.subscribers(entry)) {
694
+ const subAgent = this.agents.get(sub.client.agentId);
695
+ if (!subAgent)
696
+ continue;
697
+ this.stopTypingIndicator(subAgent, sub.client.userId, sub.client.chatId);
698
+ subAgent.processes.delete(sub.client.userId);
699
+ }
700
+ // Remove from registry without destroying (already handling exit)
701
+ this.processRegistry.remove(entry.repo, entry.sessionId);
702
+ }
558
703
  // Don't clear session — allow roaming between clients.
559
- // Just kill the current process; next message will --resume the same session.
560
704
  proc.destroy();
561
- agent.processes.delete(userId);
562
705
  });
563
706
  proc.on('exit', () => {
564
- this.stopTypingIndicator(agent, userId, chatId);
565
- // Finalize any active accumulator
566
- const accKey = `${userId}:${chatId}`;
567
- const acc = agent.accumulators.get(accKey);
568
- if (acc) {
569
- acc.finalize();
570
- agent.accumulators.delete(accKey);
707
+ // Clean up all subscribers
708
+ const entry = getEntry();
709
+ if (entry) {
710
+ for (const sub of this.processRegistry.subscribers(entry)) {
711
+ const subAgent = this.agents.get(sub.client.agentId);
712
+ if (!subAgent)
713
+ continue;
714
+ this.stopTypingIndicator(subAgent, sub.client.userId, sub.client.chatId);
715
+ const accKey = `${sub.client.userId}:${sub.client.chatId}`;
716
+ const acc = subAgent.accumulators.get(accKey);
717
+ if (acc) {
718
+ acc.finalize();
719
+ subAgent.accumulators.delete(accKey);
720
+ }
721
+ const exitTracker = subAgent.subAgentTrackers.get(accKey);
722
+ if (exitTracker) {
723
+ exitTracker.stopMailboxWatch();
724
+ }
725
+ subAgent.subAgentTrackers.delete(accKey);
726
+ }
727
+ // Remove from registry (process already exited)
728
+ this.processRegistry.remove(entry.repo, entry.sessionId);
571
729
  }
572
- // Clean up sub-agent tracker (stops mailbox watch)
573
- const exitTracker = agent.subAgentTrackers.get(accKey);
574
- if (exitTracker) {
575
- exitTracker.stopMailboxWatch();
730
+ else {
731
+ // Fallback: clean up owner only
732
+ this.stopTypingIndicator(agent, userId, chatId);
733
+ const accKey = `${userId}:${chatId}`;
734
+ const acc = agent.accumulators.get(accKey);
735
+ if (acc) {
736
+ acc.finalize();
737
+ agent.accumulators.delete(accKey);
738
+ }
739
+ const exitTracker = agent.subAgentTrackers.get(accKey);
740
+ if (exitTracker) {
741
+ exitTracker.stopMailboxWatch();
742
+ }
743
+ agent.subAgentTrackers.delete(accKey);
576
744
  }
577
- agent.subAgentTrackers.delete(accKey);
578
745
  });
579
746
  proc.on('error', (err) => {
580
- this.stopTypingIndicator(agent, userId, chatId);
581
- agent.tgBot.sendText(chatId, `<blockquote>⚠️ ${escapeHtml(String(err.message))}</blockquote>`, 'HTML');
747
+ // Broadcast error to all subscribers
748
+ const entry = getEntry();
749
+ const subscriberList = entry ? [...this.processRegistry.subscribers(entry)] : [{ client: ownerRef }];
750
+ for (const sub of subscriberList) {
751
+ const subAgent = this.agents.get(sub.client.agentId);
752
+ if (!subAgent)
753
+ continue;
754
+ this.stopTypingIndicator(subAgent, sub.client.userId, sub.client.chatId);
755
+ subAgent.tgBot.sendText(sub.client.chatId, `<blockquote>⚠️ ${escapeHtml(String(err.message))}</blockquote>`, 'HTML')
756
+ .catch(err2 => this.logger.error({ err: err2 }, 'Failed to send process error notification'));
757
+ }
582
758
  });
583
759
  return proc;
584
760
  }
@@ -593,9 +769,14 @@ export class Bridge extends EventEmitter {
593
769
  const sender = {
594
770
  sendMessage: (cid, text, parseMode) => agent.tgBot.sendText(cid, text, parseMode),
595
771
  editMessage: (cid, msgId, text, parseMode) => agent.tgBot.editText(cid, msgId, text, parseMode),
772
+ deleteMessage: (cid, msgId) => agent.tgBot.deleteMessage(cid, msgId),
596
773
  sendPhoto: (cid, buffer, caption) => agent.tgBot.sendPhotoBuffer(cid, buffer, caption),
597
774
  };
598
- acc = new StreamAccumulator({ chatId, sender });
775
+ const onError = (err, context) => {
776
+ this.logger.error({ err, context, agentId, userId }, 'Stream accumulator error');
777
+ agent.tgBot.sendText(chatId, `<blockquote>⚠️ ${escapeHtml(context)}</blockquote>`, 'HTML').catch(() => { });
778
+ };
779
+ acc = new StreamAccumulator({ chatId, sender, logger: this.logger, onError });
599
780
  agent.accumulators.set(accKey, acc);
600
781
  }
601
782
  // Sub-agent tracker — create lazily alongside the accumulator
@@ -612,12 +793,9 @@ export class Bridge extends EventEmitter {
612
793
  });
613
794
  agent.subAgentTrackers.set(accKey, tracker);
614
795
  }
615
- // On message_start: always create new message on new CC turn for deterministic behavior
796
+ // On message_start: new CC turn full reset for text accumulator.
797
+ // Tool indicator messages are independent and persist across turns.
616
798
  if (event.type === 'message_start') {
617
- // Full reset: each assistant turn gets its own TG message.
618
- // This prevents the "rewriting the wrong bubble" problem where a multi-turn
619
- // session (tool calls → responses → more tool calls) kept overwriting a single
620
- // message positioned between user messages, losing all context.
621
799
  acc.reset();
622
800
  // Only reset tracker if no agents still dispatched
623
801
  if (!tracker.hasDispatchedAgents) {
@@ -650,14 +828,13 @@ export class Bridge extends EventEmitter {
650
828
  }
651
829
  // Update session store with cost
652
830
  if (event.total_cost_usd) {
653
- this.sessionStore.updateSessionActivity(agentId, userId, event.total_cost_usd);
654
831
  }
655
832
  // Update JSONL tracking after our own CC turn completes
656
833
  // This prevents false-positive staleness on our own writes
657
- this.updateJsonlTracking(agentId, userId);
658
834
  // Handle errors
659
835
  if (event.is_error && event.result) {
660
- agent.tgBot.sendText(chatId, `<blockquote>⚠️ ${escapeHtml(String(event.result))}</blockquote>`, 'HTML');
836
+ agent.tgBot.sendText(chatId, `<blockquote>⚠️ ${escapeHtml(String(event.result))}</blockquote>`, 'HTML')
837
+ .catch(err => this.logger.error({ err }, 'Failed to send result error notification'));
661
838
  }
662
839
  // If background sub-agents are still running, mailbox watcher handles them.
663
840
  // Ensure mailbox watch is started if we have a team name and dispatched agents.
@@ -700,7 +877,33 @@ export class Bridge extends EventEmitter {
700
877
  this.logger.debug({ agentId, command: cmd.command, args: cmd.args }, 'Slash command');
701
878
  switch (cmd.command) {
702
879
  case 'start': {
703
- 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.');
880
+ const userConf = resolveUserConfig(agent.config, cmd.userId);
881
+ const userState = this.sessionStore.getUser(agentId, cmd.userId);
882
+ const repo = userState.repo || userConf.repo;
883
+ const session = userState.currentSessionId;
884
+ // Model: running process > session JSONL > user default
885
+ let model = userConf.model;
886
+ if (session && repo) {
887
+ const registryEntry = this.processRegistry.get(repo, session);
888
+ if (registryEntry?.model) {
889
+ model = registryEntry.model;
890
+ }
891
+ else {
892
+ const sessions = discoverCCSessions(repo, 20);
893
+ const sessionInfo = sessions.find(s => s.id === session);
894
+ if (sessionInfo?.model)
895
+ model = sessionInfo.model;
896
+ }
897
+ }
898
+ const lines = ['👋 <b>TGCC</b> — Telegram ↔ Claude Code bridge'];
899
+ if (repo)
900
+ lines.push(`📂 <code>${escapeHtml(shortenRepoPath(repo))}</code>`);
901
+ if (model)
902
+ lines.push(`🤖 ${escapeHtml(model)}`);
903
+ if (session)
904
+ lines.push(`📎 Session: <code>${escapeHtml(session.slice(0, 8))}</code>`);
905
+ lines.push('', 'Send a message to start, or use /help for commands.');
906
+ await agent.tgBot.sendText(cmd.chatId, lines.join('\n'), 'HTML');
704
907
  // Re-register commands with BotFather to ensure menu is up to date
705
908
  try {
706
909
  const { COMMANDS } = await import('./telegram.js');
@@ -728,7 +931,7 @@ export class Bridge extends EventEmitter {
728
931
  `<b>Agent:</b> ${escapeHtml(agentId)}`,
729
932
  `<b>Process:</b> ${(proc?.state ?? 'idle').toUpperCase()} (uptime: ${uptime})`,
730
933
  `<b>Session:</b> <code>${escapeHtml(proc?.sessionId?.slice(0, 8) ?? 'none')}</code>`,
731
- `<b>Model:</b> ${escapeHtml(userState.model || resolveUserConfig(agent.config, cmd.userId).model)}`,
934
+ `<b>Model:</b> ${escapeHtml(resolveUserConfig(agent.config, cmd.userId).model)}`,
732
935
  `<b>Repo:</b> ${escapeHtml(userState.repo || resolveUserConfig(agent.config, cmd.userId).repo)}`,
733
936
  `<b>Cost:</b> $${(proc?.totalCostUsd ?? 0).toFixed(4)}`,
734
937
  ].join('\n');
@@ -741,78 +944,158 @@ export class Bridge extends EventEmitter {
741
944
  break;
742
945
  }
743
946
  case 'new': {
744
- const proc = agent.processes.get(cmd.userId);
745
- if (proc) {
746
- proc.destroy();
747
- agent.processes.delete(cmd.userId);
748
- }
947
+ this.disconnectClient(agentId, cmd.userId, cmd.chatId);
749
948
  this.sessionStore.clearSession(agentId, cmd.userId);
750
- await agent.tgBot.sendText(cmd.chatId, 'Session cleared. Next message starts fresh.');
949
+ const newConf = resolveUserConfig(agent.config, cmd.userId);
950
+ const newState = this.sessionStore.getUser(agentId, cmd.userId);
951
+ const newRepo = newState.repo || newConf.repo;
952
+ const newModel = newState.model || newConf.model;
953
+ const newLines = ['Session cleared. Next message starts fresh.'];
954
+ if (newRepo)
955
+ newLines.push(`📂 <code>${escapeHtml(shortenRepoPath(newRepo))}</code>`);
956
+ if (newModel)
957
+ newLines.push(`🤖 ${escapeHtml(newModel)}`);
958
+ await agent.tgBot.sendText(cmd.chatId, `<blockquote>${newLines.join('\n')}</blockquote>`, 'HTML');
751
959
  break;
752
960
  }
753
961
  case 'continue': {
754
- const proc = agent.processes.get(cmd.userId);
755
- if (proc) {
756
- proc.destroy();
757
- agent.processes.delete(cmd.userId);
962
+ this.disconnectClient(agentId, cmd.userId, cmd.chatId);
963
+ const contConf = resolveUserConfig(agent.config, cmd.userId);
964
+ const contState = this.sessionStore.getUser(agentId, cmd.userId);
965
+ const contRepo = contState.repo || contConf.repo;
966
+ let contSession = contState.currentSessionId;
967
+ // If no current session, auto-pick the most recent one
968
+ if (!contSession && contRepo) {
969
+ const recent = discoverCCSessions(contRepo, 1);
970
+ if (recent.length > 0) {
971
+ contSession = recent[0].id;
972
+ this.sessionStore.setCurrentSession(agentId, cmd.userId, contSession);
973
+ }
974
+ }
975
+ // Model priority: running process registry > session JSONL > user default
976
+ let contModel = contConf.model;
977
+ if (contSession && contRepo) {
978
+ // Check if a process is already running for this session
979
+ const registryEntry = this.processRegistry.get(contRepo, contSession);
980
+ if (registryEntry?.model) {
981
+ contModel = registryEntry.model;
982
+ }
983
+ else {
984
+ const sessions = discoverCCSessions(contRepo, 20);
985
+ const sessionInfo = sessions.find(s => s.id === contSession);
986
+ if (sessionInfo?.model)
987
+ contModel = sessionInfo.model;
988
+ }
758
989
  }
759
- await agent.tgBot.sendText(cmd.chatId, 'Process respawned. Session kept — next message continues where you left off.');
990
+ const contLines = ['Process respawned. Session kept.'];
991
+ if (contRepo)
992
+ contLines.push(`📂 <code>${escapeHtml(shortenRepoPath(contRepo))}</code>`);
993
+ if (contModel)
994
+ contLines.push(`🤖 ${escapeHtml(contModel)}`);
995
+ if (contSession)
996
+ contLines.push(`📎 <code>${escapeHtml(contSession.slice(0, 8))}</code>`);
997
+ await agent.tgBot.sendText(cmd.chatId, `<blockquote>${contLines.join('\n')}</blockquote>`, 'HTML');
760
998
  break;
761
999
  }
762
1000
  case 'sessions': {
763
- const sessions = this.sessionStore.getRecentSessions(agentId, cmd.userId);
764
- if (sessions.length === 0) {
765
- await agent.tgBot.sendText(cmd.chatId, 'No recent sessions.');
1001
+ const userConf = resolveUserConfig(agent.config, cmd.userId);
1002
+ const userState = this.sessionStore.getUser(agentId, cmd.userId);
1003
+ const repo = userState.repo || userConf.repo;
1004
+ const currentSessionId = userState.currentSessionId;
1005
+ // Discover sessions from CC's session directory
1006
+ const discovered = repo ? discoverCCSessions(repo, 5) : [];
1007
+ const merged = discovered.map(d => {
1008
+ const ctx = d.contextPct !== null ? ` · ${d.contextPct}% ctx` : '';
1009
+ const modelTag = d.model ? ` · ${shortModel(d.model)}` : '';
1010
+ return {
1011
+ id: d.id,
1012
+ title: d.title,
1013
+ age: formatAge(d.mtime),
1014
+ detail: `~${d.lineCount} entries${ctx}${modelTag}`,
1015
+ isCurrent: d.id === currentSessionId,
1016
+ };
1017
+ });
1018
+ if (merged.length === 0) {
1019
+ await agent.tgBot.sendText(cmd.chatId, '<blockquote>No sessions found.</blockquote>', 'HTML');
766
1020
  break;
767
1021
  }
768
- const currentSessionId = this.sessionStore.getUser(agentId, cmd.userId).currentSessionId;
769
- const lines = [];
770
- const keyboard = new InlineKeyboard();
771
- sessions.forEach((s, i) => {
772
- const rawTitle = s.title || s.id.slice(0, 8);
773
- const displayTitle = s.title ? escapeHtml(s.title) : `<code>${escapeHtml(s.id.slice(0, 8))}</code>`;
774
- const age = formatAge(new Date(s.lastActivity));
775
- const isCurrent = s.id === currentSessionId;
776
- const current = isCurrent ? ' ✓' : '';
777
- lines.push(`<b>${i + 1}.</b> ${displayTitle}${current}\n ${s.messageCount} msgs · $${s.totalCostUsd.toFixed(2)} · ${age}`);
778
- // Button: full title (TG allows up to ~40 chars visible), no ellipsis
779
- if (!isCurrent) {
780
- const btnLabel = rawTitle.length > 35 ? rawTitle.slice(0, 35) : rawTitle;
781
- keyboard.text(`${i + 1}. ${btnLabel}`, `resume:${s.id}`).row();
782
- }
1022
+ // Sort: current session first, then by recency
1023
+ merged.sort((a, b) => {
1024
+ if (a.isCurrent && !b.isCurrent)
1025
+ return -1;
1026
+ if (!a.isCurrent && b.isCurrent)
1027
+ return 1;
1028
+ return 0; // already sorted by mtime from discovery
783
1029
  });
784
- await agent.tgBot.sendTextWithKeyboard(cmd.chatId, `📋 <b>Sessions</b>\n\n${lines.join('\n\n')}`, keyboard, 'HTML');
1030
+ // One message per session, each with its own resume button
1031
+ for (const s of merged.slice(0, 5)) {
1032
+ const displayTitle = escapeHtml(s.title);
1033
+ const kb = new InlineKeyboard();
1034
+ if (s.isCurrent) {
1035
+ const repoLine = repo ? `\n📂 <code>${escapeHtml(shortenRepoPath(repo))}</code>` : '';
1036
+ const sessModel = userConf.model;
1037
+ const modelLine = sessModel ? `\n🤖 ${escapeHtml(sessModel)}` : '';
1038
+ const sessionLine = `\n📎 <code>${escapeHtml(s.id.slice(0, 8))}</code>`;
1039
+ const text = `<blockquote><b>Current session:</b>\n${displayTitle}\n${s.detail} · ${s.age}${repoLine}${modelLine}${sessionLine}</blockquote>`;
1040
+ await agent.tgBot.sendText(cmd.chatId, text, 'HTML');
1041
+ }
1042
+ else {
1043
+ const text = `${displayTitle}\n<code>${escapeHtml(s.id.slice(0, 8))}</code> · ${s.detail} · ${s.age}`;
1044
+ const btnTitle = s.title.length > 30 ? s.title.slice(0, 30) + '…' : s.title;
1045
+ kb.text(`▶ ${btnTitle}`, `resume:${s.id}`);
1046
+ await agent.tgBot.sendTextWithKeyboard(cmd.chatId, text, kb, 'HTML');
1047
+ }
1048
+ }
785
1049
  break;
786
1050
  }
787
1051
  case 'resume': {
788
1052
  if (!cmd.args) {
789
- await agent.tgBot.sendText(cmd.chatId, 'Usage: /resume <session-id>');
1053
+ await agent.tgBot.sendText(cmd.chatId, '<blockquote>Usage: /resume &lt;session-id&gt;</blockquote>', 'HTML');
790
1054
  break;
791
1055
  }
792
- const proc = agent.processes.get(cmd.userId);
793
- if (proc) {
794
- proc.destroy();
795
- agent.processes.delete(cmd.userId);
796
- }
1056
+ this.disconnectClient(agentId, cmd.userId, cmd.chatId);
797
1057
  this.sessionStore.setCurrentSession(agentId, cmd.userId, cmd.args.trim());
798
1058
  await agent.tgBot.sendText(cmd.chatId, `Will resume session <code>${escapeHtml(cmd.args.trim().slice(0, 8))}</code> on next message.`, 'HTML');
799
1059
  break;
800
1060
  }
801
1061
  case 'session': {
802
1062
  const userState = this.sessionStore.getUser(agentId, cmd.userId);
803
- const session = userState.sessions.find(s => s.id === userState.currentSessionId);
804
- if (!session) {
805
- await agent.tgBot.sendText(cmd.chatId, 'No active session.');
1063
+ if (!userState.currentSessionId) {
1064
+ await agent.tgBot.sendText(cmd.chatId, '<blockquote>No active session.</blockquote>', 'HTML');
806
1065
  break;
807
1066
  }
808
- 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');
1067
+ const sessRepo = userState.repo || resolveUserConfig(agent.config, cmd.userId).repo;
1068
+ const discovered = sessRepo ? discoverCCSessions(sessRepo, 20) : [];
1069
+ const info = discovered.find(d => d.id === userState.currentSessionId);
1070
+ if (!info) {
1071
+ await agent.tgBot.sendText(cmd.chatId, `<b>Session:</b> <code>${escapeHtml(userState.currentSessionId.slice(0, 8))}</code>`, 'HTML');
1072
+ break;
1073
+ }
1074
+ const ctxLine = info.contextPct !== null ? `\n<b>Context:</b> ${info.contextPct}%` : '';
1075
+ const modelLine = info.model ? `\n<b>Model:</b> ${escapeHtml(info.model)}` : '';
1076
+ await agent.tgBot.sendText(cmd.chatId, `<b>Session:</b> <code>${escapeHtml(info.id.slice(0, 8))}</code>\n<b>Title:</b> ${escapeHtml(info.title)}${modelLine}${ctxLine}\n<b>Age:</b> ${formatAge(info.mtime)}`, 'HTML');
809
1077
  break;
810
1078
  }
811
1079
  case 'model': {
812
1080
  const MODEL_OPTIONS = ['opus', 'sonnet', 'haiku'];
813
1081
  if (!cmd.args) {
814
- const current = this.sessionStore.getUser(agentId, cmd.userId).model
815
- || resolveUserConfig(agent.config, cmd.userId).model;
1082
+ // Show model from: running process > JSONL > agent config
1083
+ const uState = this.sessionStore.getUser(agentId, cmd.userId);
1084
+ const uConf = resolveUserConfig(agent.config, cmd.userId);
1085
+ const uRepo = uState.repo || uConf.repo;
1086
+ const uSession = uState.currentSessionId;
1087
+ let current = uConf.model || 'default';
1088
+ if (uSession && uRepo) {
1089
+ const re = this.processRegistry.get(uRepo, uSession);
1090
+ if (re?.model)
1091
+ current = re.model;
1092
+ else {
1093
+ const ds = discoverCCSessions(uRepo, 20);
1094
+ const si = ds.find(s => s.id === uSession);
1095
+ if (si?.model)
1096
+ current = si.model;
1097
+ }
1098
+ }
816
1099
  const keyboard = new InlineKeyboard();
817
1100
  for (const m of MODEL_OPTIONS) {
818
1101
  const isCurrent = current.includes(m);
@@ -822,13 +1105,12 @@ export class Bridge extends EventEmitter {
822
1105
  await agent.tgBot.sendTextWithKeyboard(cmd.chatId, `<b>Current model:</b> <code>${escapeHtml(current)}</code>`, keyboard, 'HTML');
823
1106
  break;
824
1107
  }
825
- this.sessionStore.setModel(agentId, cmd.userId, cmd.args.trim());
826
- const proc = agent.processes.get(cmd.userId);
827
- if (proc) {
828
- proc.destroy();
829
- agent.processes.delete(cmd.userId);
830
- }
831
- await agent.tgBot.sendText(cmd.chatId, `Model set to <code>${escapeHtml(cmd.args.trim())}</code>. Process respawned.`, 'HTML');
1108
+ const newModel = cmd.args.trim();
1109
+ // Store as session-level override (not user default)
1110
+ const curSession = this.sessionStore.getUser(agentId, cmd.userId).currentSessionId;
1111
+ this.sessionModelOverrides.set(`${agentId}:${cmd.userId}`, newModel);
1112
+ this.disconnectClient(agentId, cmd.userId, cmd.chatId);
1113
+ await agent.tgBot.sendText(cmd.chatId, `<blockquote>Model set to <code>${escapeHtml(newModel)}</code>. Process respawned.</blockquote>`, 'HTML');
832
1114
  break;
833
1115
  }
834
1116
  case 'repo': {
@@ -840,11 +1122,11 @@ export class Bridge extends EventEmitter {
840
1122
  const repoName = repoArgs[1];
841
1123
  const repoAddPath = repoArgs[2];
842
1124
  if (!repoName || !repoAddPath) {
843
- await agent.tgBot.sendText(cmd.chatId, 'Usage: /repo add <name> <path>');
1125
+ await agent.tgBot.sendText(cmd.chatId, '<blockquote>Usage: /repo add &lt;name&gt; &lt;path&gt;</blockquote>', 'HTML');
844
1126
  break;
845
1127
  }
846
1128
  if (!isValidRepoName(repoName)) {
847
- await agent.tgBot.sendText(cmd.chatId, 'Invalid repo name. Use alphanumeric + hyphens only.');
1129
+ await agent.tgBot.sendText(cmd.chatId, '<blockquote>Invalid repo name. Use alphanumeric + hyphens only.</blockquote>', 'HTML');
848
1130
  break;
849
1131
  }
850
1132
  if (!existsSync(repoAddPath)) {
@@ -867,7 +1149,7 @@ export class Bridge extends EventEmitter {
867
1149
  // /repo remove <name>
868
1150
  const repoName = repoArgs[1];
869
1151
  if (!repoName) {
870
- await agent.tgBot.sendText(cmd.chatId, 'Usage: /repo remove <name>');
1152
+ await agent.tgBot.sendText(cmd.chatId, '<blockquote>Usage: /repo remove &lt;name&gt;</blockquote>', 'HTML');
871
1153
  break;
872
1154
  }
873
1155
  if (!this.config.repos[repoName]) {
@@ -893,7 +1175,7 @@ export class Bridge extends EventEmitter {
893
1175
  // /repo assign <name> — assign to THIS agent
894
1176
  const repoName = repoArgs[1];
895
1177
  if (!repoName) {
896
- await agent.tgBot.sendText(cmd.chatId, 'Usage: /repo assign <name>');
1178
+ await agent.tgBot.sendText(cmd.chatId, '<blockquote>Usage: /repo assign &lt;name&gt;</blockquote>', 'HTML');
897
1179
  break;
898
1180
  }
899
1181
  if (!this.config.repos[repoName]) {
@@ -971,42 +1253,27 @@ export class Bridge extends EventEmitter {
971
1253
  break;
972
1254
  }
973
1255
  // Kill current process (different CWD needs new process)
974
- const proc = agent.processes.get(cmd.userId);
975
- if (proc) {
976
- proc.destroy();
977
- agent.processes.delete(cmd.userId);
978
- }
1256
+ this.disconnectClient(agentId, cmd.userId, cmd.chatId);
979
1257
  this.sessionStore.setRepo(agentId, cmd.userId, repoPath);
980
- const userState = this.sessionStore.getUser(agentId, cmd.userId);
981
- const lastActive = new Date(userState.lastActivity).getTime();
982
- const staleMs = 24 * 60 * 60 * 1000;
983
- if (Date.now() - lastActive > staleMs || !userState.currentSessionId) {
984
- this.sessionStore.clearSession(agentId, cmd.userId);
985
- await agent.tgBot.sendText(cmd.chatId, `Repo set to <code>${escapeHtml(repoPath)}</code>. Session cleared (stale).`, 'HTML');
986
- }
987
- else {
988
- await agent.tgBot.sendText(cmd.chatId, `Repo set to <code>${escapeHtml(repoPath)}</code>. Session kept (active &lt;24h).`, 'HTML');
989
- }
1258
+ // Always clear session when repo changes — sessions are project-specific
1259
+ this.sessionStore.clearSession(agentId, cmd.userId);
1260
+ await agent.tgBot.sendText(cmd.chatId, `<blockquote>Repo set to <code>${escapeHtml(shortenRepoPath(repoPath))}</code>. Session cleared.</blockquote>`, 'HTML');
990
1261
  break;
991
1262
  }
992
1263
  case 'cancel': {
993
1264
  const proc = agent.processes.get(cmd.userId);
994
1265
  if (proc && proc.state === 'active') {
995
1266
  proc.cancel();
996
- await agent.tgBot.sendText(cmd.chatId, 'Cancelled.');
1267
+ await agent.tgBot.sendText(cmd.chatId, '<blockquote>Cancelled.</blockquote>', 'HTML');
997
1268
  }
998
1269
  else {
999
- await agent.tgBot.sendText(cmd.chatId, 'No active turn to cancel.');
1270
+ await agent.tgBot.sendText(cmd.chatId, '<blockquote>No active turn to cancel.</blockquote>', 'HTML');
1000
1271
  }
1001
1272
  break;
1002
1273
  }
1003
1274
  case 'catchup': {
1004
- const userState = this.sessionStore.getUser(agentId, cmd.userId);
1005
- const repo = userState.repo || resolveUserConfig(agent.config, cmd.userId).repo;
1006
- const lastActivity = new Date(userState.lastActivity || 0);
1007
- const missed = findMissedSessions(repo, userState.knownSessionIds, lastActivity);
1008
- const message = formatCatchupMessage(repo, missed);
1009
- await agent.tgBot.sendText(cmd.chatId, message, 'HTML');
1275
+ // Catchup now just shows recent sessions via /sessions
1276
+ await agent.tgBot.sendText(cmd.chatId, '<blockquote>Use /sessions to see recent sessions.</blockquote>', 'HTML');
1010
1277
  break;
1011
1278
  }
1012
1279
  case 'permissions': {
@@ -1017,16 +1284,12 @@ export class Bridge extends EventEmitter {
1017
1284
  if (cmd.args) {
1018
1285
  const mode = cmd.args.trim();
1019
1286
  if (!validModes.includes(mode)) {
1020
- await agent.tgBot.sendText(cmd.chatId, `Invalid mode. Valid: ${validModes.join(', ')}`);
1287
+ await agent.tgBot.sendText(cmd.chatId, `<blockquote>Invalid mode. Valid: ${validModes.join(', ')}</blockquote>`, 'HTML');
1021
1288
  break;
1022
1289
  }
1023
1290
  this.sessionStore.setPermissionMode(agentId, cmd.userId, mode);
1024
1291
  // Kill current process so new mode takes effect on next spawn
1025
- const proc = agent.processes.get(cmd.userId);
1026
- if (proc) {
1027
- proc.destroy();
1028
- agent.processes.delete(cmd.userId);
1029
- }
1292
+ this.disconnectClient(agentId, cmd.userId, cmd.chatId);
1030
1293
  await agent.tgBot.sendText(cmd.chatId, `Permission mode set to <code>${escapeHtml(mode)}</code>. Takes effect on next message.`, 'HTML');
1031
1294
  break;
1032
1295
  }
@@ -1048,11 +1311,7 @@ export class Bridge extends EventEmitter {
1048
1311
  switch (query.action) {
1049
1312
  case 'resume': {
1050
1313
  const sessionId = query.data;
1051
- const proc = agent.processes.get(query.userId);
1052
- if (proc) {
1053
- proc.destroy();
1054
- agent.processes.delete(query.userId);
1055
- }
1314
+ this.disconnectClient(agentId, query.userId, query.chatId);
1056
1315
  this.sessionStore.setCurrentSession(agentId, query.userId, sessionId);
1057
1316
  await agent.tgBot.answerCallbackQuery(query.callbackQueryId, 'Session set');
1058
1317
  await agent.tgBot.sendText(query.chatId, `Will resume session <code>${escapeHtml(sessionId.slice(0, 8))}</code> on next message.`, 'HTML');
@@ -1060,14 +1319,13 @@ export class Bridge extends EventEmitter {
1060
1319
  }
1061
1320
  case 'delete': {
1062
1321
  const sessionId = query.data;
1063
- const deleted = this.sessionStore.deleteSession(agentId, query.userId, sessionId);
1064
- if (deleted) {
1065
- await agent.tgBot.answerCallbackQuery(query.callbackQueryId, 'Session deleted');
1066
- await agent.tgBot.sendText(query.chatId, `Session <code>${escapeHtml(sessionId.slice(0, 8))}</code> deleted.`, 'HTML');
1067
- }
1068
- else {
1069
- await agent.tgBot.answerCallbackQuery(query.callbackQueryId, 'Session not found');
1322
+ // Clear current session if it matches
1323
+ const uState = this.sessionStore.getUser(agentId, query.userId);
1324
+ if (uState.currentSessionId === sessionId) {
1325
+ this.sessionStore.setCurrentSession(agentId, query.userId, '');
1070
1326
  }
1327
+ await agent.tgBot.answerCallbackQuery(query.callbackQueryId, 'Session cleared');
1328
+ await agent.tgBot.sendText(query.chatId, `Session <code>${escapeHtml(sessionId.slice(0, 8))}</code> cleared.`, 'HTML');
1071
1329
  break;
1072
1330
  }
1073
1331
  case 'repo': {
@@ -1078,23 +1336,12 @@ export class Bridge extends EventEmitter {
1078
1336
  break;
1079
1337
  }
1080
1338
  // Kill current process (different CWD needs new process)
1081
- const proc = agent.processes.get(query.userId);
1082
- if (proc) {
1083
- proc.destroy();
1084
- agent.processes.delete(query.userId);
1085
- }
1339
+ this.disconnectClient(agentId, query.userId, query.chatId);
1086
1340
  this.sessionStore.setRepo(agentId, query.userId, repoPath);
1087
- const userState2 = this.sessionStore.getUser(agentId, query.userId);
1088
- const lastActive2 = new Date(userState2.lastActivity).getTime();
1089
- const staleMs2 = 24 * 60 * 60 * 1000;
1341
+ // Always clear session when repo changes — sessions are project-specific
1342
+ this.sessionStore.clearSession(agentId, query.userId);
1090
1343
  await agent.tgBot.answerCallbackQuery(query.callbackQueryId, `Repo: ${repoName}`);
1091
- if (Date.now() - lastActive2 > staleMs2 || !userState2.currentSessionId) {
1092
- this.sessionStore.clearSession(agentId, query.userId);
1093
- await agent.tgBot.sendText(query.chatId, `Repo set to <code>${escapeHtml(repoPath)}</code>. Session cleared (stale).`, 'HTML');
1094
- }
1095
- else {
1096
- await agent.tgBot.sendText(query.chatId, `Repo set to <code>${escapeHtml(repoPath)}</code>. Session kept (active &lt;24h).`, 'HTML');
1097
- }
1344
+ await agent.tgBot.sendText(query.chatId, `<blockquote>Repo set to <code>${escapeHtml(shortenRepoPath(repoPath))}</code>. Session cleared.</blockquote>`, 'HTML');
1098
1345
  break;
1099
1346
  }
1100
1347
  case 'repo_add': {
@@ -1109,14 +1356,10 @@ export class Bridge extends EventEmitter {
1109
1356
  await agent.tgBot.sendText(query.chatId, 'Send: <code>/model &lt;model-name&gt;</code>', 'HTML');
1110
1357
  break;
1111
1358
  }
1112
- this.sessionStore.setModel(agentId, query.userId, model);
1113
- const proc = agent.processes.get(query.userId);
1114
- if (proc) {
1115
- proc.destroy();
1116
- agent.processes.delete(query.userId);
1117
- }
1359
+ this.sessionModelOverrides.set(`${agentId}:${query.userId}`, model);
1360
+ this.disconnectClient(agentId, query.userId, query.chatId);
1118
1361
  await agent.tgBot.answerCallbackQuery(query.callbackQueryId, `Model: ${model}`);
1119
- await agent.tgBot.sendText(query.chatId, `Model set to <code>${escapeHtml(model)}</code>. Process respawned.`, 'HTML');
1362
+ await agent.tgBot.sendText(query.chatId, `<blockquote>Model set to <code>${escapeHtml(model)}</code>. Process respawned.</blockquote>`, 'HTML');
1120
1363
  break;
1121
1364
  }
1122
1365
  case 'permissions': {
@@ -1128,11 +1371,7 @@ export class Bridge extends EventEmitter {
1128
1371
  }
1129
1372
  this.sessionStore.setPermissionMode(agentId, query.userId, mode);
1130
1373
  // Kill current process so new mode takes effect on next spawn
1131
- const proc = agent.processes.get(query.userId);
1132
- if (proc) {
1133
- proc.destroy();
1134
- agent.processes.delete(query.userId);
1135
- }
1374
+ this.disconnectClient(agentId, query.userId, query.chatId);
1136
1375
  await agent.tgBot.answerCallbackQuery(query.callbackQueryId, `Mode: ${mode}`);
1137
1376
  await agent.tgBot.sendText(query.chatId, `Permission mode set to <code>${escapeHtml(mode)}</code>. Takes effect on next message.`, 'HTML');
1138
1377
  break;
@@ -1255,15 +1494,18 @@ export class Bridge extends EventEmitter {
1255
1494
  sessionId: proc?.sessionId ?? null,
1256
1495
  repo: this.sessionStore.getUser(id, userId).repo || userConfig.repo,
1257
1496
  });
1258
- // List sessions for this agent
1497
+ // List sessions for this agent from CC's session directory
1259
1498
  const userState = this.sessionStore.getUser(id, userId);
1260
- for (const sess of userState.sessions.slice(-5).reverse()) {
1261
- sessions.push({
1262
- id: sess.id,
1263
- agentId: id,
1264
- messageCount: sess.messageCount,
1265
- totalCostUsd: sess.totalCostUsd,
1266
- });
1499
+ const sessRepo = userState.repo || userConfig.repo;
1500
+ if (sessRepo) {
1501
+ for (const d of discoverCCSessions(sessRepo, 5)) {
1502
+ sessions.push({
1503
+ id: d.id,
1504
+ agentId: id,
1505
+ messageCount: d.lineCount,
1506
+ totalCostUsd: 0,
1507
+ });
1508
+ }
1267
1509
  }
1268
1510
  }
1269
1511
  return { type: 'status', agents, sessions };
@@ -1302,6 +1544,7 @@ export class Bridge extends EventEmitter {
1302
1544
  for (const agentId of [...this.agents.keys()]) {
1303
1545
  await this.stopAgent(agentId);
1304
1546
  }
1547
+ this.processRegistry.clear();
1305
1548
  this.mcpServer.closeAll();
1306
1549
  this.ctlServer.closeAll();
1307
1550
  this.removeAllListeners();
@@ -1309,6 +1552,21 @@ export class Bridge extends EventEmitter {
1309
1552
  }
1310
1553
  }
1311
1554
  // ── Helpers ──
1555
+ function shortModel(m) {
1556
+ if (m.includes('opus'))
1557
+ return 'opus';
1558
+ if (m.includes('sonnet'))
1559
+ return 'sonnet';
1560
+ if (m.includes('haiku'))
1561
+ return 'haiku';
1562
+ return m.length > 15 ? m.slice(0, 15) + '…' : m;
1563
+ }
1564
+ function shortenRepoPath(p) {
1565
+ return p
1566
+ .replace(/^\/home\/[^/]+\/Botverse\//, '')
1567
+ .replace(/^\/home\/[^/]+\/Projects\//, '')
1568
+ .replace(/^\/home\/[^/]+\//, '~/');
1569
+ }
1312
1570
  function formatDuration(ms) {
1313
1571
  const secs = Math.floor(ms / 1000);
1314
1572
  if (secs < 60)