@fonz/tgcc 0.6.14 → 0.6.17

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 = [];
@@ -83,6 +84,7 @@ const HELP_TEXT = `<b>TGCC Commands</b>
83
84
 
84
85
  <b>Control</b>
85
86
  /cancel — Abort current CC turn
87
+ /compact [instructions] — Compact conversation context
86
88
  /model &lt;name&gt; — Switch model
87
89
  /permissions — Set permission mode
88
90
  /repo — List repos (buttons)
@@ -97,6 +99,8 @@ const HELP_TEXT = `<b>TGCC Commands</b>
97
99
  export class Bridge extends EventEmitter {
98
100
  config;
99
101
  agents = new Map();
102
+ processRegistry = new ProcessRegistry();
103
+ sessionModelOverrides = new Map(); // "agentId:userId" → model (from /model cmd, not persisted)
100
104
  mcpServer;
101
105
  ctlServer;
102
106
  sessionStore;
@@ -128,7 +132,6 @@ export class Bridge extends EventEmitter {
128
132
  accumulators: new Map(),
129
133
  subAgentTrackers: new Map(),
130
134
  batchers: new Map(),
131
- pendingTitles: new Map(),
132
135
  pendingPermissions: new Map(),
133
136
  typingIntervals: new Map(),
134
137
  };
@@ -174,6 +177,12 @@ export class Bridge extends EventEmitter {
174
177
  this.logger.info({ agentId }, 'Stopping agent');
175
178
  // Stop bot
176
179
  await agent.tgBot.stop();
180
+ // Unsubscribe all clients for this agent from the registry
181
+ for (const [userId] of agent.processes) {
182
+ const chatId = Number(userId); // In TG, private chat ID === user ID
183
+ const clientRef = { agentId, userId, chatId };
184
+ this.processRegistry.unsubscribe(clientRef);
185
+ }
177
186
  // Kill all CC processes and wait for them to exit
178
187
  const processExitPromises = [];
179
188
  for (const [, proc] of agent.processes) {
@@ -223,7 +232,6 @@ export class Bridge extends EventEmitter {
223
232
  clearInterval(interval);
224
233
  }
225
234
  agent.typingIntervals.clear();
226
- agent.pendingTitles.clear();
227
235
  agent.pendingPermissions.clear();
228
236
  agent.processes.clear();
229
237
  agent.batchers.clear();
@@ -273,13 +281,17 @@ export class Bridge extends EventEmitter {
273
281
  else {
274
282
  ccMsg = createTextMessage(data.text);
275
283
  }
276
- // Get or create CC process
277
- let proc = agent.processes.get(userId);
284
+ const clientRef = { agentId, userId, chatId };
285
+ // Check if this client already has a process via the registry
286
+ let existingEntry = this.processRegistry.findByClient(clientRef);
287
+ let proc = existingEntry?.ccProcess;
278
288
  if (proc?.takenOver) {
279
289
  // Session was taken over externally — discard old process
280
- proc.destroy();
290
+ const entry = this.processRegistry.findByClient(clientRef);
291
+ this.processRegistry.destroy(entry.repo, entry.sessionId);
281
292
  agent.processes.delete(userId);
282
293
  proc = undefined;
294
+ existingEntry = null;
283
295
  }
284
296
  // Staleness check: detect if session was modified by another client
285
297
  // Skip if background sub-agents are running — their results grow the JSONL
@@ -292,9 +304,11 @@ export class Bridge extends EventEmitter {
292
304
  if (staleInfo) {
293
305
  // Session was modified externally — silently reconnect for roaming support
294
306
  this.logger.info({ agentId, userId }, 'Session modified externally — reconnecting for roaming');
295
- proc.destroy();
307
+ const entry = this.processRegistry.findByClient(clientRef);
308
+ this.processRegistry.unsubscribe(clientRef);
296
309
  agent.processes.delete(userId);
297
310
  proc = undefined;
311
+ existingEntry = null;
298
312
  }
299
313
  }
300
314
  if (!proc || proc.state === 'idle') {
@@ -304,9 +318,25 @@ export class Bridge extends EventEmitter {
304
318
  if (resolvedRepo === homedir()) {
305
319
  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
320
  }
321
+ // Check if another client already has a process for this repo+session
322
+ const sessionId = userState2.currentSessionId;
323
+ if (sessionId && resolvedRepo) {
324
+ const sharedEntry = this.processRegistry.get(resolvedRepo, sessionId);
325
+ if (sharedEntry && sharedEntry.ccProcess.state !== 'idle') {
326
+ // Attach to existing process as subscriber
327
+ this.processRegistry.subscribe(resolvedRepo, sessionId, clientRef);
328
+ proc = sharedEntry.ccProcess;
329
+ agent.processes.set(userId, proc);
330
+ // Notify the user they've attached
331
+ agent.tgBot.sendText(chatId, '<blockquote>📎 Attached to existing session process.</blockquote>', 'HTML').catch(err => this.logger.error({ err }, 'Failed to send attach notification'));
332
+ // Show typing indicator and forward message
333
+ this.startTypingIndicator(agent, userId, chatId);
334
+ proc.sendMessage(ccMsg);
335
+ return;
336
+ }
337
+ }
307
338
  // Save first message text as pending session title
308
339
  if (data.text) {
309
- agent.pendingTitles.set(userId, data.text);
310
340
  }
311
341
  proc = this.spawnCCProcess(agentId, userId, chatId);
312
342
  agent.processes.set(userId, proc);
@@ -314,52 +344,33 @@ export class Bridge extends EventEmitter {
314
344
  // Show typing indicator (repeated every 4s — TG typing expires after ~5s)
315
345
  this.startTypingIndicator(agent, userId, chatId);
316
346
  proc.sendMessage(ccMsg);
317
- this.sessionStore.updateSessionActivity(agentId, userId);
318
347
  }
319
348
  /** 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
- }
349
+ checkSessionStaleness(_agentId, _userId) {
350
+ // With shared process registry, staleness is handled by the registry itself
351
+ return null;
346
352
  }
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)
353
+ // ── Process cleanup helper ──
354
+ /**
355
+ * Disconnect a client from its CC process.
356
+ * If other subscribers remain, the process stays alive.
357
+ * If this was the last subscriber, the process is destroyed.
358
+ */
359
+ disconnectClient(agentId, userId, chatId) {
360
+ const agent = this.agents.get(agentId);
361
+ if (!agent)
352
362
  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);
363
+ const clientRef = { agentId, userId, chatId };
364
+ const proc = agent.processes.get(userId);
365
+ const destroyed = this.processRegistry.unsubscribe(clientRef);
366
+ if (!destroyed && proc) {
367
+ // Other subscribers still attached — just remove from this agent's map
358
368
  }
359
- catch {
360
- // File doesn't exist yetclear tracking
361
- this.sessionStore.clearJsonlTracking(agentId, userId);
369
+ else if (proc && !destroyed) {
370
+ // Not in registry but has a process destroy directly (legacy path)
371
+ proc.destroy();
362
372
  }
373
+ agent.processes.delete(userId);
363
374
  }
364
375
  // ── Typing indicator management ──
365
376
  startTypingIndicator(agent, userId, chatId) {
@@ -385,12 +396,32 @@ export class Bridge extends EventEmitter {
385
396
  spawnCCProcess(agentId, userId, chatId) {
386
397
  const agent = this.agents.get(agentId);
387
398
  const userConfig = resolveUserConfig(agent.config, userId);
388
- // Check session store for model/repo/permission overrides
399
+ // Check session store for repo/permission overrides
389
400
  const userState = this.sessionStore.getUser(agentId, userId);
390
- if (userState.model)
391
- userConfig.model = userState.model;
392
401
  if (userState.repo)
393
402
  userConfig.repo = userState.repo;
403
+ // Model priority: /model override > running process > session JSONL > agent config default
404
+ const modelOverride = this.sessionModelOverrides.get(`${agentId}:${userId}`);
405
+ if (modelOverride) {
406
+ userConfig.model = modelOverride;
407
+ // Don't delete yet — cleared when CC writes first assistant message
408
+ }
409
+ else {
410
+ const currentSessionId = userState.currentSessionId;
411
+ if (currentSessionId && userConfig.repo) {
412
+ const registryEntry = this.processRegistry.get(userConfig.repo, currentSessionId);
413
+ if (registryEntry?.model) {
414
+ userConfig.model = registryEntry.model;
415
+ }
416
+ else {
417
+ const sessions = discoverCCSessions(userConfig.repo, 20);
418
+ const sessionInfo = sessions.find(s => s.id === currentSessionId);
419
+ if (sessionInfo?.model) {
420
+ userConfig.model = sessionInfo.model;
421
+ }
422
+ }
423
+ }
424
+ }
394
425
  if (userState.permissionMode) {
395
426
  userConfig.permissionMode = userState.permissionMode;
396
427
  }
@@ -410,106 +441,171 @@ export class Bridge extends EventEmitter {
410
441
  continueSession: !!userState.currentSessionId,
411
442
  logger: this.logger,
412
443
  });
413
- // ── Wire up event handlers ──
444
+ // Register in the process registry
445
+ const ownerRef = { agentId, userId, chatId };
446
+ // We'll register once we know the sessionId (on init), but we need a
447
+ // temporary entry for pre-init event routing. Use a placeholder sessionId
448
+ // that gets updated on init.
449
+ const tentativeSessionId = userState.currentSessionId ?? `pending-${Date.now()}`;
450
+ const registryEntry = this.processRegistry.register(userConfig.repo, tentativeSessionId, userConfig.model || 'default', proc, ownerRef);
451
+ // ── Helper: get all subscribers for this process from the registry ──
452
+ const getEntry = () => this.processRegistry.findByProcess(proc);
453
+ // ── Wire up event handlers (broadcast to all subscribers) ──
414
454
  proc.on('init', (event) => {
415
455
  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);
456
+ // Update registry key if session ID changed from tentative
457
+ if (event.session_id !== tentativeSessionId) {
458
+ // Re-register with the real session ID
459
+ const entry = getEntry();
460
+ if (entry) {
461
+ // Save subscribers before removing
462
+ const savedSubs = [...entry.subscribers.entries()];
463
+ this.processRegistry.remove(userConfig.repo, tentativeSessionId);
464
+ const newEntry = this.processRegistry.register(userConfig.repo, event.session_id, userConfig.model || 'default', proc, ownerRef);
465
+ // Restore additional subscribers
466
+ for (const [, sub] of savedSubs) {
467
+ if (sub.client.agentId !== agentId || sub.client.userId !== userId || sub.client.chatId !== chatId) {
468
+ this.processRegistry.subscribe(userConfig.repo, event.session_id, sub.client);
469
+ const newSub = this.processRegistry.getSubscriber(newEntry, sub.client);
470
+ if (newSub) {
471
+ newSub.accumulator = sub.accumulator;
472
+ newSub.tracker = sub.tracker;
473
+ }
474
+ }
475
+ }
476
+ }
421
477
  }
422
- // Initialize JSONL tracking for staleness detection
423
- this.updateJsonlTracking(agentId, userId);
424
478
  });
425
479
  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)
480
+ const entry = getEntry();
481
+ if (!entry) {
482
+ // Fallback: single subscriber mode (shouldn't happen)
483
+ this.handleStreamEvent(agentId, userId, chatId, event);
440
484
  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
485
  }
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
- });
486
+ for (const sub of this.processRegistry.subscribers(entry)) {
487
+ this.handleStreamEvent(sub.client.agentId, sub.client.userId, sub.client.chatId, event);
488
+ }
489
+ });
490
+ proc.on('tool_result', (event) => {
491
+ const entry = getEntry();
492
+ const subscriberList = entry ? [...this.processRegistry.subscribers(entry)] : [{ client: ownerRef }];
493
+ for (const sub of subscriberList) {
494
+ const subAgent = this.agents.get(sub.client.agentId);
495
+ if (!subAgent)
496
+ continue;
497
+ const accKey = `${sub.client.userId}:${sub.client.chatId}`;
498
+ // Resolve tool indicator message with success/failure status
499
+ const acc2 = subAgent.accumulators.get(accKey);
500
+ if (acc2 && event.tool_use_id) {
501
+ const isError = event.is_error === true;
502
+ const contentStr = typeof event.content === 'string' ? event.content : JSON.stringify(event.content);
503
+ const errorMsg = isError ? contentStr : undefined;
504
+ acc2.resolveToolMessage(event.tool_use_id, isError, errorMsg, contentStr, event.tool_use_result);
459
505
  }
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
- });
506
+ const tracker = subAgent.subAgentTrackers.get(accKey);
507
+ if (!tracker)
508
+ continue;
509
+ const resultText = typeof event.content === 'string' ? event.content : JSON.stringify(event.content);
510
+ const meta = event.tool_use_result;
511
+ // Log warning if structured metadata is missing
512
+ if (!meta && /agent_id:\s*\S+@\S+/.test(resultText)) {
513
+ this.logger.warn({ agentId: sub.client.agentId, toolUseId: event.tool_use_id }, 'Spawn detected in text but no structured tool_use_result metadata - skipping');
514
+ }
515
+ const spawnMeta = meta?.status === 'teammate_spawned' ? meta : undefined;
516
+ if (spawnMeta?.status === 'teammate_spawned' && spawnMeta.team_name) {
517
+ if (!tracker.currentTeamName) {
518
+ this.logger.info({ agentId: sub.client.agentId, teamName: spawnMeta.team_name, agentName: spawnMeta.name, agentType: spawnMeta.agent_type }, 'Spawn detected');
519
+ tracker.setTeamName(spawnMeta.team_name);
520
+ // Wire the "all agents reported" callback to send follow-up to CC
521
+ tracker.setOnAllReported(() => {
522
+ if (proc.state === 'active') {
523
+ 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.'));
524
+ }
525
+ });
526
+ }
527
+ // Set agent metadata from structured data or text fallback
528
+ if (event.tool_use_id && spawnMeta.name) {
529
+ tracker.setAgentMetadata(event.tool_use_id, {
530
+ agentName: spawnMeta.name,
531
+ agentType: spawnMeta.agent_type,
532
+ color: spawnMeta.color,
533
+ });
534
+ }
535
+ }
536
+ // Handle tool result (sets status, edits TG message)
537
+ if (event.tool_use_id) {
538
+ tracker.handleToolResult(event.tool_use_id, resultText);
539
+ }
540
+ // Start mailbox watch AFTER handleToolResult has set agent names
541
+ if (tracker.currentTeamName && tracker.hasDispatchedAgents && !tracker.isMailboxWatching) {
542
+ tracker.startMailboxWatch();
467
543
  }
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
544
  }
477
545
  });
