@emqo/claudebridge 0.6.3 → 0.8.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.
@@ -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,6 +14,7 @@ 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;
19
20
  start(): Promise<void>;
@@ -31,6 +31,11 @@ export class DiscordAdapter {
31
31
  });
32
32
  this.setup();
33
33
  }
34
+ reloadConfig(config, locale) {
35
+ this.config = config;
36
+ this.locale = locale;
37
+ this.maxParallel = this.engine.getMaxParallel();
38
+ }
34
39
  setup() {
35
40
  this.client.on("messageCreate", async (msg) => {
36
41
  if (msg.author.bot)
@@ -239,14 +244,21 @@ export class DiscordAdapter {
239
244
  const channel = ch;
240
245
  await channel.send(t(this.locale, "auto_starting", { id: task.id, desc: task.description }));
241
246
  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);
247
+ // Always use runParallel for auto-tasks: fresh session, no user session pollution
248
+ const res = await this.engine.runParallel(task.user_id, task.description, "discord", task.chat_id, undefined, 0);
245
249
  if (res.timedOut) {
246
250
  this.store.markTaskResult(task.id, "failed");
247
251
  if (res.text)
248
252
  this.store.setTaskResult(task.id, res.text.slice(0, 10000));
249
253
  await channel.send(t(this.locale, "auto_failed", { id: task.id, err: "timed out" }));
254
+ const retryMatch = task.description.match(/\[retry (\d+)\/3\]/);
255
+ const retryCount = retryMatch ? parseInt(retryMatch[1]) : 0;
256
+ if (retryCount < 3) {
257
+ const retryDesc = retryCount === 0
258
+ ? `[retry 1/3] Previous attempt of task #${task.id} timed out. Continue from where it left off: ${task.description}`
259
+ : task.description.replace(`[retry ${retryCount}/3]`, `[retry ${retryCount + 1}/3]`);
260
+ this.store.addTask(task.user_id, "discord", task.chat_id, retryDesc, undefined, true, task.parent_id || task.id, Date.now() + 120000);
261
+ }
250
262
  return;
251
263
  }
252
264
  this.store.markTaskResult(task.id, "done");
@@ -267,6 +279,15 @@ export class DiscordAdapter {
267
279
  catch (err) {
268
280
  this.store.markTaskResult(task.id, "failed");
269
281
  console.error(`[discord] auto-task #${task.id} failed:`, err);
282
+ // Self-healing: auto-retry failed tasks (max 3 retries)
283
+ const retryMatch = task.description.match(/\[retry (\d+)\/3\]/);
284
+ const retryCount = retryMatch ? parseInt(retryMatch[1]) : 0;
285
+ if (retryCount < 3) {
286
+ const retryDesc = retryCount === 0
287
+ ? `[retry 1/3] Previous attempt of task #${task.id} failed (${(err.message || "unknown").slice(0, 100)}). Analyze the failure, fix the issue, then: ${task.description}`
288
+ : task.description.replace(`[retry ${retryCount}/3]`, `[retry ${retryCount + 1}/3]`);
289
+ this.store.addTask(task.user_id, "discord", task.chat_id, retryDesc, undefined, true, task.parent_id || task.id, Date.now() + 120000);
290
+ }
270
291
  try {
271
292
  const ch = await this.client.channels.fetch(task.chat_id);
272
293
  if (ch?.isTextBased() && "send" in ch) {
@@ -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;
@@ -23,6 +23,11 @@ export class TelegramAdapter {
23
23
  this.config = config;
24
24
  this.locale = locale;
25
25
  }
26
+ reloadConfig(config, locale) {
27
+ this.config = config;
28
+ this.locale = locale;
29
+ this.maxParallel = this.engine.getMaxParallel();
30
+ }
26
31
  get api() {
27
32
  return `https://api.telegram.org/bot${this.config.token}`;
28
33
  }
@@ -286,6 +291,11 @@ export class TelegramAdapter {
286
291
  else {
287
292
  // Multi-page — store pages and show inline keyboard
288
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
+ }
289
299
  this.pages.set(key, { chunks: mdChunks, raw: rawChunks, ts: Date.now() });
290
300
  setTimeout(() => this.pages.delete(key), TelegramAdapter.PAGE_TTL);
291
301
  const keyboard = this.pageKeyboard(chatId, msgId, 0, mdChunks.length);
@@ -325,25 +335,29 @@ export class TelegramAdapter {
325
335
  this.autoTimer = setInterval(() => this.processAutoTasks(), 60000);
326
336
  this.approvalTimer = setInterval(() => this.checkApprovals(), 15000);
327
337
  await this.registerCommands();
338
+ let pollBackoff = 0;
328
339
  while (this.running) {
329
340
  try {
330
341
  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 });
342
+ const timer = setTimeout(() => ctrl.abort(), 30000);
343
+ const res = await fetch(`${this.api}/getUpdates?offset=${this.offset}&timeout=10`, { signal: ctrl.signal });
333
344
  clearTimeout(timer);
334
345
  const json = await res.json();
335
346
  if (!json.ok) {
336
347
  console.error("[telegram] poll error:", json);
337
348
  continue;
338
349
  }
350
+ pollBackoff = 0; // reset on success
339
351
  for (const update of json.result) {
340
352
  this.offset = update.update_id + 1;
341
353
  this.handleUpdate(update).catch(e => console.error("[telegram] handler error:", e));
342
354
  }
343
355
  }
344
356
  catch (err) {
345
- console.error("[telegram] poll fetch error:", err);
346
- await new Promise(r => setTimeout(r, 3000));
357
+ pollBackoff = Math.min(pollBackoff + 1, 6);
358
+ 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"}`);
360
+ await new Promise(r => setTimeout(r, delay));
347
361
  }
348
362
  }
349
363
  }
@@ -393,14 +407,23 @@ export class TelegramAdapter {
393
407
  await this.reply(chatId, t(this.locale, "auto_starting", { id: task.id, desc: task.description }));
394
408
  try {
395
409
  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);
410
+ // Always use runParallel for auto-tasks: fresh session, no user session pollution
411
+ const res = await this.engine.runParallel(task.user_id, task.description, "telegram", task.chat_id, undefined, 0);
399
412
  if (res.timedOut) {
400
413
  this.store.markTaskResult(task.id, "failed");
401
414
  if (res.text)
402
415
  this.store.setTaskResult(task.id, res.text.slice(0, 10000));
403
416
  await this.reply(chatId, t(this.locale, "auto_failed", { id: task.id, err: "timed out" }));
417
+ // Self-healing: auto-retry timed out tasks
418
+ const retryMatch = task.description.match(/\[retry (\d+)\/3\]/);
419
+ const retryCount = retryMatch ? parseInt(retryMatch[1]) : 0;
420
+ if (retryCount < 3) {
421
+ const retryDesc = retryCount === 0
422
+ ? `[retry 1/3] Previous attempt of task #${task.id} timed out. Continue from where it left off: ${task.description}`
423
+ : task.description.replace(`[retry ${retryCount}/3]`, `[retry ${retryCount + 1}/3]`);
424
+ const retryId = this.store.addTask(task.user_id, "telegram", task.chat_id, retryDesc, undefined, true, task.parent_id || task.id, Date.now() + 120000);
425
+ await this.reply(chatId, t(this.locale, "auto_retry", { id: retryId, attempt: retryCount + 1, parent: task.id }));
426
+ }
404
427
  return;
405
428
  }
406
429
  this.store.markTaskResult(task.id, "done");
@@ -428,6 +451,16 @@ export class TelegramAdapter {
428
451
  catch (err) {
429
452
  this.store.markTaskResult(task.id, "failed");
430
453
  await this.reply(chatId, t(this.locale, "auto_failed", { id: task.id, err: err.message || "unknown" }));
454
+ // Self-healing: auto-retry failed tasks (max 3 retries)
455
+ const retryMatch = task.description.match(/\[retry (\d+)\/3\]/);
456
+ const retryCount = retryMatch ? parseInt(retryMatch[1]) : 0;
457
+ if (retryCount < 3) {
458
+ const retryDesc = retryCount === 0
459
+ ? `[retry 1/3] Previous attempt of task #${task.id} failed (${(err.message || "unknown").slice(0, 100)}). Analyze the failure, fix the issue, then: ${task.description}`
460
+ : task.description.replace(`[retry ${retryCount}/3]`, `[retry ${retryCount + 1}/3]`);
461
+ const retryId = this.store.addTask(task.user_id, "telegram", task.chat_id, retryDesc, undefined, true, task.parent_id || task.id, Date.now() + 120000);
462
+ await this.reply(chatId, t(this.locale, "auto_retry", { id: retryId, attempt: retryCount + 1, parent: task.id }));
463
+ }
431
464
  }
432
465
  }
433
466
  async checkApprovals() {
@@ -333,6 +333,10 @@ export class AgentEngine {
333
333
  args.push("--model", ep.model);
334
334
  const child = spawn("claude", args, { env, stdio: ["pipe", "pipe", "pipe"] });
335
335
  child.stdin.end();
336
+ const killTimer = setTimeout(() => { try {
337
+ child.kill("SIGTERM");
338
+ }
339
+ catch { } }, 60000);
336
340
  console.log(`[agent] auto-summary spawned pid=${child.pid} for ${userId}`);
337
341
  let result = "";
338
342
  let cost = 0;
@@ -359,6 +363,7 @@ export class AgentEngine {
359
363
  });
360
364
  child.stderr.on("data", (data) => { stderr += data.toString(); });
361
365
  child.on("close", (code) => {
366
+ clearTimeout(killTimer);
362
367
  if (code !== 0) {
363
368
  console.warn(`[agent] auto-summary failed code=${code} stderr=${stderr.slice(0, 200)} for ${userId}`);
364
369
  }
@@ -10,7 +10,7 @@ export function loadConfig(path) {
10
10
  endpoints: raw.endpoints || [],
11
11
  agent: {
12
12
  ...raw.agent,
13
- timeout_seconds: raw.agent?.timeout_seconds ?? 300,
13
+ timeout_seconds: raw.agent?.timeout_seconds ?? 0,
14
14
  max_parallel: raw.agent?.max_parallel ?? 1,
15
15
  memory: { enabled: true, auto_summary: true, max_memories: 50, ...raw.agent?.memory },
16
16
  skill: { enabled: true, ...raw.agent?.skill },
package/dist/core/i18n.js CHANGED
@@ -14,6 +14,7 @@ const messages = {
14
14
  auto_scheduled: "Auto task #{id} scheduled (executes in {minutes} min):\n{desc}",
15
15
  auto_done: "Auto task #{id} done (cost: ${cost}):",
16
16
  auto_failed: "Auto task #{id} failed: {err}",
17
+ auto_retry: "Self-healing: auto task #{parent} failed, retry #{attempt}/3 queued as task #{id} (in 2min)",
17
18
  page_expired: "Page expired. Please resend your question.",
18
19
  approval_request: "Approval needed for auto task #{id}:\n{desc}",
19
20
  approval_approved: "Auto task #{id} approved -- queued for execution.",
@@ -38,6 +39,7 @@ const messages = {
38
39
  auto_scheduled: "自动任务 #{id} 已排程({minutes} 分钟后执行):\n{desc}",
39
40
  auto_done: "自动任务 #{id} 完成(花费:${cost}):",
40
41
  auto_failed: "自动任务 #{id} 失败:{err}",
42
+ auto_retry: "自愈机制:自动任务 #{parent} 失败,重试 #{attempt}/3 已排队为任务 #{id}(2分钟后执行)",
41
43
  page_expired: "页面已过期,请重新发送问题。",
42
44
  approval_request: "自动任务 #{id} 需要审批:\n{desc}",
43
45
  approval_approved: "自动任务 #{id} 已批准 -- 已加入执行队列。",
package/dist/core/keys.js CHANGED
@@ -8,6 +8,8 @@ export class EndpointRotator {
8
8
  this.endpoints = endpoints.filter(e => e.api_key);
9
9
  }
10
10
  next() {
11
+ if (!this.endpoints.length)
12
+ throw new Error("No endpoints configured");
11
13
  const now = Date.now();
12
14
  const len = this.endpoints.length;
13
15
  for (let i = 0; i < len; i++) {
package/dist/core/lock.js CHANGED
@@ -43,11 +43,14 @@ export class UserLock {
43
43
  }
44
44
  async _acquireRedis(userId) {
45
45
  const key = this.prefix + userId;
46
- // spin until acquired
46
+ const maxWait = this.ttl * 1000 + 5000; // TTL + 5s grace
47
+ const start = Date.now();
47
48
  while (true) {
48
49
  const ok = await this.redis.set(key, "1", "EX", this.ttl, "NX");
49
50
  if (ok)
50
51
  break;
52
+ if (Date.now() - start > maxWait)
53
+ throw new Error(`Lock timeout for user ${userId}`);
51
54
  await new Promise((r) => setTimeout(r, 500));
52
55
  }
53
56
  return async () => {
@@ -70,10 +70,18 @@ export class Store {
70
70
  catch { }
71
71
  this.db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks(parent_id)");
72
72
  // Startup recovery: reset orphaned 'running' tasks back to 'auto' so they get re-executed
73
- const recovered = this.db.prepare("UPDATE tasks SET status = 'auto' WHERE status = 'running'").run();
74
- if (recovered.changes > 0) {
75
- console.log(`[store] recovered ${recovered.changes} orphaned running task(s) back to auto queue`);
73
+ const orphaned = this.db.prepare("SELECT id, description FROM tasks WHERE status = 'running'").all();
74
+ for (const t of orphaned) {
75
+ const desc = t.description.startsWith("[recovered]") ? t.description : `[recovered] Check current state before making changes previous attempt was interrupted. Original task: ${t.description}`;
76
+ this.db.prepare("UPDATE tasks SET status = 'auto', description = ? WHERE id = ?").run(desc, t.id);
76
77
  }
78
+ if (orphaned.length > 0) {
79
+ console.log(`[store] recovered ${orphaned.length} orphaned running task(s) back to auto queue`);
80
+ }
81
+ // Startup cleanup: prune history/usage older than 30 days
82
+ const cutoff = Date.now() - 30 * 86400000;
83
+ this.db.prepare("DELETE FROM history WHERE created_at < ?").run(cutoff);
84
+ this.db.prepare("DELETE FROM usage WHERE created_at < ?").run(cutoff);
77
85
  }
78
86
  // --- sessions ---
79
87
  getSession(userId) {
package/dist/ctl.js CHANGED
@@ -16,6 +16,17 @@ function fail(msg) {
16
16
  console.error(msg);
17
17
  process.exit(1);
18
18
  }
19
+ function extractFlag(parts, flag) {
20
+ // Search from end to avoid matching flag text inside description
21
+ for (let i = parts.length - 2; i >= 0; i--) {
22
+ if (parts[i] === flag) {
23
+ const val = parts[i + 1];
24
+ parts.splice(i, 2);
25
+ return val;
26
+ }
27
+ }
28
+ return null;
29
+ }
19
30
  if (category === "memory") {
20
31
  if (action === "add") {
21
32
  const [userId, ...contentParts] = rest;
@@ -102,21 +113,10 @@ else if (category === "auto") {
102
113
  const [userId, platform, chatId, ...descParts] = rest;
103
114
  if (!userId || !platform || !chatId || !descParts.length)
104
115
  fail("Usage: auto add <user_id> <platform> <chat_id> <description> [--parent <id>]");
105
- // Parse optional --parent flag
106
- let parentId = null;
107
- const parentIdx = descParts.indexOf("--parent");
108
- if (parentIdx !== -1 && descParts[parentIdx + 1]) {
109
- parentId = parseInt(descParts[parentIdx + 1]);
110
- descParts.splice(parentIdx, 2);
111
- }
112
- // Parse optional --delay flag
113
- let scheduledAt = null;
114
- const delayIdx = descParts.indexOf("--delay");
115
- if (delayIdx !== -1 && descParts[delayIdx + 1]) {
116
- const delayMin = parseInt(descParts[delayIdx + 1]);
117
- scheduledAt = Date.now() + delayMin * 60000;
118
- descParts.splice(delayIdx, 2);
119
- }
116
+ const parentRaw = extractFlag(descParts, "--parent");
117
+ const parentId = parentRaw ? parseInt(parentRaw) : null;
118
+ const delayRaw = extractFlag(descParts, "--delay");
119
+ const scheduledAt = delayRaw ? Date.now() + parseInt(delayRaw) * 60000 : null;
120
120
  const desc = descParts.join(" ");
121
121
  const r = db.prepare("INSERT INTO tasks (user_id, platform, chat_id, description, status, parent_id, scheduled_at, created_at) VALUES (?, ?, ?, ?, 'auto', ?, ?, ?)").run(userId, platform, chatId, desc, parentId, scheduledAt, Date.now());
122
122
  output({ ok: true, id: Number(r.lastInsertRowid), scheduled_at: scheduledAt, message: scheduledAt ? `Auto task scheduled (in ${Math.ceil((scheduledAt - Date.now()) / 60000)} min)` : "Auto task queued" });
@@ -125,20 +125,10 @@ else if (category === "auto") {
125
125
  const [userId, platform, chatId, ...descParts] = rest;
126
126
  if (!userId || !platform || !chatId || !descParts.length)
127
127
  fail("Usage: auto add-approval <user_id> <platform> <chat_id> <description> [--parent <id>] [--delay <minutes>]");
128
- let parentId = null;
129
- const parentIdx = descParts.indexOf("--parent");
130
- if (parentIdx !== -1 && descParts[parentIdx + 1]) {
131
- parentId = parseInt(descParts[parentIdx + 1]);
132
- descParts.splice(parentIdx, 2);
133
- }
134
- // Parse optional --delay flag
135
- let scheduledAt = null;
136
- const delayIdx = descParts.indexOf("--delay");
137
- if (delayIdx !== -1 && descParts[delayIdx + 1]) {
138
- const delayMin = parseInt(descParts[delayIdx + 1]);
139
- scheduledAt = Date.now() + delayMin * 60000;
140
- descParts.splice(delayIdx, 2);
141
- }
128
+ const parentRaw = extractFlag(descParts, "--parent");
129
+ const parentId = parentRaw ? parseInt(parentRaw) : null;
130
+ const delayRaw = extractFlag(descParts, "--delay");
131
+ const scheduledAt = delayRaw ? Date.now() + parseInt(delayRaw) * 60000 : null;
142
132
  const desc = descParts.join(" ");
143
133
  const r = db.prepare("INSERT INTO tasks (user_id, platform, chat_id, description, status, parent_id, scheduled_at, created_at) VALUES (?, ?, ?, ?, 'approval_pending', ?, ?, ?)").run(userId, platform, chatId, desc, parentId, scheduledAt, Date.now());
144
134
  output({ ok: true, id: Number(r.lastInsertRowid), scheduled_at: scheduledAt, message: scheduledAt ? `Auto task queued for approval (scheduled in ${Math.ceil((scheduledAt - Date.now()) / 60000)} min)` : "Auto task queued for approval" });
package/dist/index.js CHANGED
@@ -57,6 +57,12 @@ async function main() {
57
57
  try {
58
58
  config = reloadConfig();
59
59
  engine.reloadConfig(config);
60
+ for (const a of adapters) {
61
+ if ('reloadConfig' in a && typeof a.reloadConfig === 'function') {
62
+ const plat = a.constructor.name === 'TelegramAdapter' ? config.platforms.telegram : config.platforms.discord;
63
+ a.reloadConfig(plat, config.locale);
64
+ }
65
+ }
60
66
  console.log("[claudebridge] config reloaded (SIGHUP)");
61
67
  }
62
68
  catch (err) {
@@ -72,6 +78,12 @@ async function main() {
72
78
  try {
73
79
  config = reloadConfig();
74
80
  engine.reloadConfig(config);
81
+ for (const a of adapters) {
82
+ if ('reloadConfig' in a && typeof a.reloadConfig === 'function') {
83
+ const plat = a.constructor.name === 'TelegramAdapter' ? config.platforms.telegram : config.platforms.discord;
84
+ a.reloadConfig(plat, config.locale);
85
+ }
86
+ }
75
87
  console.log("[claudebridge] config reloaded");
76
88
  }
77
89
  catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@emqo/claudebridge",
3
- "version": "0.6.3",
3
+ "version": "0.8.0",
4
4
  "description": "Bridge claude CLI to chat platforms (Telegram, Discord) with scheduled auto-tasks, autonomous project management, HITL approval, conditional branching, webhook triggers, parallel execution, and observability",
5
5
  "main": "dist/index.js",
6
6
  "bin": {