@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.d.ts +8 -2
- package/dist/bridge.js +556 -280
- package/dist/bridge.js.map +1 -1
- package/dist/cc-process.js +11 -1
- package/dist/cc-process.js.map +1 -1
- package/dist/cc-protocol.d.ts +11 -1
- package/dist/cc-protocol.js.map +1 -1
- package/dist/cli.js +0 -0
- 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 +11 -2
- package/dist/streaming.js +103 -15
- package/dist/streaming.js.map +1 -1
- package/dist/telegram.js +1 -0
- package/dist/telegram.js.map +1 -1
- package/package.json +1 -1
- package/dist/telegram-html.d.ts +0 -5
- package/dist/telegram-html.js +0 -126
- package/dist/telegram-html.js.map +0 -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 = [];
|
|
@@ -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 <name> — 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
|
-
|
|
277
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
}
|
|
349
|
+
checkSessionStaleness(_agentId, _userId) {
|
|
350
|
+
// With shared process registry, staleness is handled by the registry itself
|
|
351
|
+
return null;
|
|
346
352
|
}
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
|
354
|
-
const
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
this
|
|
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
|
-
|
|
360
|
-
//
|
|
361
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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
|
-
|
|
461
|
-
if (
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
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
|
|
481
|
-
const
|
|
482
|
-
|
|
483
|
-
|
|
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
|
|
488
|
-
const
|
|
489
|
-
|
|
490
|
-
|
|
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
|
|
495
|
-
const
|
|
496
|
-
|
|
497
|
-
|
|
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
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
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
|
-
|
|
521
|
-
this.
|
|
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
|
-
|
|
559
|
-
|
|
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
|
-
|
|
563
|
-
|
|
564
|
-
|
|
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
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
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
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
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
|
-
|
|
592
|
-
|
|
593
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
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
|
-
|
|
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
|
|
779
|
-
|
|
780
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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 <session-id></blockquote>', 'HTML');
|
|
805
1072
|
break;
|
|
806
1073
|
}
|
|
807
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
830
|
-
|
|
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
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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');
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1103
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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 <model-name></code>', 'HTML');
|
|
1125
1390
|
break;
|
|
1126
1391
|
}
|
|
1127
|
-
this.
|
|
1128
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
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)
|