478
- // System events for background task tracking
546
+ // System events for background task tracking — broadcast to all subscribers
479
547
  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);
548
+ const entry = getEntry();
549
+ const subscriberList = entry ? [...this.processRegistry.subscribers(entry)] : [{ client: ownerRef }];
550
+ for (const sub of subscriberList) {
551
+ const subAgent = this.agents.get(sub.client.agentId);
552
+ if (!subAgent)
553
+ continue;
554
+ const accKey = `${sub.client.userId}:${sub.client.chatId}`;
555
+ const tracker = subAgent.subAgentTrackers.get(accKey);
556
+ if (tracker) {
557
+ tracker.handleTaskStarted(event.tool_use_id, event.description, event.task_type);
558
+ }
484
559
  }
485
560
  });
486
561
  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);
562
+ const entry = getEntry();
563
+ const subscriberList = entry ? [...this.processRegistry.subscribers(entry)] : [{ client: ownerRef }];
564
+ for (const sub of subscriberList) {
565
+ const subAgent = this.agents.get(sub.client.agentId);
566
+ if (!subAgent)
567
+ continue;
568
+ const accKey = `${sub.client.userId}:${sub.client.chatId}`;
569
+ const tracker = subAgent.subAgentTrackers.get(accKey);
570
+ if (tracker) {
571
+ tracker.handleTaskProgress(event.tool_use_id, event.description, event.last_tool_name);
572
+ }
491
573
  }
