@fonz/tgcc 0.6.14 → 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') {
@@ -304,9 +317,25 @@ export class Bridge extends EventEmitter {
304
317
  if (resolvedRepo === homedir()) {
305
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'));
306
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
+ }
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,106 +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
- // Resolve tool indicator message with success/failure status
431
- const acc2 = agent.accumulators.get(accKey);
432
- if (acc2 && event.tool_use_id) {
433
- const isError = event.is_error === true;
434
- const contentStr = typeof event.content === 'string' ? event.content : JSON.stringify(event.content);
435
- const errorMsg = isError ? contentStr : undefined;
436
- acc2.resolveToolMessage(event.tool_use_id, isError, errorMsg, contentStr);
437
- }
438
- const tracker = agent.subAgentTrackers.get(accKey);
439
- if (!tracker)
479
+ const entry = getEntry();
480
+ if (!entry) {
481
+ // Fallback: single subscriber mode (shouldn't happen)
482
+ this.handleStreamEvent(agentId, userId, chatId, event);
440
483
  return;
441
- const resultText = typeof event.content === 'string' ? event.content : JSON.stringify(event.content);
442
- const meta = event.tool_use_result;
443
- // Log warning if structured metadata is missing
444
- if (!meta && /agent_id:\s*\S+@\S+/.test(resultText)) {
445
- this.logger.warn({ agentId, toolUseId: event.tool_use_id }, 'Spawn detected in text but no structured tool_use_result metadata - skipping');
446
484
  }
447
- const spawnMeta = meta?.status === 'teammate_spawned' ? meta : undefined;
448
- if (spawnMeta?.status === 'teammate_spawned' && spawnMeta.team_name) {
449
- if (!tracker.currentTeamName) {
450
- this.logger.info({ agentId, teamName: spawnMeta.team_name, agentName: spawnMeta.name, agentType: spawnMeta.agent_type }, 'Spawn detected');
451
- tracker.setTeamName(spawnMeta.team_name);
452
- // Wire the "all agents reported" callback to send follow-up to CC
453
- tracker.setOnAllReported(() => {
454
- const ccProc = agent.processes.get(userId);
455
- if (ccProc && ccProc.state === 'active') {
456
- 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.'));
457
- }
458
- });
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);
459
504
  }
460
- // Set agent metadata from structured data or text fallback
461
- if (event.tool_use_id && spawnMeta.name) {
462
- tracker.setAgentMetadata(event.tool_use_id, {
463
- agentName: spawnMeta.name,
464
- agentType: spawnMeta.agent_type,
465
- color: spawnMeta.color,
466
- });
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();
467
542
  }
468
- }
469
- // Handle tool result (sets status, edits TG message)
470
- if (event.tool_use_id) {
471
- tracker.handleToolResult(event.tool_use_id, resultText);
472
- }
473
- // Start mailbox watch AFTER handleToolResult has set agent names
474
- if (tracker.currentTeamName && tracker.hasDispatchedAgents && !tracker.isMailboxWatching) {
475
- tracker.startMailboxWatch();
476
543
  }
477
544
  });
478
- // System events for background task tracking
545
+ // System events for background task tracking — broadcast to all subscribers
479
546
  proc.on('task_started', (event) => {
480
- const accKey = `${userId}:${chatId}`;
481
- const tracker = agent.subAgentTrackers.get(accKey);
482
- if (tracker) {
483
- 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
+ }
484
558
  }
485
559
  });
486
560
  proc.on('task_progress', (event) => {
487
- const accKey = `${userId}:${chatId}`;
488
- const tracker = agent.subAgentTrackers.get(accKey);
489
- if (tracker) {
490
- 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
+ }
491
572
  }
492
573
  });
493
574
  proc.on('task_completed', (event) => {
494
- const accKey = `${userId}:${chatId}`;
495
- const tracker = agent.subAgentTrackers.get(accKey);
496
- if (tracker) {
497
- 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
+ }
498
586
  }
499
587
  });
