@emqo/claudebridge 0.8.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"
@@ -17,6 +17,8 @@ export declare class DiscordAdapter implements Adapter {
17
17
  reloadConfig(config: DiscordConfig, locale: string): void;
18
18
  private setup;
19
19
  private handlePrompt;
20
+ /** Chunk text and send via edit + follow-up replies */
21
+ private sendChunkedResponse;
20
22
  start(): Promise<void>;
21
23
  stop(): void;
22
24
  private checkReminders;
@@ -24,4 +26,5 @@ export declare class DiscordAdapter implements Adapter {
24
26
  private runAutoTask;
25
27
  private checkApprovals;
26
28
  private handleStatusCommand;
29
+ private handleSessionsCommand;
27
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;
@@ -48,12 +50,17 @@ export class DiscordAdapter {
48
50
  if (!this.engine.access.isAllowed(msg.author.id, groupId))
49
51
  return;
50
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;
51
55
  // Management commands
52
56
  if (text === "!help") {
53
57
  await msg.reply(t(this.locale, "help").replaceAll("/", "!"));
54
58
  return;
55
59
  }
56
60
  if (text === "!new") {
61
+ if (this.engine.isMultiSessionEnabled()) {
62
+ this.engine.getSessionManager().closeAll(msg.author.id);
63
+ }
57
64
  this.store.clearSession(msg.author.id);
58
65
  await msg.reply(t(this.locale, "session_cleared"));
59
66
  return;
@@ -129,6 +136,10 @@ export class DiscordAdapter {
129
136
  await this.handleStatusCommand(msg);
130
137
  return;
131
138
  }
139
+ if (text === "!sessions") {
140
+ await this.handleSessionsCommand(msg);
141
+ return;
142
+ }
132
143
  // File upload handling
133
144
  if (msg.attachments.size > 0) {
134
145
  const ws = this.engine.getWorkDir(msg.author.id);
@@ -142,16 +153,55 @@ export class DiscordAdapter {
142
153
  }
143
154
  const names = [...msg.attachments.values()].map(a => a.name).join(", ");
144
155
  const prompt = text || `Analyze the uploaded file(s): ${names}`;
145
- await this.handlePrompt(msg, prompt);
156
+ await this.handlePrompt(msg, prompt, replyToMsgId);
146
157
  return;
147
158
  }
148
159
  // Text message — send to Claude (skill system handles intents)
149
160
  if (!text)
150
161
  return;
151
- await this.handlePrompt(msg, text);
162
+ await this.handlePrompt(msg, text, replyToMsgId);
152
163
  });
153
164
  }
154
- 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
155
205
  if (this.engine.isLocked(msg.author.id)) {
156
206
  await msg.reply(t(this.locale, "still_processing"));
157
207
  return;
@@ -174,30 +224,34 @@ export class DiscordAdapter {
174
224
  }
175
225
  catch { }
176
226
  });
177
- const maxLen = this.config.chunk_size || 1900;
178
- const chunks = chunkText(res.text, maxLen);
179
- try {
180
- await placeholder.edit(chunks[0]);
181
- }
182
- catch { }
183
- for (let i = 1; i < chunks.length; i++) {
184
- await msg.reply(chunks[i]);
185
- }
227
+ await this.sendChunkedResponse(msg, placeholder, res.text);
186
228
  }
187
229
  catch (err) {
188
- console.error("[discord] error:", err);
230
+ log.error("error", { error: err?.message });
189
231
  try {
190
232
  await placeholder.edit(`Error: ${err.message || "unknown"}`);
191
233
  }
192
234
  catch { }
193
235
  }
194
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
+ }
195
249
  async start() {
196
- console.log("[discord] starting bot...");
250
+ log.info("starting bot...");
197
251
  await this.client.login(this.config.token);
198
- console.log(`[discord] logged in as ${this.client.user?.tag}`);
252
+ log.info("logged in", { tag: this.client.user?.tag });
199
253
  this.maxParallel = this.engine.getMaxParallel();
200
- console.log(`[discord] max_parallel=${this.maxParallel}`);
254
+ log.info("ready", { maxParallel: this.maxParallel, multiSession: this.engine.isMultiSessionEnabled() });
201
255
  this.reminderTimer = setInterval(() => this.checkReminders(), 30000);
202
256
  this.autoTimer = setInterval(() => this.processAutoTasks(), 60000);
203
257
  this.approvalTimer = setInterval(() => this.checkApprovals(), 15000);
@@ -222,7 +276,7 @@ export class DiscordAdapter {
222
276
  }
223
277
  }
