ctb 1.1.0 → 1.3.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/src/index.ts CHANGED
@@ -17,19 +17,27 @@ 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,
23
- handlePreview,
27
+ handlePlan,
24
28
  handleRestart,
25
29
  handleResume,
26
30
  handleRetry,
31
+ handleSkill,
27
32
  handleStart,
28
33
  handleStatus,
29
34
  handleStop,
30
35
  handleText,
36
+ handleThink,
37
+ handleUndo,
31
38
  handleVoice,
32
39
  } from "./handlers";
40
+ import { session } from "./session";
33
41
 
34
42
  // Create bot instance
35
43
  const bot = new Bot(TELEGRAM_TOKEN);
@@ -60,12 +68,22 @@ bot.use(
60
68
  bot.command("start", handleStart);
61
69
  bot.command("new", handleNew);
62
70
  bot.command("stop", handleStop);
71
+ bot.command("c", handleStop);
72
+ bot.command("kill", handleStop);
73
+ bot.command("dc", handleStop);
63
74
  bot.command("status", handleStatus);
64
75
  bot.command("resume", handleResume);
65
76
  bot.command("restart", handleRestart);
66
77
  bot.command("retry", handleRetry);
67
78
  bot.command("cd", handleCd);
68
- bot.command("preview", handlePreview);
79
+ bot.command("skill", handleSkill);
80
+ bot.command("file", handleFile);
81
+ bot.command("model", handleModel);
82
+ bot.command("cost", handleCost);
83
+ bot.command("think", handleThink);
84
+ bot.command("plan", handlePlan);
85
+ bot.command("compact", handleCompact);
86
+ bot.command("undo", handleUndo);
69
87
  bot.command("bookmarks", handleBookmarks);
70
88
 
71
89
  // ============== Message Handlers ==============
@@ -132,21 +150,38 @@ if (existsSync(RESTART_FILE)) {
132
150
  const runner = run(bot);
133
151
 
134
152
  // Graceful shutdown
135
- const stopRunner = () => {
136
- if (runner.isRunning()) {
137
- console.log("Stopping bot...");
138
- runner.stop();
139
- }
140
- };
153
+ const SHUTDOWN_TIMEOUT_MS = 5000;
141
154
 
142
- process.on("SIGINT", () => {
143
- console.log("Received SIGINT");
144
- stopRunner();
145
- process.exit(0);
146
- });
155
+ async function gracefulShutdown(signal: string): Promise<void> {
156
+ console.log(`\n${signal} received - initiating graceful shutdown...`);
147
157
 
148
- process.on("SIGTERM", () => {
149
- console.log("Received SIGTERM");
150
- stopRunner();
151
- process.exit(0);
152
- });
158
+ // Set a hard timeout
159
+ const forceExit = setTimeout(() => {
160
+ console.error("Shutdown timeout - forcing exit");
161
+ process.exit(1);
162
+ }, SHUTDOWN_TIMEOUT_MS);
163
+
164
+ try {
165
+ // Stop the runner (stops polling)
166
+ if (runner.isRunning()) {
167
+ runner.stop();
168
+ console.log("Bot stopped");
169
+ }
170
+
171
+ // Flush session data
172
+ session.flushSession();
173
+ console.log("Session flushed");
174
+
175
+ // Clear the timeout and exit cleanly
176
+ clearTimeout(forceExit);
177
+ console.log("Shutdown complete");
178
+ process.exit(0);
179
+ } catch (error) {
180
+ console.error("Error during shutdown:", error);
181
+ clearTimeout(forceExit);
182
+ process.exit(1);
183
+ }
184
+ }
185
+
186
+ process.on("SIGINT", () => gracefulShutdown("SIGINT"));
187
+ process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
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";
@@ -24,15 +25,19 @@ import {
24
25
  THINKING_KEYWORDS,
25
26
  WORKING_DIR,
26
27
  } from "./config";
28
+ import { botEvents } from "./events";
27
29
  import { formatToolStatus } from "./formatting";
28
30
  import { checkPendingAskUserRequests } from "./handlers/streaming";
29
31
  import { checkCommandSafety, isPathAllowed } from "./security";
30
32
  import type { SessionData, StatusCallback, TokenUsage } from "./types";
31
33
 
34
+ const SESSION_VERSION = 1;
35
+
32
36
  /**
33
37
  * Determine thinking token budget based on message keywords.
38
+ * Exported for testing.
34
39
  */
35
- function getThinkingLevel(message: string): number {
40
+ export function getThinkingLevel(message: string): number {
36
41
  const msgLower = message.toLowerCase();
37
42
 
38
43
  // Check deep thinking triggers first (more specific)
@@ -78,6 +83,26 @@ class ClaudeSession {
78
83
  lastErrorTime: Date | null = null;
79
84
  lastUsage: TokenUsage | null = null;
80
85
  lastMessage: string | null = null;
86
+ lastBotResponse: string | null = null;
87
+
88
+ // Model and mode settings
89
+ currentModel: "sonnet" | "opus" | "haiku" = "sonnet";
90
+ forceThinking: number | null = null; // Tokens for next message, then resets
91
+ planMode = false;
92
+
93
+ // Cumulative usage tracking
94
+ totalInputTokens = 0;
95
+ totalOutputTokens = 0;
96
+ totalCacheReadTokens = 0;
97
+
98
+ // File checkpointing for /undo
99
+ private _queryInstance: Query | null = null;
100
+ private _userMessageUuids: string[] = [];
101
+
102
+ // Debounced session saving
103
+ private _saveTimeout: ReturnType<typeof setTimeout> | null = null;
104
+ private _pendingSave = false;
105
+ private readonly SAVE_DEBOUNCE_MS = 500;
81
106
 
82
107
  // Mutable working directory (can be changed with /cd)
83
108
  private _workingDir: string = WORKING_DIR;
@@ -88,6 +113,15 @@ class ClaudeSession {
88
113
  private _isProcessing = false;
89
114
  private _wasInterruptedByNewMessage = false;
90
115
 
116
+ constructor() {
117
+ botEvents.on("interruptRequested", () => {
118
+ if (this.isRunning) {
119
+ this.markInterrupt();
120
+ this.stop();
121
+ }
122
+ });
123
+ }
124
+
91
125
  get workingDir(): string {
92
126
  return this._workingDir;
93
127
  }
@@ -192,11 +226,27 @@ class ClaudeSession {
192
226
  }
193
227
 
194
228
  const isNewSession = !this.isActive;
195
- const thinkingTokens = getThinkingLevel(message);
229
+
230
+ // Determine thinking tokens - forceThinking overrides keyword detection
231
+ let thinkingTokens: number;
232
+ if (this.forceThinking !== null) {
233
+ thinkingTokens = this.forceThinking;
234
+ this.forceThinking = null; // Reset after use
235
+ } else {
236
+ thinkingTokens = getThinkingLevel(message);
237
+ }
196
238
  const thinkingLabel =
197
239
  { 0: "off", 10000: "normal", 50000: "deep" }[thinkingTokens] ||
198
240
  String(thinkingTokens);
199
241
 
242
+ // Determine model based on currentModel setting
243
+ const modelMap = {
244
+ sonnet: "claude-sonnet-4-5",
245
+ opus: "claude-opus-4-5",
246
+ haiku: "claude-haiku-3-5",
247
+ };
248
+ const modelId = modelMap[this.currentModel];
249
+
200
250
  // Inject current date/time at session start so Claude doesn't need to call a tool for it
201
251
  let messageToSend = message;
202
252
  if (isNewSession) {
@@ -218,16 +268,17 @@ class ClaudeSession {
218
268
 
219
269
  // Build SDK V1 options - supports all features
220
270
  const options: Options = {
221
- model: "claude-sonnet-4-5",
271
+ model: modelId,
222
272
  cwd: this._workingDir,
223
273
  settingSources: ["user", "project"],
224
- permissionMode: "bypassPermissions",
225
- allowDangerouslySkipPermissions: true,
274
+ permissionMode: this.planMode ? "plan" : "bypassPermissions",
275
+ allowDangerouslySkipPermissions: !this.planMode,
226
276
  systemPrompt: SAFETY_PROMPT,
227
277
  mcpServers: MCP_SERVERS,
228
278
  maxThinkingTokens: thinkingTokens,
229
279
  additionalDirectories: ALLOWED_PATHS,
230
280
  resume: this.sessionId || undefined,
281
+ enableFileCheckpointing: true, // Enable /undo support
231
282
  };
232
283
 
233
284
  // Add Claude Code executable path if set (required for standalone builds)
@@ -259,6 +310,7 @@ class ClaudeSession {
259
310
  // Create abort controller for cancellation
260
311
  this.abortController = new AbortController();
261
312
  this.isQueryRunning = true;
313
+ botEvents.emit("sessionRunning", true);
262
314
  this.stopRequested = false;
263
315
  this.queryStarted = new Date();
264
316
  this.currentTool = null;
@@ -281,6 +333,9 @@ class ClaudeSession {
281
333
  },
282
334
  });
283
335
 
336
+ // Store query instance for /undo support
337
+ this._queryInstance = queryInstance;
338
+
284
339
  // Process streaming response
285
340
  for await (const event of queryInstance) {
286
341
  // Check for abort
@@ -308,6 +363,12 @@ class ClaudeSession {
308
363
  this.saveSession();
309
364
  }
310
365
 
366
+ // Capture user message UUIDs for /undo checkpoints
367
+ if (event.type === "user" && event.uuid) {
368
+ this._userMessageUuids.push(event.uuid);
369
+ console.log(`Checkpoint: user message ${event.uuid.slice(0, 8)}...`);
370
+ }
371
+
311
372
  // Handle different message types
312
373
  if (event.type === "assistant") {
313
374
  for (const block of event.message.content) {
@@ -436,6 +497,10 @@ class ClaudeSession {
436
497
  if ("usage" in event && event.usage) {
437
498
  this.lastUsage = event.usage as TokenUsage;
438
499
  const u = this.lastUsage;
500
+ // Accumulate totals
501
+ this.totalInputTokens += u.input_tokens || 0;
502
+ this.totalOutputTokens += u.output_tokens || 0;
503
+ this.totalCacheReadTokens += u.cache_read_input_tokens || 0;
439
504
  console.log(
440
505
  `Usage: in=${u.input_tokens} out=${u.output_tokens} cache_read=${
441
506
  u.cache_read_input_tokens || 0
@@ -464,6 +529,7 @@ class ClaudeSession {
464
529
  }
465
530
  } finally {
466
531
  this.isQueryRunning = false;
532
+ botEvents.emit("sessionRunning", false);
467
533
  this.abortController = null;
468
534
  this.queryStarted = null;
469
535
  this.currentTool = null;
@@ -486,7 +552,9 @@ class ClaudeSession {
486
552
 
487
553
  await statusCallback("done", "");
488
554
 
489
- return responseParts.join("") || "No response from Claude.";
555
+ const finalResponse = responseParts.join("") || "No response from Claude.";
556
+ this.lastBotResponse = finalResponse;
557
+ return finalResponse;
490
558
  }
491
559
 
492
560
  /**
@@ -495,17 +563,120 @@ class ClaudeSession {
495
563
  async kill(): Promise<void> {
496
564
  this.sessionId = null;
497
565
  this.lastActivity = null;
566
+ // Reset usage totals
567
+ this.totalInputTokens = 0;
568
+ this.totalOutputTokens = 0;
569
+ this.totalCacheReadTokens = 0;
570
+ // Reset modes
571
+ this.planMode = false;
572
+ this.forceThinking = null;
573
+ // Reset checkpoints
574
+ this._queryInstance = null;
575
+ this._userMessageUuids = [];
498
576
  console.log("Session cleared");
499
577
  }
500
578
 
501
579
  /**
502
- * Save session to disk for resume after restart.
580
+ * Check if undo is available (has checkpoints).
581
+ */
582
+ get canUndo(): boolean {
583
+ return this._queryInstance !== null && this._userMessageUuids.length > 0;
584
+ }
585
+
586
+ /**
587
+ * Get number of available undo checkpoints.
588
+ */
589
+ get undoCheckpoints(): number {
590
+ return this._userMessageUuids.length;
591
+ }
592
+
593
+ /**
594
+ * Undo file changes by rewinding to the last user message checkpoint.
595
+ * Returns [success, message].
596
+ */
597
+ async undo(): Promise<[boolean, string]> {
598
+ if (!this._queryInstance) {
599
+ return [false, "No active session to undo"];
600
+ }
601
+
602
+ if (this._userMessageUuids.length === 0) {
603
+ return [false, "No checkpoints available"];
604
+ }
605
+
606
+ // Get and remove the last user message UUID
607
+ const targetUuid = this._userMessageUuids.pop();
608
+ if (!targetUuid) {
609
+ return [false, "No checkpoints available"];
610
+ }
611
+
612
+ try {
613
+ console.log(`Rewinding files to checkpoint ${targetUuid.slice(0, 8)}...`);
614
+ await this._queryInstance.rewindFiles(targetUuid);
615
+
616
+ const remaining = this._userMessageUuids.length;
617
+ return [
618
+ true,
619
+ `✅ Reverted file changes to checkpoint \`${targetUuid.slice(0, 8)}...\`\n${remaining} checkpoint${remaining !== 1 ? "s" : ""} remaining`,
620
+ ];
621
+ } catch (error) {
622
+ // Restore the checkpoint on failure
623
+ this._userMessageUuids.push(targetUuid);
624
+ console.error(`Undo failed: ${error}`);
625
+ return [false, `Failed to undo: ${error}`];
626
+ }
627
+ }
628
+
629
+ /**
630
+ * Estimate cost based on current model and usage.
631
+ */
632
+ estimateCost(): { inputCost: number; outputCost: number; total: number } {
633
+ // Pricing per 1M tokens (approximate as of 2024)
634
+ const pricing = {
635
+ sonnet: { input: 3, output: 15 },
636
+ opus: { input: 15, output: 75 },
637
+ haiku: { input: 0.25, output: 1.25 },
638
+ };
639
+ const rates = pricing[this.currentModel];
640
+
641
+ const inputCost = (this.totalInputTokens / 1_000_000) * rates.input;
642
+ const outputCost = (this.totalOutputTokens / 1_000_000) * rates.output;
643
+
644
+ return {
645
+ inputCost,
646
+ outputCost,
647
+ total: inputCost + outputCost,
648
+ };
649
+ }
650
+
651
+ /**
652
+ * Save session to disk for resume after restart (debounced).
653
+ * Multiple calls within SAVE_DEBOUNCE_MS will only result in one write.
503
654
  */
504
655
  private saveSession(): void {
505
656
  if (!this.sessionId) return;
506
657
 
658
+ this._pendingSave = true;
659
+
660
+ // Clear existing timeout if any
661
+ if (this._saveTimeout) {
662
+ clearTimeout(this._saveTimeout);
663
+ }
664
+
665
+ // Schedule the actual write
666
+ this._saveTimeout = setTimeout(() => {
667
+ this._doSaveSession();
668
+ }, this.SAVE_DEBOUNCE_MS);
669
+ }
670
+
671
+ /**
672
+ * Actually perform the session write to disk.
673
+ */
674
+ private _doSaveSession(): void {
675
+ if (!this.sessionId || !this._pendingSave) return;
676
+
507
677
  try {
508
678
  const data: SessionData = {
679
+ version: SESSION_VERSION,
509
680
  session_id: this.sessionId,
510
681
  saved_at: new Date().toISOString(),
511
682
  working_dir: this._workingDir,
@@ -514,6 +685,25 @@ class ClaudeSession {
514
685
  console.log(`Session saved to ${SESSION_FILE}`);
515
686
  } catch (error) {
516
687
  console.warn(`Failed to save session: ${error}`);
688
+ } finally {
689
+ this._pendingSave = false;
690
+ this._saveTimeout = null;
691
+ }
692
+ }
693
+
694
+ /**
695
+ * Immediately flush any pending session save to disk.
696
+ * Use this for graceful shutdown to ensure session is persisted.
697
+ */
698
+ flushSession(): void {
699
+ if (this._saveTimeout) {
700
+ clearTimeout(this._saveTimeout);
701
+ this._saveTimeout = null;
702
+ }
703
+
704
+ if (this._pendingSave || this.sessionId) {
705
+ this._pendingSave = true; // Ensure _doSaveSession runs
706
+ this._doSaveSession();
517
707
  }
518
708
  }
519
709
 
@@ -534,6 +724,13 @@ class ClaudeSession {
534
724
  return [false, "Saved session file is empty"];
535
725
  }
536
726
 
727
+ if (data.version !== SESSION_VERSION) {
728
+ return [
729
+ false,
730
+ `Session version mismatch (found v${data.version ?? 0}, expected v${SESSION_VERSION})`,
731
+ ];
732
+ }
733
+
537
734
  if (data.working_dir && data.working_dir !== this._workingDir) {
538
735
  return [
539
736
  false,
@@ -561,5 +758,8 @@ class ClaudeSession {
561
758
  }
562
759
  }
563
760
 
761
+ // Export class for testing
762
+ export { ClaudeSession };
763
+
564
764
  // Global session instance
565
765
  export const session = new ClaudeSession();
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Telegram API utilities with retry logic.
3
+ *
4
+ * Provides error handling and automatic retry for transient Telegram API failures.
5
+ */
6
+
7
+ /**
8
+ * Options for retry behavior.
9
+ */
10
+ export interface RetryOptions {
11
+ /** Maximum number of retry attempts (default: 3) */
12
+ maxRetries?: number;
13
+ /** Base delay in milliseconds for exponential backoff (default: 1000) */
14
+ baseDelay?: number;
15
+ /** Maximum delay in milliseconds (default: 30000) */
16
+ maxDelay?: number;
17
+ }
18
+
19
+ const DEFAULT_RETRY_OPTIONS: Required<RetryOptions> = {
20
+ maxRetries: 3,
21
+ baseDelay: 1000,
22
+ maxDelay: 30000,
23
+ };
24
+
25
+ /**
26
+ * Custom error class for Telegram API errors.
27
+ */
28
+ export class TelegramApiError extends Error {
29
+ readonly statusCode: number;
30
+ readonly retryAfter?: number;
31
+
32
+ constructor(message: string, statusCode: number) {
33
+ super(message);
34
+ this.name = "TelegramApiError";
35
+ this.statusCode = statusCode;
36
+
37
+ // Parse retry-after from message if present
38
+ const retryMatch = message.match(/retry after (\d+)/i);
39
+ if (retryMatch) {
40
+ this.retryAfter = Number.parseInt(retryMatch[1]!, 10);
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Returns true if this error is transient and can be retried.
46
+ */
47
+ get isTransient(): boolean {
48
+ return isTransientError(this);
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Network error patterns that indicate transient issues.
54
+ */
55
+ const NETWORK_ERROR_PATTERNS = [
56
+ "etimedout",
57
+ "econnreset",
58
+ "enotfound",
59
+ "eai_again",
60
+ "econnrefused",
61
+ "epipe",
62
+ "socket hang up",
63
+ ];
64
+
65
+ /**
66
+ * Check if an error is transient (can be retried).
67
+ */
68
+ function isTransientError(error: unknown): boolean {
69
+ if (error instanceof TelegramApiError) {
70
+ // 429 Too Many Requests
71
+ if (error.statusCode === 429) {
72
+ return true;
73
+ }
74
+ // 5xx server errors
75
+ if (error.statusCode >= 500 && error.statusCode < 600) {
76
+ return true;
77
+ }
78
+ // Network errors (status 0)
79
+ if (error.statusCode === 0) {
80
+ return true;
81
+ }
82
+ }
83
+
84
+ const message = error instanceof Error ? error.message.toLowerCase() : "";
85
+
86
+ // Rate limiting
87
+ if (message.includes("too many requests") || message.includes("flood")) {
88
+ return true;
89
+ }
90
+
91
+ // Retry-after header
92
+ if (message.includes("retry after")) {
93
+ return true;
94
+ }
95
+
96
+ // Network errors
97
+ for (const pattern of NETWORK_ERROR_PATTERNS) {
98
+ if (message.includes(pattern)) {
99
+ return true;
100
+ }
101
+ }
102
+
103
+ return false;
104
+ }
105
+
106
+ /**
107
+ * Parse retry-after delay from error message.
108
+ */
109
+ function parseRetryAfter(error: unknown): number | undefined {
110
+ if (error instanceof TelegramApiError && error.retryAfter) {
111
+ return error.retryAfter * 1000; // Convert seconds to milliseconds
112
+ }
113
+
114
+ const message = error instanceof Error ? error.message : "";
115
+ const match = message.match(/retry after (\d+)/i);
116
+ if (match) {
117
+ return Number.parseInt(match[1]!, 10) * 1000;
118
+ }
119
+
120
+ return undefined;
121
+ }
122
+
123
+ /**
124
+ * Execute a function with automatic retry on transient failures.
125
+ *
126
+ * Uses exponential backoff with jitter for retry delays.
127
+ */
128
+ export async function withRetry<T>(
129
+ fn: () => Promise<T>,
130
+ options?: RetryOptions,
131
+ ): Promise<T> {
132
+ const opts = { ...DEFAULT_RETRY_OPTIONS, ...options };
133
+ let lastError: Error | undefined;
134
+
135
+ for (let attempt = 1; attempt <= opts.maxRetries; attempt++) {
136
+ try {
137
+ return await fn();
138
+ } catch (error) {
139
+ lastError = error instanceof Error ? error : new Error(String(error));
140
+
141
+ // Don't retry non-transient errors
142
+ if (!isTransientError(error)) {
143
+ throw lastError;
144
+ }
145
+
146
+ // Don't retry after max attempts
147
+ if (attempt >= opts.maxRetries) {
148
+ break;
149
+ }
150
+
151
+ // Calculate delay with exponential backoff and jitter
152
+ const exponentialDelay = opts.baseDelay * 2 ** (attempt - 1);
153
+ const jitter = Math.random() * 0.1 * exponentialDelay;
154
+ const calculatedDelay = exponentialDelay + jitter;
155
+
156
+ // Use retry-after from server if specified and larger than calculated delay,
157
+ // but only if using default baseDelay (for testing with small delays)
158
+ const retryAfter = parseRetryAfter(error);
159
+ const useRetryAfter =
160
+ retryAfter &&
161
+ retryAfter > calculatedDelay &&
162
+ opts.baseDelay === DEFAULT_RETRY_OPTIONS.baseDelay;
163
+ const delay = Math.min(
164
+ useRetryAfter ? retryAfter : calculatedDelay,
165
+ opts.maxDelay,
166
+ );
167
+
168
+ console.debug(
169
+ `Telegram API retry attempt ${attempt}/${opts.maxRetries}, waiting ${Math.round(delay)}ms`,
170
+ );
171
+
172
+ await Bun.sleep(delay);
173
+ }
174
+ }
175
+
176
+ throw lastError!;
177
+ }
178
+
179
+ /**
180
+ * Safely execute a Telegram API call, logging errors but not throwing.
181
+ *
182
+ * Use this for non-critical operations where failure is acceptable.
183
+ */
184
+ export async function safeTelegramCall<T>(
185
+ operation: string,
186
+ fn: () => Promise<T>,
187
+ options?: { fallback?: T; retry?: RetryOptions },
188
+ ): Promise<T | undefined> {
189
+ try {
190
+ return await withRetry(fn, options?.retry);
191
+ } catch (error) {
192
+ console.debug(`Telegram API ${operation} failed:`, error);
193
+ return options?.fallback;
194
+ }
195
+ }