@gonzih/cc-tg 0.9.20 → 0.9.22

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/cron.d.ts CHANGED
@@ -11,9 +11,15 @@ export interface CronJob {
11
11
  createdAt: string;
12
12
  schedule: string;
13
13
  }
14
- type FireCallback = (chatId: number, prompt: string) => void;
14
+ /** Called when a job fires. `done` must be called when the task completes so
15
+ * the next scheduled tick is allowed to run. Until `done` is called, concurrent
16
+ * ticks for the same job are silently skipped (prevents the resume-loop explosion
17
+ * where each tick spawns more agents than the last). */
18
+ type FireCallback = (chatId: number, prompt: string, jobId: string, done: () => void) => void;
15
19
  export declare class CronManager {
16
20
  private jobs;
21
+ /** Job IDs whose fire callback has been invoked but whose `done` hasn't fired yet. */
22
+ private activeJobs;
17
23
  private storePath;
18
24
  private fire;
19
25
  constructor(cwd: string, fire: FireCallback);
package/dist/cron.js CHANGED
@@ -7,6 +7,8 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
7
7
  import { join } from "path";
8
8
  export class CronManager {
9
9
  jobs = new Map();
10
+ /** Job IDs whose fire callback has been invoked but whose `done` hasn't fired yet. */
11
+ activeJobs = new Set();
10
12
  storePath;
11
13
  fire;
12
14
  constructor(cwd, fire) {
@@ -36,8 +38,13 @@ export class CronManager {
36
38
  const id = `${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
37
39
  const job = { id, chatId, intervalMs, prompt, schedule, createdAt: new Date().toISOString() };
38
40
  const timer = setInterval(() => {
41
+ if (this.activeJobs.has(id)) {
42
+ console.log(`[cron:${id}] skipping tick — previous task still running`);
43
+ return;
44
+ }
45
+ this.activeJobs.add(id);
39
46
  console.log(`[cron:${id}] firing for chat=${chatId} prompt="${prompt}"`);
40
- this.fire(chatId, prompt);
47
+ this.fire(chatId, prompt, id, () => { this.activeJobs.delete(id); });
41
48
  }, intervalMs);
42
49
  this.jobs.set(id, { ...job, timer });
43
50
  this.persist();
@@ -48,6 +55,7 @@ export class CronManager {
48
55
  if (!job || job.chatId !== chatId)
49
56
  return false;
50
57
  clearInterval(job.timer);
58
+ this.activeJobs.delete(id);
51
59
  this.jobs.delete(id);
52
60
  this.persist();
53
61
  return true;
@@ -57,6 +65,7 @@ export class CronManager {
57
65
  for (const [id, job] of this.jobs) {
58
66
  if (job.chatId === chatId) {
59
67
  clearInterval(job.timer);
68
+ this.activeJobs.delete(id);
60
69
  this.jobs.delete(id);
61
70
  count++;
62
71
  }
@@ -86,9 +95,16 @@ export class CronManager {
86
95
  }
87
96
  // Recreate timer so it uses updated intervalMs and always reads latest job.prompt
88
97
  clearInterval(job.timer);
98
+ // Also clear any active-job lock so the updated timer can fire immediately next tick
99
+ this.activeJobs.delete(job.id);
89
100
  job.timer = setInterval(() => {
101
+ if (this.activeJobs.has(job.id)) {
102
+ console.log(`[cron:${job.id}] skipping tick — previous task still running`);
103
+ return;
104
+ }
105
+ this.activeJobs.add(job.id);
90
106
  console.log(`[cron:${job.id}] firing for chat=${job.chatId} prompt="${job.prompt}"`);
91
- this.fire(job.chatId, job.prompt);
107
+ this.fire(job.chatId, job.prompt, job.id, () => { this.activeJobs.delete(job.id); });
92
108
  }, job.intervalMs);
93
109
  this.persist();
94
110
  const { timer: _t, ...cronJob } = job;
@@ -113,8 +129,13 @@ export class CronManager {
113
129
  const data = JSON.parse(readFileSync(this.storePath, "utf8"));
114
130
  for (const job of data) {
115
131
  const timer = setInterval(() => {
132
+ if (this.activeJobs.has(job.id)) {
133
+ console.log(`[cron:${job.id}] skipping tick — previous task still running`);
134
+ return;
135
+ }
136
+ this.activeJobs.add(job.id);
116
137
  console.log(`[cron:${job.id}] firing for chat=${job.chatId} prompt="${job.prompt}"`);
117
- this.fire(job.chatId, job.prompt);
138
+ this.fire(job.chatId, job.prompt, job.id, () => { this.activeJobs.delete(job.id); });
118
139
  }, job.intervalMs);
119
140
  this.jobs.set(job.id, { ...job, timer });
120
141
  }
@@ -1,23 +1,25 @@
1
1
  /**
2
- * Telegram MarkdownV2 post-processor.
3
- * Converts standard markdown to Telegram's MarkdownV2 format.
2
+ * Telegram HTML post-processor.
3
+ * Converts standard markdown to Telegram's HTML parse mode format.
4
4
  */
5
5
  /**
6
- * Convert standard markdown text to Telegram MarkdownV2 format.
6
+ * Convert standard markdown text to Telegram HTML format.
7
7
  *
8
8
  * Processing order:
9
- * 1. Extract code blocks (fenced + inline) protect from further processing
10
- * 2. Strip raw HTML tags
11
- * 3. Convert ---blank line
12
- * 4. Convert ## headings *bold*
13
- * 5. Convert **bold***bold*
14
- * 6. Convert - list items • item
15
- * 7. Escape MarkdownV2 special chars (outside code blocks)
16
- * 8. Reinsert code blocks unchanged
9
+ * 1. Extract fenced code blocks (``` ... ```) <pre>, protect from further processing
10
+ * 2. Extract inline code (`...`) → <code>, protect from further processing
11
+ * 3. HTML-escape remaining text: & &amp; < → &lt; > → &gt;
12
+ * 4. Convert ---blank line
13
+ * 5. Convert ## headings <b>Heading</b>
14
+ * 6. Convert **bold**<b>bold</b>
15
+ * 7. Convert - item / * item → • item
16
+ * 8. Convert *bold* <b>bold</b>
17
+ * 9. Convert _italic_ → <i>italic</i>
18
+ * 10. Reinsert code blocks
17
19
  */
18
20
  export declare function formatForTelegram(text: string): string;
19
21
  /**
20
22
  * Split a long message at natural boundaries (paragraph > line > word).
21
- * Never splits mid-word. Chunks are at most maxLen characters.
23
+ * Never splits mid-word or inside <pre> blocks. Chunks are at most maxLen characters.
22
24
  */
23
25
  export declare function splitLongMessage(text: string, maxLen?: number): string[];
package/dist/formatter.js CHANGED
@@ -1,54 +1,82 @@
1
1
  /**
2
- * Telegram MarkdownV2 post-processor.
3
- * Converts standard markdown to Telegram's MarkdownV2 format.
2
+ * Telegram HTML post-processor.
3
+ * Converts standard markdown to Telegram's HTML parse mode format.
4
4
  */
5
+ function htmlEscape(text) {
6
+ return text
7
+ .replace(/&/g, "&amp;")
8
+ .replace(/</g, "&lt;")
9
+ .replace(/>/g, "&gt;");
10
+ }
5
11
  /**
6
- * Convert standard markdown text to Telegram MarkdownV2 format.
12
+ * Convert standard markdown text to Telegram HTML format.
7
13
  *
8
14
  * Processing order:
9
- * 1. Extract code blocks (fenced + inline) protect from further processing
10
- * 2. Strip raw HTML tags
11
- * 3. Convert ---blank line
12
- * 4. Convert ## headings *bold*
13
- * 5. Convert **bold***bold*
14
- * 6. Convert - list items • item
15
- * 7. Escape MarkdownV2 special chars (outside code blocks)
16
- * 8. Reinsert code blocks unchanged
15
+ * 1. Extract fenced code blocks (``` ... ```) <pre>, protect from further processing
16
+ * 2. Extract inline code (`...`) → <code>, protect from further processing
17
+ * 3. HTML-escape remaining text: & &amp; < → &lt; > → &gt;
18
+ * 4. Convert ---blank line
19
+ * 5. Convert ## headings <b>Heading</b>
20
+ * 6. Convert **bold**<b>bold</b>
21
+ * 7. Convert - item / * item → • item
22
+ * 8. Convert *bold* <b>bold</b>
23
+ * 9. Convert _italic_ → <i>italic</i>
24
+ * 10. Reinsert code blocks
17
25
  */
18
26
  export function formatForTelegram(text) {
19
- // Step 1: Extract code blocks and inline code to protect them
20
27
  const placeholders = [];
21
- // Fenced code blocks first (``` ... ```)
22
- let out = text.replace(/```[\s\S]*?```/g, (match) => {
23
- placeholders.push(match);
28
+ // Step 1: Extract fenced code blocks (``` ... ```) → <pre>
29
+ let out = text.replace(/```(?:\w*)\n?([\s\S]*?)```/g, (_, content) => {
30
+ placeholders.push(`<pre>${htmlEscape(content)}</pre>`);
24
31
  return `\x00P${placeholders.length - 1}\x00`;
25
32
  });
26
- // Inline code (`...`)
27
- out = out.replace(/`[^`\n]+`/g, (match) => {
28
- placeholders.push(match);
33
+ // Step 2: Extract inline code (`...`) → <code>
34
+ out = out.replace(/`([^`\n]+)`/g, (_, content) => {
35
+ placeholders.push(`<code>${htmlEscape(content)}</code>`);
29
36
  return `\x00P${placeholders.length - 1}\x00`;
30
37
  });
31
- // Step 2: Strip raw HTML tags
32
- out = out.replace(/<[^>]+>/g, "");
33
- // Step 3: Convert --- → blank line
38
+ // Step 3: HTML-escape remaining text
39
+ out = htmlEscape(out);
40
+ // Step 4: Convert --- → blank line
34
41
  out = out.replace(/^-{3,}$/gm, "");
35
- // Step 4: Convert ## headings → *bold*
36
- out = out.replace(/^#{1,6}\s+(.+)$/gm, "*$1*");
37
- // Step 5: Convert **bold** → *bold*
38
- out = out.replace(/\*\*(.+?)\*\*/gs, "*$1*");
39
- // Step 6: Convert - list items → • item (leading - or * bullet)
42
+ // Step 5: Convert ## headings → <b>Heading</b>
43
+ out = out.replace(/^#{1,6}\s+(.+)$/gm, "<b>$1</b>");
44
+ // Step 6: Convert **bold** → <b>bold</b>
45
+ out = out.replace(/\*\*(.+?)\*\*/gs, "<b>$1</b>");
46
+ // Step 7: Convert - item / * item → • item
40
47
  out = out.replace(/^[ \t]*[-*]\s+(.+)$/gm, "• $1");
41
- // Step 7: Escape MarkdownV2 special chars outside code blocks.
42
- // Per Telegram spec, these must be escaped: _ [ ] ( ) ~ > # + - = | { } . ! \
43
- // * is intentionally NOT escaped — it is used for bold formatting above.
44
- out = out.replace(/([_\[\]()~>#+\-=|{}.!\\])/g, "\\$1");
45
- // Step 8: Reinsert code blocks unchanged (no escaping inside them)
48
+ // Step 8: Convert *bold* <b>bold</b> (single asterisk, after bullets handled)
49
+ out = out.replace(/\*([^*\n]+)\*/g, "<b>$1</b>");
50
+ // Step 9: Convert _italic_ <i>italic</i>
51
+ // Use word-boundary guards to avoid mangling snake_case identifiers
52
+ out = out.replace(/(?<![a-zA-Z0-9])_([^_\n]+?)_(?![a-zA-Z0-9])/g, "<i>$1</i>");
53
+ // Step 10: Reinsert code blocks
46
54
  out = out.replace(/\x00P(\d+)\x00/g, (_, i) => placeholders[parseInt(i, 10)]);
47
55
  return out;
48
56
  }
57
+ function findPreRanges(text) {
58
+ const ranges = [];
59
+ const open = "<pre>";
60
+ const close = "</pre>";
61
+ let i = 0;
62
+ while (i < text.length) {
63
+ const start = text.indexOf(open, i);
64
+ if (start === -1)
65
+ break;
66
+ const end = text.indexOf(close, start);
67
+ if (end === -1)
68
+ break;
69
+ ranges.push([start, end + close.length]);
70
+ i = end + close.length;
71
+ }
72
+ return ranges;
73
+ }
74
+ function isInsidePre(pos, ranges) {
75
+ return ranges.some(([start, end]) => pos > start && pos < end);
76
+ }
49
77
  /**
50
78
  * Split a long message at natural boundaries (paragraph > line > word).
51
- * Never splits mid-word. Chunks are at most maxLen characters.
79
+ * Never splits mid-word or inside <pre> blocks. Chunks are at most maxLen characters.
52
80
  */
53
81
  export function splitLongMessage(text, maxLen = 4096) {
54
82
  if (text.length <= maxLen)
@@ -57,6 +85,7 @@ export function splitLongMessage(text, maxLen = 4096) {
57
85
  let remaining = text;
58
86
  while (remaining.length > maxLen) {
59
87
  const slice = remaining.slice(0, maxLen);
88
+ const preRanges = findPreRanges(remaining);
60
89
  // Prefer paragraph boundary (\n\n)
61
90
  const lastPara = slice.lastIndexOf("\n\n");
62
91
  // Then line boundary (\n)
@@ -64,17 +93,24 @@ export function splitLongMessage(text, maxLen = 4096) {
64
93
  // Then word boundary (space)
65
94
  const lastSpace = slice.lastIndexOf(" ");
66
95
  let splitAt;
67
- if (lastPara > 0) {
96
+ if (lastPara > 0 && !isInsidePre(lastPara, preRanges)) {
68
97
  splitAt = lastPara + 2;
69
98
  }
70
- else if (lastLine > 0) {
99
+ else if (lastLine > 0 && !isInsidePre(lastLine, preRanges)) {
71
100
  splitAt = lastLine + 1;
72
101
  }
73
- else if (lastSpace > 0) {
102
+ else if (lastSpace > 0 && !isInsidePre(lastSpace, preRanges)) {
74
103
  splitAt = lastSpace + 1;
75
104
  }
76
105
  else {
77
- splitAt = maxLen;
106
+ // If all candidate split points are inside a <pre> block, split after it
107
+ const coveringPre = preRanges.find(([start, end]) => start < maxLen && end > maxLen);
108
+ if (coveringPre) {
109
+ splitAt = coveringPre[1];
110
+ }
111
+ else {
112
+ splitAt = maxLen;
113
+ }
78
114
  }
79
115
  chunks.push(remaining.slice(0, splitAt).trimEnd());
80
116
  remaining = remaining.slice(splitAt).trimStart();
package/dist/index.js CHANGED
@@ -15,21 +15,27 @@
15
15
  * CWD — working directory for Claude Code (default: process.cwd())
16
16
  */
17
17
  import { createServer, createConnection } from "net";
18
- import { unlinkSync } from "fs";
18
+ import { unlinkSync, readFileSync } from "fs";
19
19
  import { tmpdir } from "os";
20
- import { join } from "path";
21
- import { createHash } from "crypto";
20
+ import os from "os";
21
+ import { join, dirname } from "path";
22
+ import { fileURLToPath } from "url";
23
+ import TelegramBot from "node-telegram-bot-api";
22
24
  import { CcTgBot } from "./bot.js";
23
- // Derive socket path from token hash so multiple instances (feral/law/simorgh)
24
- // never collide, and the path is stable across restarts on the same machine.
25
- function lockSocketPath(token) {
26
- const hash = createHash("sha256").update(token).digest("hex").slice(0, 12);
27
- return join(tmpdir(), `cc-tg-${hash}.sock`);
28
- }
29
- function acquireLock(socketPath) {
25
+ import { loadTokens } from "./tokens.js";
26
+ import { Registry, startControlServer } from "@gonzih/agent-ops";
27
+ import { Redis } from "ioredis";
28
+ import { startNotifier } from "./notifier.js";
29
+ const __filename = fileURLToPath(import.meta.url);
30
+ const __dirname = dirname(__filename);
31
+ const pkg = JSON.parse(readFileSync(join(__dirname, "../package.json"), "utf-8"));
32
+ // Make lock socket unique per bot token so multiple users on the same machine don't collide
33
+ const _tokenHash = Buffer.from(process.env.TELEGRAM_BOT_TOKEN ?? "default").toString("base64").replace(/[^a-z0-9]/gi, "").slice(0, 16);
34
+ const LOCK_SOCKET = join(tmpdir(), `cc-tg-${_tokenHash}.sock`);
35
+ function acquireLock() {
30
36
  return new Promise((resolve) => {
31
37
  const server = createServer();
32
- server.listen(socketPath, () => {
38
+ server.listen(LOCK_SOCKET, () => {
33
39
  // Bound successfully — we own the lock. Socket auto-released on any exit incl. SIGKILL.
34
40
  resolve(true);
35
41
  });
@@ -39,7 +45,7 @@ function acquireLock(socketPath) {
39
45
  return;
40
46
  }
41
47
  // Socket path exists — probe if anything is actually listening
42
- const probe = createConnection(socketPath);
48
+ const probe = createConnection(LOCK_SOCKET);
43
49
  probe.on("connect", () => {
44
50
  probe.destroy();
45
51
  console.error("[cc-tg] Another instance is already running. Exiting.");
@@ -48,16 +54,20 @@ function acquireLock(socketPath) {
48
54
  probe.on("error", () => {
49
55
  // Nothing listening — stale socket, remove and retry
50
56
  try {
51
- unlinkSync(socketPath);
57
+ unlinkSync(LOCK_SOCKET);
52
58
  }
53
59
  catch { }
54
60
  const retry = createServer();
55
- retry.listen(socketPath, () => resolve(true));
61
+ retry.listen(LOCK_SOCKET, () => resolve(true));
56
62
  retry.on("error", () => resolve(true)); // give up on lock, just start
57
63
  });
58
64
  });
59
65
  });
60
66
  }
67
+ const lockAcquired = await acquireLock();
68
+ if (!lockAcquired) {
69
+ process.exit(1);
70
+ }
61
71
  function required(name) {
62
72
  const val = process.env[name];
63
73
  if (!val) {
@@ -78,13 +88,6 @@ Or add to your shell profile / .env file.
78
88
  return val;
79
89
  }
80
90
  const telegramToken = required("TELEGRAM_BOT_TOKEN");
81
- // Acquire lock before doing anything else. Socket derived from token hash so
82
- // multiple instances (different bots / users) never share the same socket.
83
- const LOCK_SOCKET = lockSocketPath(telegramToken);
84
- const lockAcquired = await acquireLock(LOCK_SOCKET);
85
- if (!lockAcquired) {
86
- process.exit(1);
87
- }
88
91
  // Accept CLAUDE_CODE_TOKEN, CLAUDE_CODE_OAUTH_TOKEN, or ANTHROPIC_API_KEY
89
92
  const claudeToken = process.env.CLAUDE_CODE_TOKEN ??
90
93
  process.env.CLAUDE_CODE_OAUTH_TOKEN ??
@@ -98,6 +101,11 @@ Set one and run again:
98
101
  `);
99
102
  process.exit(1);
100
103
  }
104
+ // Load OAuth token pool (supports CLAUDE_CODE_OAUTH_TOKENS for multi-account rotation)
105
+ const tokenPool = loadTokens();
106
+ if (tokenPool.length > 1) {
107
+ console.log(`[cc-tg] Token pool loaded: ${tokenPool.length} tokens — will rotate on usage limit`);
108
+ }
101
109
  const allowedUserIds = process.env.ALLOWED_USER_IDS
102
110
  ? process.env.ALLOWED_USER_IDS.split(",").map((s) => parseInt(s.trim(), 10)).filter(Boolean)
103
111
  : [];
@@ -105,13 +113,61 @@ const groupChatIds = process.env.GROUP_CHAT_IDS
105
113
  ? process.env.GROUP_CHAT_IDS.split(",").map((s) => parseInt(s.trim(), 10)).filter(Boolean)
106
114
  : [];
107
115
  const cwd = process.env.CWD ?? process.cwd();
116
+ // agent-ops / chat bridge — Redis is always initialized so the chat bridge works
117
+ // regardless of whether CC_AGENT_OPS_PORT or CC_AGENT_NOTIFY_CHAT_ID are set.
118
+ const redisUrl = process.env.REDIS_URL || "redis://localhost:6379";
119
+ const namespace = process.env.CC_AGENT_NAMESPACE || "default";
120
+ const sharedRedis = new Redis(redisUrl);
121
+ sharedRedis.on("error", (err) => {
122
+ // Non-fatal — Redis features (chat bridge, ops) degrade gracefully
123
+ console.warn("[redis] connection error:", err.message);
124
+ });
125
+ sharedRedis.once("ready", () => {
126
+ sharedRedis.set("cca:meta:cc-tg:version", pkg.version).catch((err) => {
127
+ console.warn("[redis] failed to write version:", err.message);
128
+ });
129
+ console.log(`[cc-tg] version:reported ${pkg.version}`);
130
+ });
108
131
  const bot = new CcTgBot({
109
132
  telegramToken,
110
133
  claudeToken,
111
134
  cwd,
112
135
  allowedUserIds,
113
136
  groupChatIds,
137
+ redis: sharedRedis,
138
+ namespace,
114
139
  });
140
+ if (process.env.CC_AGENT_OPS_PORT) {
141
+ const botInfo = await bot.getMe();
142
+ const registry = new Registry(sharedRedis);
143
+ await registry.register({
144
+ namespace,
145
+ hostname: os.hostname(),
146
+ user: os.userInfo().username,
147
+ pid: String(process.pid),
148
+ version: pkg.version,
149
+ cwd: process.env.CWD || process.cwd(),
150
+ control_port: process.env.CC_AGENT_OPS_PORT,
151
+ bot_username: botInfo.username ?? "",
152
+ started_at: new Date().toISOString(),
153
+ });
154
+ setInterval(() => registry.heartbeat(namespace), 60_000);
155
+ startControlServer(Number(process.env.CC_AGENT_OPS_PORT), {
156
+ namespace,
157
+ version: pkg.version,
158
+ logFile: process.env.CC_AGENT_LOG_FILE || process.env.LOG_FILE,
159
+ });
160
+ console.log(`[ops] control server on port ${process.env.CC_AGENT_OPS_PORT}`);
161
+ }
162
+ // Notifier — always subscribe to cca:notify and cca:chat:incoming channels.
163
+ // CC_AGENT_NOTIFY_CHAT_ID pins a fixed Telegram chatId; without it the last
164
+ // active chatId is used dynamically for the chat bridge.
165
+ const notifyChatId = process.env.CC_AGENT_NOTIFY_CHAT_ID
166
+ ? Number(process.env.CC_AGENT_NOTIFY_CHAT_ID)
167
+ : null;
168
+ const notifierBot = new TelegramBot(telegramToken, { polling: false });
169
+ startNotifier(notifierBot, notifyChatId, namespace, sharedRedis, (cid, text) => bot.handleUserMessage(cid, text), () => bot.getLastActiveChatId());
170
+ console.log(`[notifier] started for namespace=${namespace} chatId=${notifyChatId ?? "dynamic"}`);
115
171
  process.on("SIGINT", () => {
116
172
  console.log("\nShutting down...");
117
173
  bot.stop();
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Notifier — subscribes to Redis pub/sub channels and bridges messages to Telegram.
3
+ *
4
+ * Channels:
5
+ * cca:notify:{namespace} — job completion notifications from cc-agent → forward to Telegram
6
+ * cca:chat:incoming:{namespace} — messages from the web UI → echo to Telegram + feed into Claude session
7
+ *
8
+ * All messages (Telegram incoming, Claude responses) are also written to:
9
+ * cca:chat:log:{namespace} — LPUSH + LTRIM 0 499 (last 500 messages)
10
+ * cca:chat:outgoing:{namespace} — PUBLISH for web UI to consume
11
+ */
12
+ import { Redis } from "ioredis";
13
+ import TelegramBot from "node-telegram-bot-api";
14
+ export interface ChatMessage {
15
+ id: string;
16
+ source: "telegram" | "ui" | "claude" | "cc-tg";
17
+ role: "user" | "assistant" | "tool";
18
+ content: string;
19
+ timestamp: string;
20
+ chatId: number;
21
+ }
22
+ /**
23
+ * Parse a notification payload and return the display text.
24
+ * Appends a [model] or [driver] badge if the driver is non-claude.
25
+ *
26
+ * Payload format (JSON): { text: string, driver?: string, model?: string }
27
+ * Falls back to raw string if not valid JSON.
28
+ */
29
+ export declare function parseNotification(raw: string): string;
30
+ /**
31
+ * Write a message to the chat log in Redis.
32
+ * Fire-and-forget — errors are logged but not thrown.
33
+ */
34
+ export declare function writeChatLog(redis: Redis, namespace: string, msg: ChatMessage): void;
35
+ /**
36
+ * Start the notifier.
37
+ *
38
+ * @param bot - Telegram bot instance (for sending messages)
39
+ * @param chatId - Telegram chat ID to forward notifications to. Pass null to use getActiveChatId.
40
+ * @param namespace - cc-agent namespace (used to build Redis channel names)
41
+ * @param redis - ioredis client in normal mode (will be duplicated for pub/sub)
42
+ * @param handleUserMessage - Optional callback to feed UI messages into the active Claude session
43
+ * @param getActiveChatId - Optional callback to resolve chatId dynamically (used when chatId is null)
44
+ */
45
+ export declare function startNotifier(bot: TelegramBot, chatId: number | null, namespace: string, redis: Redis, handleUserMessage?: (chatId: number, text: string) => void, getActiveChatId?: () => number | undefined): void;