492
574
  });
493
575
  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);
576
+ const entry = getEntry();
577
+ const subscriberList = entry ? [...this.processRegistry.subscribers(entry)] : [{ client: ownerRef }];
578
+ for (const sub of subscriberList) {
579
+ const subAgent = this.agents.get(sub.client.agentId);
580
+ if (!subAgent)
581
+ continue;
582
+ const accKey = `${sub.client.userId}:${sub.client.chatId}`;
583
+ const tracker = subAgent.subAgentTrackers.get(accKey);
584
+ if (tracker) {
585
+ tracker.handleTaskCompleted(event.tool_use_id);
586
+ }
498
587
  }
499
588
  });
500
- // Media from tool results (images, PDFs, etc.)
589
+ // Media from tool results (images, PDFs, etc.) — broadcast to all subscribers
501
590
  proc.on('media', (media) => {
502
591
  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
- });
592
+ const entry = getEntry();
593
+ const subscriberList = entry ? [...this.processRegistry.subscribers(entry)] : [{ client: ownerRef }];
594
+ for (const sub of subscriberList) {
595
+ const subAgent = this.agents.get(sub.client.agentId);
596
+ if (!subAgent)
597
+ continue;
598
+ if (media.kind === 'image') {
599
+ subAgent.tgBot.sendPhotoBuffer(sub.client.chatId, buf).catch(err => {
600
+ this.logger.error({ err, agentId: sub.client.agentId, userId: sub.client.userId }, 'Failed to send tool_result image');
601
+ });
602
+ }
603
+ else if (media.kind === 'document') {
604
+ const ext = media.media_type === 'application/pdf' ? '.pdf' : '';
605
+ subAgent.tgBot.sendDocumentBuffer(sub.client.chatId, buf, `document${ext}`).catch(err => {
606
+ this.logger.error({ err, agentId: sub.client.agentId, userId: sub.client.userId }, 'Failed to send tool_result document');
607
+ });
608
+ }
513
609
  }
514
610
  });