500
- // Media from tool results (images, PDFs, etc.)
588
+ // Media from tool results (images, PDFs, etc.) — broadcast to all subscribers
501
589
  proc.on('media', (media) => {
502
590
  const buf = Buffer.from(media.data, 'base64');
503
- if (media.kind === 'image') {
504
- agent.tgBot.sendPhotoBuffer(chatId, buf).catch(err => {
505
- this.logger.error({ err, agentId, userId }, 'Failed to send tool_result image');
506
- });
507
- }
508
- else if (media.kind === 'document') {
509
- const ext = media.media_type === 'application/pdf' ? '.pdf' : '';
510
- agent.tgBot.sendDocumentBuffer(chatId, buf, `document${ext}`).catch(err => {
511
- this.logger.error({ err, agentId, userId }, 'Failed to send tool_result document');
512
- });
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
+ }
513
608
  }
514
609
  });
515
610
  proc.on('assistant', (event) => {
@@ -517,13 +612,24 @@ export class Bridge extends EventEmitter {
517
612
  // In practice, stream_events handle the display
518
613
  });
519
614
  proc.on('result', (event) => {
520
- this.stopTypingIndicator(agent, userId, chatId);
521
- 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
+ }
522
627
  });
523
628
  proc.on('permission_request', (event) => {
629
+ // Send permission request only to the owner (first subscriber)
524
630
  const req = event.request;
525
631
  const requestId = event.request_id;
526
- // Store pending permission
632
+ // Store pending permission on the owner's agent
527
633
  agent.pendingPermissions.set(requestId, {
528
634
  requestId,
529
635
  userId,
@@ -555,42 +661,100 @@ export class Bridge extends EventEmitter {
555
661
  const text = isOverloaded
556
662
  ? `<blockquote>⚠️ API overloaded, retrying...${retryInfo}</blockquote>`
557
663
  : `<blockquote>⚠️ ${escapeHtml(errMsg)}${retryInfo}</blockquote>`;
558
- agent.tgBot.sendText(chatId, text, 'HTML')
559
- .catch(err => this.logger.error({ err }, 'Failed to send API error notification'));
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
+ }
560
674
  });
561
675
  proc.on('hang', () => {
562
- this.stopTypingIndicator(agent, userId, chatId);
563
- agent.tgBot.sendText(chatId, '<blockquote>⏸ Session paused. Send a message to continue.</blockquote>', 'HTML')
564
- .catch(err => this.logger.error({ err }, 'Failed to send hang notification'));
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
+ }
565
687
  });
566
688
  proc.on('takeover', () => {
567
- this.stopTypingIndicator(agent, userId, chatId);
568
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
+ }
569
703
  // Don't clear session — allow roaming between clients.
570
- // Just kill the current process; next message will --resume the same session.
571
704
  proc.destroy();
572
- agent.processes.delete(userId);
573
705
  });
574
706
  proc.on('exit', () => {
575
- this.stopTypingIndicator(agent, userId, chatId);
576
- // Finalize any active accumulator
577
- const accKey = `${userId}:${chatId}`;
578
- const acc = agent.accumulators.get(accKey);
579
- if (acc) {
580
- acc.finalize();
581
- 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);
582
729
  }
583
- // Clean up sub-agent tracker (stops mailbox watch)
584
- const exitTracker = agent.subAgentTrackers.get(accKey);
585
- if (exitTracker) {
586
- 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);
587
744
  }
588
- agent.subAgentTrackers.delete(accKey);
589
745
  });
590
746
  proc.on('error', (err) => {
591
- this.stopTypingIndicator(agent, userId, chatId);
592
- agent.tgBot.sendText(chatId, `<blockquote>⚠️ ${escapeHtml(String(err.message))}</blockquote>`, 'HTML')
593
- .catch(err2 => this.logger.error({ err: err2 }, 'Failed to send process error notification'));
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
+ }
594
758
  });
595
759
  return proc;
596
760
  }
@@ -664,11 +828,9 @@ export class Bridge extends EventEmitter {
664
828
  }
665
829
  // Update session store with cost
666
830
  if (event.total_cost_usd) {
667
- this.sessionStore.updateSessionActivity(agentId, userId, event.total_cost_usd);
668
831
  }
669
832
  // Update JSONL tracking after our own CC turn completes
670
833
  // This prevents false-positive staleness on our own writes
