@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.
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" });
@@ -176,7 +166,19 @@ else if (category === "auto") {
176
166
  fail("Usage: auto <add|add-approval|result|list|cancel|clear> ...");
177
167
  }
178
168
  }
169
+ else if (category === "session") {
170
+ if (action === "list") {
171
+ const [userId] = rest;
172
+ if (!userId)
173
+ fail("Usage: session list <user_id>");
174
+ const rows = db.prepare("SELECT id, user_id, platform, chat_id, claude_session_id, label, status, created_at, last_active_at, message_count, total_cost FROM sub_sessions WHERE user_id = ? ORDER BY last_active_at DESC").all(userId);
175
+ output({ ok: true, sessions: rows });
176
+ }
177
+ else {
178
+ fail("Usage: session <list> ...");
179
+ }
180
+ }
179
181
  else {
180
- fail("Usage: claudebridge-ctl <memory|task|reminder|auto> <action> [args...]");
182
+ fail("Usage: claudebridge-ctl <memory|task|reminder|auto|session> <action> [args...]");
181
183
  }
182
184
  db.close();
package/dist/index.js CHANGED
@@ -8,10 +8,13 @@ import { AgentEngine } from "./core/agent.js";
8
8
  import { TelegramAdapter } from "./adapters/telegram.js";
9
9
  import { DiscordAdapter } from "./adapters/discord.js";
10
10
  import { WebhookServer } from "./webhook.js";
11
+ import { log, setLogLevel } from "./core/logger.js";
11
12
  async function main() {
12
13
  const _cfgIdx = process.argv.indexOf("--config");
13
14
  const _cfgPath = _cfgIdx !== -1 ? process.argv[_cfgIdx + 1] : undefined;
14
15
  let config = loadConfig(_cfgPath);
16
+ if (config.log_level)
17
+ setLogLevel(config.log_level);
15
18
  // Derive DB path from config file directory (not CWD)
16
19
  const configDir = _cfgPath ? dirname(resolve(_cfgPath)) : process.cwd();
17
20
  const dbPath = join(configDir, "data", "claudebridge.db");
@@ -21,20 +24,20 @@ async function main() {
21
24
  let webhookServer = null;
22
25
  if (config.platforms.telegram.enabled) {
23
26
  if (!config.platforms.telegram.token) {
24
- console.error("[fatal] TELEGRAM_BOT_TOKEN not set");
27
+ log.error("TELEGRAM_BOT_TOKEN not set");
25
28
  process.exit(1);
26
29
  }
27
30
  adapters.push(new TelegramAdapter(engine, store, config.platforms.telegram, config.locale));
28
31
  }
29
32
  if (config.platforms.discord.enabled) {
30
33
  if (!config.platforms.discord.token) {
31
- console.error("[fatal] DISCORD_BOT_TOKEN not set");
34
+ log.error("DISCORD_BOT_TOKEN not set");
32
35
  process.exit(1);
33
36
  }
34
37
  adapters.push(new DiscordAdapter(engine, store, config.platforms.discord, config.locale));
35
38
  }
36
39
  if (!adapters.length) {
37
- console.error("[fatal] no platform enabled");
40
+ log.error("no platform enabled");
38
41
  process.exit(1);
39
42
  }
40
43
  // Start webhook server if enabled
@@ -44,23 +47,38 @@ async function main() {
44
47
  }
45
48
  // --- Register signal handlers and hot-reload BEFORE starting adapters ---
46
49
  const shutdown = () => {
47
- console.log("[claudebridge] shutting down...");
50
+ log.info("shutting down...");
48
51
  for (const a of adapters)
49
52
  a.stop();
50
53
  if (webhookServer)
51
54
  webhookServer.stop();
55
+ store.close();
52
56
  setTimeout(() => process.exit(0), 1000);
53
57
  };
54
58
  process.on("SIGINT", shutdown);
55
59
  process.on("SIGTERM", shutdown);
60
+ process.on("uncaughtException", (err) => {
61
+ log.error("uncaught exception", { error: err.message, stack: err.stack });
62
+ shutdown();
63
+ });
64
+ process.on("unhandledRejection", (reason) => {
65
+ const msg = reason instanceof Error ? reason.message : String(reason);
66
+ log.error("unhandled rejection", { error: msg });
67
+ });
56
68
  process.on("SIGHUP", () => {
57
69
  try {
58
70
  config = reloadConfig();
59
71
  engine.reloadConfig(config);
60
- console.log("[claudebridge] config reloaded (SIGHUP)");
72
+ for (const a of adapters) {
73
+ if ('reloadConfig' in a && typeof a.reloadConfig === 'function') {
74
+ const plat = a.constructor.name === 'TelegramAdapter' ? config.platforms.telegram : config.platforms.discord;
75
+ a.reloadConfig(plat, config.locale);
76
+ }
77
+ }
78
+ log.info("config reloaded (SIGHUP)");
61
79
  }
62
80
  catch (err) {
63
- console.error("[claudebridge] config reload failed:", err);
81
+ log.error("config reload failed", { error: err?.message });
64
82
  }
65
83
  });
66
84
  // Hot reload config.yaml on file change
@@ -72,23 +90,34 @@ async function main() {
72
90
  try {
73
91
  config = reloadConfig();
74
92
  engine.reloadConfig(config);
75
- console.log("[claudebridge] config reloaded");
93
+ for (const a of adapters) {
94
+ if ('reloadConfig' in a && typeof a.reloadConfig === 'function') {
95
+ const plat = a.constructor.name === 'TelegramAdapter' ? config.platforms.telegram : config.platforms.discord;
96
+ a.reloadConfig(plat, config.locale);
97
+ }
98
+ }
99
+ log.info("config reloaded");
76
100
  }
77
101
  catch (err) {
78
- console.error("[claudebridge] config reload failed:", err);
102
+ log.error("config reload failed", { error: err?.message });
79
103
  }
80
104
  }, 500); // debounce
81
105
  });
