@emqo/claudebridge 0.7.0 → 0.9.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.
@@ -1,12 +1,15 @@
1
1
  # Leave endpoints empty to use claude CLI's own authentication
2
2
  # 留空则直接使用 claude CLI 自身的认证配置
3
3
  endpoints: []
4
- # - name: "default"
5
- # base_url: ""
6
- # api_key: "sk-your-api-key"
4
+ # - name: "claude-main"
5
+ # provider: "claude"
7
6
  # model: "claude-sonnet-4-20250514"
7
+ # - name: "codex"
8
+ # provider: "codex"
9
+ # model: "o3-mini"
8
10
 
9
11
  locale: en # "zh" for Chinese
12
+ log_level: info # debug | info | warn | error
10
13
 
11
14
  agent:
12
15
  allowed_tools:
@@ -30,6 +33,12 @@ agent:
30
33
  max_memories: 50
31
34
  skill:
32
35
  enabled: true
36
+ session:
37
+ enabled: true # Enable multi-session (concurrent conversations per user)
38
+ max_per_user: 3 # Max concurrent sub-sessions per user
39
+ idle_timeout_minutes: 30 # Auto-close idle sub-sessions after this time
40
+ classifier_budget: 0.05 # Max budget for routing classifier (Tier 3)
41
+ classifier_model: "" # Model for classifier (empty = use default)
33
42
 
34
43
  workspace:
35
44
  base_dir: "./workspaces"
