@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.d.ts +8 -2
- package/dist/bridge.js +523 -280
- package/dist/bridge.js.map +1 -1
- package/dist/cc-process.js +7 -1
- package/dist/cc-process.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/process-registry.d.ts +60 -0
- package/dist/process-registry.js +176 -0
- package/dist/process-registry.js.map +1 -0
- package/dist/session.d.ts +11 -33
- package/dist/session.js +185 -309
- package/dist/session.js.map +1 -1
- package/dist/streaming.d.ts +1 -1
- package/dist/streaming.js +67 -11
- package/dist/streaming.js.map +1 -1
- package/package.json +1 -1
package/dist/bridge.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { join } from 'node:path';
|
|
2
|
-
import { existsSync, readFileSync
|
|
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,
|
|
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
|
-
|
|
277
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
321
|
-
|
|
322
|
-
|
|
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
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
|
354
|
-
const
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
this
|
|
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
|
-
|
|
360
|
-
//
|
|
361
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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
|
-
|
|
461
|
-
if (
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
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
|
|
481
|
-
const
|
|
482
|
-
|
|
483
|
-
|
|
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
|
|
488
|
-
const
|
|
489
|
-
|
|
490
|
-
|
|
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
|
|
495
|
-
const
|
|
496
|
-
|
|
497
|
-
|
|
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
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
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
|
-
|
|
521
|
-
this.
|
|
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
|
-
|
|
559
|
-
|
|
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
|
-
|
|
563
|
-
|
|
564
|
-
|
|
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
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
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
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
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
|
-
|
|
592
|
-
|
|
593
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
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
|
-
|
|
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
|
|
779
|
-
|
|
780
|
-
|
|
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
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
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
|
-
|
|
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 <session-id></blockquote>', 'HTML');
|
|
805
1054
|
break;
|
|
806
1055
|
}
|
|
807
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
830
|
-
|
|
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
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
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
|
-
|
|
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
|
-
|
|
996
|
-
|
|
997
|
-
|
|
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 <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
|
-
|
|
1020
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1103
|
-
|
|
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
|
-
|
|
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 <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 <model-name></code>', 'HTML');
|
|
1125
1357
|
break;
|
|
1126
1358
|
}
|
|
1127
|
-
this.
|
|
1128
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
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)
|