224
278
  catch (e) {
225
- console.error("[discord] reminder error:", e);
279
+ log.error("reminder error", { error: e?.message });
226
280
  }
227
281
  }
228
282
  async processAutoTasks() {
@@ -243,7 +297,7 @@ export class DiscordAdapter {
243
297
  throw new Error("channel not found");
244
298
  const channel = ch;
245
299
  await channel.send(t(this.locale, "auto_starting", { id: task.id, desc: task.description }));
246
- console.log(`[discord] auto-task #${task.id} for ${task.user_id}`);
300
+ log.info("auto-task starting", { taskId: task.id, userId: task.user_id });
247
301
  // Always use runParallel for auto-tasks: fresh session, no user session pollution
248
302
  const res = await this.engine.runParallel(task.user_id, task.description, "discord", task.chat_id, undefined, 0);
249
303
  if (res.timedOut) {
@@ -278,7 +332,7 @@ export class DiscordAdapter {
278
332
  }
279
333
  catch (err) {
280
334
  this.store.markTaskResult(task.id, "failed");
281
- console.error(`[discord] auto-task #${task.id} failed:`, err);
335
+ log.error("auto-task failed", { taskId: task.id, error: err?.message });
282
336
  // Self-healing: auto-retry failed tasks (max 3 retries)
283
337
  const retryMatch = task.description.match(/\[retry (\d+)\/3\]/);
284
338
  const retryCount = retryMatch ? parseInt(retryMatch[1]) : 0;
@@ -310,7 +364,7 @@ export class DiscordAdapter {
310
364
  }
311
365
  }
312
366
  catch (e) {
313
- console.error("[discord] approval check error:", e);
367
+ log.error("approval check error", { error: e?.message });
314
368
  }
315
369
  }
316
370
  async handleStatusCommand(msg) {
@@ -337,4 +391,22 @@ export class DiscordAdapter {
337
391
  const report = `${t(this.locale, "status_report")}\n${lines.join("\n")}\n\nSummary: ${summary}`;
338
392
  await msg.reply(report);
339
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
+ }
340
412
  }
@@ -26,6 +26,8 @@ export declare class TelegramAdapter implements Adapter {
26
26
  private pageKeyboard;
27
27
  private handlePageCallback;
28
28
  private handlePrompt;
29
+ /** Format and send a response with MarkdownV2 + pagination support */
30
+ private sendFormattedResponse;
29
31
  start(): Promise<void>;
30
32
  stop(): void;
31
33
  private registerCommands;
@@ -35,4 +37,5 @@ export declare class TelegramAdapter implements Adapter {
35
37
  private checkApprovals;
36
38
  private handleApprovalCallback;
37
39
  private handleStatusCommand;
40
+ private handleSessionsCommand;
38
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;
@@ -45,7 +47,7 @@ export class TelegramAdapter {
45
47
  clearTimeout(timer);
46
48
  const json = await res.json();
47
49
  if (!json.ok) {
48
- console.error(`[telegram] API error ${method}:`, json.description || json);
50
+ log.error("API error", { method, description: json.description });
49
51
  const err = new Error(json.description || `Telegram API error: ${method}`);
50
52
  err.apiError = true;
51
53
  throw err;
@@ -59,11 +61,12 @@ export class TelegramAdapter {
59
61
  }
60
62
  }
61
63
  }
62
- async reply(chatId, text, parseMode) {
64
+ async reply(chatId, text, parseMode, replyToMsgId) {
63
65
  return this.call("sendMessage", {
64
66
  chat_id: chatId,
65
67
  text,
66
68
  ...(parseMode ? { parse_mode: parseMode } : {}),
69
+ ...(replyToMsgId ? { reply_to_message_id: replyToMsgId } : {}),
67
70
  });
68
71
  }
69
72
  async editMsg(chatId, msgId, text, parseMode) {
@@ -97,17 +100,24 @@ export class TelegramAdapter {
97
100
  return;
98
101
  const groupId = msg.chat.type !== "private" ? String(chatId) : undefined;
99
102
  if (!this.engine.access.isAllowed(String(uid), groupId)) {
100
- console.log(`[telegram] user ${uid} not allowed`);
103
+ log.info("user not allowed", { uid });
101
104
  return;
102
105
  }
103
106
  const text = (msg.text || "").trim();
104
- 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;
105
112
  // Management commands
106
113
  if (text === "/start" || text === "/help") {
107
114
  await this.reply(chatId, t(this.locale, "help"));
108
115
  return;
109
116
  }
110
117
  if (text === "/new") {
118
+ if (this.engine.isMultiSessionEnabled()) {
119
+ this.engine.getSessionManager().closeAll(String(uid));
120
+ }
111
121
  this.store.clearSession(String(uid));
112
122
  await this.reply(chatId, t(this.locale, "session_cleared"));
113
123
  return;
@@ -157,6 +167,10 @@ export class TelegramAdapter {
157
167
  await this.handleStatusCommand(chatId, String(uid));
158
168
  return;
159
169
  }
170
+ if (text === "/sessions") {
171
+ await this.handleSessionsCommand(chatId, String(uid));
172
+ return;
173
+ }
160
174
  // File upload
161
175
  if (msg.document || msg.photo) {
162
176
  let fileId;
@@ -180,7 +194,7 @@ export class TelegramAdapter {
180
194
  const ws = this.engine.getWorkDir(String(uid));
181
195
  writeFileSync(join(ws, fileName), buf);
182
196
  const prompt = msg.caption || `Analyze the uploaded file: ${fileName}`;
183
- await this.handlePrompt(chatId, String(uid), prompt);
197
+ await this.handlePrompt(chatId, String(uid), prompt, replyToMsgId);
184
198
  }
185
199
  catch (e) {
186
200
  await this.reply(chatId, t(this.locale, "upload_failed") + e.message);
@@ -189,7 +203,7 @@ export class TelegramAdapter {
189
203
  }
190
204
  // Text message — send to Claude (skill system handles intents)
191
205
  if (text)
192
- await this.handlePrompt(chatId, String(uid), text);
206
+ await this.handlePrompt(chatId, String(uid), text, replyToMsgId);
193
207
  }
194
208
  pageKeyboard(chatId, msgId, cur, total) {
195
209
  const btns = [];
@@ -256,7 +270,39 @@ export class TelegramAdapter {
256
270
  }
257
271
  await answer();
258
272
  }
259
- 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)
260
306
  if (this.engine.isLocked(uid)) {
261
307
  await this.reply(chatId, t(this.locale, "still_processing"));
262
308
  return;
@@ -265,7 +311,7 @@ export class TelegramAdapter {
265
311
  const msgId = placeholder.message_id;
266
312
  let lastEdit = 0;
267
313
  try {
268
- console.log(`[telegram] running claude for ${uid}...`);
314
+ log.info("running claude", { uid });
269
315
  const res = await this.engine.runStream(uid, text, "telegram", String(chatId), async (_chunk, full) => {
270
316
  const now = Date.now();
271
317
  if (now - lastEdit < EDIT_INTERVAL)
@@ -274,63 +320,64 @@ export class TelegramAdapter {
274
320
  const preview = full.slice(-3500) + "\n\n...";
275
321
  await this.editMsg(chatId, msgId, preview);
276
322
  });
277
- console.log(`[telegram] claude done for ${uid}, cost=$${res.cost?.toFixed(4)}`);
278
- const maxLen = this.config.chunk_size || 4000;
279
- const md = toTelegramMarkdown(res.text);
280
- const mdChunks = chunkText(md, maxLen);
281
- const rawChunks = chunkText(res.text, maxLen);
282
- if (mdChunks.length <= 1) {
283
- // Single page — no pagination needed
284
- try {
285
- await this.editMsg(chatId, msgId, mdChunks[0], "MarkdownV2");
286
- }
287
- catch {
288
- await this.editMsg(chatId, msgId, res.text);
289
- }
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");
290
341
  }
291
- else {
292
- // Multi-page store pages and show inline keyboard
293
- const key = `${chatId}:${msgId}`;
294
- // Evict oldest if too many pages cached
295
- if (this.pages.size >= 50) {
296
- const oldest = this.pages.keys().next().value;
297
- this.pages.delete(oldest);
298
- }
299
- this.pages.set(key, { chunks: mdChunks, raw: rawChunks, ts: Date.now() });
300
- setTimeout(() => this.pages.delete(key), TelegramAdapter.PAGE_TTL);
301
- 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 {
302
365
  try {
303
366
  await this.call("editMessageText", {
304
367
  chat_id: chatId,
305
368
  message_id: msgId,
306
- text: mdChunks[0],
307
- parse_mode: "MarkdownV2",
369
+ text: rawChunks[0],
308
370
  reply_markup: keyboard,
309
371
  });
310
372
  }
311
- catch {
312
- // MarkdownV2 failed, fallback to raw text
313
- try {
314
- await this.call("editMessageText", {
315
- chat_id: chatId,
316
- message_id: msgId,
317
- text: rawChunks[0],
318
- reply_markup: keyboard,
319
- });
320
- }
321
- catch { }
322
- }
373
+ catch { }
323
374
  }
324
375
  }
325
- catch (err) {
326
- console.error("[telegram] claude error:", err);
327
- await this.editMsg(chatId, msgId, `Error: ${err.message || "unknown"}`);
328
- }
329
376
  }
330
377
  async start() {
331
378
  this.running = true;
332
379
  this.maxParallel = this.engine.getMaxParallel();
333
- console.log(`[telegram] starting long polling... (max_parallel=${this.maxParallel})`);
380
+ log.info("starting long polling...", { maxParallel: this.maxParallel, multiSession: this.engine.isMultiSessionEnabled() });
334
381
  this.reminderTimer = setInterval(() => this.checkReminders(), 30000);
335
382
  this.autoTimer = setInterval(() => this.processAutoTasks(), 60000);
336
383
  this.approvalTimer = setInterval(() => this.checkApprovals(), 15000);
@@ -344,19 +391,19 @@ export class TelegramAdapter {
344
391
  clearTimeout(timer);
345
392
  const json = await res.json();
346
393
  if (!json.ok) {
347
- console.error("[telegram] poll error:", json);
394
+ log.error("poll error", { response: json });
348
395
  continue;
349
396
  }
350
397
  pollBackoff = 0; // reset on success
351
398
  for (const update of json.result) {
352
399
  this.offset = update.update_id + 1;
353
- this.handleUpdate(update).catch(e => console.error("[telegram] handler error:", e));
400
+ this.handleUpdate(update).catch(e => log.error("handler error", { error: e?.message }));
354
401
  }
355
402
  }
356
403
  catch (err) {
357
404
  pollBackoff = Math.min(pollBackoff + 1, 6);
358
405
  const delay = Math.min(3000 * Math.pow(2, pollBackoff), 120000);
359
- console.warn(`[telegram] poll error (retry in ${delay / 1000}s): ${err.cause?.code || err.message || "unknown"}`);
406
+ log.warn("poll error", { retryIn: delay / 1000, error: err.cause?.code || err.message || "unknown" });
360
407
  await new Promise(r => setTimeout(r, delay));
361
408
  }
362
409
  }
@@ -373,10 +420,10 @@ export class TelegramAdapter {
373
420
  async registerCommands() {
374
421
  try {
375
422
  await this.call("setMyCommands", { commands: getCommandDescriptions(this.locale) });
376
- console.log("[telegram] commands registered");
423
+ log.info("commands registered");
377
424
  }
378
425
  catch (e) {
379
- console.error("[telegram] failed to register commands:", e);
426
+ log.error("failed to register commands", { error: e?.message });
380
427
  }
381
428
  }
382
429
  async checkReminders() {
@@ -388,7 +435,7 @@ export class TelegramAdapter {
388
435
  }
389
436
  }
390
437
  catch (e) {
391
- console.error("[telegram] reminder error:", e);
438
+ log.error("reminder error", { error: e?.message });
392
439
  }
393
440
  }
394
441
  async processAutoTasks() {
@@ -406,7 +453,7 @@ export class TelegramAdapter {
406
453
  const chatId = Number(task.chat_id);
407
454
  await this.reply(chatId, t(this.locale, "auto_starting", { id: task.id, desc: task.description }));
408
455
  try {
409
- console.log(`[telegram] auto-task #${task.id} for ${task.user_id}`);
456
+ log.info("auto-task starting", { taskId: task.id, userId: task.user_id });
410
457
  // Always use runParallel for auto-tasks: fresh session, no user session pollution
411
458
  const res = await this.engine.runParallel(task.user_id, task.description, "telegram", task.chat_id, undefined, 0);
412
459
  if (res.timedOut) {
@@ -483,7 +530,7 @@ export class TelegramAdapter {
483
530
  }
484
531
  }
485
532
  catch (e) {
486
- console.error("[telegram] approval check error:", e);
533
+ log.error("approval check error", { error: e?.message });
487
534
  }
488
535
  }
489
536
  async handleApprovalCallback(cb) {
@@ -560,4 +607,22 @@ export class TelegramAdapter {
560
607
  const report = `${t(this.locale, "status_report")}\n${lines.join("\n")}\n\nSummary: ${summary}`;
561
608
  await this.reply(chatId, report);
562
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
+ }
563
628
  }
@@ -2,11 +2,15 @@ import { Config } from "./config.js";
2
2
  import { Store } from "./store.js";
3
3
  import { AccessControl } from "./permissions.js";
4
4
  import { EndpointRotator } from "./keys.js";
5
+ import { SessionManager } from "./session.js";
6
+ import { SessionRouter } from "./router.js";
5
7
  export interface AgentResponse {
6
8
  text: string;
7
9
  sessionId: string;
8
10
  cost?: number;
9
11
  timedOut?: boolean;
12
+ subSessionId?: string;
13
+ label?: string;
10
14
  }
11
15
  export type StreamCallback = (chunk: string, full: string) => void | Promise<void>;
12
16
  export declare class AgentEngine {
@@ -14,6 +18,9 @@ export declare class AgentEngine {
14
18
  private store;
15
19
  private lock;
16
20
  private rotator;
21
+ private sessionMgr;
22
+ private router;
23
+ private sessionExpiryTimer?;
17
24
  access: AccessControl;
18
25
  constructor(config: Config, store: Store);
19
26
  reloadConfig(config: Config): void;
@@ -24,12 +31,42 @@ export declare class AgentEngine {
24
31
  getRotator(): EndpointRotator;
25
32
  getEndpointCount(): number;
26
33
  getMaxParallel(): number;
34
+ getSessionManager(): SessionManager;
35
+ getRouter(): SessionRouter;
27
36
  getWorkDir(userId: string): string;
37
+ /** @deprecated Use isSessionLocked() for multi-session mode */
28
38
  isLocked(userId: string): boolean;
39
+ isSessionLocked(subSessionId: string): boolean;
40
+ isMultiSessionEnabled(): boolean;
41
+ /**
42
+ * Main entry point for user messages in multi-session mode.
43
+ * Routes to the correct sub-session and executes concurrently.
44
+ */
45
+ handleUserMessage(userId: string, prompt: string, platform: string, chatId: string, replyToMsgId?: string, onChunk?: StreamCallback, overrideTimeoutMs?: number): Promise<AgentResponse>;
46
+ /**
47
+ * Execute a prompt within a specific sub-session.
48
+ * Acquires per-session lock, resumes claude session via -r flag.
49
+ */
50
+ private _executeSubSession;
51
+ /**
52
+ * Core execution: spawn claude CLI with session resume for a sub-session.
53
+ * Thin wrapper around _spawnAgent with sub-session persistence.
54
+ */
55
+ private _executeWithSession;
56
+ /** Build the append system prompt (memories + skill doc) */
57
+ private _buildAppendPrompt;
58
+ /**
59
+ * Unified core: spawn provider CLI, parse stream, handle timeout.
60
+ * All execute methods delegate here.
61
+ */
62
+ private _spawnAgent;
63
+ /** @deprecated Use handleUserMessage() for multi-session mode */
29
64
  runStream(userId: string, prompt: string, platform: string, chatId: string, onChunk?: StreamCallback, overrideTimeoutMs?: number): Promise<AgentResponse>;
30
65
  runParallel(userId: string, prompt: string, platform: string, chatId: string, onChunk?: StreamCallback, overrideTimeoutMs?: number): Promise<AgentResponse>;
31
66
  private _executeWithRetry;
67
+ /** Legacy single-session execution. Thin wrapper around _spawnAgent with store persistence. */
32
68
  private _execute;
69
+ /** Parallel execution without session resume. Thin wrapper around _spawnAgent. */
33
70
  private _executeNoSession;
34
71
  private _autoSummarize;
35
72
  }