@@ -7,6 +7,7 @@ export interface MessageContext {
7
7
  export interface Adapter {
8
8
  start(): Promise<void>;
9
9
  stop(): void;
10
+ reloadConfig?(config: any, locale: string): void;
10
11
  }
11
12
  /** Split long text into chunks respecting newlines, with code-block-aware balancing */
12
13
  export declare function chunkText(text: string, maxLen: number): string[];
@@ -14,8 +14,11 @@ export declare class DiscordAdapter implements Adapter {
14
14
  private activeAutoTasks;
15
15
  private maxParallel;
16
16
  constructor(engine: AgentEngine, store: Store, config: DiscordConfig, locale?: string);
17
+ reloadConfig(config: DiscordConfig, locale: string): void;
17
18
  private setup;
18
19
  private handlePrompt;
20
+ /** Chunk text and send via edit + follow-up replies */
21
+ private sendChunkedResponse;
19
22
  start(): Promise<void>;
20
23
  stop(): void;
21
24
  private checkReminders;
@@ -23,4 +26,5 @@ export declare class DiscordAdapter implements Adapter {
23
26
  private runAutoTask;
24
27
  private checkApprovals;
25
28
  private handleStatusCommand;
29
+ private handleSessionsCommand;
26
30
  }
@@ -4,6 +4,8 @@ import { join } from "path";
4
4
  import { chunkText } from "./base.js";
5
5
  import { reloadConfig } from "../core/config.js";
6
6
  import { t } from "../core/i18n.js";
7
+ import { log as rootLog } from "../core/logger.js";
8
+ const log = rootLog.child("discord");
7
9
  const EDIT_INTERVAL = 1500;
8
10
  export class DiscordAdapter {
9
11
  engine;
@@ -31,6 +33,11 @@ export class DiscordAdapter {
31
33
  });
32
34
  this.setup();
33
35
  }
36
+ reloadConfig(config, locale) {
37
+ this.config = config;
38
+ this.locale = locale;
39
+ this.maxParallel = this.engine.getMaxParallel();
40
+ }
34
41
  setup() {
35
42
  this.client.on("messageCreate", async (msg) => {
36
43
  if (msg.author.bot)
@@ -43,12 +50,17 @@ export class DiscordAdapter {
43
50
  if (!this.engine.access.isAllowed(msg.author.id, groupId))
44
51
  return;
45
52
  const text = msg.content.replace(/<@!?\d+>/g, "").trim();
53
+ // Extract reply-to message ID for session routing
54
+ const replyToMsgId = msg.reference?.messageId || undefined;
46
55
  // Management commands
47
56
  if (text === "!help") {
48
57
  await msg.reply(t(this.locale, "help").replaceAll("/", "!"));
49
58
  return;
50
59
  }
51
60
  if (text === "!new") {
61
+ if (this.engine.isMultiSessionEnabled()) {
62
+ this.engine.getSessionManager().closeAll(msg.author.id);
63
+ }
52
64
  this.store.clearSession(msg.author.id);
53
65
  await msg.reply(t(this.locale, "session_cleared"));
54
66
  return;
@@ -124,6 +136,10 @@ export class DiscordAdapter {
124
136
  await this.handleStatusCommand(msg);
125
137
  return;
126
138
  }
139
+ if (text === "!sessions") {
140
+ await this.handleSessionsCommand(msg);
141
+ return;
142
+ }
127
143
  // File upload handling
128
144
  if (msg.attachments.size > 0) {
129
145
  const ws = this.engine.getWorkDir(msg.author.id);
@@ -137,16 +153,55 @@ export class DiscordAdapter {
137
153
  }
138
154
  const names = [...msg.attachments.values()].map(a => a.name).join(", ");
139
155
  const prompt = text || `Analyze the uploaded file(s): ${names}`;
140
- await this.handlePrompt(msg, prompt);
156
+ await this.handlePrompt(msg, prompt, replyToMsgId);
141
157
  return;
142
158
  }
143
159
  // Text message — send to Claude (skill system handles intents)
144
160
  if (!text)
145
161
  return;
146
- await this.handlePrompt(msg, text);
162
+ await this.handlePrompt(msg, text, replyToMsgId);
147
163
  });
148
164
  }
149
- async handlePrompt(msg, text) {
165
+ async handlePrompt(msg, text, replyToMsgId) {
166
+ // Multi-session mode: route and execute concurrently
167
+ if (this.engine.isMultiSessionEnabled()) {
168
+ const placeholder = await msg.reply(t(this.locale, "thinking"));
169
+ let lastEdit = 0;
170
+ let lastText = "";
171
+ try {
172
+ const res = await this.engine.handleUserMessage(msg.author.id, text, "discord", String(msg.channelId), replyToMsgId, async (_chunk, full) => {
173
+ const now = Date.now();
174
+ if (now - lastEdit < EDIT_INTERVAL)
175
+ return;
176
+ const preview = full.slice(-1900) + "\n\n...";
177
+ if (preview === lastText)
178
+ return;
179
+ lastText = preview;
180
+ lastEdit = now;
181
+ try {
182
+ await placeholder.edit(preview);
183
+ }
184
+ catch { }
185
+ });
186
+ // Track response message for reply-to routing
187
+ if (res.subSessionId) {
188
+ this.engine.getSessionManager().trackMessage(placeholder.id, String(msg.channelId), res.subSessionId);
189
+ }
190
+ // Add label prefix if multiple active sessions
191
+ const activeSessions = this.engine.getSessionManager().getActive(msg.author.id, "discord");
192
+ const labelPrefix = activeSessions.length > 1 && res.label ? `[${res.label.slice(0, 30)}]\n` : "";
193
+ await this.sendChunkedResponse(msg, placeholder, res.text, labelPrefix);
194
+ }
195
+ catch (err) {
196
+ log.error("error", { error: err?.message });
197
+ try {
198
+ await placeholder.edit(`Error: ${err.message || "unknown"}`);
199
+ }
200
+ catch { }
201
+ }
202
+ return;
203
+ }
204
+ // Legacy single-session mode
150
205
  if (this.engine.isLocked(msg.author.id)) {
151
206
  await msg.reply(t(this.locale, "still_processing"));
152
207
  return;
@@ -169,30 +224,34 @@ export class DiscordAdapter {
169
224
  }
170
225
  catch { }
171
226
  });
172
- const maxLen = this.config.chunk_size || 1900;
173
- const chunks = chunkText(res.text, maxLen);
174
- try {
175
- await placeholder.edit(chunks[0]);
176
- }
177
- catch { }
178
- for (let i = 1; i < chunks.length; i++) {
179
- await msg.reply(chunks[i]);
180
- }
227
+ await this.sendChunkedResponse(msg, placeholder, res.text);
181
228
  }
182
229
  catch (err) {
183
- console.error("[discord] error:", err);
230
+ log.error("error", { error: err?.message });
184
231
  try {
185
232
  await placeholder.edit(`Error: ${err.message || "unknown"}`);
186
233
  }
187
234
  catch { }
188
235
  }
189
236
  }
237
+ /** Chunk text and send via edit + follow-up replies */
238
+ async sendChunkedResponse(msg, placeholder, text, labelPrefix = "") {
239
+ const maxLen = this.config.chunk_size || 1900;
240
+ const chunks = chunkText(labelPrefix + text, maxLen);
241
+ try {
242
+ await placeholder.edit(chunks[0]);
243
+ }
244
+ catch { }
245
+ for (let i = 1; i < chunks.length; i++) {
246
+ await msg.reply(chunks[i]);
247
+ }
248
+ }
190
249
  async start() {
191
- console.log("[discord] starting bot...");
250
+ log.info("starting bot...");
192
251
  await this.client.login(this.config.token);
193
- console.log(`[discord] logged in as ${this.client.user?.tag}`);
252
+ log.info("logged in", { tag: this.client.user?.tag });
194
253
  this.maxParallel = this.engine.getMaxParallel();
195
- console.log(`[discord] max_parallel=${this.maxParallel}`);
254
+ log.info("ready", { maxParallel: this.maxParallel, multiSession: this.engine.isMultiSessionEnabled() });
196
255
  this.reminderTimer = setInterval(() => this.checkReminders(), 30000);
197
256
  this.autoTimer = setInterval(() => this.processAutoTasks(), 60000);
198
257
  this.approvalTimer = setInterval(() => this.checkApprovals(), 15000);
@@ -217,7 +276,7 @@ export class DiscordAdapter {
217
276
  }
218
277
  }
219
278
  catch (e) {
220
- console.error("[discord] reminder error:", e);
279
+ log.error("reminder error", { error: e?.message });
221
280
  }
222
281
  }
223
282
  async processAutoTasks() {
@@ -238,10 +297,9 @@ export class DiscordAdapter {
238
297
  throw new Error("channel not found");
239
298
  const channel = ch;
240
299
  await channel.send(t(this.locale, "auto_starting", { id: task.id, desc: task.description }));
241
- console.log(`[discord] auto-task #${task.id} for ${task.user_id}`);
242
- const res = this.maxParallel > 1
243
- ? await this.engine.runParallel(task.user_id, task.description, "discord", task.chat_id, undefined, 0)
244
- : await this.engine.runStream(task.user_id, task.description, "discord", task.chat_id, undefined, 0);
300
+ log.info("auto-task starting", { taskId: task.id, userId: task.user_id });
301
+ // Always use runParallel for auto-tasks: fresh session, no user session pollution
302
+ const res = await this.engine.runParallel(task.user_id, task.description, "discord", task.chat_id, undefined, 0);
245
303
  if (res.timedOut) {
246
304
  this.store.markTaskResult(task.id, "failed");
247
305
  if (res.text)
@@ -274,7 +332,7 @@ export class DiscordAdapter {
274
332
  }
275
333
  catch (err) {
276
334
  this.store.markTaskResult(task.id, "failed");
277
- console.error(`[discord] auto-task #${task.id} failed:`, err);
335
+ log.error("auto-task failed", { taskId: task.id, error: err?.message });
278
336
  // Self-healing: auto-retry failed tasks (max 3 retries)
279
337
  const retryMatch = task.description.match(/\[retry (\d+)\/3\]/);
280
338
  const retryCount = retryMatch ? parseInt(retryMatch[1]) : 0;
@@ -306,7 +364,7 @@ export class DiscordAdapter {
306
364
  }
307
365
  }
308
366
  catch (e) {
309
- console.error("[discord] approval check error:", e);
367
+ log.error("approval check error", { error: e?.message });
310
368
  }
311
369
  }
312
370
  async handleStatusCommand(msg) {
@@ -333,4 +391,22 @@ export class DiscordAdapter {
333
391
  const report = `${t(this.locale, "status_report")}\n${lines.join("\n")}\n\nSummary: ${summary}`;
334
392
  await msg.reply(report);
335
393
  }
394
+ async handleSessionsCommand(msg) {
395
+ if (!this.engine.isMultiSessionEnabled()) {
396
+ await msg.reply("Multi-session mode is disabled.");
397
+ return;
398
+ }
399
+ const sessions = this.engine.getSessionManager().getActive(msg.author.id, "discord");
400
+ if (!sessions.length) {
401
+ await msg.reply(t(this.locale, "no_sessions"));
402
+ return;
403
+ }
404
+ const statusIcon = { active: "🟢", idle: "🟡", expired: "🔴", closed: "⚫" };
405
+ const lines = sessions.map(s => {
406
+ const ago = Math.round((Date.now() - s.lastActiveAt) / 60000);
407
+ const locked = this.engine.isSessionLocked(s.id) ? " [processing]" : "";
408
+ return `${statusIcon[s.status] || "⚪"} ${s.id.slice(0, 8)} "${s.label || "(no topic)"}" (${ago}min ago, ${s.messageCount} msgs, $${s.totalCost.toFixed(4)})${locked}`;
409
+ });
410
+ await msg.reply(`${t(this.locale, "sessions_list")}\n${lines.join("\n")}`);
411
+ }
336
412
  }
@@ -17,6 +17,7 @@ export declare class TelegramAdapter implements Adapter {
17
17
  private pages;
18
18
  private static PAGE_TTL;
19
19
  constructor(engine: AgentEngine, store: Store, config: TelegramConfig, locale?: string);
20
+ reloadConfig(config: TelegramConfig, locale: string): void;
20
21
  private get api();
21
22
  private call;
22
23
  private reply;
@@ -25,6 +26,8 @@ export declare class TelegramAdapter implements Adapter {
25
26
  private pageKeyboard;
26
27
  private handlePageCallback;
27
28
  private handlePrompt;
29
+ /** Format and send a response with MarkdownV2 + pagination support */
30
+ private sendFormattedResponse;
28
31
  start(): Promise<void>;
29
32
  stop(): void;
30
33
  private registerCommands;
@@ -34,4 +37,5 @@ export declare class TelegramAdapter implements Adapter {
34
37
  private checkApprovals;
35
38
  private handleApprovalCallback;
36
39
  private handleStatusCommand;
40
+ private handleSessionsCommand;
37
41
  }
@@ -2,6 +2,8 @@ import { chunkText } from "./base.js";
2
2
  import { reloadConfig } from "../core/config.js";
3
3
  import { toTelegramMarkdown } from "../core/markdown.js";
4
4
  import { t, getCommandDescriptions } from "../core/i18n.js";
5
+ import { log as rootLog } from "../core/logger.js";
6
+ const log = rootLog.child("telegram");
5
7
  const EDIT_INTERVAL = 1500;
6
8
  export class TelegramAdapter {
7
9
  engine;
@@ -23,6 +25,11 @@ export class TelegramAdapter {
23
25
  this.config = config;
24
26
  this.locale = locale;
25
27
  }
28
+ reloadConfig(config, locale) {
29
+ this.config = config;
30
+ this.locale = locale;
31
+ this.maxParallel = this.engine.getMaxParallel();
32
+ }
26
33
  get api() {
27
34
  return `https://api.telegram.org/bot${this.config.token}`;
28
35
  }
@@ -40,7 +47,7 @@ export class TelegramAdapter {
40
47
  clearTimeout(timer);
41
48
  const json = await res.json();
42
49
  if (!json.ok) {
43
- console.error(`[telegram] API error ${method}:`, json.description || json);
50
+ log.error("API error", { method, description: json.description });
44
51
  const err = new Error(json.description || `Telegram API error: ${method}`);
45
52
  err.apiError = true;
46
53
  throw err;
@@ -54,11 +61,12 @@ export class TelegramAdapter {
54
61
  }
55
62
  }
56
63
  }
57
- async reply(chatId, text, parseMode) {
64
+ async reply(chatId, text, parseMode, replyToMsgId) {
58
65
  return this.call("sendMessage", {
59
66
  chat_id: chatId,
60
67
  text,
61
68
  ...(parseMode ? { parse_mode: parseMode } : {}),
69
+ ...(replyToMsgId ? { reply_to_message_id: replyToMsgId } : {}),
62
70
  });
63
71
  }
64
72
  async editMsg(chatId, msgId, text, parseMode) {
@@ -92,17 +100,24 @@ export class TelegramAdapter {
92
100
  return;
93
101
  const groupId = msg.chat.type !== "private" ? String(chatId) : undefined;
94
102
  if (!this.engine.access.isAllowed(String(uid), groupId)) {
95
- console.log(`[telegram] user ${uid} not allowed`);
103
+ log.info("user not allowed", { uid });
96
104
  return;
97
105
  }
98
106
  const text = (msg.text || "").trim();
99
- console.log(`[telegram] ${uid}: ${text.slice(0, 50)}`);
107
+ log.debug("message", { uid, text: text.slice(0, 50) });
108
+ // Extract reply-to message ID for session routing
109
+ const replyToMsgId = msg.reply_to_message?.message_id
110
+ ? String(msg.reply_to_message.message_id)
111
+ : undefined;
100
112
  // Management commands
101
113
  if (text === "/start" || text === "/help") {
102
114
  await this.reply(chatId, t(this.locale, "help"));
103
115
  return;
104
116
  }
105
117
  if (text === "/new") {
118
+ if (this.engine.isMultiSessionEnabled()) {
119
+ this.engine.getSessionManager().closeAll(String(uid));
120
+ }
106
121
  this.store.clearSession(String(uid));
107
122
  await this.reply(chatId, t(this.locale, "session_cleared"));
108
123
  return;
@@ -152,6 +167,10 @@ export class TelegramAdapter {
152
167
  await this.handleStatusCommand(chatId, String(uid));
153
168
  return;
154
169
  }
170
+ if (text === "/sessions") {
171
+ await this.handleSessionsCommand(chatId, String(uid));
172
+ return;
173
+ }
155
174
  // File upload
156
175
  if (msg.document || msg.photo) {
157
176
  let fileId;
@@ -175,7 +194,7 @@ export class TelegramAdapter {
175
194
  const ws = this.engine.getWorkDir(String(uid));
176
195
  writeFileSync(join(ws, fileName), buf);
177
196
  const prompt = msg.caption || `Analyze the uploaded file: ${fileName}`;
178
- await this.handlePrompt(chatId, String(uid), prompt);
197
+ await this.handlePrompt(chatId, String(uid), prompt, replyToMsgId);
179
198
  }
180
199
  catch (e) {
181
200
  await this.reply(chatId, t(this.locale, "upload_failed") + e.message);
@@ -184,7 +203,7 @@ export class TelegramAdapter {
184
203
  }
185
204
  // Text message — send to Claude (skill system handles intents)
186
205
  if (text)
187
- await this.handlePrompt(chatId, String(uid), text);
206
+ await this.handlePrompt(chatId, String(uid), text, replyToMsgId);
188
207
  }
189
208
  pageKeyboard(chatId, msgId, cur, total) {
190
209
  const btns = [];
@@ -251,7 +270,39 @@ export class TelegramAdapter {
251
270
  }
252
271
  await answer();
253
272
  }
254
- async handlePrompt(chatId, uid, text) {
273
+ async handlePrompt(chatId, uid, text, replyToMsgId) {
274
+ // Multi-session mode: route and execute concurrently (no global lock check)
275
+ if (this.engine.isMultiSessionEnabled()) {
276
+ const placeholder = await this.reply(chatId, t(this.locale, "thinking"));
277
+ const msgId = placeholder.message_id;
278
+ let lastEdit = 0;
279
+ try {
280
+ log.info("running claude (multi-session)", { uid });
281
+ const res = await this.engine.handleUserMessage(uid, text, "telegram", String(chatId), replyToMsgId, async (_chunk, full) => {
282
+ const now = Date.now();
283
+ if (now - lastEdit < EDIT_INTERVAL)
284
+ return;
285
+ lastEdit = now;
286
+ const preview = full.slice(-3500) + "\n\n...";
287
+ await this.editMsg(chatId, msgId, preview);
288
+ });
289
+ log.info("claude done", { uid, session: res.subSessionId?.slice(0, 8), cost: res.cost?.toFixed(4) });
290
+ // Track response message → sub-session mapping for future reply-to routing
291
+ if (res.subSessionId) {
292
+ this.engine.getSessionManager().trackMessage(String(msgId), String(chatId), res.subSessionId);
293
+ }
294
+ // Check if user has multiple active sessions — add label prefix
295
+ const activeSessions = this.engine.getSessionManager().getActive(uid, "telegram");
296
+ const labelPrefix = activeSessions.length > 1 && res.label ? `[${res.label.slice(0, 30)}]\n` : "";
297
+ await this.sendFormattedResponse(chatId, msgId, res.text, labelPrefix);
298
+ }
299
+ catch (err) {
300
+ log.error("claude error", { error: err?.message });
301
+ await this.editMsg(chatId, msgId, `Error: ${err.message || "unknown"}`);
302
+ }
303
+ return;
304
+ }
305
+ // Legacy single-session mode (session.enabled: false)
255
306
  if (this.engine.isLocked(uid)) {
256
307
  await this.reply(chatId, t(this.locale, "still_processing"));
257
308
  return;
@@ -260,7 +311,7 @@ export class TelegramAdapter {
260
311
  const msgId = placeholder.message_id;
261
312
  let lastEdit = 0;
262
313
  try {
263
- console.log(`[telegram] running claude for ${uid}...`);
314
+ log.info("running claude", { uid });
264
315
  const res = await this.engine.runStream(uid, text, "telegram", String(chatId), async (_chunk, full) => {
265
316
  const now = Date.now();
266
317
  if (now - lastEdit < EDIT_INTERVAL)
@@ -269,81 +320,91 @@ export class TelegramAdapter {
269
320
  const preview = full.slice(-3500) + "\n\n...";
270
321
  await this.editMsg(chatId, msgId, preview);
271
322
  });
272
- console.log(`[telegram] claude done for ${uid}, cost=$${res.cost?.toFixed(4)}`);
273
- const maxLen = this.config.chunk_size || 4000;
274
- const md = toTelegramMarkdown(res.text);
275
- const mdChunks = chunkText(md, maxLen);
276
- const rawChunks = chunkText(res.text, maxLen);
277
- if (mdChunks.length <= 1) {
278
- // Single page — no pagination needed
279
- try {
280
- await this.editMsg(chatId, msgId, mdChunks[0], "MarkdownV2");
281
- }
282
- catch {
283
- await this.editMsg(chatId, msgId, res.text);
284
- }
323
+ log.info("claude done", { uid, cost: res.cost?.toFixed(4) });
324
+ await this.sendFormattedResponse(chatId, msgId, res.text);
325
+ }
326
+ catch (err) {
327
+ log.error("claude error", { error: err?.message });
328
+ await this.editMsg(chatId, msgId, `Error: ${err.message || "unknown"}`);
329
+ }
330
+ }
331
+ /** Format and send a response with MarkdownV2 + pagination support */
332
+ async sendFormattedResponse(chatId, msgId, text, labelPrefix = "") {
333
+ const maxLen = this.config.chunk_size || 4000;
334
+ const fullText = labelPrefix + text;
335
+ const md = toTelegramMarkdown(fullText);
336
+ const mdChunks = chunkText(md, maxLen);
337
+ const rawChunks = chunkText(fullText, maxLen);
338
+ if (mdChunks.length <= 1) {
339
+ try {
340
+ await this.editMsg(chatId, msgId, mdChunks[0], "MarkdownV2");
285
341
  }
286
- else {
287
- // Multi-page store pages and show inline keyboard
288
- const key = `${chatId}:${msgId}`;
289
- this.pages.set(key, { chunks: mdChunks, raw: rawChunks, ts: Date.now() });
290
- setTimeout(() => this.pages.delete(key), TelegramAdapter.PAGE_TTL);
291
- const keyboard = this.pageKeyboard(chatId, msgId, 0, mdChunks.length);
342
+ catch {
343
+ await this.editMsg(chatId, msgId, fullText);
344
+ }
345
+ }
346
+ else {
347
+ const key = `${chatId}:${msgId}`;
348
+ if (this.pages.size >= 50) {
349
+ const oldest = this.pages.keys().next().value;
350
+ this.pages.delete(oldest);
351
+ }
352
+ this.pages.set(key, { chunks: mdChunks, raw: rawChunks, ts: Date.now() });
353
+ setTimeout(() => this.pages.delete(key), TelegramAdapter.PAGE_TTL);
354
+ const keyboard = this.pageKeyboard(chatId, msgId, 0, mdChunks.length);
355
+ try {
356
+ await this.call("editMessageText", {
357
+ chat_id: chatId,
358
+ message_id: msgId,
359
+ text: mdChunks[0],
360
+ parse_mode: "MarkdownV2",
361
+ reply_markup: keyboard,
362
+ });
363
+ }
364
+ catch {
292
365
  try {
293
366
  await this.call("editMessageText", {
294
367
  chat_id: chatId,
295
368
  message_id: msgId,
296
- text: mdChunks[0],
297
- parse_mode: "MarkdownV2",
369
+ text: rawChunks[0],
298
370
  reply_markup: keyboard,
299
371
  });
300
372
  }
301
- catch {
302
- // MarkdownV2 failed, fallback to raw text
303
- try {
304
- await this.call("editMessageText", {
305
- chat_id: chatId,
306
- message_id: msgId,
307
- text: rawChunks[0],
308
- reply_markup: keyboard,
309
- });
310
- }
311
- catch { }
312
- }
373
+ catch { }
313
374
  }
314
375
  }
315
- catch (err) {
316
- console.error("[telegram] claude error:", err);
317
- await this.editMsg(chatId, msgId, `Error: ${err.message || "unknown"}`);
318
- }
319
376
  }
320
377
  async start() {
321
378
  this.running = true;
322
379
  this.maxParallel = this.engine.getMaxParallel();
323
- console.log(`[telegram] starting long polling... (max_parallel=${this.maxParallel})`);
380
+ log.info("starting long polling...", { maxParallel: this.maxParallel, multiSession: this.engine.isMultiSessionEnabled() });
324
381
  this.reminderTimer = setInterval(() => this.checkReminders(), 30000);
325
382
  this.autoTimer = setInterval(() => this.processAutoTasks(), 60000);
326
383
  this.approvalTimer = setInterval(() => this.checkApprovals(), 15000);
327
384
  await this.registerCommands();
385
+ let pollBackoff = 0;
328
386
  while (this.running) {
329
387
  try {
330
388
  const ctrl = new AbortController();
331
- const timer = setTimeout(() => ctrl.abort(), 15000);
332
- const res = await fetch(`${this.api}/getUpdates?offset=${this.offset}&timeout=5`, { signal: ctrl.signal });
389
+ const timer = setTimeout(() => ctrl.abort(), 30000);
390
+ const res = await fetch(`${this.api}/getUpdates?offset=${this.offset}&timeout=10`, { signal: ctrl.signal });
333
391
  clearTimeout(timer);
334
392
  const json = await res.json();
335
393
  if (!json.ok) {
336
- console.error("[telegram] poll error:", json);
394
+ log.error("poll error", { response: json });
337
395
  continue;
338
396
  }
397
+ pollBackoff = 0; // reset on success
339
398
  for (const update of json.result) {
340
399
  this.offset = update.update_id + 1;
341
- this.handleUpdate(update).catch(e => console.error("[telegram] handler error:", e));
400
+ this.handleUpdate(update).catch(e => log.error("handler error", { error: e?.message }));
342
401
  }
343
402
  }
344
403
  catch (err) {
345
- console.error("[telegram] poll fetch error:", err);
346
- await new Promise(r => setTimeout(r, 3000));
404
+ pollBackoff = Math.min(pollBackoff + 1, 6);
405
+ const delay = Math.min(3000 * Math.pow(2, pollBackoff), 120000);
406
+ log.warn("poll error", { retryIn: delay / 1000, error: err.cause?.code || err.message || "unknown" });
407
+ await new Promise(r => setTimeout(r, delay));
347
408
  }
348
409
  }
349
410
  }
@@ -359,10 +420,10 @@ export class TelegramAdapter {
359
420
  async registerCommands() {
360
421
  try {
361
422
  await this.call("setMyCommands", { commands: getCommandDescriptions(this.locale) });
362
- console.log("[telegram] commands registered");
423
+ log.info("commands registered");
363
424
  }
364
425
  catch (e) {
365
- console.error("[telegram] failed to register commands:", e);
426
+ log.error("failed to register commands", { error: e?.message });
366
427
  }
367
428
  }
368
429
  async checkReminders() {
@@ -374,7 +435,7 @@ export class TelegramAdapter {
374
435
  }
375
436
  }
376
437
  catch (e) {
377
- console.error("[telegram] reminder error:", e);
438
+ log.error("reminder error", { error: e?.message });
378
439
  }
379
440
  }
380
441
  async processAutoTasks() {
@@ -392,10 +453,9 @@ export class TelegramAdapter {
392
453
  const chatId = Number(task.chat_id);
393
454
  await this.reply(chatId, t(this.locale, "auto_starting", { id: task.id, desc: task.description }));
394
455
  try {
395
- console.log(`[telegram] auto-task #${task.id} for ${task.user_id}`);
396
- const res = this.maxParallel > 1
397
- ? await this.engine.runParallel(task.user_id, task.description, "telegram", task.chat_id, undefined, 0)
398
- : await this.engine.runStream(task.user_id, task.description, "telegram", task.chat_id, undefined, 0);
456
+ log.info("auto-task starting", { taskId: task.id, userId: task.user_id });
457
+ // Always use runParallel for auto-tasks: fresh session, no user session pollution
458
+ const res = await this.engine.runParallel(task.user_id, task.description, "telegram", task.chat_id, undefined, 0);
399
459
  if (res.timedOut) {
400
460
  this.store.markTaskResult(task.id, "failed");
401
461
  if (res.text)
@@ -470,7 +530,7 @@ export class TelegramAdapter {
470
530
  }
471
531
  }
472
532
  catch (e) {
473
- console.error("[telegram] approval check error:", e);
533
+ log.error("approval check error", { error: e?.message });
474
534
  }
475
535
  }
476
536
  async handleApprovalCallback(cb) {
@@ -547,4 +607,22 @@ export class TelegramAdapter {
547
607
  const report = `${t(this.locale, "status_report")}\n${lines.join("\n")}\n\nSummary: ${summary}`;
548
608
  await this.reply(chatId, report);
549
609
  }
610
+ async handleSessionsCommand(chatId, userId) {
611
+ if (!this.engine.isMultiSessionEnabled()) {
612
+ await this.reply(chatId, "Multi-session mode is disabled.");
613
+ return;
614
+ }
615
+ const sessions = this.engine.getSessionManager().getActive(userId, "telegram");
616
+ if (!sessions.length) {
617
+ await this.reply(chatId, t(this.locale, "no_sessions"));
618
+ return;
619
+ }
620
+ const statusIcon = { active: "🟢", idle: "🟡", expired: "🔴", closed: "⚫" };
621
+ const lines = sessions.map(s => {
622
+ const ago = Math.round((Date.now() - s.lastActiveAt) / 60000);
623
+ const locked = this.engine.isSessionLocked(s.id) ? " [processing]" : "";
624
+ return `${statusIcon[s.status] || "⚪"} ${s.id.slice(0, 8)} "${s.label || "(no topic)"}" (${ago}min ago, ${s.messageCount} msgs, $${s.totalCost.toFixed(4)})${locked}`;
625
+ });
626
+ await this.reply(chatId, `${t(this.locale, "sessions_list")}\n${lines.join("\n")}`);
627
+ }
550
628
  }