515
611
  proc.on('assistant', (event) => {
@@ -517,13 +613,39 @@ export class Bridge extends EventEmitter {
517
613
  // In practice, stream_events handle the display
518
614
  });
519
615
  proc.on('result', (event) => {
520
- this.stopTypingIndicator(agent, userId, chatId);
521
- this.handleResult(agentId, userId, chatId, event);
616
+ // Model override consumed — CC has written to JSONL
617
+ this.sessionModelOverrides.delete(`${agentId}:${userId}`);
618
+ // Broadcast result to all subscribers
619
+ const entry = getEntry();
620
+ const subscriberList = entry ? [...this.processRegistry.subscribers(entry)] : [{ client: ownerRef }];
621
+ for (const sub of subscriberList) {
622
+ const subAgent = this.agents.get(sub.client.agentId);
623
+ if (!subAgent)
624
+ continue;
625
+ this.stopTypingIndicator(subAgent, sub.client.userId, sub.client.chatId);
626
+ this.handleResult(sub.client.agentId, sub.client.userId, sub.client.chatId, event);
627
+ }
628
+ });
629
+ proc.on('compact', (event) => {
630
+ // Notify all subscribers that compaction happened
631
+ const trigger = event.compact_metadata?.trigger ?? 'manual';
632
+ const preTokens = event.compact_metadata?.pre_tokens;
633
+ const tokenInfo = preTokens ? ` (was ${Math.round(preTokens / 1000)}k tokens)` : '';
634
+ const entry = getEntry();
635
+ const subscriberList = entry ? [...this.processRegistry.subscribers(entry)] : [{ client: ownerRef }];
636
+ for (const sub of subscriberList) {
637
+ const subAgent = this.agents.get(sub.client.agentId);
638
+ if (!subAgent)
639
+ continue;
640
+ const label = trigger === 'auto' ? '🗜️ Auto-compacted' : '🗜️ Compacted';
641
+ subAgent.tgBot.sendText(sub.client.chatId, `<blockquote>${escapeHtml(label + tokenInfo)}</blockquote>`, 'HTML').catch((err) => this.logger.error({ err }, 'Failed to send compact notification'));
642
+ }
522
643
  });
523
644
  proc.on('permission_request', (event) => {
645
+ // Send permission request only to the owner (first subscriber)
524
646
  const req = event.request;
525
647
  const requestId = event.request_id;
526
- // Store pending permission
648
+ // Store pending permission on the owner's agent
527
649
  agent.pendingPermissions.set(requestId, {
528
650
  requestId,
529
651
  userId,
@@ -555,42 +677,100 @@ export class Bridge extends EventEmitter {
555
677
  const text = isOverloaded
556
678
  ? `<blockquote>⚠️ API overloaded, retrying...${retryInfo}</blockquote>`
557
679
  : `<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'));
680
+ // Broadcast API error to all subscribers
681
+ const entry = getEntry();
682
+ const subscriberList = entry ? [...this.processRegistry.subscribers(entry)] : [{ client: ownerRef }];
683
+ for (const sub of subscriberList) {
684
+ const subAgent = this.agents.get(sub.client.agentId);
685
+ if (!subAgent)
686
+ continue;
687
+ subAgent.tgBot.sendText(sub.client.chatId, text, 'HTML')
688
+ .catch(err => this.logger.error({ err }, 'Failed to send API error notification'));
689
+ }
560
690
  });
561
691
  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'));
692
+ // Broadcast hang notification to all subscribers
693
+ const entry = getEntry();
694
+ const subscriberList = entry ? [...this.processRegistry.subscribers(entry)] : [{ client: ownerRef }];
695
+ for (const sub of subscriberList) {
696
+ const subAgent = this.agents.get(sub.client.agentId);
697
+ if (!subAgent)
698
+ continue;
699
+ this.stopTypingIndicator(subAgent, sub.client.userId, sub.client.chatId);
700
+ subAgent.tgBot.sendText(sub.client.chatId, '<blockquote>⏸ Session paused. Send a message to continue.</blockquote>', 'HTML')
701
+ .catch(err => this.logger.error({ err }, 'Failed to send hang notification'));
702
+ }
565
703
  });
566
704
  proc.on('takeover', () => {
567
- this.stopTypingIndicator(agent, userId, chatId);
568
705
  this.logger.warn({ agentId, userId }, 'Session takeover detected — keeping session for roaming');
706
+ // Notify and clean up all subscribers
707
+ const entry = getEntry();
708
+ if (entry) {
709
+ for (const sub of this.processRegistry.subscribers(entry)) {
710
+ const subAgent = this.agents.get(sub.client.agentId);
711
+ if (!subAgent)
712
+ continue;
713
+ this.stopTypingIndicator(subAgent, sub.client.userId, sub.client.chatId);
714
+ subAgent.processes.delete(sub.client.userId);
715
+ }
716
+ // Remove from registry without destroying (already handling exit)
717
+ this.processRegistry.remove(entry.repo, entry.sessionId);
718
+ }
569
719
  // Don't clear session — allow roaming between clients.
570
- // Just kill the current process; next message will --resume the same session.
571
720
  proc.destroy();
572
- agent.processes.delete(userId);
573
721
  });
574
722
  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);
723
+ // Clean up all subscribers
724
+ const entry = getEntry();
725
+ if (entry) {
726
+ for (const sub of this.processRegistry.subscribers(entry)) {
727
+ const subAgent = this.agents.get(sub.client.agentId);
728
+ if (!subAgent)
729
+ continue;
730
+ this.stopTypingIndicator(subAgent, sub.client.userId, sub.client.chatId);
731
+ const accKey = `${sub.client.userId}:${sub.client.chatId}`;
732
+ const acc = subAgent.accumulators.get(accKey);
733
+ if (acc) {
734
+ acc.finalize();
735
+ subAgent.accumulators.delete(accKey);
736
+ }
737
+ const exitTracker = subAgent.subAgentTrackers.get(accKey);
738
+ if (exitTracker) {
739
+ exitTracker.stopMailboxWatch();
740
+ }
741
+ subAgent.subAgentTrackers.delete(accKey);
742
+ }
743
+ // Remove from registry (process already exited)
744
+ this.processRegistry.remove(entry.repo, entry.sessionId);
582
745
  }
583
- // Clean up sub-agent tracker (stops mailbox watch)
584
- const exitTracker = agent.subAgentTrackers.get(accKey);
585
- if (exitTracker) {
586
- exitTracker.stopMailboxWatch();
746
+ else {
747
+ // Fallback: clean up owner only
748
+ this.stopTypingIndicator(agent, userId, chatId);
749
+ const accKey = `${userId}:${chatId}`;
750
+ const acc = agent.accumulators.get(accKey);
751
+ if (acc) {
752
+ acc.finalize();
753
+ agent.accumulators.delete(accKey);
754
+ }
755
+ const exitTracker = agent.subAgentTrackers.get(accKey);
756
+ if (exitTracker) {
757
+ exitTracker.stopMailboxWatch();
758
+ }
759
+ agent.subAgentTrackers.delete(accKey);
587
760
  }
588
- agent.subAgentTrackers.delete(accKey);
589
761
  });
590
762
  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'));
763
+ // Broadcast error to all subscribers
764
+ const entry = getEntry();
765
+ const subscriberList = entry ? [...this.processRegistry.subscribers(entry)] : [{ client: ownerRef }];
766
+ for (const sub of subscriberList) {
767
+ const subAgent = this.agents.get(sub.client.agentId);
768
+ if (!subAgent)
769
+ continue;
770
+ this.stopTypingIndicator(subAgent, sub.client.userId, sub.client.chatId);
771
+ subAgent.tgBot.sendText(sub.client.chatId, `<blockquote>⚠️ ${escapeHtml(String(err.message))}</blockquote>`, 'HTML')
772
+ .catch(err2 => this.logger.error({ err: err2 }, 'Failed to send process error notification'));
773
+ }
594
774
  });
595
775
  return proc;
596
776
  }
@@ -657,6 +837,8 @@ export class Bridge extends EventEmitter {
657
837
  cacheReadTokens: event.usage.cache_read_input_tokens ?? 0,
658
838
  cacheCreationTokens: event.usage.cache_creation_input_tokens ?? 0,
659
839
  costUsd: event.total_cost_usd ?? null,
840
+ model: event.model
841
+ ?? this.processRegistry.findByClient({ agentId, userId, chatId })?.model,
660
842
  });
661
843
  }
662
844
  acc.finalize();
@@ -664,11 +846,9 @@ export class Bridge extends EventEmitter {
664
846
  }
665
847
  // Update session store with cost
666
848
  if (event.total_cost_usd) {
667
- this.sessionStore.updateSessionActivity(agentId, userId, event.total_cost_usd);
668
849
  }
669
850
  // Update JSONL tracking after our own CC turn completes
670
851
  // This prevents false-positive staleness on our own writes
671
- this.updateJsonlTracking(agentId, userId);
672
852
  // Handle errors
673
853
  if (event.is_error && event.result) {
674
854
  agent.tgBot.sendText(chatId, `<blockquote>⚠️ ${escapeHtml(String(event.result))}</blockquote>`, 'HTML')
@@ -715,7 +895,33 @@ export class Bridge extends EventEmitter {
715
895
  this.logger.debug({ agentId, command: cmd.command, args: cmd.args }, 'Slash command');
716
896
  switch (cmd.command) {
717
897
  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.');
898
+ const userConf = resolveUserConfig(agent.config, cmd.userId);
899
+ const userState = this.sessionStore.getUser(agentId, cmd.userId);
900
+ const repo = userState.repo || userConf.repo;
901
+ const session = userState.currentSessionId;
902
+ // Model: running process > session JSONL > user default
903
+ let model = userConf.model;
904
+ if (session && repo) {
905
+ const registryEntry = this.processRegistry.get(repo, session);
906
+ if (registryEntry?.model) {
907
+ model = registryEntry.model;
908
+ }
909
+ else {
910
+ const sessions = discoverCCSessions(repo, 20);
911
+ const sessionInfo = sessions.find(s => s.id === session);
912
+ if (sessionInfo?.model)
913
+ model = sessionInfo.model;
914
+ }
915
+ }
916
+ const lines = ['👋 <b>TGCC</b> — Telegram ↔ Claude Code bridge'];
917
+ if (repo)
918
+ lines.push(`📂 <code>${escapeHtml(shortenRepoPath(repo))}</code>`);
919
+ if (model)
920
+ lines.push(`🤖 ${escapeHtml(model)}`);
921
+ if (session)
922
+ lines.push(`📎 Session: <code>${escapeHtml(session.slice(0, 8))}</code>`);
923
+ lines.push('', 'Send a message to start, or use /help for commands.');
924
+ await agent.tgBot.sendText(cmd.chatId, lines.join('\n'), 'HTML');
719
925
  // Re-register commands with BotFather to ensure menu is up to date
720
926
  try {
721
927
  const { COMMANDS } = await import('./telegram.js');
@@ -743,7 +949,7 @@ export class Bridge extends EventEmitter {
743
949
  `<b>Agent:</b> ${escapeHtml(agentId)}`,
744
950
  `<b>Process:</b> ${(proc?.state ?? 'idle').toUpperCase()} (uptime: ${uptime})`,
745
951
  `<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)}`,
952
+ `<b>Model:</b> ${escapeHtml(resolveUserConfig(agent.config, cmd.userId).model)}`,
747
953
  `<b>Repo:</b> ${escapeHtml(userState.repo || resolveUserConfig(agent.config, cmd.userId).repo)}`,
748
954
  `<b>Cost:</b> $${(proc?.totalCostUsd ?? 0).toFixed(4)}`,
749
955
  ].join('\n');
@@ -756,47 +962,108 @@ export class Bridge extends EventEmitter {
756
962
  break;
757
963
  }
758
964
  case 'new': {
759
- const proc = agent.processes.get(cmd.userId);
760
- if (proc) {
761
- proc.destroy();
762
- agent.processes.delete(cmd.userId);
763
- }
965
+ this.disconnectClient(agentId, cmd.userId, cmd.chatId);
764
966
  this.sessionStore.clearSession(agentId, cmd.userId);
765
- await agent.tgBot.sendText(cmd.chatId, '<blockquote>Session cleared. Next message starts fresh.</blockquote>', 'HTML');
967
+ const newConf = resolveUserConfig(agent.config, cmd.userId);
968
+ const newState = this.sessionStore.getUser(agentId, cmd.userId);
969
+ const newRepo = newState.repo || newConf.repo;
970
+ const newModel = newState.model || newConf.model;
971
+ const newLines = ['Session cleared. Next message starts fresh.'];
972
+ if (newRepo)
973
+ newLines.push(`📂 <code>${escapeHtml(shortenRepoPath(newRepo))}</code>`);
974
+ if (newModel)
975
+ newLines.push(`🤖 ${escapeHtml(newModel)}`);
976
+ await agent.tgBot.sendText(cmd.chatId, `<blockquote>${newLines.join('\n')}</blockquote>`, 'HTML');
766
977
  break;
767
978
  }
768
979
  case 'continue': {
769
- const proc = agent.processes.get(cmd.userId);
770
- if (proc) {
771
- proc.destroy();
772
- agent.processes.delete(cmd.userId);
980
+ this.disconnectClient(agentId, cmd.userId, cmd.chatId);
981
+ const contConf = resolveUserConfig(agent.config, cmd.userId);
982
+ const contState = this.sessionStore.getUser(agentId, cmd.userId);
983
+ const contRepo = contState.repo || contConf.repo;
984
+ let contSession = contState.currentSessionId;
985
+ // If no current session, auto-pick the most recent one
986
+ if (!contSession && contRepo) {
987
+ const recent = discoverCCSessions(contRepo, 1);
988
+ if (recent.length > 0) {
989
+ contSession = recent[0].id;
990
+ this.sessionStore.setCurrentSession(agentId, cmd.userId, contSession);
991
+ }
992
+ }
993
+ // Model priority: running process registry > session JSONL > user default
994
+ let contModel = contConf.model;
995
+ if (contSession && contRepo) {
996
+ // Check if a process is already running for this session
997
+ const registryEntry = this.processRegistry.get(contRepo, contSession);
998
+ if (registryEntry?.model) {
999
+ contModel = registryEntry.model;
1000
+ }
1001
+ else {
1002
+ const sessions = discoverCCSessions(contRepo, 20);
1003
+ const sessionInfo = sessions.find(s => s.id === contSession);
1004
+ if (sessionInfo?.model)
1005
+ contModel = sessionInfo.model;
1006
+ }
773
1007
  }
774
- await agent.tgBot.sendText(cmd.chatId, '<blockquote>Process respawned. Session kept — next message continues where you left off.</blockquote>', 'HTML');
1008
+ const contLines = ['Process respawned. Session kept.'];
1009
+ if (contRepo)
1010
+ contLines.push(`📂 <code>${escapeHtml(shortenRepoPath(contRepo))}</code>`);
1011
+ if (contModel)
1012
+ contLines.push(`🤖 ${escapeHtml(contModel)}`);
1013
+ if (contSession)
1014
+ contLines.push(`📎 <code>${escapeHtml(contSession.slice(0, 8))}</code>`);
1015
+ await agent.tgBot.sendText(cmd.chatId, `<blockquote>${contLines.join('\n')}</blockquote>`, 'HTML');
775
1016
  break;
776
1017
  }
777
1018
  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');
1019
+ const userConf = resolveUserConfig(agent.config, cmd.userId);
1020
+ const userState = this.sessionStore.getUser(agentId, cmd.userId);
1021
+ const repo = userState.repo || userConf.repo;
1022
+ const currentSessionId = userState.currentSessionId;
1023
+ // Discover sessions from CC's session directory
1024
+ const discovered = repo ? discoverCCSessions(repo, 5) : [];
1025
+ const merged = discovered.map(d => {
1026
+ const ctx = d.contextPct !== null ? ` · ${d.contextPct}% ctx` : '';
1027
+ const modelTag = d.model ? ` · ${shortModel(d.model)}` : '';
1028
+ return {
1029
+ id: d.id,
1030
+ title: d.title,
1031
+ age: formatAge(d.mtime),
1032
+ detail: `~${d.lineCount} entries${ctx}${modelTag}`,
1033
+ isCurrent: d.id === currentSessionId,
1034
+ };
1035
+ });
1036
+ if (merged.length === 0) {
1037
+ await agent.tgBot.sendText(cmd.chatId, '<blockquote>No sessions found.</blockquote>', 'HTML');
781
1038
  break;
782
1039
  }
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
- }
1040
+ // Sort: current session first, then by recency
1041
+ merged.sort((a, b) => {
1042
+ if (a.isCurrent && !b.isCurrent)
1043
+ return -1;
1044
+ if (!a.isCurrent && b.isCurrent)
1045
+ return 1;
1046
+ return 0; // already sorted by mtime from discovery
798
1047
  });
799
- await agent.tgBot.sendTextWithKeyboard(cmd.chatId, `📋 <b>Sessions</b>\n\n${lines.join('\n\n')}`, keyboard, 'HTML');
1048
+ // One message per session, each with its own resume button
1049
+ for (const s of merged.slice(0, 5)) {
1050
+ const displayTitle = escapeHtml(s.title);
1051
+ const kb = new InlineKeyboard();
1052
+ if (s.isCurrent) {
1053
+ const repoLine = repo ? `\n📂 <code>${escapeHtml(shortenRepoPath(repo))}</code>` : '';
1054
+ const sessModel = userConf.model;
1055
+ const modelLine = sessModel ? `\n🤖 ${escapeHtml(sessModel)}` : '';
1056
+ const sessionLine = `\n📎 <code>${escapeHtml(s.id.slice(0, 8))}</code>`;
1057
+ const text = `<blockquote><b>Current session:</b>\n${displayTitle}\n${s.detail} · ${s.age}${repoLine}${modelLine}${sessionLine}</blockquote>`;
1058
+ await agent.tgBot.sendText(cmd.chatId, text, 'HTML');
1059
+ }
1060
+ else {
1061
+ const text = `${displayTitle}\n<code>${escapeHtml(s.id.slice(0, 8))}</code> · ${s.detail} · ${s.age}`;
1062
+ const btnTitle = s.title.length > 30 ? s.title.slice(0, 30) + '…' : s.title;
1063
+ kb.text(`▶ ${btnTitle}`, `resume:${s.id}`);
1064
+ await agent.tgBot.sendTextWithKeyboard(cmd.chatId, text, kb, 'HTML');
1065
+ }
1066
+ }
800
1067
  break;
801
1068
  }
802
1069
  case 'resume': {
@@ -804,30 +1071,49 @@ export class Bridge extends EventEmitter {
804
1071
  await agent.tgBot.sendText(cmd.chatId, '<blockquote>Usage: /resume &lt;session-id&gt;</blockquote>', 'HTML');
805
1072
  break;
806
1073
  }
807
- const proc = agent.processes.get(cmd.userId);
808
- if (proc) {
809
- proc.destroy();
810
- agent.processes.delete(cmd.userId);
811
- }
1074
+ this.disconnectClient(agentId, cmd.userId, cmd.chatId);
812
1075
  this.sessionStore.setCurrentSession(agentId, cmd.userId, cmd.args.trim());
813
1076
  await agent.tgBot.sendText(cmd.chatId, `Will resume session <code>${escapeHtml(cmd.args.trim().slice(0, 8))}</code> on next message.`, 'HTML');
814
1077
  break;
815
1078
  }
816
1079
  case 'session': {
817
1080
  const userState = this.sessionStore.getUser(agentId, cmd.userId);
818
- const session = userState.sessions.find(s => s.id === userState.currentSessionId);
819
- if (!session) {
1081
+ if (!userState.currentSessionId) {
820
1082
  await agent.tgBot.sendText(cmd.chatId, '<blockquote>No active session.</blockquote>', 'HTML');
821
1083
  break;
822
1084
  }
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');
1085
+ const sessRepo = userState.repo || resolveUserConfig(agent.config, cmd.userId).repo;
1086
+ const discovered = sessRepo ? discoverCCSessions(sessRepo, 20) : [];
1087
+ const info = discovered.find(d => d.id === userState.currentSessionId);
1088
+ if (!info) {
1089
+ await agent.tgBot.sendText(cmd.chatId, `<b>Session:</b> <code>${escapeHtml(userState.currentSessionId.slice(0, 8))}</code>`, 'HTML');
1090
+ break;
1091
+ }
1092
+ const ctxLine = info.contextPct !== null ? `\n<b>Context:</b> ${info.contextPct}%` : '';
1093
+ const modelLine = info.model ? `\n<b>Model:</b> ${escapeHtml(info.model)}` : '';
1094
+ 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
1095
  break;
825
1096
  }
826
1097
  case 'model': {
827
1098
  const MODEL_OPTIONS = ['opus', 'sonnet', 'haiku'];
828
1099
  if (!cmd.args) {
829
- const current = this.sessionStore.getUser(agentId, cmd.userId).model
830
- || resolveUserConfig(agent.config, cmd.userId).model;
1100
+ // Show model from: running process > JSONL > agent config
1101
+ const uState = this.sessionStore.getUser(agentId, cmd.userId);
1102
+ const uConf = resolveUserConfig(agent.config, cmd.userId);
1103
+ const uRepo = uState.repo || uConf.repo;
1104
+ const uSession = uState.currentSessionId;
1105
+ let current = uConf.model || 'default';
1106
+ if (uSession && uRepo) {
1107
+ const re = this.processRegistry.get(uRepo, uSession);
1108
+ if (re?.model)
1109
+ current = re.model;
1110
+ else {
1111
+ const ds = discoverCCSessions(uRepo, 20);
1112
+ const si = ds.find(s => s.id === uSession);
1113
+ if (si?.model)
1114
+ current = si.model;
1115
+ }
1116
+ }
831
1117
  const keyboard = new InlineKeyboard();
832
1118
  for (const m of MODEL_OPTIONS) {
833
1119
  const isCurrent = current.includes(m);
@@ -837,13 +1123,12 @@ export class Bridge extends EventEmitter {
837
1123
  await agent.tgBot.sendTextWithKeyboard(cmd.chatId, `<b>Current model:</b> <code>${escapeHtml(current)}</code>`, keyboard, 'HTML');
838
1124
  break;
839
1125
  }
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');
1126
+ const newModel = cmd.args.trim();
1127
+ // Store as session-level override (not user default)
1128
+ const curSession = this.sessionStore.getUser(agentId, cmd.userId).currentSessionId;
1129
+ this.sessionModelOverrides.set(`${agentId}:${cmd.userId}`, newModel);
1130
+ this.disconnectClient(agentId, cmd.userId, cmd.chatId);
1131
+ await agent.tgBot.sendText(cmd.chatId, `<blockquote>Model set to <code>${escapeHtml(newModel)}</code>. Process respawned.</blockquote>`, 'HTML');
847
1132
  break;
848
1133
  }
849
1134
  case 'repo': {
@@ -986,22 +1271,11 @@ export class Bridge extends EventEmitter {
986
1271
  break;
987
1272
  }
988
1273
  // 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
- }
1274
+ this.disconnectClient(agentId, cmd.userId, cmd.chatId);
994
1275
  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
- }
1276
+ // Always clear session when repo changes — sessions are project-specific
1277
+ this.sessionStore.clearSession(agentId, cmd.userId);
1278
+ await agent.tgBot.sendText(cmd.chatId, `<blockquote>Repo set to <code>${escapeHtml(shortenRepoPath(repoPath))}</code>. Session cleared.</blockquote>`, 'HTML');
1005
1279
  break;
1006
1280
  }
1007
1281
  case 'cancel': {
@@ -1015,13 +1289,24 @@ export class Bridge extends EventEmitter {
1015
1289
  }
1016
1290
  break;
1017
1291
  }
1292
+ case 'compact': {
1293
+ // Trigger CC's built-in /compact slash command — like the Claude Code extension does
1294
+ const proc = agent.processes.get(cmd.userId);
1295
+ if (!proc || proc.state !== 'active') {
1296
+ await agent.tgBot.sendText(cmd.chatId, '<blockquote>No active session to compact. Start one first.</blockquote>', 'HTML');
1297
+ break;
1298
+ }
1299
+ // Build the compact message: "/compact [optional-instructions]"
1300
+ const compactMsg = cmd.args?.trim()
1301
+ ? `/compact ${cmd.args.trim()}`
1302
+ : '/compact';
1303
+ await agent.tgBot.sendText(cmd.chatId, '<blockquote>🗜️ Compacting…</blockquote>', 'HTML');
1304
+ proc.sendMessage(createTextMessage(compactMsg));
1305
+ break;
1306
+ }
1018
1307
  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');
1308
+ // Catchup now just shows recent sessions via /sessions
1309
+ await agent.tgBot.sendText(cmd.chatId, '<blockquote>Use /sessions to see recent sessions.</blockquote>', 'HTML');
1025
1310
  break;
1026
1311
  }
1027
1312
  case 'permissions': {
@@ -1037,11 +1322,7 @@ export class Bridge extends EventEmitter {
1037
1322
  }
1038
1323
  this.sessionStore.setPermissionMode(agentId, cmd.userId, mode);
1039
1324
  // 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
- }
1325
+ this.disconnectClient(agentId, cmd.userId, cmd.chatId);
1045
1326
  await agent.tgBot.sendText(cmd.chatId, `Permission mode set to <code>${escapeHtml(mode)}</code>. Takes effect on next message.`, 'HTML');
1046
1327
  break;
1047
1328
  }
@@ -1063,11 +1344,7 @@ export class Bridge extends EventEmitter {
1063
1344
  switch (query.action) {
1064
1345
  case 'resume': {
1065
1346
  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
- }
1347
+ this.disconnectClient(agentId, query.userId, query.chatId);
1071
1348
  this.sessionStore.setCurrentSession(agentId, query.userId, sessionId);
1072
1349
  await agent.tgBot.answerCallbackQuery(query.callbackQueryId, 'Session set');
1073
1350
  await agent.tgBot.sendText(query.chatId, `Will resume session <code>${escapeHtml(sessionId.slice(0, 8))}</code> on next message.`, 'HTML');
@@ -1075,14 +1352,13 @@ export class Bridge extends EventEmitter {
1075
1352
  }
1076
1353
  case 'delete': {
1077
1354
  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');
1355
+ // Clear current session if it matches
1356
+ const uState = this.sessionStore.getUser(agentId, query.userId);
1357
+ if (uState.currentSessionId === sessionId) {
1358
+ this.sessionStore.setCurrentSession(agentId, query.userId, '');
1085
1359
  }
1360
+ await agent.tgBot.answerCallbackQuery(query.callbackQueryId, 'Session cleared');
1361
+ await agent.tgBot.sendText(query.chatId, `Session <code>${escapeHtml(sessionId.slice(0, 8))}</code> cleared.`, 'HTML');
1086
1362
  break;
1087
1363
  }
1088
1364
  case 'repo': {
@@ -1093,23 +1369,12 @@ export class Bridge extends EventEmitter {
1093
1369
  break;
1094
1370
  }
1095
1371
  // 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
- }
1372
+ this.disconnectClient(agentId, query.userId, query.chatId);
1101
1373
  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;
1374
+ // Always clear session when repo changes — sessions are project-specific
1375
+ this.sessionStore.clearSession(agentId, query.userId);
1105
1376
  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
- }
1377
+ await agent.tgBot.sendText(query.chatId, `<blockquote>Repo set to <code>${escapeHtml(shortenRepoPath(repoPath))}</code>. Session cleared.</blockquote>`, 'HTML');
1113
1378
  break;
1114
1379
  }
1115
1380
  case 'repo_add': {
@@ -1124,14 +1389,10 @@ export class Bridge extends EventEmitter {
1124
1389
  await agent.tgBot.sendText(query.chatId, 'Send: <code>/model &lt;model-name&gt;</code>', 'HTML');
1125
1390
  break;
1126
1391
  }
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
- }
1392
+ this.sessionModelOverrides.set(`${agentId}:${query.userId}`, model);
1393
+ this.disconnectClient(agentId, query.userId, query.chatId);
1133
1394
  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');
1395
+ await agent.tgBot.sendText(query.chatId, `<blockquote>Model set to <code>${escapeHtml(model)}</code>. Process respawned.</blockquote>`, 'HTML');
1135
1396
  break;
1136
1397
  }
1137
1398
  case 'permissions': {
@@ -1143,11 +1404,7 @@ export class Bridge extends EventEmitter {
1143
1404
  }
1144
1405
  this.sessionStore.setPermissionMode(agentId, query.userId, mode);
1145
1406
  // 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
- }
1407
+ this.disconnectClient(agentId, query.userId, query.chatId);
1151
1408
  await agent.tgBot.answerCallbackQuery(query.callbackQueryId, `Mode: ${mode}`);
1152
1409
  await agent.tgBot.sendText(query.chatId, `Permission mode set to <code>${escapeHtml(mode)}</code>. Takes effect on next message.`, 'HTML');
1153
1410
  break;
@@ -1270,15 +1527,18 @@ export class Bridge extends EventEmitter {
1270
1527
  sessionId: proc?.sessionId ?? null,
1271
1528
  repo: this.sessionStore.getUser(id, userId).repo || userConfig.repo,
1272
1529
  });
1273
- // List sessions for this agent
1530
+ // List sessions for this agent from CC's session directory
1274
1531
  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
- });
1532
+ const sessRepo = userState.repo || userConfig.repo;
1533
+ if (sessRepo) {
1534
+ for (const d of discoverCCSessions(sessRepo, 5)) {
1535
+ sessions.push({
1536
+ id: d.id,
1537
+ agentId: id,
1538
+ messageCount: d.lineCount,
1539
+ totalCostUsd: 0,
1540
+ });
1541
+ }
1282
1542
  }
1283
1543
  }
1284
1544
  return { type: 'status', agents, sessions };
@@ -1317,6 +1577,7 @@ export class Bridge extends EventEmitter {
1317
1577
  for (const agentId of [...this.agents.keys()]) {
1318
1578
  await this.stopAgent(agentId);
1319
1579
  }
1580
+ this.processRegistry.clear();
1320
1581
  this.mcpServer.closeAll();
1321
1582
  this.ctlServer.closeAll();
1322
1583
  this.removeAllListeners();
@@ -1324,6 +1585,21 @@ export class Bridge extends EventEmitter {
1324
1585
  }
1325
1586
  }
1326
1587
  // ── Helpers ──
1588
+ function shortModel(m) {
1589
+ if (m.includes('opus'))
1590
+ return 'opus';
1591
+ if (m.includes('sonnet'))
1592
+ return 'sonnet';
1593
+ if (m.includes('haiku'))
1594
+ return 'haiku';
1595
+ return m.length > 15 ? m.slice(0, 15) + '…' : m;
1596
+ }
1597
+ function shortenRepoPath(p) {
1598
+ return p
1599
+ .replace(/^\/home\/[^/]+\/Botverse\//, '')
1600
+ .replace(/^\/home\/[^/]+\/Projects\//, '')
1601
+ .replace(/^\/home\/[^/]+\//, '~/');
1602
+ }
1327
1603
  function formatDuration(ms) {
1328
1604
  const secs = Math.floor(ms / 1000);
1329
1605
  if (secs < 60)