ctb 1.0.0 → 1.2.1

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.
@@ -2,127 +2,205 @@
2
2
  * Text message handler for Claude Telegram Bot.
3
3
  */
4
4
 
5
+ import { spawn } from "node:child_process";
5
6
  import type { Context } from "grammy";
6
- import { session } from "../session";
7
7
  import { ALLOWED_USERS } from "../config";
8
8
  import { isAuthorized, rateLimiter } from "../security";
9
+ import { session } from "../session";
9
10
  import {
10
- auditLog,
11
- auditLogRateLimit,
12
- checkInterrupt,
13
- startTypingIndicator,
11
+ auditLog,
12
+ auditLogRateLimit,
13
+ checkInterrupt,
14
+ startTypingIndicator,
14
15
  } from "../utils";
15
- import { StreamingState, createStatusCallback } from "./streaming";
16
+ import { createStatusCallback, StreamingState } from "./streaming";
17
+
18
+ /**
19
+ * Execute a shell command and return output.
20
+ */
21
+ async function execShellCommand(
22
+ command: string,
23
+ cwd: string,
24
+ ): Promise<{ stdout: string; stderr: string; exitCode: number }> {
25
+ return new Promise((resolve) => {
26
+ const proc = spawn("bash", ["-c", command], {
27
+ cwd,
28
+ timeout: 30000, // 30s timeout
29
+ });
30
+
31
+ let stdout = "";
32
+ let stderr = "";
33
+
34
+ proc.stdout.on("data", (data) => {
35
+ stdout += data.toString();
36
+ });
37
+
38
+ proc.stderr.on("data", (data) => {
39
+ stderr += data.toString();
40
+ });
41
+
42
+ proc.on("close", (code) => {
43
+ resolve({ stdout, stderr, exitCode: code ?? 0 });
44
+ });
45
+
46
+ proc.on("error", (err) => {
47
+ resolve({ stdout, stderr: err.message, exitCode: 1 });
48
+ });
49
+ });
50
+ }
16
51
 
17
52
  /**
18
53
  * Handle incoming text messages.
19
54
  */