671
- this.updateJsonlTracking(agentId, userId);
672
834
  // Handle errors
673
835
  if (event.is_error && event.result) {
674
836
  agent.tgBot.sendText(chatId, `<blockquote>⚠️ ${escapeHtml(String(event.result))}</blockquote>`, 'HTML')
@@ -715,7 +877,33 @@ export class Bridge extends EventEmitter {
715
877
  this.logger.debug({ agentId, command: cmd.command, args: cmd.args }, 'Slash command');
716
878
  switch (cmd.command) {
717
879
  case 'start': {
718
- 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');
719
907
  // Re-register commands with BotFather to ensure menu is up to date
720
908
  try {
721
909
  const { COMMANDS } = await import('./telegram.js');
@@ -743,7 +931,7 @@ export class Bridge extends EventEmitter {
743
931
  `<b>Agent:</b> ${escapeHtml(agentId)}`,
744
932
  `<b>Process:</b> ${(proc?.state ?? 'idle').toUpperCase()} (uptime: ${uptime})`,
745
933
  `<b>Session:</b> <code>${escapeHtml(proc?.sessionId?.slice(0, 8) ?? 'none')}</code>`,
746
- `<b>Model:</b> ${escapeHtml(userState.model || resolveUserConfig(agent.config, cmd.userId).model)}`,
934
+ `<b>Model:</b> ${escapeHtml(resolveUserConfig(agent.config, cmd.userId).model)}`,
747
935
  `<b>Repo:</b> ${escapeHtml(userState.repo || resolveUserConfig(agent.config, cmd.userId).repo)}`,
748
936
  `<b>Cost:</b> $${(proc?.totalCostUsd ?? 0).toFixed(4)}`,
749
937
  ].join('\n');
@@ -756,47 +944,108 @@ export class Bridge extends EventEmitter {
756
944
  break;
757
945
  }
758
946
  case 'new': {
759
- const proc = agent.processes.get(cmd.userId);
760
- if (proc) {
761
- proc.destroy();
762
- agent.processes.delete(cmd.userId);
763
- }
947
+ this.disconnectClient(agentId, cmd.userId, cmd.chatId);
764
948
  this.sessionStore.clearSession(agentId, cmd.userId);
765
- await agent.tgBot.sendText(cmd.chatId, '<blockquote>Session cleared. Next message starts fresh.</blockquote>', 'HTML');
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');
766
959
  break;
767
960
  }
768
961
  case 'continue': {
769
- const proc = agent.processes.get(cmd.userId);
770
- if (proc) {
771
- proc.destroy();
772
- 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
+ }
773
989
  }
774
- await agent.tgBot.sendText(cmd.chatId, '<blockquote>Process respawned. Session kept — next message continues where you left off.</blockquote>', 'HTML');
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');
775
998
  break;
776
999
  }
777
1000
  case 'sessions': {
778
- const sessions = this.sessionStore.getRecentSessions(agentId, cmd.userId);
779
- if (sessions.length === 0) {
780
- await agent.tgBot.sendText(cmd.chatId, '<blockquote>No recent sessions.</blockquote>', 'HTML');
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');
781
1020
  break;
782
1021
  }
783
- const currentSessionId = this.sessionStore.getUser(agentId, cmd.userId).currentSessionId;
784
- const lines = [];
785
- const keyboard = new InlineKeyboard();
786
- sessions.forEach((s, i) => {
787
- const rawTitle = s.title || s.id.slice(0, 8);
788
- const displayTitle = s.title ? escapeHtml(s.title) : `<code>${escapeHtml(s.id.slice(0, 8))}</code>`;
789
- const age = formatAge(new Date(s.lastActivity));
790
- const isCurrent = s.id === currentSessionId;
791
- const current = isCurrent ? ' ✓' : '';
792
- lines.push(`<b>${i + 1}.</b> ${displayTitle}${current}\n ${s.messageCount} msgs · $${s.totalCostUsd.toFixed(2)} · ${age}`);
793
- // Button: full title (TG allows up to ~40 chars visible), no ellipsis
794
- if (!isCurrent) {
795
- const btnLabel = rawTitle.length > 35 ? rawTitle.slice(0, 35) : rawTitle;
796
- keyboard.text(`${i + 1}. ${btnLabel}`, `resume:${s.id}`).row();
797
- }
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
798
1029
  });
799
- 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
+ }
800
1049
  break;
801
1050
  }
802
1051
  case 'resume': {
@@ -804,30 +1053,49 @@ export class Bridge extends EventEmitter {
804
1053
  await agent.tgBot.sendText(cmd.chatId, '<blockquote>Usage: /resume &lt;session-id&gt;</blockquote>', 'HTML');
805
1054
  break;
806
1055
  }
807
- const proc = agent.processes.get(cmd.userId);
808
- if (proc) {
809
- proc.destroy();
810
- agent.processes.delete(cmd.userId);
811
- }
1056
+ this.disconnectClient(agentId, cmd.userId, cmd.chatId);
812
1057
  this.sessionStore.setCurrentSession(agentId, cmd.userId, cmd.args.trim());
813
1058
  await agent.tgBot.sendText(cmd.chatId, `Will resume session <code>${escapeHtml(cmd.args.trim().slice(0, 8))}</code> on next message.`, 'HTML');
814
1059
  break;
815
1060
  }
816
1061
  case 'session': {
817
1062
  const userState = this.sessionStore.getUser(agentId, cmd.userId);
818
- const session = userState.sessions.find(s => s.id === userState.currentSessionId);
819
- if (!session) {
1063
+ if (!userState.currentSessionId) {
820
1064
  await agent.tgBot.sendText(cmd.chatId, '<blockquote>No active session.</blockquote>', 'HTML');
821
1065
  break;
822
1066
  }
823
- 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');
824
1077
  break;
825
1078
  }
826
1079
  case 'model': {
827
1080
  const MODEL_OPTIONS = ['opus', 'sonnet', 'haiku'];
828
1081
  if (!cmd.args) {
829
- const current = this.sessionStore.getUser(agentId, cmd.userId).model
830
- || 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
+ }
831
1099
  const keyboard = new InlineKeyboard();
832
1100
  for (const m of MODEL_OPTIONS) {
833
1101
  const isCurrent = current.includes(m);
@@ -837,13 +1105,12 @@ export class Bridge extends EventEmitter {
837
1105
  await agent.tgBot.sendTextWithKeyboard(cmd.chatId, `<b>Current model:</b> <code>${escapeHtml(current)}</code>`, keyboard, 'HTML');
838
1106
  break;
839
1107
  }
840
- this.sessionStore.setModel(agentId, cmd.userId, cmd.args.trim());
841
- const proc = agent.processes.get(cmd.userId);
842
- if (proc) {
843
- proc.destroy();
844
- agent.processes.delete(cmd.userId);
845
- }
846
- 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');
847
1114
  break;
848
1115
  }
849
1116
  case 'repo': {
@@ -986,22 +1253,11 @@ export class Bridge extends EventEmitter {
986
1253
  break;
987
1254
  }
988
1255
  // Kill current process (different CWD needs new process)
989
- const proc = agent.processes.get(cmd.userId);
990
- if (proc) {
991
- proc.destroy();
992
- agent.processes.delete(cmd.userId);
993
- }
1256
+ this.disconnectClient(agentId, cmd.userId, cmd.chatId);
994
1257
  this.sessionStore.setRepo(agentId, cmd.userId, repoPath);
995
- const userState = this.sessionStore.getUser(agentId, cmd.userId);
996
- const lastActive = new Date(userState.lastActivity).getTime();
997
- const staleMs = 24 * 60 * 60 * 1000;
998
- if (Date.now() - lastActive > staleMs || !userState.currentSessionId) {
999
- this.sessionStore.clearSession(agentId, cmd.userId);
1000
- await agent.tgBot.sendText(cmd.chatId, `Repo set to <code>${escapeHtml(repoPath)}</code>. Session cleared (stale).`, 'HTML');
1001
- }
1002
- else {
1003
- await agent.tgBot.sendText(cmd.chatId, `Repo set to <code>${escapeHtml(repoPath)}</code>. Session kept (active &lt;24h).`, 'HTML');
1004
- }
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');
1005
1261
  break;
1006
1262
  }
1007
1263
  case 'cancel': {
@@ -1016,12 +1272,8 @@ export class Bridge extends EventEmitter {
1016
1272
  break;
1017
1273
  }
1018
1274
  case 'catchup': {
1019
- const userState = this.sessionStore.getUser(agentId, cmd.userId);
1020
- const repo = userState.repo || resolveUserConfig(agent.config, cmd.userId).repo;
1021
- const lastActivity = new Date(userState.lastActivity || 0);
1022
- const missed = findMissedSessions(repo, userState.knownSessionIds, lastActivity);
1023
- const message = formatCatchupMessage(repo, missed);
1024
- 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');
1025
1277
  break;
1026
1278
  }
1027
1279
  case 'permissions': {
@@ -1037,11 +1289,7 @@ export class Bridge extends EventEmitter {
1037
1289
  }
1038
1290
  this.sessionStore.setPermissionMode(agentId, cmd.userId, mode);
1039
1291
  // Kill current process so new mode takes effect on next spawn
1040
- const proc = agent.processes.get(cmd.userId);
1041
- if (proc) {
1042
- proc.destroy();
1043
- agent.processes.delete(cmd.userId);
1044
- }
1292
+ this.disconnectClient(agentId, cmd.userId, cmd.chatId);
1045
1293
  await agent.tgBot.sendText(cmd.chatId, `Permission mode set to <code>${escapeHtml(mode)}</code>. Takes effect on next message.`, 'HTML');
1046
1294
  break;
1047
1295
  }
@@ -1063,11 +1311,7 @@ export class Bridge extends EventEmitter {
1063
1311
  switch (query.action) {
1064
1312
  case 'resume': {
1065
1313
  const sessionId = query.data;
1066
- const proc = agent.processes.get(query.userId);
1067
- if (proc) {
1068
- proc.destroy();
1069
- agent.processes.delete(query.userId);
1070
- }
1314
+ this.disconnectClient(agentId, query.userId, query.chatId);
1071
1315
  this.sessionStore.setCurrentSession(agentId, query.userId, sessionId);
1072
1316
  await agent.tgBot.answerCallbackQuery(query.callbackQueryId, 'Session set');
1073
1317
  await agent.tgBot.sendText(query.chatId, `Will resume session <code>${escapeHtml(sessionId.slice(0, 8))}</code> on next message.`, 'HTML');
@@ -1075,14 +1319,13 @@ export class Bridge extends EventEmitter {
1075
1319
  }
1076
1320
  case 'delete': {
1077
1321
  const sessionId = query.data;
1078
- const deleted = this.sessionStore.deleteSession(agentId, query.userId, sessionId);
1079
- if (deleted) {
1080
- await agent.tgBot.answerCallbackQuery(query.callbackQueryId, 'Session deleted');
1081
- await agent.tgBot.sendText(query.chatId, `Session <code>${escapeHtml(sessionId.slice(0, 8))}</code> deleted.`, 'HTML');
1082
- }
1083
- else {
1084
- 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, '');
1085
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');
1086
1329
  break;
1087
1330
  }
1088
1331
  case 'repo': {
@@ -1093,23 +1336,12 @@ export class Bridge extends EventEmitter {
1093
1336
  break;
1094
1337
  }
1095
1338
  // Kill current process (different CWD needs new process)
1096
- const proc = agent.processes.get(query.userId);
1097
- if (proc) {
1098
- proc.destroy();
1099
- agent.processes.delete(query.userId);
1100
- }
1339
+ this.disconnectClient(agentId, query.userId, query.chatId);
1101
1340
  this.sessionStore.setRepo(agentId, query.userId, repoPath);
1102
- const userState2 = this.sessionStore.getUser(agentId, query.userId);
1103
- const lastActive2 = new Date(userState2.lastActivity).getTime();
1104
- const staleMs2 = 24 * 60 * 60 * 1000;
1341
+ // Always clear session when repo changes — sessions are project-specific
1342
+ this.sessionStore.clearSession(agentId, query.userId);
1105
1343
  await agent.tgBot.answerCallbackQuery(query.callbackQueryId, `Repo: ${repoName}`);
1106
- if (Date.now() - lastActive2 > staleMs2 || !userState2.currentSessionId) {
1107
- this.sessionStore.clearSession(agentId, query.userId);
1108
- await agent.tgBot.sendText(query.chatId, `Repo set to <code>${escapeHtml(repoPath)}</code>. Session cleared (stale).`, 'HTML');
1109
- }
1110
- else {
1111
- await agent.tgBot.sendText(query.chatId, `Repo set to <code>${escapeHtml(repoPath)}</code>. Session kept (active &lt;24h).`, 'HTML');
1112
- }
1344
+ await agent.tgBot.sendText(query.chatId, `<blockquote>Repo set to <code>${escapeHtml(shortenRepoPath(repoPath))}</code>. Session cleared.</blockquote>`, 'HTML');
1113
1345
  break;
1114
1346
  }
1115
1347
  case 'repo_add': {
@@ -1124,14 +1356,10 @@ export class Bridge extends EventEmitter {
1124
1356
  await agent.tgBot.sendText(query.chatId, 'Send: <code>/model &lt;model-name&gt;</code>', 'HTML');
1125
1357
  break;
1126
1358
  }
1127
- this.sessionStore.setModel(agentId, query.userId, model);
1128
- const proc = agent.processes.get(query.userId);
1129
- if (proc) {
1130
- proc.destroy();
1131
- agent.processes.delete(query.userId);
1132
- }
1359
+ this.sessionModelOverrides.set(`${agentId}:${query.userId}`, model);
1360
+ this.disconnectClient(agentId, query.userId, query.chatId);
1133
1361
  await agent.tgBot.answerCallbackQuery(query.callbackQueryId, `Model: ${model}`);
1134
- 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');
1135
1363
  break;
1136
1364
  }
1137
1365
  case 'permissions': {
@@ -1143,11 +1371,7 @@ export class Bridge extends EventEmitter {
1143
1371
  }
1144
1372
  this.sessionStore.setPermissionMode(agentId, query.userId, mode);
1145
1373
  // Kill current process so new mode takes effect on next spawn
1146
- const proc = agent.processes.get(query.userId);
1147
- if (proc) {
1148
- proc.destroy();
1149
- agent.processes.delete(query.userId);
1150
- }
1374
+ this.disconnectClient(agentId, query.userId, query.chatId);
1151
1375
  await agent.tgBot.answerCallbackQuery(query.callbackQueryId, `Mode: ${mode}`);
1152
1376
  await agent.tgBot.sendText(query.chatId, `Permission mode set to <code>${escapeHtml(mode)}</code>. Takes effect on next message.`, 'HTML');
1153
1377
  break;
@@ -1270,15 +1494,18 @@ export class Bridge extends EventEmitter {
1270
1494
  sessionId: proc?.sessionId ?? null,
1271
1495
  repo: this.sessionStore.getUser(id, userId).repo || userConfig.repo,
1272
1496
  });
1273
- // List sessions for this agent
1497
+ // List sessions for this agent from CC's session directory
1274
1498
  const userState = this.sessionStore.getUser(id, userId);
1275
- for (const sess of userState.sessions.slice(-5).reverse()) {
1276
- sessions.push({
1277
- id: sess.id,
1278
- agentId: id,
1279
- messageCount: sess.messageCount,
1280
- totalCostUsd: sess.totalCostUsd,
1281
- });
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
+ }
1282
1509
  }
1283
1510
  }
1284
1511
  return { type: 'status', agents, sessions };
@@ -1317,6 +1544,7 @@ export class Bridge extends EventEmitter {
1317
1544
  for (const agentId of [...this.agents.keys()]) {
1318
1545
  await this.stopAgent(agentId);
1319
1546
  }
1547
+ this.processRegistry.clear();
1320
1548
  this.mcpServer.closeAll();
1321
1549
  this.ctlServer.closeAll();
1322
1550
  this.removeAllListeners();
@@ -1324,6 +1552,21 @@ export class Bridge extends EventEmitter {
1324
1552
  }
1325
1553
  }
1326
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
+ }
1327
1570
  function formatDuration(ms) {
1328
1571
  const secs = Math.floor(ms / 1000);
1329
1572
  if (secs < 60)