82
- // --- Start adapters (fire-and-forget, they run infinite polling loops) ---
106
+ // --- Start adapters with crash recovery ---
83
107
  for (const a of adapters) {
84
108
  a.start().catch(err => {
85
- console.error("[fatal] adapter crashed:", err);
86
- process.exit(1);
109
+ log.error("adapter crashed, retry in 10s", { adapter: a.constructor.name, error: err?.message });
110
+ setTimeout(() => {
111
+ a.start().catch(err2 => {
112
+ log.error("adapter restart failed, exiting", { error: err2?.message });
113
+ process.exit(1);
114
+ });
115
+ }, 10000);
87
116
  });
88
117
  }
89
- console.log(`[claudebridge] running with ${adapters.length} adapter(s)`);
118
+ log.info("running", { adapters: adapters.length });
90
119
  }
91
120
  main().catch((err) => {
92
- console.error("[fatal]", err);
121
+ log.error("fatal", { error: err?.message });
93
122
  process.exit(1);
94
123
  });
@@ -0,0 +1,26 @@
1
+ export interface ProviderStreamEvent {
2
+ type: "session_init" | "text_chunk" | "result" | "unknown";
3
+ sessionId?: string;
4
+ text?: string;
5
+ cost?: number;
6
+ isError?: boolean;
7
+ }
8
+ export interface ProviderExecOpts {
9
+ prompt: string;
10
+ model: string;
11
+ resumeSessionId?: string;
12
+ systemPrompt?: string;
13
+ appendSystemPrompt?: string;
14
+ allowedTools?: string[];
15
+ maxTurns?: number;
16
+ maxBudgetUsd?: number;
17
+ permissionMode?: string;
18
+ }
19
+ export interface Provider {
20
+ readonly binary: string;
21
+ readonly supportsSessionResume: boolean;
22
+ readonly supportsAppendSystemPrompt: boolean;
23
+ buildArgs(opts: ProviderExecOpts): string[];
24
+ buildEnv(extra: Record<string, string>): Record<string, string>;
25
+ parseLine(line: string): ProviderStreamEvent;
26
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,9 @@
1
+ import type { Provider, ProviderExecOpts, ProviderStreamEvent } from "./base.js";
2
+ export declare class ClaudeProvider implements Provider {
3
+ readonly binary = "claude";
4
+ readonly supportsSessionResume = true;
5
+ readonly supportsAppendSystemPrompt = true;
6
+ buildArgs(opts: ProviderExecOpts): string[];
7
+ buildEnv(extra: Record<string, string>): Record<string, string>;
8
+ parseLine(line: string): ProviderStreamEvent;
9
+ }
@@ -0,0 +1,53 @@
1
+ export class ClaudeProvider {
2
+ binary = "claude";
3
+ supportsSessionResume = true;
4
+ supportsAppendSystemPrompt = true;
5
+ buildArgs(opts) {
6
+ const args = ["-p", opts.prompt, "--verbose", "--output-format", "stream-json"];
7
+ if (opts.permissionMode)
8
+ args.push("--permission-mode", opts.permissionMode);
9
+ if (opts.model)
10
+ args.push("--model", opts.model);
11
+ if (opts.resumeSessionId)
12
+ args.push("-r", opts.resumeSessionId);
13
+ if (opts.systemPrompt)
14
+ args.push("--system-prompt", opts.systemPrompt);
15
+ if (opts.appendSystemPrompt)
16
+ args.push("--append-system-prompt", opts.appendSystemPrompt);
17
+ if (opts.allowedTools?.length)
18
+ args.push("--allowed-tools", opts.allowedTools.join(","));
19
+ if (opts.maxTurns)
20
+ args.push("--max-turns", String(opts.maxTurns));
21
+ if (opts.maxBudgetUsd)
22
+ args.push("--max-budget-usd", String(opts.maxBudgetUsd));
23
+ return args;
24
+ }
25
+ buildEnv(extra) {
26
+ return { ...process.env, ...extra };
27
+ }
28
+ parseLine(line) {
29
+ try {
30
+ const msg = JSON.parse(line);
31
+ if (msg.type === "system" && msg.subtype === "init" && msg.session_id) {
32
+ return { type: "session_init", sessionId: msg.session_id };
33
+ }
34
+ if (msg.type === "assistant" && msg.message?.content) {
35
+ const texts = msg.message.content
36
+ .filter((b) => b.type === "text" && b.text)
37
+ .map((b) => b.text);
38
+ if (texts.length)
39
+ return { type: "text_chunk", text: texts.join("") };
40
+ }
41
+ if (msg.type === "result") {
42
+ return {
43
+ type: "result",
44
+ text: msg.result || undefined,
45
+ cost: msg.total_cost_usd || undefined,
46
+ isError: msg.is_error || false,
47
+ };
48
+ }
49
+ }
50
+ catch { }
51
+ return { type: "unknown" };
52
+ }
53
+ }
@@ -0,0 +1,9 @@
1
+ import type { Provider, ProviderExecOpts, ProviderStreamEvent } from "./base.js";
2
+ export declare class CodexProvider implements Provider {
3
+ readonly binary = "codex";
4
+ readonly supportsSessionResume = false;
5
+ readonly supportsAppendSystemPrompt = false;
6
+ buildArgs(opts: ProviderExecOpts): string[];
7
+ buildEnv(extra: Record<string, string>): Record<string, string>;
8
+ parseLine(line: string): ProviderStreamEvent;
9
+ }
@@ -0,0 +1,35 @@
1
+ export class CodexProvider {
2
+ binary = "codex";
3
+ supportsSessionResume = false;
4
+ supportsAppendSystemPrompt = false;
5
+ buildArgs(opts) {
6
+ const prompt = opts.appendSystemPrompt
7
+ ? `[System Context]\n${opts.appendSystemPrompt}\n\n[User Message]\n${opts.prompt}`
8
+ : opts.prompt;
9
+ const args = ["exec", prompt, "--json", "--dangerously-bypass-approvals-and-sandbox"];
10
+ if (opts.model)
11
+ args.push("-m", opts.model);
12
+ return args;
13
+ }
14
+ buildEnv(extra) {
15
+ return { ...process.env, ...extra };
16
+ }
17
+ parseLine(line) {
18
+ try {
19
+ const msg = JSON.parse(line);
20
+ if (msg.type === "thread.started" && msg.thread_id) {
21
+ return { type: "session_init", sessionId: msg.thread_id };
22
+ }
23
+ if (msg.type === "item.completed" && msg.item?.type === "agent_message" && msg.item.text) {
24
+ return { type: "text_chunk", text: msg.item.text };
25
+ }
26
+ if (msg.type === "turn.completed") {
27
+ const usage = msg.usage || {};
28
+ const cost = ((usage.input_tokens || 0) * 0.000003 + (usage.output_tokens || 0) * 0.000012);
29
+ return { type: "result", cost: cost || undefined };
30
+ }
31
+ }
32
+ catch { }
33
+ return { type: "unknown" };
34
+ }
35
+ }
@@ -0,0 +1,2 @@
1
+ import type { Provider } from "./base.js";
2
+ export declare function getProvider(name: string): Provider;
@@ -0,0 +1,12 @@
1
+ import { ClaudeProvider } from "./claude.js";
2
+ import { CodexProvider } from "./codex.js";
3
+ const providers = new Map([
4
+ ["claude", new ClaudeProvider()],
5
+ ["codex", new CodexProvider()],
6
+ ]);
7
+ export function getProvider(name) {
8
+ const p = providers.get(name);
9
+ if (!p)
10
+ throw new Error(`Unknown provider: ${name}. Available: ${[...providers.keys()].join(", ")}`);
11
+ return p;
12
+ }
@@ -3,5 +3,7 @@ export interface SkillContext {
3
3
  chatId: string;
4
4
  platform: string;
5
5
  locale: string;
6
+ subSessionId?: string;
7
+ subSessionLabel?: string;
6
8
  }
7
9
  export declare function generateSkillDoc(ctx: SkillContext): string;
@@ -11,6 +11,7 @@ export function generateSkillDoc(ctx) {
11
11
  ``,
12
12
  `你正在 ClaudeBridge 中运行,连接着 ${ctx.platform} 平台。`,
13
13
  `当前用户 ID: ${ctx.userId} | 聊天 ID: ${ctx.chatId} | 平台: ${ctx.platform}`,
14
+ ...(ctx.subSessionId ? [`当前子会话: ${ctx.subSessionId.slice(0, 8)} (话题: "${ctx.subSessionLabel || ""}")`] : []),
14
15
  ``,
15
16
  `你可以通过 Bash 工具调用以下命令来管理用户的记忆、任务、提醒和自动任务:`,
16
17
  ``,
@@ -120,6 +121,7 @@ export function generateSkillDoc(ctx) {
120
121
  ``,
121
122
  `You are running inside ClaudeBridge, connected to the ${ctx.platform} platform.`,
122
123
  `Current user ID: ${ctx.userId} | Chat ID: ${ctx.chatId} | Platform: ${ctx.platform}`,
124
+ ...(ctx.subSessionId ? [`Current sub-session: ${ctx.subSessionId.slice(0, 8)} (topic: "${ctx.subSessionLabel || ""}")`] : []),
123
125
  ``,
124
126
  `You can use the Bash tool to call these commands to manage the user's memories, tasks, reminders, and auto-tasks:`,
125
127
  ``,
package/dist/webhook.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import { createServer } from "node:http";
2
2
  import { createHmac, timingSafeEqual } from "node:crypto";
3
+ import { log as rootLog } from "./core/logger.js";
4
+ const log = rootLog.child("webhook");
3
5
  export class WebhookServer {
4
6
  store;
5
7
  config;
@@ -14,7 +16,7 @@ export class WebhookServer {
14
16
  start() {
15
17
  this.server = createServer((req, res) => this.handleRequest(req, res));
16
18
  this.server.listen(this.config.port, () => {
17
- console.log(`[webhook] HTTP server listening on port ${this.config.port}`);
19
+ log.info("HTTP server listening", { port: this.config.port });
18
20
  });
19
21
  // Start cron schedulers
20
22
  for (const entry of this.cronEntries) {
@@ -22,14 +24,14 @@ export class WebhookServer {
22
24
  const timer = setInterval(() => {
23
25
  try {
24
26
  const id = this.store.addTask(entry.user_id, entry.platform, entry.chat_id, entry.description, undefined, true);
25
- console.log(`[cron] created auto-task #${id}: ${entry.description}`);
27
+ log.info("cron created auto-task", { id, description: entry.description });
26
28
  }
27
29
  catch (e) {
28
- console.error(`[cron] failed to create task:`, e);
30
+ log.error("cron failed to create task", { error: e?.message });
29
31
  }
30
32
  }, ms);
31
33
  this.cronTimers.push(timer);
32
- console.log(`[cron] scheduled every ${entry.schedule_minutes}min: ${entry.description}`);
34
+ log.info("cron scheduled", { minutes: entry.schedule_minutes, description: entry.description });
33
35
  }
34
36
  }
35
37
  stop() {
@@ -97,7 +99,7 @@ export class WebhookServer {
97
99
  const event = req.headers["x-github-event"] || "unknown";
98
100
  const description = this.buildGitHubDescription(event, payload);
99
101
  const id = this.store.addTask(userId, platform, chatId, description, undefined, true);
100
- console.log(`[webhook] github ${event} auto-task #${id}`);
102
+ log.info("github webhook", { event, taskId: id });
101
103
  this.json(res, 201, { ok: true, id, event });
102
104
  }
103
105
  catch (e) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@emqo/claudebridge",
3
- "version": "0.7.0",
3
+ "version": "0.9.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": {
@@ -11,7 +11,9 @@
11
11
  "scripts": {
12
12
  "build": "tsc",
13
13
  "start": "node dist/index.js",
14
- "dev": "tsx src/index.ts"
14
+ "dev": "tsx src/index.ts",
15
+ "test": "vitest run",
16
+ "test:watch": "vitest"
15
17
  },
16
18
  "keywords": [
17
19
  "claude",
@@ -40,14 +42,16 @@
40
42
  "discord.js": "^14.25.1",
41
43
  "dotenv": "^17.3.1",
42
44
  "ioredis": "^5.9.3",
43
- "yaml": "^2.8.2"
45
+ "yaml": "^2.8.2",
46
+ "zod": "^4.3.6"
44
47
  },
45
48
  "devDependencies": {
46
49
  "@types/better-sqlite3": "^7.6.13",
47
50
  "@types/ioredis": "^4.28.10",
48
51
  "@types/node": "^25.3.0",
49
52
  "tsx": "^4.21.0",
50
- "typescript": "^5.9.3"
53
+ "typescript": "^5.9.3",
54
+ "vitest": "^4.0.18"
51
55
  },
52
56
  "overrides": {
53
57
  "undici": ">=6.23.0"