20
55
  export async function handleText(ctx: Context): Promise<void> {
21
- const userId = ctx.from?.id;
22
- const username = ctx.from?.username || "unknown";
23
- const chatId = ctx.chat?.id;
24
- let message = ctx.message?.text;
25
-
26
- if (!userId || !message || !chatId) {
27
- return;
28
- }
29
-
30
- // 1. Authorization check
31
- if (!isAuthorized(userId, ALLOWED_USERS)) {
32
- await ctx.reply("Unauthorized. Contact the bot owner for access.");
33
- return;
34
- }
35
-
36
- // 2. Check for interrupt prefix
37
- message = await checkInterrupt(message);
38
- if (!message.trim()) {
39
- return;
40
- }
41
-
42
- // 3. Rate limit check
43
- const [allowed, retryAfter] = rateLimiter.check(userId);
44
- if (!allowed) {
45
- await auditLogRateLimit(userId, username, retryAfter!);
46
- await ctx.reply(
47
- `⏳ Rate limited. Please wait ${retryAfter!.toFixed(1)} seconds.`
48
- );
49
- return;
50
- }
51
-
52
- // 4. Store message for retry
53
- session.lastMessage = message;
54
-
55
- // 5. Mark processing started
56
- const stopProcessing = session.startProcessing();
57
-
58
- // 6. Start typing indicator
59
- const typing = startTypingIndicator(ctx);
60
-
61
- // 7. Create streaming state and callback
62
- let state = new StreamingState();
63
- let statusCallback = createStatusCallback(ctx, state);
64
-
65
- // 8. Send to Claude with retry logic for crashes
66
- const MAX_RETRIES = 1;
67
-
68
- for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
69
- try {
70
- const response = await session.sendMessageStreaming(
71
- message,
72
- username,
73
- userId,
74
- statusCallback,
75
- chatId,
76
- ctx
77
- );
78
-
79
- // 9. Audit log
80
- await auditLog(userId, username, "TEXT", message, response);
81
- break; // Success - exit retry loop
82
- } catch (error) {
83
- const errorStr = String(error);
84
- const isClaudeCodeCrash = errorStr.includes("exited with code");
85
-
86
- // Clean up any partial messages from this attempt
87
- for (const toolMsg of state.toolMessages) {
88
- try {
89
- await ctx.api.deleteMessage(toolMsg.chat.id, toolMsg.message_id);
90
- } catch {
91
- // Ignore cleanup errors
92
- }
93
- }
94
-
95
- // Retry on Claude Code crash (not user cancellation)
96
- if (isClaudeCodeCrash && attempt < MAX_RETRIES) {
97
- console.log(
98
- `Claude Code crashed, retrying (attempt ${attempt + 2}/${MAX_RETRIES + 1})...`
99
- );
100
- await session.kill(); // Clear corrupted session
101
- await ctx.reply(`⚠️ Claude crashed, retrying...`);
102
- // Reset state for retry
103
- state = new StreamingState();
104
- statusCallback = createStatusCallback(ctx, state);
105
- continue;
106
- }
107
-
108
- // Final attempt failed or non-retryable error
109
- console.error("Error processing message:", error);
110
-
111
- // Check if it was a cancellation
112
- if (errorStr.includes("abort") || errorStr.includes("cancel")) {
113
- // Only show "Query stopped" if it was an explicit stop, not an interrupt from a new message
114
- const wasInterrupt = session.consumeInterruptFlag();
115
- if (!wasInterrupt) {
116
- await ctx.reply("🛑 Query stopped.");
117
- }
118
- } else {
119
- await ctx.reply(`❌ Error: ${errorStr.slice(0, 200)}`);
120
- }
121
- break; // Exit loop after handling error
122
- }
123
- }
124
-
125
- // 10. Cleanup
126
- stopProcessing();
127
- typing.stop();
56
+ const userId = ctx.from?.id;
57
+ const username = ctx.from?.username || "unknown";
58
+ const chatId = ctx.chat?.id;
59
+ let message = ctx.message?.text;
60
+
61
+ if (!userId || !message || !chatId) {
62
+ return;
63
+ }
64
+
65
+ // 1. Authorization check
66
+ if (!isAuthorized(userId, ALLOWED_USERS)) {
67
+ await ctx.reply("Unauthorized. Contact the bot owner for access.");
68
+ return;
69
+ }
70
+
71
+ // 2. Shell command shortcut: !command executes directly
72
+ if (message.startsWith("!")) {
73
+ const shellCmd = message.slice(1).trim();
74
+ if (shellCmd) {
75
+ const cwd = session.workingDir;
76
+ await ctx.reply(
77
+ `⚡ Running in <code>${cwd}</code>:\n<code>${shellCmd}</code>`,
78
+ {
79
+ parse_mode: "HTML",
80
+ },
81
+ );
82
+
83
+ const { stdout, stderr, exitCode } = await execShellCommand(
84
+ shellCmd,
85
+ cwd,
86
+ );
87
+ const output = (stdout + stderr).trim();
88
+ const maxLen = 4000;
89
+ const truncated =
90
+ output.length > maxLen
91
+ ? `${output.slice(0, maxLen)}...(truncated)`
92
+ : output;
93
+
94
+ if (exitCode === 0) {
95
+ await ctx.reply(
96
+ `✅ Exit code: ${exitCode}\n<pre>${truncated || "(no output)"}</pre>`,
97
+ {
98
+ parse_mode: "HTML",
99
+ },
100
+ );
101
+ } else {
102
+ await ctx.reply(
103
+ `❌ Exit code: ${exitCode}\n<pre>${truncated || "(no output)"}</pre>`,
104
+ {
105
+ parse_mode: "HTML",
106
+ },
107
+ );
108
+ }
109
+ await auditLog(userId, username, "SHELL", shellCmd, `exit=${exitCode}`);
110
+ return;
111
+ }
112
+ }
113
+
114
+ // 3. Check for interrupt prefix
115
+ message = await checkInterrupt(message);
116
+ if (!message.trim()) {
117
+ return;
118
+ }
119
+
120
+ // 3. Rate limit check
121
+ const [allowed, retryAfter] = rateLimiter.check(userId);
122
+ if (!allowed) {
123
+ await auditLogRateLimit(userId, username, retryAfter!);
124
+ await ctx.reply(
125
+ `⏳ Rate limited. Please wait ${retryAfter?.toFixed(1)} seconds.`,
126
+ );
127
+ return;
128
+ }
129
+
130
+ // 4. Store message for retry
131
+ session.lastMessage = message;
132
+
133
+ // 5. Mark processing started
134
+ const stopProcessing = session.startProcessing();
135
+
136
+ // 6. Start typing indicator
137
+ const typing = startTypingIndicator(ctx);
138
+
139
+ // 7. Create streaming state and callback
140
+ let state = new StreamingState();
141
+ let statusCallback = createStatusCallback(ctx, state);
142
+
143
+ // 8. Send to Claude with retry logic for crashes
144
+ const MAX_RETRIES = 1;
145
+
146
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
147
+ try {
148
+ const response = await session.sendMessageStreaming(
149
+ message,
150
+ username,
151
+ userId,
152
+ statusCallback,
153
+ chatId,
154
+ ctx,
155
+ );
156
+
157
+ // 9. Audit log
158
+ await auditLog(userId, username, "TEXT", message, response);
159
+ break; // Success - exit retry loop
160
+ } catch (error) {
161
+ const errorStr = String(error);
162
+ const isClaudeCodeCrash = errorStr.includes("exited with code");
163
+
164
+ // Clean up any partial messages from this attempt
165
+ for (const toolMsg of state.toolMessages) {
166
+ try {
167
+ await ctx.api.deleteMessage(toolMsg.chat.id, toolMsg.message_id);
168
+ } catch {
169
+ // Ignore cleanup errors
170
+ }
171
+ }
172
+
173
+ // Retry on Claude Code crash (not user cancellation)
174
+ if (isClaudeCodeCrash && attempt < MAX_RETRIES) {
175
+ console.log(
176
+ `Claude Code crashed, retrying (attempt ${attempt + 2}/${MAX_RETRIES + 1})...`,
177
+ );
178
+ await session.kill(); // Clear corrupted session
179
+ await ctx.reply(`⚠️ Claude crashed, retrying...`);
180
+ // Reset state for retry
181
+ state = new StreamingState();
182
+ statusCallback = createStatusCallback(ctx, state);
183
+ continue;
184
+ }
185
+
186
+ // Final attempt failed or non-retryable error
187
+ console.error("Error processing message:", error);
188
+
189
+ // Check if it was a cancellation
190
+ if (errorStr.includes("abort") || errorStr.includes("cancel")) {
191
+ // Only show "Query stopped" if it was an explicit stop, not an interrupt from a new message
192
+ const wasInterrupt = session.consumeInterruptFlag();
193
+ if (!wasInterrupt) {
194
+ await ctx.reply("🛑 Query stopped.");
195
+ }
196
+ } else {
197
+ await ctx.reply(`❌ Error: ${errorStr.slice(0, 200)}`);
198
+ }
199
+ break; // Exit loop after handling error
200
+ }
201
+ }
202
+
203
+ // 10. Cleanup
204
+ stopProcessing();
205
+ typing.stop();
128
206
  }
