@fonz/tgcc 0.0.1 → 0.2.0
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/LICENSE +21 -0
- package/README.md +266 -0
- package/dist/bridge.d.ts +32 -0
- package/dist/bridge.js +1065 -0
- package/dist/bridge.js.map +1 -0
- package/dist/cc-process.d.ts +75 -0
- package/dist/cc-process.js +397 -0
- package/dist/cc-process.js.map +1 -0
- package/dist/cc-protocol.d.ts +227 -0
- package/dist/cc-protocol.js +108 -0
- package/dist/cc-protocol.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +668 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +75 -0
- package/dist/config.js +268 -0
- package/dist/config.js.map +1 -0
- package/dist/ctl-server.d.ts +57 -0
- package/dist/ctl-server.js +98 -0
- package/dist/ctl-server.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +13 -0
- package/dist/index.js.map +1 -0
- package/dist/mcp-bridge.d.ts +45 -0
- package/dist/mcp-bridge.js +182 -0
- package/dist/mcp-bridge.js.map +1 -0
- package/dist/mcp-server.d.ts +1 -0
- package/dist/mcp-server.js +109 -0
- package/dist/mcp-server.js.map +1 -0
- package/dist/service.d.ts +1 -0
- package/dist/service.js +84 -0
- package/dist/service.js.map +1 -0
- package/dist/session.d.ts +71 -0
- package/dist/session.js +435 -0
- package/dist/session.js.map +1 -0
- package/dist/streaming.d.ts +112 -0
- package/dist/streaming.js +511 -0
- package/dist/streaming.js.map +1 -0
- package/dist/telegram.d.ts +70 -0
- package/dist/telegram.js +380 -0
- package/dist/telegram.js.map +1 -0
- package/package.json +87 -4
package/dist/bridge.js
ADDED
|
@@ -0,0 +1,1065 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { EventEmitter } from 'node:events';
|
|
5
|
+
import pino from 'pino';
|
|
6
|
+
import { resolveUserConfig, resolveRepoPath, updateConfig, isValidRepoName, findRepoOwner } from './config.js';
|
|
7
|
+
import { CCProcess, generateMcpConfig } from './cc-process.js';
|
|
8
|
+
import { createTextMessage, createImageMessage, createDocumentMessage, } from './cc-protocol.js';
|
|
9
|
+
import { StreamAccumulator, SubAgentTracker, escapeHtml } from './streaming.js';
|
|
10
|
+
import { TelegramBot } from './telegram.js';
|
|
11
|
+
import { InlineKeyboard } from 'grammy';
|
|
12
|
+
import { McpBridgeServer } from './mcp-bridge.js';
|
|
13
|
+
import { SessionStore, findMissedSessions, formatCatchupMessage, getSessionJsonlPath, summarizeJsonlDelta, } from './session.js';
|
|
14
|
+
import { CtlServer, } from './ctl-server.js';
|
|
15
|
+
// ── Message Batcher ──
|
|
16
|
+
class MessageBatcher {
|
|
17
|
+
pending = [];
|
|
18
|
+
timer = null;
|
|
19
|
+
windowMs;
|
|
20
|
+
flush;
|
|
21
|
+
constructor(windowMs, flushFn) {
|
|
22
|
+
this.windowMs = windowMs;
|
|
23
|
+
this.flush = flushFn;
|
|
24
|
+
}
|
|
25
|
+
add(msg) {
|
|
26
|
+
this.pending.push(msg);
|
|
27
|
+
// If this is a media message, flush immediately (don't batch media)
|
|
28
|
+
if (msg.imageBase64 || msg.filePath) {
|
|
29
|
+
this.doFlush();
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (!this.timer) {
|
|
33
|
+
this.timer = setTimeout(() => this.doFlush(), this.windowMs);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
doFlush() {
|
|
37
|
+
if (this.timer) {
|
|
38
|
+
clearTimeout(this.timer);
|
|
39
|
+
this.timer = null;
|
|
40
|
+
}
|
|
41
|
+
if (this.pending.length === 0)
|
|
42
|
+
return;
|
|
43
|
+
// If there's a single message with media, send it directly
|
|
44
|
+
if (this.pending.length === 1) {
|
|
45
|
+
this.flush(this.pending[0]);
|
|
46
|
+
this.pending = [];
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
// Combine text-only messages
|
|
50
|
+
const combined = this.pending.map(m => m.text).filter(Boolean).join('\n\n');
|
|
51
|
+
this.flush({ text: combined });
|
|
52
|
+
this.pending = [];
|
|
53
|
+
}
|
|
54
|
+
cancel() {
|
|
55
|
+
if (this.timer) {
|
|
56
|
+
clearTimeout(this.timer);
|
|
57
|
+
this.timer = null;
|
|
58
|
+
}
|
|
59
|
+
this.pending = [];
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
// ── Help text ──
|
|
63
|
+
const HELP_TEXT = `<b>TGCC Commands</b>
|
|
64
|
+
|
|
65
|
+
<b>Session</b>
|
|
66
|
+
/new — Start a fresh session
|
|
67
|
+
/sessions — List recent sessions
|
|
68
|
+
/resume <id> — Resume a session by ID
|
|
69
|
+
/session — Current session info
|
|
70
|
+
|
|
71
|
+
<b>Info</b>
|
|
72
|
+
/status — Process state, model, uptime
|
|
73
|
+
/cost — Show session cost
|
|
74
|
+
/catchup — Summarize external CC activity
|
|
75
|
+
/ping — Liveness check
|
|
76
|
+
|
|
77
|
+
<b>Control</b>
|
|
78
|
+
/cancel — Abort current CC turn
|
|
79
|
+
/model <name> — Switch model
|
|
80
|
+
/permissions — Set permission mode
|
|
81
|
+
/repo — List repos (buttons)
|
|
82
|
+
/repo help — Repo management commands
|
|
83
|
+
/repo add <name> <path> — Register a repo
|
|
84
|
+
/repo remove <name> — Unregister a repo
|
|
85
|
+
/repo assign <name> — Set as agent default
|
|
86
|
+
/repo clear — Clear agent default
|
|
87
|
+
|
|
88
|
+
/help — This message`;
|
|
89
|
+
// ── Bridge ──
|
|
90
|
+
export class Bridge extends EventEmitter {
|
|
91
|
+
config;
|
|
92
|
+
agents = new Map();
|
|
93
|
+
mcpServer;
|
|
94
|
+
ctlServer;
|
|
95
|
+
sessionStore;
|
|
96
|
+
logger;
|
|
97
|
+
constructor(config, logger) {
|
|
98
|
+
super();
|
|
99
|
+
this.config = config;
|
|
100
|
+
this.logger = logger ?? pino({ level: config.global.logLevel });
|
|
101
|
+
this.sessionStore = new SessionStore(config.global.stateFile, this.logger);
|
|
102
|
+
this.mcpServer = new McpBridgeServer((req) => this.handleMcpToolRequest(req), this.logger);
|
|
103
|
+
this.ctlServer = new CtlServer(this, this.logger);
|
|
104
|
+
}
|
|
105
|
+
// ── Startup ──
|
|
106
|
+
async start() {
|
|
107
|
+
this.logger.info('Starting bridge');
|
|
108
|
+
for (const [agentId, agentConfig] of Object.entries(this.config.agents)) {
|
|
109
|
+
await this.startAgent(agentId, agentConfig);
|
|
110
|
+
}
|
|
111
|
+
this.logger.info({ agents: Object.keys(this.config.agents) }, 'Bridge started');
|
|
112
|
+
}
|
|
113
|
+
async startAgent(agentId, agentConfig) {
|
|
114
|
+
this.logger.info({ agentId }, 'Starting agent');
|
|
115
|
+
const tgBot = new TelegramBot(agentId, agentConfig, this.config.global.mediaDir, (msg) => this.handleTelegramMessage(agentId, msg), (cmd) => this.handleSlashCommand(agentId, cmd), this.logger, (query) => this.handleCallbackQuery(agentId, query));
|
|
116
|
+
const instance = {
|
|
117
|
+
id: agentId,
|
|
118
|
+
config: agentConfig,
|
|
119
|
+
tgBot,
|
|
120
|
+
processes: new Map(),
|
|
121
|
+
accumulators: new Map(),
|
|
122
|
+
subAgentTrackers: new Map(),
|
|
123
|
+
batchers: new Map(),
|
|
124
|
+
pendingTitles: new Map(),
|
|
125
|
+
pendingPermissions: new Map(),
|
|
126
|
+
};
|
|
127
|
+
this.agents.set(agentId, instance);
|
|
128
|
+
await tgBot.start();
|
|
129
|
+
// Start control socket for CLI access
|
|
130
|
+
const ctlSocketPath = join('/tmp/tgcc/ctl', `${agentId}.sock`);
|
|
131
|
+
this.ctlServer.listen(ctlSocketPath);
|
|
132
|
+
}
|
|
133
|
+
// ── Hot reload ──
|
|
134
|
+
async handleConfigChange(newConfig, diff) {
|
|
135
|
+
this.logger.info({ diff }, 'Handling config change');
|
|
136
|
+
// Remove agents
|
|
137
|
+
for (const agentId of diff.removed) {
|
|
138
|
+
await this.stopAgent(agentId);
|
|
139
|
+
}
|
|
140
|
+
// Add new agents
|
|
141
|
+
for (const agentId of diff.added) {
|
|
142
|
+
await this.startAgent(agentId, newConfig.agents[agentId]);
|
|
143
|
+
}
|
|
144
|
+
// Handle changed agents
|
|
145
|
+
for (const agentId of diff.changed) {
|
|
146
|
+
const oldAgent = this.agents.get(agentId);
|
|
147
|
+
const newAgentConfig = newConfig.agents[agentId];
|
|
148
|
+
if (!oldAgent)
|
|
149
|
+
continue;
|
|
150
|
+
// If bot token changed, full restart
|
|
151
|
+
if (oldAgent.config.botToken !== newAgentConfig.botToken) {
|
|
152
|
+
await this.stopAgent(agentId);
|
|
153
|
+
await this.startAgent(agentId, newAgentConfig);
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
// Update in-memory config — active processes keep old config
|
|
157
|
+
oldAgent.config = newAgentConfig;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
this.config = newConfig;
|
|
161
|
+
}
|
|
162
|
+
async stopAgent(agentId) {
|
|
163
|
+
const agent = this.agents.get(agentId);
|
|
164
|
+
if (!agent)
|
|
165
|
+
return;
|
|
166
|
+
this.logger.info({ agentId }, 'Stopping agent');
|
|
167
|
+
// Stop bot
|
|
168
|
+
await agent.tgBot.stop();
|
|
169
|
+
// Kill all CC processes
|
|
170
|
+
for (const [, proc] of agent.processes) {
|
|
171
|
+
proc.destroy();
|
|
172
|
+
}
|
|
173
|
+
// Cancel batchers
|
|
174
|
+
for (const [, batcher] of agent.batchers) {
|
|
175
|
+
batcher.cancel();
|
|
176
|
+
}
|
|
177
|
+
// Close MCP sockets
|
|
178
|
+
for (const userId of agent.processes.keys()) {
|
|
179
|
+
const socketPath = join(this.config.global.socketDir, `${agentId}-${userId}.sock`);
|
|
180
|
+
this.mcpServer.close(socketPath);
|
|
181
|
+
}
|
|
182
|
+
// Close control socket
|
|
183
|
+
const ctlSocketPath = join('/tmp/tgcc/ctl', `${agentId}.sock`);
|
|
184
|
+
this.ctlServer.close(ctlSocketPath);
|
|
185
|
+
this.agents.delete(agentId);
|
|
186
|
+
}
|
|
187
|
+
// ── Message handling ──
|
|
188
|
+
handleTelegramMessage(agentId, msg) {
|
|
189
|
+
const agent = this.agents.get(agentId);
|
|
190
|
+
if (!agent)
|
|
191
|
+
return;
|
|
192
|
+
this.logger.debug({ agentId, userId: msg.userId, type: msg.type }, 'TG message received');
|
|
193
|
+
// Ensure batcher exists
|
|
194
|
+
if (!agent.batchers.has(msg.userId)) {
|
|
195
|
+
agent.batchers.set(msg.userId, new MessageBatcher(2000, (combined) => {
|
|
196
|
+
this.sendToCC(agentId, msg.userId, msg.chatId, combined);
|
|
197
|
+
}));
|
|
198
|
+
}
|
|
199
|
+
// Prepare text with reply context
|
|
200
|
+
let text = msg.text;
|
|
201
|
+
if (msg.replyToText) {
|
|
202
|
+
text = `[Replying to: '${msg.replyToText}']\n\n${text}`;
|
|
203
|
+
}
|
|
204
|
+
const batcher = agent.batchers.get(msg.userId);
|
|
205
|
+
batcher.add({
|
|
206
|
+
text,
|
|
207
|
+
imageBase64: msg.imageBase64,
|
|
208
|
+
imageMediaType: msg.imageMediaType,
|
|
209
|
+
filePath: msg.filePath,
|
|
210
|
+
fileName: msg.fileName,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
sendToCC(agentId, userId, chatId, data) {
|
|
214
|
+
const agent = this.agents.get(agentId);
|
|
215
|
+
if (!agent)
|
|
216
|
+
return;
|
|
217
|
+
// Construct CC message
|
|
218
|
+
let ccMsg;
|
|
219
|
+
if (data.imageBase64) {
|
|
220
|
+
ccMsg = createImageMessage(data.text || 'What do you see in this image?', data.imageBase64, data.imageMediaType);
|
|
221
|
+
}
|
|
222
|
+
else if (data.filePath && data.fileName) {
|
|
223
|
+
ccMsg = createDocumentMessage(data.text, data.filePath, data.fileName);
|
|
224
|
+
}
|
|
225
|
+
else {
|
|
226
|
+
ccMsg = createTextMessage(data.text);
|
|
227
|
+
}
|
|
228
|
+
// Get or create CC process
|
|
229
|
+
let proc = agent.processes.get(userId);
|
|
230
|
+
if (proc?.takenOver) {
|
|
231
|
+
// Session was taken over externally — discard old process
|
|
232
|
+
proc.destroy();
|
|
233
|
+
agent.processes.delete(userId);
|
|
234
|
+
proc = undefined;
|
|
235
|
+
}
|
|
236
|
+
// Staleness check: detect if session was modified by another client
|
|
237
|
+
if (proc && proc.state !== 'idle') {
|
|
238
|
+
const staleInfo = this.checkSessionStaleness(agentId, userId);
|
|
239
|
+
if (staleInfo) {
|
|
240
|
+
this.logger.info({ agentId, userId }, 'Session stale — killing process for reconnect');
|
|
241
|
+
proc.destroy();
|
|
242
|
+
agent.processes.delete(userId);
|
|
243
|
+
proc = undefined;
|
|
244
|
+
agent.tgBot.sendText(chatId, staleInfo.summary, 'HTML');
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
if (!proc || proc.state === 'idle') {
|
|
248
|
+
// Save first message text as pending session title
|
|
249
|
+
if (data.text) {
|
|
250
|
+
agent.pendingTitles.set(userId, data.text);
|
|
251
|
+
}
|
|
252
|
+
proc = this.spawnCCProcess(agentId, userId, chatId);
|
|
253
|
+
agent.processes.set(userId, proc);
|
|
254
|
+
}
|
|
255
|
+
// Show typing indicator
|
|
256
|
+
agent.tgBot.sendTyping(chatId);
|
|
257
|
+
proc.sendMessage(ccMsg);
|
|
258
|
+
this.sessionStore.updateSessionActivity(agentId, userId);
|
|
259
|
+
}
|
|
260
|
+
/** Check if the session JSONL was modified externally since we last tracked it. */
|
|
261
|
+
checkSessionStaleness(agentId, userId) {
|
|
262
|
+
const userState = this.sessionStore.getUser(agentId, userId);
|
|
263
|
+
const sessionId = userState.currentSessionId;
|
|
264
|
+
if (!sessionId)
|
|
265
|
+
return null;
|
|
266
|
+
const repo = userState.repo || resolveUserConfig(this.agents.get(agentId).config, userId).repo;
|
|
267
|
+
const jsonlPath = getSessionJsonlPath(sessionId, repo);
|
|
268
|
+
const tracking = this.sessionStore.getJsonlTracking(agentId, userId);
|
|
269
|
+
// No tracking yet (first message or new session) — not stale
|
|
270
|
+
if (!tracking)
|
|
271
|
+
return null;
|
|
272
|
+
try {
|
|
273
|
+
const stat = statSync(jsonlPath);
|
|
274
|
+
// File grew or was modified since we last tracked
|
|
275
|
+
if (stat.size <= tracking.size && stat.mtimeMs <= tracking.mtimeMs) {
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
// Session is stale — build a summary of what happened
|
|
279
|
+
const summary = summarizeJsonlDelta(jsonlPath, tracking.size)
|
|
280
|
+
?? '<i>ℹ️ Session was updated from another client. Reconnecting...</i>';
|
|
281
|
+
return { summary };
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
// File doesn't exist or stat failed — skip check
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
/** Update JSONL tracking from the current file state. */
|
|
289
|
+
updateJsonlTracking(agentId, userId) {
|
|
290
|
+
const userState = this.sessionStore.getUser(agentId, userId);
|
|
291
|
+
const sessionId = userState.currentSessionId;
|
|
292
|
+
if (!sessionId)
|
|
293
|
+
return;
|
|
294
|
+
const repo = userState.repo || resolveUserConfig(this.agents.get(agentId).config, userId).repo;
|
|
295
|
+
const jsonlPath = getSessionJsonlPath(sessionId, repo);
|
|
296
|
+
try {
|
|
297
|
+
const stat = statSync(jsonlPath);
|
|
298
|
+
this.sessionStore.updateJsonlTracking(agentId, userId, stat.size, stat.mtimeMs);
|
|
299
|
+
}
|
|
300
|
+
catch {
|
|
301
|
+
// File doesn't exist yet — clear tracking
|
|
302
|
+
this.sessionStore.clearJsonlTracking(agentId, userId);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
spawnCCProcess(agentId, userId, chatId) {
|
|
306
|
+
const agent = this.agents.get(agentId);
|
|
307
|
+
const userConfig = resolveUserConfig(agent.config, userId);
|
|
308
|
+
// Check session store for model/repo/permission overrides
|
|
309
|
+
const userState = this.sessionStore.getUser(agentId, userId);
|
|
310
|
+
if (userState.model)
|
|
311
|
+
userConfig.model = userState.model;
|
|
312
|
+
if (userState.repo)
|
|
313
|
+
userConfig.repo = userState.repo;
|
|
314
|
+
if (userState.permissionMode) {
|
|
315
|
+
userConfig.permissionMode = userState.permissionMode;
|
|
316
|
+
}
|
|
317
|
+
// Generate MCP config
|
|
318
|
+
const mcpServerPath = join(import.meta.dirname ?? '.', 'mcp-server.js');
|
|
319
|
+
const mcpConfigPath = generateMcpConfig(agentId, userId, this.config.global.socketDir, mcpServerPath);
|
|
320
|
+
// Start MCP socket listener for this agent-user pair
|
|
321
|
+
const socketPath = join(this.config.global.socketDir, `${agentId}-${userId}.sock`);
|
|
322
|
+
this.mcpServer.listen(socketPath);
|
|
323
|
+
const proc = new CCProcess({
|
|
324
|
+
agentId,
|
|
325
|
+
userId,
|
|
326
|
+
ccBinaryPath: this.config.global.ccBinaryPath,
|
|
327
|
+
userConfig,
|
|
328
|
+
mcpConfigPath,
|
|
329
|
+
sessionId: userState.currentSessionId ?? undefined,
|
|
330
|
+
continueSession: !!userState.currentSessionId,
|
|
331
|
+
logger: this.logger,
|
|
332
|
+
});
|
|
333
|
+
// ── Wire up event handlers ──
|
|
334
|
+
proc.on('init', (event) => {
|
|
335
|
+
this.sessionStore.setCurrentSession(agentId, userId, event.session_id);
|
|
336
|
+
// Set session title from the first user message
|
|
337
|
+
const pendingTitle = agent.pendingTitles.get(userId);
|
|
338
|
+
if (pendingTitle) {
|
|
339
|
+
this.sessionStore.setSessionTitle(agentId, userId, event.session_id, pendingTitle);
|
|
340
|
+
agent.pendingTitles.delete(userId);
|
|
341
|
+
}
|
|
342
|
+
// Initialize JSONL tracking for staleness detection
|
|
343
|
+
this.updateJsonlTracking(agentId, userId);
|
|
344
|
+
});
|
|
345
|
+
proc.on('stream_event', (event) => {
|
|
346
|
+
this.handleStreamEvent(agentId, userId, chatId, event);
|
|
347
|
+
});
|
|
348
|
+
proc.on('assistant', (event) => {
|
|
349
|
+
// Non-streaming fallback — only used if stream_events don't fire
|
|
350
|
+
// In practice, stream_events handle the display
|
|
351
|
+
});
|
|
352
|
+
proc.on('result', (event) => {
|
|
353
|
+
this.handleResult(agentId, userId, chatId, event);
|
|
354
|
+
});
|
|
355
|
+
proc.on('permission_request', (event) => {
|
|
356
|
+
const req = event.request;
|
|
357
|
+
const requestId = event.request_id;
|
|
358
|
+
// Store pending permission
|
|
359
|
+
agent.pendingPermissions.set(requestId, {
|
|
360
|
+
requestId,
|
|
361
|
+
userId,
|
|
362
|
+
toolName: req.tool_name,
|
|
363
|
+
input: req.input,
|
|
364
|
+
});
|
|
365
|
+
// Build description of what CC wants to do
|
|
366
|
+
const toolName = escapeHtml(req.tool_name);
|
|
367
|
+
const inputPreview = req.input
|
|
368
|
+
? escapeHtml(JSON.stringify(req.input).slice(0, 200))
|
|
369
|
+
: '';
|
|
370
|
+
const text = inputPreview
|
|
371
|
+
? `🔐 CC wants to use <code>${toolName}</code>\n<pre>${inputPreview}</pre>`
|
|
372
|
+
: `🔐 CC wants to use <code>${toolName}</code>`;
|
|
373
|
+
const keyboard = new InlineKeyboard()
|
|
374
|
+
.text('✅ Allow', `perm_allow:${requestId}`)
|
|
375
|
+
.text('❌ Deny', `perm_deny:${requestId}`)
|
|
376
|
+
.text('✅ Allow All', `perm_allow_all:${userId}`);
|
|
377
|
+
agent.tgBot.sendTextWithKeyboard(chatId, text, keyboard, 'HTML');
|
|
378
|
+
});
|
|
379
|
+
proc.on('api_error', (event) => {
|
|
380
|
+
const errMsg = event.error?.message || 'Unknown API error';
|
|
381
|
+
const status = event.error?.status;
|
|
382
|
+
const isOverloaded = status === 529 || errMsg.includes('overloaded');
|
|
383
|
+
const retryInfo = event.retryAttempt != null && event.maxRetries != null
|
|
384
|
+
? ` (retry ${event.retryAttempt}/${event.maxRetries})`
|
|
385
|
+
: '';
|
|
386
|
+
const text = isOverloaded
|
|
387
|
+
? `<blockquote>⚠️ API overloaded, retrying...${retryInfo}</blockquote>`
|
|
388
|
+
: `<blockquote>⚠️ ${escapeHtml(errMsg)}${retryInfo}</blockquote>`;
|
|
389
|
+
agent.tgBot.sendText(chatId, text, 'HTML');
|
|
390
|
+
});
|
|
391
|
+
proc.on('hang', () => {
|
|
392
|
+
agent.tgBot.sendText(chatId, '<i>CC session paused. Send a message to continue.</i>', 'HTML');
|
|
393
|
+
});
|
|
394
|
+
proc.on('takeover', () => {
|
|
395
|
+
this.logger.warn({ agentId, userId }, 'Session takeover detected');
|
|
396
|
+
// Clear session so next message starts fresh instead of --resume
|
|
397
|
+
this.sessionStore.clearSession(agentId, userId);
|
|
398
|
+
agent.tgBot.sendText(chatId, '<i>Session was picked up by another client. Next message will start a fresh session.</i>', 'HTML');
|
|
399
|
+
});
|
|
400
|
+
proc.on('exit', () => {
|
|
401
|
+
// Finalize any active accumulator
|
|
402
|
+
const accKey = `${userId}:${chatId}`;
|
|
403
|
+
const acc = agent.accumulators.get(accKey);
|
|
404
|
+
if (acc) {
|
|
405
|
+
acc.finalize();
|
|
406
|
+
agent.accumulators.delete(accKey);
|
|
407
|
+
}
|
|
408
|
+
// Clean up sub-agent tracker
|
|
409
|
+
agent.subAgentTrackers.delete(accKey);
|
|
410
|
+
});
|
|
411
|
+
proc.on('error', (err) => {
|
|
412
|
+
agent.tgBot.sendText(chatId, `<i>CC error: ${escapeHtml(String(err.message))}</i>`, 'HTML');
|
|
413
|
+
});
|
|
414
|
+
return proc;
|
|
415
|
+
}
|
|
416
|
+
// ── Stream event handling ──
|
|
417
|
+
handleStreamEvent(agentId, userId, chatId, event) {
|
|
418
|
+
const agent = this.agents.get(agentId);
|
|
419
|
+
if (!agent)
|
|
420
|
+
return;
|
|
421
|
+
const accKey = `${userId}:${chatId}`;
|
|
422
|
+
let acc = agent.accumulators.get(accKey);
|
|
423
|
+
if (!acc) {
|
|
424
|
+
const sender = {
|
|
425
|
+
sendMessage: (cid, text, parseMode) => agent.tgBot.sendText(cid, text, parseMode),
|
|
426
|
+
editMessage: (cid, msgId, text, parseMode) => agent.tgBot.editText(cid, msgId, text, parseMode),
|
|
427
|
+
sendPhoto: (cid, buffer, caption) => agent.tgBot.sendPhotoBuffer(cid, buffer, caption),
|
|
428
|
+
};
|
|
429
|
+
acc = new StreamAccumulator({ chatId, sender });
|
|
430
|
+
agent.accumulators.set(accKey, acc);
|
|
431
|
+
}
|
|
432
|
+
// Sub-agent tracker — create lazily alongside the accumulator
|
|
433
|
+
let tracker = agent.subAgentTrackers.get(accKey);
|
|
434
|
+
if (!tracker) {
|
|
435
|
+
const subAgentSender = {
|
|
436
|
+
replyToMessage: (cid, text, replyTo, parseMode) => agent.tgBot.replyToMessage(cid, text, replyTo, parseMode),
|
|
437
|
+
editMessage: (cid, msgId, text, parseMode) => agent.tgBot.editText(cid, msgId, text, parseMode),
|
|
438
|
+
};
|
|
439
|
+
tracker = new SubAgentTracker({
|
|
440
|
+
chatId,
|
|
441
|
+
sender: subAgentSender,
|
|
442
|
+
getMainMessageId: () => {
|
|
443
|
+
const ids = acc.allMessageIds;
|
|
444
|
+
return ids.length > 0 ? ids[0] : null;
|
|
445
|
+
},
|
|
446
|
+
});
|
|
447
|
+
agent.subAgentTrackers.set(accKey, tracker);
|
|
448
|
+
}
|
|
449
|
+
acc.handleEvent(event);
|
|
450
|
+
tracker.handleEvent(event);
|
|
451
|
+
}
|
|
452
|
+
handleResult(agentId, userId, chatId, event) {
|
|
453
|
+
const agent = this.agents.get(agentId);
|
|
454
|
+
if (!agent)
|
|
455
|
+
return;
|
|
456
|
+
// Set usage stats on the accumulator before finalizing
|
|
457
|
+
const accKey = `${userId}:${chatId}`;
|
|
458
|
+
const acc = agent.accumulators.get(accKey);
|
|
459
|
+
if (acc) {
|
|
460
|
+
// Extract usage from result event
|
|
461
|
+
if (event.usage) {
|
|
462
|
+
acc.setTurnUsage({
|
|
463
|
+
inputTokens: event.usage.input_tokens ?? 0,
|
|
464
|
+
outputTokens: event.usage.output_tokens ?? 0,
|
|
465
|
+
cacheReadTokens: event.usage.cache_read_input_tokens ?? 0,
|
|
466
|
+
cacheCreationTokens: event.usage.cache_creation_input_tokens ?? 0,
|
|
467
|
+
costUsd: event.total_cost_usd ?? null,
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
acc.finalize();
|
|
471
|
+
// Don't delete — next turn will softReset via message_start and edit the same message
|
|
472
|
+
}
|
|
473
|
+
// Update session store with cost
|
|
474
|
+
if (event.total_cost_usd) {
|
|
475
|
+
this.sessionStore.updateSessionActivity(agentId, userId, event.total_cost_usd);
|
|
476
|
+
}
|
|
477
|
+
// Update JSONL tracking after our own CC turn completes
|
|
478
|
+
// This prevents false-positive staleness on our own writes
|
|
479
|
+
this.updateJsonlTracking(agentId, userId);
|
|
480
|
+
// Handle errors
|
|
481
|
+
if (event.is_error && event.result) {
|
|
482
|
+
agent.tgBot.sendText(chatId, `<i>Error: ${escapeHtml(String(event.result))}</i>`, 'HTML');
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
// ── Slash commands ──
|
|
486
|
+
async handleSlashCommand(agentId, cmd) {
|
|
487
|
+
const agent = this.agents.get(agentId);
|
|
488
|
+
if (!agent)
|
|
489
|
+
return;
|
|
490
|
+
this.logger.debug({ agentId, command: cmd.command, args: cmd.args }, 'Slash command');
|
|
491
|
+
switch (cmd.command) {
|
|
492
|
+
case 'start': {
|
|
493
|
+
await agent.tgBot.sendText(cmd.chatId, '👋 TGCC — Telegram ↔ Claude Code bridge\n\nSend me a message to start a CC session, or use /help for commands.');
|
|
494
|
+
// Re-register commands with BotFather to ensure menu is up to date
|
|
495
|
+
try {
|
|
496
|
+
const { COMMANDS } = await import('./telegram.js');
|
|
497
|
+
await agent.tgBot.bot.api.setMyCommands(COMMANDS);
|
|
498
|
+
}
|
|
499
|
+
catch { }
|
|
500
|
+
break;
|
|
501
|
+
}
|
|
502
|
+
case 'help':
|
|
503
|
+
await agent.tgBot.sendText(cmd.chatId, HELP_TEXT, 'HTML');
|
|
504
|
+
break;
|
|
505
|
+
case 'ping': {
|
|
506
|
+
const proc = agent.processes.get(cmd.userId);
|
|
507
|
+
const state = proc?.state ?? 'idle';
|
|
508
|
+
await agent.tgBot.sendText(cmd.chatId, `pong — process: ${state.toUpperCase()}`);
|
|
509
|
+
break;
|
|
510
|
+
}
|
|
511
|
+
case 'status': {
|
|
512
|
+
const proc = agent.processes.get(cmd.userId);
|
|
513
|
+
const userState = this.sessionStore.getUser(agentId, cmd.userId);
|
|
514
|
+
const uptime = proc?.spawnedAt
|
|
515
|
+
? formatDuration(Date.now() - proc.spawnedAt.getTime())
|
|
516
|
+
: 'N/A';
|
|
517
|
+
const status = [
|
|
518
|
+
`<b>Agent:</b> ${escapeHtml(agentId)}`,
|
|
519
|
+
`<b>Process:</b> ${(proc?.state ?? 'idle').toUpperCase()} (uptime: ${uptime})`,
|
|
520
|
+
`<b>Session:</b> <code>${escapeHtml(proc?.sessionId?.slice(0, 8) ?? 'none')}</code>`,
|
|
521
|
+
`<b>Model:</b> ${escapeHtml(userState.model || resolveUserConfig(agent.config, cmd.userId).model)}`,
|
|
522
|
+
`<b>Repo:</b> ${escapeHtml(userState.repo || resolveUserConfig(agent.config, cmd.userId).repo)}`,
|
|
523
|
+
`<b>Cost:</b> $${(proc?.totalCostUsd ?? 0).toFixed(4)}`,
|
|
524
|
+
].join('\n');
|
|
525
|
+
await agent.tgBot.sendText(cmd.chatId, status, 'HTML');
|
|
526
|
+
break;
|
|
527
|
+
}
|
|
528
|
+
case 'cost': {
|
|
529
|
+
const proc = agent.processes.get(cmd.userId);
|
|
530
|
+
await agent.tgBot.sendText(cmd.chatId, `Session cost: $${(proc?.totalCostUsd ?? 0).toFixed(4)}`);
|
|
531
|
+
break;
|
|
532
|
+
}
|
|
533
|
+
case 'new': {
|
|
534
|
+
const proc = agent.processes.get(cmd.userId);
|
|
535
|
+
if (proc) {
|
|
536
|
+
proc.destroy();
|
|
537
|
+
agent.processes.delete(cmd.userId);
|
|
538
|
+
}
|
|
539
|
+
this.sessionStore.clearSession(agentId, cmd.userId);
|
|
540
|
+
await agent.tgBot.sendText(cmd.chatId, 'Session cleared. Next message starts fresh.');
|
|
541
|
+
break;
|
|
542
|
+
}
|
|
543
|
+
case 'sessions': {
|
|
544
|
+
const sessions = this.sessionStore.getRecentSessions(agentId, cmd.userId);
|
|
545
|
+
if (sessions.length === 0) {
|
|
546
|
+
await agent.tgBot.sendText(cmd.chatId, 'No recent sessions.');
|
|
547
|
+
break;
|
|
548
|
+
}
|
|
549
|
+
const currentSessionId = this.sessionStore.getUser(agentId, cmd.userId).currentSessionId;
|
|
550
|
+
const lines = [];
|
|
551
|
+
const keyboard = new InlineKeyboard();
|
|
552
|
+
sessions.forEach((s, i) => {
|
|
553
|
+
const title = s.title ? `"${escapeHtml(s.title)}"` : `<code>${escapeHtml(s.id.slice(0, 8))}</code>`;
|
|
554
|
+
const age = formatAge(new Date(s.lastActivity));
|
|
555
|
+
const isCurrent = s.id === currentSessionId;
|
|
556
|
+
lines.push(`${i + 1}. ${title} — ${s.messageCount} msgs, $${s.totalCostUsd.toFixed(2)} (${age})${isCurrent ? ' ✓' : ''}`);
|
|
557
|
+
if (!isCurrent) {
|
|
558
|
+
keyboard.text('Resume', `resume:${s.id}`);
|
|
559
|
+
}
|
|
560
|
+
keyboard.text('Delete', `delete:${s.id}`).row();
|
|
561
|
+
});
|
|
562
|
+
await agent.tgBot.sendTextWithKeyboard(cmd.chatId, `📋 <b>Recent sessions:</b>\n\n${lines.join('\n')}`, keyboard, 'HTML');
|
|
563
|
+
break;
|
|
564
|
+
}
|
|
565
|
+
case 'resume': {
|
|
566
|
+
if (!cmd.args) {
|
|
567
|
+
await agent.tgBot.sendText(cmd.chatId, 'Usage: /resume <session-id>');
|
|
568
|
+
break;
|
|
569
|
+
}
|
|
570
|
+
const proc = agent.processes.get(cmd.userId);
|
|
571
|
+
if (proc) {
|
|
572
|
+
proc.destroy();
|
|
573
|
+
agent.processes.delete(cmd.userId);
|
|
574
|
+
}
|
|
575
|
+
this.sessionStore.setCurrentSession(agentId, cmd.userId, cmd.args.trim());
|
|
576
|
+
await agent.tgBot.sendText(cmd.chatId, `Will resume session <code>${escapeHtml(cmd.args.trim().slice(0, 8))}</code> on next message.`, 'HTML');
|
|
577
|
+
break;
|
|
578
|
+
}
|
|
579
|
+
case 'session': {
|
|
580
|
+
const userState = this.sessionStore.getUser(agentId, cmd.userId);
|
|
581
|
+
const session = userState.sessions.find(s => s.id === userState.currentSessionId);
|
|
582
|
+
if (!session) {
|
|
583
|
+
await agent.tgBot.sendText(cmd.chatId, 'No active session.');
|
|
584
|
+
break;
|
|
585
|
+
}
|
|
586
|
+
await agent.tgBot.sendText(cmd.chatId, `<b>Session:</b> <code>${escapeHtml(session.id.slice(0, 8))}</code>\n<b>Messages:</b> ${session.messageCount}\n<b>Cost:</b> $${session.totalCostUsd.toFixed(4)}\n<b>Started:</b> ${session.startedAt}`, 'HTML');
|
|
587
|
+
break;
|
|
588
|
+
}
|
|
589
|
+
case 'model': {
|
|
590
|
+
if (!cmd.args) {
|
|
591
|
+
const current = this.sessionStore.getUser(agentId, cmd.userId).model
|
|
592
|
+
|| resolveUserConfig(agent.config, cmd.userId).model;
|
|
593
|
+
await agent.tgBot.sendText(cmd.chatId, `Current model: ${current}\n\nUsage: /model <model-name>`);
|
|
594
|
+
break;
|
|
595
|
+
}
|
|
596
|
+
this.sessionStore.setModel(agentId, cmd.userId, cmd.args.trim());
|
|
597
|
+
await agent.tgBot.sendText(cmd.chatId, `Model set to <code>${escapeHtml(cmd.args.trim())}</code>. Takes effect on next spawn.`, 'HTML');
|
|
598
|
+
break;
|
|
599
|
+
}
|
|
600
|
+
case 'repo': {
|
|
601
|
+
const repoArgs = cmd.args?.trim().split(/\s+/) ?? [];
|
|
602
|
+
const repoSub = repoArgs[0];
|
|
603
|
+
this.logger.debug({ repoSub, repoArgs, repos: Object.keys(this.config.repos), hasArgs: !!cmd.args, argsRaw: cmd.args }, '/repo command debug');
|
|
604
|
+
if (repoSub === 'add') {
|
|
605
|
+
// /repo add <name> <path>
|
|
606
|
+
const repoName = repoArgs[1];
|
|
607
|
+
const repoAddPath = repoArgs[2];
|
|
608
|
+
if (!repoName || !repoAddPath) {
|
|
609
|
+
await agent.tgBot.sendText(cmd.chatId, 'Usage: /repo add <name> <path>');
|
|
610
|
+
break;
|
|
611
|
+
}
|
|
612
|
+
if (!isValidRepoName(repoName)) {
|
|
613
|
+
await agent.tgBot.sendText(cmd.chatId, 'Invalid repo name. Use alphanumeric + hyphens only.');
|
|
614
|
+
break;
|
|
615
|
+
}
|
|
616
|
+
if (!existsSync(repoAddPath)) {
|
|
617
|
+
await agent.tgBot.sendText(cmd.chatId, `Path not found: ${repoAddPath}`);
|
|
618
|
+
break;
|
|
619
|
+
}
|
|
620
|
+
if (this.config.repos[repoName]) {
|
|
621
|
+
await agent.tgBot.sendText(cmd.chatId, `Repo "${repoName}" already exists.`);
|
|
622
|
+
break;
|
|
623
|
+
}
|
|
624
|
+
updateConfig((cfg) => {
|
|
625
|
+
const repos = (cfg.repos ?? {});
|
|
626
|
+
repos[repoName] = repoAddPath;
|
|
627
|
+
cfg.repos = repos;
|
|
628
|
+
});
|
|
629
|
+
await agent.tgBot.sendText(cmd.chatId, `Repo <code>${escapeHtml(repoName)}</code> added → ${escapeHtml(repoAddPath)}`, 'HTML');
|
|
630
|
+
break;
|
|
631
|
+
}
|
|
632
|
+
if (repoSub === 'remove') {
|
|
633
|
+
// /repo remove <name>
|
|
634
|
+
const repoName = repoArgs[1];
|
|
635
|
+
if (!repoName) {
|
|
636
|
+
await agent.tgBot.sendText(cmd.chatId, 'Usage: /repo remove <name>');
|
|
637
|
+
break;
|
|
638
|
+
}
|
|
639
|
+
if (!this.config.repos[repoName]) {
|
|
640
|
+
await agent.tgBot.sendText(cmd.chatId, `Repo "${repoName}" not found.`);
|
|
641
|
+
break;
|
|
642
|
+
}
|
|
643
|
+
// Check if any agent has it assigned
|
|
644
|
+
const rawCfg = JSON.parse(readFileSync(join(homedir(), '.tgcc', 'config.json'), 'utf-8'));
|
|
645
|
+
const owner = findRepoOwner(rawCfg, repoName);
|
|
646
|
+
if (owner) {
|
|
647
|
+
await agent.tgBot.sendText(cmd.chatId, `Can't remove: repo "${repoName}" is assigned to agent "${owner}". Use /repo clear on that agent first.`);
|
|
648
|
+
break;
|
|
649
|
+
}
|
|
650
|
+
updateConfig((cfg) => {
|
|
651
|
+
const repos = (cfg.repos ?? {});
|
|
652
|
+
delete repos[repoName];
|
|
653
|
+
cfg.repos = repos;
|
|
654
|
+
});
|
|
655
|
+
await agent.tgBot.sendText(cmd.chatId, `Repo <code>${escapeHtml(repoName)}</code> removed.`, 'HTML');
|
|
656
|
+
break;
|
|
657
|
+
}
|
|
658
|
+
if (repoSub === 'assign') {
|
|
659
|
+
// /repo assign <name> — assign to THIS agent
|
|
660
|
+
const repoName = repoArgs[1];
|
|
661
|
+
if (!repoName) {
|
|
662
|
+
await agent.tgBot.sendText(cmd.chatId, 'Usage: /repo assign <name>');
|
|
663
|
+
break;
|
|
664
|
+
}
|
|
665
|
+
if (!this.config.repos[repoName]) {
|
|
666
|
+
await agent.tgBot.sendText(cmd.chatId, `Repo "${repoName}" not found in registry.`);
|
|
667
|
+
break;
|
|
668
|
+
}
|
|
669
|
+
const rawCfg2 = JSON.parse(readFileSync(join(homedir(), '.tgcc', 'config.json'), 'utf-8'));
|
|
670
|
+
const existingOwner = findRepoOwner(rawCfg2, repoName);
|
|
671
|
+
if (existingOwner && existingOwner !== agentId) {
|
|
672
|
+
await agent.tgBot.sendText(cmd.chatId, `Repo "${repoName}" is already assigned to agent "${existingOwner}".`);
|
|
673
|
+
break;
|
|
674
|
+
}
|
|
675
|
+
updateConfig((cfg) => {
|
|
676
|
+
const agents = (cfg.agents ?? {});
|
|
677
|
+
const agentCfg = agents[agentId];
|
|
678
|
+
if (agentCfg) {
|
|
679
|
+
const defaults = (agentCfg.defaults ?? {});
|
|
680
|
+
defaults.repo = repoName;
|
|
681
|
+
agentCfg.defaults = defaults;
|
|
682
|
+
}
|
|
683
|
+
});
|
|
684
|
+
await agent.tgBot.sendText(cmd.chatId, `Repo <code>${escapeHtml(repoName)}</code> assigned to agent <code>${escapeHtml(agentId)}</code>.`, 'HTML');
|
|
685
|
+
break;
|
|
686
|
+
}
|
|
687
|
+
if (repoSub === 'help') {
|
|
688
|
+
const helpText = [
|
|
689
|
+
'<b>Repo Management</b>',
|
|
690
|
+
'',
|
|
691
|
+
'/repo — List repos (buttons)',
|
|
692
|
+
'/repo help — This help text',
|
|
693
|
+
'/repo add <name> <path> — Register a repo',
|
|
694
|
+
'/repo remove <name> — Unregister a repo',
|
|
695
|
+
'/repo assign <name> — Set as this agent\'s default',
|
|
696
|
+
'/repo clear — Clear this agent\'s default',
|
|
697
|
+
].join('\n');
|
|
698
|
+
await agent.tgBot.sendText(cmd.chatId, helpText, 'HTML');
|
|
699
|
+
break;
|
|
700
|
+
}
|
|
701
|
+
if (repoSub === 'clear') {
|
|
702
|
+
// /repo clear — clear THIS agent's default repo
|
|
703
|
+
updateConfig((cfg) => {
|
|
704
|
+
const agents = (cfg.agents ?? {});
|
|
705
|
+
const agentCfg = agents[agentId];
|
|
706
|
+
if (agentCfg) {
|
|
707
|
+
const defaults = (agentCfg.defaults ?? {});
|
|
708
|
+
delete defaults.repo;
|
|
709
|
+
agentCfg.defaults = defaults;
|
|
710
|
+
}
|
|
711
|
+
});
|
|
712
|
+
await agent.tgBot.sendText(cmd.chatId, `Default repo cleared for agent <code>${escapeHtml(agentId)}</code>.`, 'HTML');
|
|
713
|
+
break;
|
|
714
|
+
}
|
|
715
|
+
if (!cmd.args) {
|
|
716
|
+
const current = this.sessionStore.getUser(agentId, cmd.userId).repo
|
|
717
|
+
|| resolveUserConfig(agent.config, cmd.userId).repo;
|
|
718
|
+
// Show available repos as inline keyboard buttons
|
|
719
|
+
const repoEntries = Object.entries(this.config.repos);
|
|
720
|
+
if (repoEntries.length > 0) {
|
|
721
|
+
const keyboard = new InlineKeyboard();
|
|
722
|
+
for (const [name] of repoEntries) {
|
|
723
|
+
keyboard.text(name, `repo:${name}`).row();
|
|
724
|
+
}
|
|
725
|
+
keyboard.text('➕ Add', 'repo_add:prompt').text('❓ Help', 'repo_help:show').row();
|
|
726
|
+
await agent.tgBot.sendTextWithKeyboard(cmd.chatId, `Current repo: <code>${escapeHtml(current)}</code>\n\nSelect a repo:\n\n<i>Type /repo help for management commands</i>`, keyboard, 'HTML');
|
|
727
|
+
}
|
|
728
|
+
else {
|
|
729
|
+
await agent.tgBot.sendText(cmd.chatId, `Current repo: ${current}\n\nUsage: /repo <path>`);
|
|
730
|
+
}
|
|
731
|
+
break;
|
|
732
|
+
}
|
|
733
|
+
// Fallback: /repo <path-or-name> — switch working directory for session
|
|
734
|
+
const repoPath = resolveRepoPath(this.config.repos, cmd.args.trim());
|
|
735
|
+
if (!existsSync(repoPath)) {
|
|
736
|
+
await agent.tgBot.sendText(cmd.chatId, `Path not found: ${repoPath}`);
|
|
737
|
+
break;
|
|
738
|
+
}
|
|
739
|
+
// Kill current process (different CWD needs new process)
|
|
740
|
+
const proc = agent.processes.get(cmd.userId);
|
|
741
|
+
if (proc) {
|
|
742
|
+
proc.destroy();
|
|
743
|
+
agent.processes.delete(cmd.userId);
|
|
744
|
+
}
|
|
745
|
+
this.sessionStore.setRepo(agentId, cmd.userId, repoPath);
|
|
746
|
+
this.sessionStore.clearSession(agentId, cmd.userId);
|
|
747
|
+
await agent.tgBot.sendText(cmd.chatId, `Repo set to <code>${escapeHtml(repoPath)}</code>. Session cleared.`, 'HTML');
|
|
748
|
+
break;
|
|
749
|
+
}
|
|
750
|
+
case 'cancel': {
|
|
751
|
+
const proc = agent.processes.get(cmd.userId);
|
|
752
|
+
if (proc && proc.state === 'active') {
|
|
753
|
+
proc.cancel();
|
|
754
|
+
await agent.tgBot.sendText(cmd.chatId, 'Cancelled.');
|
|
755
|
+
}
|
|
756
|
+
else {
|
|
757
|
+
await agent.tgBot.sendText(cmd.chatId, 'No active turn to cancel.');
|
|
758
|
+
}
|
|
759
|
+
break;
|
|
760
|
+
}
|
|
761
|
+
case 'catchup': {
|
|
762
|
+
const userState = this.sessionStore.getUser(agentId, cmd.userId);
|
|
763
|
+
const repo = userState.repo || resolveUserConfig(agent.config, cmd.userId).repo;
|
|
764
|
+
const lastActivity = new Date(userState.lastActivity || 0);
|
|
765
|
+
const missed = findMissedSessions(repo, userState.knownSessionIds, lastActivity);
|
|
766
|
+
const message = formatCatchupMessage(repo, missed);
|
|
767
|
+
await agent.tgBot.sendText(cmd.chatId, message, 'HTML');
|
|
768
|
+
break;
|
|
769
|
+
}
|
|
770
|
+
case 'permissions': {
|
|
771
|
+
const validModes = ['dangerously-skip', 'acceptEdits', 'default', 'plan'];
|
|
772
|
+
const userState = this.sessionStore.getUser(agentId, cmd.userId);
|
|
773
|
+
const agentDefault = agent.config.defaults.permissionMode;
|
|
774
|
+
const currentMode = userState.permissionMode || agentDefault;
|
|
775
|
+
if (cmd.args) {
|
|
776
|
+
const mode = cmd.args.trim();
|
|
777
|
+
if (!validModes.includes(mode)) {
|
|
778
|
+
await agent.tgBot.sendText(cmd.chatId, `Invalid mode. Valid: ${validModes.join(', ')}`);
|
|
779
|
+
break;
|
|
780
|
+
}
|
|
781
|
+
this.sessionStore.setPermissionMode(agentId, cmd.userId, mode);
|
|
782
|
+
// Kill current process so new mode takes effect on next spawn
|
|
783
|
+
const proc = agent.processes.get(cmd.userId);
|
|
784
|
+
if (proc) {
|
|
785
|
+
proc.destroy();
|
|
786
|
+
agent.processes.delete(cmd.userId);
|
|
787
|
+
}
|
|
788
|
+
await agent.tgBot.sendText(cmd.chatId, `Permission mode set to <code>${escapeHtml(mode)}</code>. Takes effect on next message.`, 'HTML');
|
|
789
|
+
break;
|
|
790
|
+
}
|
|
791
|
+
// No args — show current mode + inline keyboard
|
|
792
|
+
const keyboard = new InlineKeyboard();
|
|
793
|
+
keyboard.text('🔓 Bypass', 'permissions:dangerously-skip').text('✏️ Accept Edits', 'permissions:acceptEdits').row();
|
|
794
|
+
keyboard.text('🔒 Default', 'permissions:default').text('📋 Plan', 'permissions:plan').row();
|
|
795
|
+
await agent.tgBot.sendTextWithKeyboard(cmd.chatId, `Current: <code>${escapeHtml(currentMode)}</code>\nDefault: <code>${escapeHtml(agentDefault)}</code>\n\nSelect a mode for this session:`, keyboard, 'HTML');
|
|
796
|
+
break;
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
// ── Callback query handling (inline buttons) ──
|
|
801
|
+
async handleCallbackQuery(agentId, query) {
|
|
802
|
+
const agent = this.agents.get(agentId);
|
|
803
|
+
if (!agent)
|
|
804
|
+
return;
|
|
805
|
+
this.logger.debug({ agentId, action: query.action, data: query.data }, 'Callback query');
|
|
806
|
+
switch (query.action) {
|
|
807
|
+
case 'resume': {
|
|
808
|
+
const sessionId = query.data;
|
|
809
|
+
const proc = agent.processes.get(query.userId);
|
|
810
|
+
if (proc) {
|
|
811
|
+
proc.destroy();
|
|
812
|
+
agent.processes.delete(query.userId);
|
|
813
|
+
}
|
|
814
|
+
this.sessionStore.setCurrentSession(agentId, query.userId, sessionId);
|
|
815
|
+
await agent.tgBot.answerCallbackQuery(query.callbackQueryId, 'Session set');
|
|
816
|
+
await agent.tgBot.sendText(query.chatId, `Will resume session <code>${escapeHtml(sessionId.slice(0, 8))}</code> on next message.`, 'HTML');
|
|
817
|
+
break;
|
|
818
|
+
}
|
|
819
|
+
case 'delete': {
|
|
820
|
+
const sessionId = query.data;
|
|
821
|
+
const deleted = this.sessionStore.deleteSession(agentId, query.userId, sessionId);
|
|
822
|
+
if (deleted) {
|
|
823
|
+
await agent.tgBot.answerCallbackQuery(query.callbackQueryId, 'Session deleted');
|
|
824
|
+
await agent.tgBot.sendText(query.chatId, `Session <code>${escapeHtml(sessionId.slice(0, 8))}</code> deleted.`, 'HTML');
|
|
825
|
+
}
|
|
826
|
+
else {
|
|
827
|
+
await agent.tgBot.answerCallbackQuery(query.callbackQueryId, 'Session not found');
|
|
828
|
+
}
|
|
829
|
+
break;
|
|
830
|
+
}
|
|
831
|
+
case 'repo': {
|
|
832
|
+
const repoName = query.data;
|
|
833
|
+
const repoPath = resolveRepoPath(this.config.repos, repoName);
|
|
834
|
+
if (!existsSync(repoPath)) {
|
|
835
|
+
await agent.tgBot.answerCallbackQuery(query.callbackQueryId, 'Path not found');
|
|
836
|
+
break;
|
|
837
|
+
}
|
|
838
|
+
// Kill current process (different CWD needs new process)
|
|
839
|
+
const proc = agent.processes.get(query.userId);
|
|
840
|
+
if (proc) {
|
|
841
|
+
proc.destroy();
|
|
842
|
+
agent.processes.delete(query.userId);
|
|
843
|
+
}
|
|
844
|
+
this.sessionStore.setRepo(agentId, query.userId, repoPath);
|
|
845
|
+
this.sessionStore.clearSession(agentId, query.userId);
|
|
846
|
+
await agent.tgBot.answerCallbackQuery(query.callbackQueryId, `Repo: ${repoName}`);
|
|
847
|
+
await agent.tgBot.sendText(query.chatId, `Repo set to <code>${escapeHtml(repoPath)}</code>. Session cleared.`, 'HTML');
|
|
848
|
+
break;
|
|
849
|
+
}
|
|
850
|
+
case 'repo_add': {
|
|
851
|
+
await agent.tgBot.answerCallbackQuery(query.callbackQueryId, 'Usage below');
|
|
852
|
+
await agent.tgBot.sendText(query.chatId, 'Send: <code>/repo add <name> <path></code>', 'HTML');
|
|
853
|
+
break;
|
|
854
|
+
}
|
|
855
|
+
case 'permissions': {
|
|
856
|
+
const validModes = ['dangerously-skip', 'acceptEdits', 'default', 'plan'];
|
|
857
|
+
const mode = query.data;
|
|
858
|
+
if (!validModes.includes(mode)) {
|
|
859
|
+
await agent.tgBot.answerCallbackQuery(query.callbackQueryId, 'Invalid mode');
|
|
860
|
+
break;
|
|
861
|
+
}
|
|
862
|
+
this.sessionStore.setPermissionMode(agentId, query.userId, mode);
|
|
863
|
+
// Kill current process so new mode takes effect on next spawn
|
|
864
|
+
const proc = agent.processes.get(query.userId);
|
|
865
|
+
if (proc) {
|
|
866
|
+
proc.destroy();
|
|
867
|
+
agent.processes.delete(query.userId);
|
|
868
|
+
}
|
|
869
|
+
await agent.tgBot.answerCallbackQuery(query.callbackQueryId, `Mode: ${mode}`);
|
|
870
|
+
await agent.tgBot.sendText(query.chatId, `Permission mode set to <code>${escapeHtml(mode)}</code>. Takes effect on next message.`, 'HTML');
|
|
871
|
+
break;
|
|
872
|
+
}
|
|
873
|
+
case 'repo_help': {
|
|
874
|
+
await agent.tgBot.answerCallbackQuery(query.callbackQueryId);
|
|
875
|
+
const helpText = [
|
|
876
|
+
'<b>Repo Management</b>',
|
|
877
|
+
'',
|
|
878
|
+
'/repo — List repos (buttons)',
|
|
879
|
+
'/repo help — This help text',
|
|
880
|
+
'/repo add <name> <path> — Register a repo',
|
|
881
|
+
'/repo remove <name> — Unregister a repo',
|
|
882
|
+
'/repo assign <name> — Set as this agent\'s default',
|
|
883
|
+
'/repo clear — Clear this agent\'s default',
|
|
884
|
+
].join('\n');
|
|
885
|
+
await agent.tgBot.sendText(query.chatId, helpText, 'HTML');
|
|
886
|
+
break;
|
|
887
|
+
}
|
|
888
|
+
case 'perm_allow': {
|
|
889
|
+
const requestId = query.data;
|
|
890
|
+
const pending = agent.pendingPermissions.get(requestId);
|
|
891
|
+
if (!pending) {
|
|
892
|
+
await agent.tgBot.answerCallbackQuery(query.callbackQueryId, 'Permission expired');
|
|
893
|
+
break;
|
|
894
|
+
}
|
|
895
|
+
const proc = agent.processes.get(pending.userId);
|
|
896
|
+
if (proc) {
|
|
897
|
+
proc.respondToPermission(requestId, true);
|
|
898
|
+
}
|
|
899
|
+
agent.pendingPermissions.delete(requestId);
|
|
900
|
+
await agent.tgBot.answerCallbackQuery(query.callbackQueryId, '✅ Allowed');
|
|
901
|
+
break;
|
|
902
|
+
}
|
|
903
|
+
case 'perm_deny': {
|
|
904
|
+
const requestId = query.data;
|
|
905
|
+
const pending = agent.pendingPermissions.get(requestId);
|
|
906
|
+
if (!pending) {
|
|
907
|
+
await agent.tgBot.answerCallbackQuery(query.callbackQueryId, 'Permission expired');
|
|
908
|
+
break;
|
|
909
|
+
}
|
|
910
|
+
const proc = agent.processes.get(pending.userId);
|
|
911
|
+
if (proc) {
|
|
912
|
+
proc.respondToPermission(requestId, false);
|
|
913
|
+
}
|
|
914
|
+
agent.pendingPermissions.delete(requestId);
|
|
915
|
+
await agent.tgBot.answerCallbackQuery(query.callbackQueryId, '❌ Denied');
|
|
916
|
+
break;
|
|
917
|
+
}
|
|
918
|
+
case 'perm_allow_all': {
|
|
919
|
+
// Allow all pending permissions for this user
|
|
920
|
+
const targetUserId = query.data;
|
|
921
|
+
const toAllow = [];
|
|
922
|
+
for (const [reqId, pending] of agent.pendingPermissions) {
|
|
923
|
+
if (pending.userId === targetUserId) {
|
|
924
|
+
toAllow.push(reqId);
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
const proc = agent.processes.get(targetUserId);
|
|
928
|
+
for (const reqId of toAllow) {
|
|
929
|
+
if (proc)
|
|
930
|
+
proc.respondToPermission(reqId, true);
|
|
931
|
+
agent.pendingPermissions.delete(reqId);
|
|
932
|
+
}
|
|
933
|
+
await agent.tgBot.answerCallbackQuery(query.callbackQueryId, `✅ Allowed ${toAllow.length} permission(s)`);
|
|
934
|
+
break;
|
|
935
|
+
}
|
|
936
|
+
default:
|
|
937
|
+
await agent.tgBot.answerCallbackQuery(query.callbackQueryId);
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
// ── Control socket handlers (CLI interface) ──
|
|
941
|
+
handleCtlMessage(agentId, text, sessionId) {
|
|
942
|
+
const agent = this.agents.get(agentId);
|
|
943
|
+
if (!agent) {
|
|
944
|
+
// Return error via the CtlAckResponse shape won't work — but the ctl-server
|
|
945
|
+
// protocol handles errors separately. We'll throw and let it catch.
|
|
946
|
+
throw new Error(`Unknown agent: ${agentId}`);
|
|
947
|
+
}
|
|
948
|
+
// Use the first allowed user as the "CLI user" identity
|
|
949
|
+
const userId = agent.config.allowedUsers[0];
|
|
950
|
+
const chatId = Number(userId);
|
|
951
|
+
// If explicit session requested, set it
|
|
952
|
+
if (sessionId) {
|
|
953
|
+
this.sessionStore.setCurrentSession(agentId, userId, sessionId);
|
|
954
|
+
}
|
|
955
|
+
// Route through the same sendToCC path as Telegram
|
|
956
|
+
this.sendToCC(agentId, userId, chatId, { text });
|
|
957
|
+
const proc = agent.processes.get(userId);
|
|
958
|
+
return {
|
|
959
|
+
type: 'ack',
|
|
960
|
+
sessionId: proc?.sessionId ?? this.sessionStore.getUser(agentId, userId).currentSessionId,
|
|
961
|
+
state: proc?.state ?? 'idle',
|
|
962
|
+
};
|
|
963
|
+
}
|
|
964
|
+
handleCtlStatus(agentId) {
|
|
965
|
+
const agents = [];
|
|
966
|
+
const sessions = [];
|
|
967
|
+
const agentIds = agentId ? [agentId] : [...this.agents.keys()];
|
|
968
|
+
for (const id of agentIds) {
|
|
969
|
+
const agent = this.agents.get(id);
|
|
970
|
+
if (!agent)
|
|
971
|
+
continue;
|
|
972
|
+
// Aggregate process state across users
|
|
973
|
+
let state = 'idle';
|
|
974
|
+
for (const [, proc] of agent.processes) {
|
|
975
|
+
if (proc.state === 'active') {
|
|
976
|
+
state = 'active';
|
|
977
|
+
break;
|
|
978
|
+
}
|
|
979
|
+
if (proc.state === 'spawning')
|
|
980
|
+
state = 'spawning';
|
|
981
|
+
}
|
|
982
|
+
const userId = agent.config.allowedUsers[0];
|
|
983
|
+
const proc = agent.processes.get(userId);
|
|
984
|
+
const userConfig = resolveUserConfig(agent.config, userId);
|
|
985
|
+
agents.push({
|
|
986
|
+
id,
|
|
987
|
+
state,
|
|
988
|
+
sessionId: proc?.sessionId ?? null,
|
|
989
|
+
repo: this.sessionStore.getUser(id, userId).repo || userConfig.repo,
|
|
990
|
+
});
|
|
991
|
+
// List sessions for this agent
|
|
992
|
+
const userState = this.sessionStore.getUser(id, userId);
|
|
993
|
+
for (const sess of userState.sessions.slice(-5).reverse()) {
|
|
994
|
+
sessions.push({
|
|
995
|
+
id: sess.id,
|
|
996
|
+
agentId: id,
|
|
997
|
+
messageCount: sess.messageCount,
|
|
998
|
+
totalCostUsd: sess.totalCostUsd,
|
|
999
|
+
});
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
return { type: 'status', agents, sessions };
|
|
1003
|
+
}
|
|
1004
|
+
// ── MCP tool handling ──
|
|
1005
|
+
async handleMcpToolRequest(request) {
|
|
1006
|
+
const agent = this.agents.get(request.agentId);
|
|
1007
|
+
if (!agent) {
|
|
1008
|
+
return { id: request.id, success: false, error: `Unknown agent: ${request.agentId}` };
|
|
1009
|
+
}
|
|
1010
|
+
// Find the chat ID for this user (from the most recent message)
|
|
1011
|
+
// We use the userId to find which chat to send to
|
|
1012
|
+
const chatId = Number(request.userId); // In TG, private chat ID === user ID
|
|
1013
|
+
try {
|
|
1014
|
+
switch (request.tool) {
|
|
1015
|
+
case 'send_file':
|
|
1016
|
+
await agent.tgBot.sendFile(chatId, request.params.path, request.params.caption);
|
|
1017
|
+
return { id: request.id, success: true };
|
|
1018
|
+
case 'send_image':
|
|
1019
|
+
await agent.tgBot.sendImage(chatId, request.params.path, request.params.caption);
|
|
1020
|
+
return { id: request.id, success: true };
|
|
1021
|
+
case 'send_voice':
|
|
1022
|
+
await agent.tgBot.sendVoice(chatId, request.params.path, request.params.caption);
|
|
1023
|
+
return { id: request.id, success: true };
|
|
1024
|
+
default:
|
|
1025
|
+
return { id: request.id, success: false, error: `Unknown tool: ${request.tool}` };
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
catch (err) {
|
|
1029
|
+
return { id: request.id, success: false, error: err instanceof Error ? err.message : 'Unknown error' };
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
// ── Shutdown ──
|
|
1033
|
+
async stop() {
|
|
1034
|
+
this.logger.info('Stopping bridge');
|
|
1035
|
+
for (const agentId of [...this.agents.keys()]) {
|
|
1036
|
+
await this.stopAgent(agentId);
|
|
1037
|
+
}
|
|
1038
|
+
this.mcpServer.closeAll();
|
|
1039
|
+
this.ctlServer.closeAll();
|
|
1040
|
+
this.logger.info('Bridge stopped');
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
// ── Helpers ──
|
|
1044
|
+
function formatDuration(ms) {
|
|
1045
|
+
const secs = Math.floor(ms / 1000);
|
|
1046
|
+
if (secs < 60)
|
|
1047
|
+
return `${secs}s`;
|
|
1048
|
+
const mins = Math.floor(secs / 60);
|
|
1049
|
+
if (mins < 60)
|
|
1050
|
+
return `${mins}m`;
|
|
1051
|
+
const hours = Math.floor(mins / 60);
|
|
1052
|
+
return `${hours}h ${mins % 60}m`;
|
|
1053
|
+
}
|
|
1054
|
+
function formatAge(date) {
|
|
1055
|
+
const diffMs = Date.now() - date.getTime();
|
|
1056
|
+
const mins = Math.floor(diffMs / 60000);
|
|
1057
|
+
if (mins < 60)
|
|
1058
|
+
return `${mins}m ago`;
|
|
1059
|
+
const hours = Math.floor(mins / 60);
|
|
1060
|
+
if (hours < 24)
|
|
1061
|
+
return `${hours}h ago`;
|
|
1062
|
+
const days = Math.floor(hours / 24);
|
|
1063
|
+
return `${days}d ago`;
|
|
1064
|
+
}
|
|
1065
|
+
//# sourceMappingURL=bridge.js.map
|