package/src/index.ts CHANGED
@@ -17,16 +17,24 @@ import {
17
17
  handleBookmarks,
18
18
  handleCallback,
19
19
  handleCd,
20
+ handleCompact,
21
+ handleCost,
20
22
  handleDocument,
23
+ handleFile,
24
+ handleModel,
21
25
  handleNew,
22
26
  handlePhoto,
27
+ handlePlan,
23
28
  handleRestart,
24
29
  handleResume,
25
30
  handleRetry,
31
+ handleSkill,
26
32
  handleStart,
27
33
  handleStatus,
28
34
  handleStop,
29
35
  handleText,
36
+ handleThink,
37
+ handleUndo,
30
38
  handleVoice,
31
39
  } from "./handlers";
32
40
 
@@ -59,11 +67,22 @@ bot.use(
59
67
  bot.command("start", handleStart);
60
68
  bot.command("new", handleNew);
61
69
  bot.command("stop", handleStop);
70
+ bot.command("c", handleStop);
71
+ bot.command("kill", handleStop);
72
+ bot.command("dc", handleStop);
62
73
  bot.command("status", handleStatus);
63
74
  bot.command("resume", handleResume);
64
75
  bot.command("restart", handleRestart);
65
76
  bot.command("retry", handleRetry);
66
77
  bot.command("cd", handleCd);
78
+ bot.command("skill", handleSkill);
79
+ bot.command("file", handleFile);
80
+ bot.command("model", handleModel);
81
+ bot.command("cost", handleCost);
82
+ bot.command("think", handleThink);
83
+ bot.command("plan", handlePlan);
84
+ bot.command("compact", handleCompact);
85
+ bot.command("undo", handleUndo);
67
86
  bot.command("bookmarks", handleBookmarks);
68
87
 
69
88
  // ============== Message Handlers ==============
package/src/session.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  import { readFileSync } from "node:fs";
9
9
  import {
10
10
  type Options,
11
+ type Query,
11
12
  query,
12
13
  type SDKMessage,
13
14
  } from "@anthropic-ai/claude-agent-sdk";
@@ -31,8 +32,9 @@ import type { SessionData, StatusCallback, TokenUsage } from "./types";
31
32
 
32
33
  /**
33
34
  * Determine thinking token budget based on message keywords.
35
+ * Exported for testing.
34
36
  */
35
- function getThinkingLevel(message: string): number {
37
+ export function getThinkingLevel(message: string): number {
36
38
  const msgLower = message.toLowerCase();
37
39
 
38
40
  // Check deep thinking triggers first (more specific)
@@ -78,6 +80,21 @@ class ClaudeSession {
78
80
  lastErrorTime: Date | null = null;
79
81
  lastUsage: TokenUsage | null = null;
80
82
  lastMessage: string | null = null;
83
+ lastBotResponse: string | null = null;
84
+
85
+ // Model and mode settings
86
+ currentModel: "sonnet" | "opus" | "haiku" = "sonnet";
87
+ forceThinking: number | null = null; // Tokens for next message, then resets
88
+ planMode = false;
89
+
90
+ // Cumulative usage tracking
91
+ totalInputTokens = 0;
92
+ totalOutputTokens = 0;
93
+ totalCacheReadTokens = 0;
94
+
95
+ // File checkpointing for /undo
96
+ private _queryInstance: Query | null = null;
97
+ private _userMessageUuids: string[] = [];
81
98
 
82
99
  // Mutable working directory (can be changed with /cd)
83
100
  private _workingDir: string = WORKING_DIR;
@@ -192,11 +209,27 @@ class ClaudeSession {
192
209
  }
193
210
 
194
211
  const isNewSession = !this.isActive;
195
- const thinkingTokens = getThinkingLevel(message);
212
+
213
+ // Determine thinking tokens - forceThinking overrides keyword detection
214
+ let thinkingTokens: number;
215
+ if (this.forceThinking !== null) {
216
+ thinkingTokens = this.forceThinking;
217
+ this.forceThinking = null; // Reset after use
218
+ } else {
219
+ thinkingTokens = getThinkingLevel(message);
220
+ }
196
221
  const thinkingLabel =
197
222
  { 0: "off", 10000: "normal", 50000: "deep" }[thinkingTokens] ||
198
223
  String(thinkingTokens);
199
224
 
225
+ // Determine model based on currentModel setting
226
+ const modelMap = {
227
+ sonnet: "claude-sonnet-4-5",
228
+ opus: "claude-opus-4-5",
229
+ haiku: "claude-haiku-3-5",
230
+ };
231
+ const modelId = modelMap[this.currentModel];
232
+
200
233
  // Inject current date/time at session start so Claude doesn't need to call a tool for it
201
234
  let messageToSend = message;
202
235
  if (isNewSession) {
@@ -218,16 +251,17 @@ class ClaudeSession {
218
251
 
219
252
  // Build SDK V1 options - supports all features
220
253
  const options: Options = {
221
- model: "claude-sonnet-4-5",
254
+ model: modelId,
222
255
  cwd: this._workingDir,
223
256
  settingSources: ["user", "project"],
224
- permissionMode: "bypassPermissions",
225
- allowDangerouslySkipPermissions: true,
257
+ permissionMode: this.planMode ? "plan" : "bypassPermissions",
258
+ allowDangerouslySkipPermissions: !this.planMode,
226
259
  systemPrompt: SAFETY_PROMPT,
227
260
  mcpServers: MCP_SERVERS,
228
261
  maxThinkingTokens: thinkingTokens,
229
262
  additionalDirectories: ALLOWED_PATHS,
230
263
  resume: this.sessionId || undefined,
264
+ enableFileCheckpointing: true, // Enable /undo support
231
265
  };
232
266
 
233
267
  // Add Claude Code executable path if set (required for standalone builds)
@@ -281,6 +315,9 @@ class ClaudeSession {
281
315
  },
282
316
  });
283
317
 
318
+ // Store query instance for /undo support
319
+ this._queryInstance = queryInstance;
320
+
284
321
  // Process streaming response
285
322
  for await (const event of queryInstance) {
286
323
  // Check for abort
@@ -308,6 +345,12 @@ class ClaudeSession {
308
345
  this.saveSession();
309
346
  }
310
347
 
348
+ // Capture user message UUIDs for /undo checkpoints
349
+ if (event.type === "user" && event.uuid) {
350
+ this._userMessageUuids.push(event.uuid);
351
+ console.log(`Checkpoint: user message ${event.uuid.slice(0, 8)}...`);
352
+ }
353
+
311
354
  // Handle different message types
312
355
  if (event.type === "assistant") {
313
356
  for (const block of event.message.content) {
@@ -436,6 +479,10 @@ class ClaudeSession {
436
479
  if ("usage" in event && event.usage) {
437
480
  this.lastUsage = event.usage as TokenUsage;
438
481
  const u = this.lastUsage;
482
+ // Accumulate totals
483
+ this.totalInputTokens += u.input_tokens || 0;
484
+ this.totalOutputTokens += u.output_tokens || 0;
485
+ this.totalCacheReadTokens += u.cache_read_input_tokens || 0;
439
486
  console.log(
440
487
  `Usage: in=${u.input_tokens} out=${u.output_tokens} cache_read=${
441
488
  u.cache_read_input_tokens || 0
@@ -486,7 +533,9 @@ class ClaudeSession {
486
533
 
487
534
  await statusCallback("done", "");
488
535
 
489
- return responseParts.join("") || "No response from Claude.";
536
+ const finalResponse = responseParts.join("") || "No response from Claude.";
537
+ this.lastBotResponse = finalResponse;
538
+ return finalResponse;
490
539
  }
491
540
 
492
541
  /**
@@ -495,9 +544,91 @@ class ClaudeSession {
495
544
  async kill(): Promise<void> {
496
545
  this.sessionId = null;
497
546
  this.lastActivity = null;
547
+ // Reset usage totals
548
+ this.totalInputTokens = 0;
549
+ this.totalOutputTokens = 0;
550
+ this.totalCacheReadTokens = 0;
551
+ // Reset modes
552
+ this.planMode = false;
553
+ this.forceThinking = null;
554
+ // Reset checkpoints
555
+ this._queryInstance = null;
556
+ this._userMessageUuids = [];
498
557
  console.log("Session cleared");
499
558
  }
500
559
 
560
+ /**
561
+ * Check if undo is available (has checkpoints).
562
+ */
563
+ get canUndo(): boolean {
564
+ return this._queryInstance !== null && this._userMessageUuids.length > 0;
565
+ }
566
+
567
+ /**
568
+ * Get number of available undo checkpoints.
569
+ */
570
+ get undoCheckpoints(): number {
571
+ return this._userMessageUuids.length;
572
+ }
573
+
574
+ /**
575
+ * Undo file changes by rewinding to the last user message checkpoint.
576
+ * Returns [success, message].
577
+ */
578
+ async undo(): Promise<[boolean, string]> {
579
+ if (!this._queryInstance) {
580
+ return [false, "No active session to undo"];
581
+ }
582
+
583
+ if (this._userMessageUuids.length === 0) {
584
+ return [false, "No checkpoints available"];
585
+ }
586
+
587
+ // Get and remove the last user message UUID
588
+ const targetUuid = this._userMessageUuids.pop();
589
+ if (!targetUuid) {
590
+ return [false, "No checkpoints available"];
591
+ }
592
+
593
+ try {
594
+ console.log(`Rewinding files to checkpoint ${targetUuid.slice(0, 8)}...`);
595
+ await this._queryInstance.rewindFiles(targetUuid);
596
+
597
+ const remaining = this._userMessageUuids.length;
598
+ return [
599
+ true,
600
+ `✅ Reverted file changes to checkpoint \`${targetUuid.slice(0, 8)}...\`\n${remaining} checkpoint${remaining !== 1 ? "s" : ""} remaining`,
601
+ ];
602
+ } catch (error) {
603
+ // Restore the checkpoint on failure
604
+ this._userMessageUuids.push(targetUuid);
605
+ console.error(`Undo failed: ${error}`);
606
+ return [false, `Failed to undo: ${error}`];
607
+ }
608
+ }
609
+
610
+ /**
611
+ * Estimate cost based on current model and usage.
612
+ */
613
+ estimateCost(): { inputCost: number; outputCost: number; total: number } {
614
+ // Pricing per 1M tokens (approximate as of 2024)
615
+ const pricing = {
616
+ sonnet: { input: 3, output: 15 },
617
+ opus: { input: 15, output: 75 },
618
+ haiku: { input: 0.25, output: 1.25 },
619
+ };
620
+ const rates = pricing[this.currentModel];
621
+
622
+ const inputCost = (this.totalInputTokens / 1_000_000) * rates.input;
623
+ const outputCost = (this.totalOutputTokens / 1_000_000) * rates.output;
624
+
625
+ return {
626
+ inputCost,
627
+ outputCost,
628
+ total: inputCost + outputCost,
629
+ };
630
+ }
631
+
501
632
  /**
502
633
  * Save session to disk for resume after restart.
503
634
  */
@@ -561,5 +692,8 @@ class ClaudeSession {
561
692
  }
562
693
  }
563
694
 
695
+ // Export class for testing
696
+ export { ClaudeSession };
697
+
564
698
  // Global session instance
565
699
  export const session = new ClaudeSession();