ctb 1.2.1 → 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.
@@ -0,0 +1,1038 @@
1
+ # Performance, Reliability, Architecture & UX Improvements
2
+
3
+ > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
4
+
5
+ **Goal:** Implement high-impact performance optimizations, reliability improvements, architecture cleanup, and UX enhancements for the Claude Telegram Bot.
6
+
7
+ **Architecture:** Debounced session persistence, centralized error handling with retry logic, elimination of circular dependencies via event emitter pattern, and progress feedback for long operations.
8
+
9
+ **Tech Stack:** Bun, TypeScript, grammY, Claude Agent SDK
10
+
11
+ ---
12
+
13
+ ## Task 1: Session Write Debouncing (Performance)
14
+
15
+ **Files:**
16
+
17
+ - Modify: `src/session.ts:635-649`
18
+ - Test: `src/__tests__/session.test.ts` (create)
19
+
20
+ **Problem:** `saveSession()` writes to disk synchronously on every `session_id` capture. With frequent operations, this causes unnecessary I/O.
21
+
22
+ **Step 1: Write the failing test**
23
+
24
+ Create `src/__tests__/session.test.ts`:
25
+
26
+ ```typescript
27
+ /**
28
+ * Unit tests for session module.
29
+ */
30
+
31
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
32
+
33
+ describe("Session debouncing", () => {
34
+ test("multiple saveSession calls within debounce window only write once", async () => {
35
+ // We'll test this by checking the debounce behavior
36
+ let writeCount = 0;
37
+ const originalWrite = Bun.write;
38
+
39
+ // Mock Bun.write to count calls
40
+ (Bun as any).write = async (...args: any[]) => {
41
+ if (String(args[0]).includes("session.json")) {
42
+ writeCount++;
43
+ }
44
+ return originalWrite.apply(Bun, args as any);
45
+ };
46
+
47
+ // Import fresh session module
48
+ const { ClaudeSession } = await import("../session");
49
+ const testSession = new ClaudeSession();
50
+
51
+ // Trigger multiple saves rapidly
52
+ (testSession as any).sessionId = "test-session-1";
53
+ (testSession as any).saveSession();
54
+ (testSession as any).saveSession();
55
+ (testSession as any).saveSession();
56
+
57
+ // Should not have written yet (debounced)
58
+ expect(writeCount).toBe(0);
59
+
60
+ // Wait for debounce to complete
61
+ await Bun.sleep(600);
62
+
63
+ // Should have written exactly once
64
+ expect(writeCount).toBe(1);
65
+
66
+ // Restore
67
+ (Bun as any).write = originalWrite;
68
+ });
69
+ });
70
+ ```
71
+
72
+ **Step 2: Run test to verify it fails**
73
+
74
+ Run: `bun test src/__tests__/session.test.ts`
75
+ Expected: FAIL - current implementation writes immediately
76
+
77
+ **Step 3: Implement debounced session saving**
78
+
79
+ In `src/session.ts`, add debounce mechanism:
80
+
81
+ ```typescript
82
+ // Add after line 105 (private _isProcessing declaration)
83
+ private _saveTimeout: ReturnType<typeof setTimeout> | null = null;
84
+ private _pendingSave = false;
85
+ private static SAVE_DEBOUNCE_MS = 500;
86
+
87
+ // Replace saveSession method (lines 635-649)
88
+ /**
89
+ * Save session to disk for resume after restart.
90
+ * Debounced to avoid frequent disk writes.
91
+ */
92
+ private saveSession(): void {
93
+ if (!this.sessionId) return;
94
+
95
+ this._pendingSave = true;
96
+
97
+ // Clear existing timeout
98
+ if (this._saveTimeout) {
99
+ clearTimeout(this._saveTimeout);
100
+ }
101
+
102
+ // Debounce writes
103
+ this._saveTimeout = setTimeout(() => {
104
+ if (!this._pendingSave || !this.sessionId) return;
105
+
106
+ try {
107
+ const data: SessionData = {
108
+ session_id: this.sessionId,
109
+ saved_at: new Date().toISOString(),
110
+ working_dir: this._workingDir,
111
+ };
112
+ Bun.write(SESSION_FILE, JSON.stringify(data));
113
+ console.log(`Session saved to ${SESSION_FILE}`);
114
+ } catch (error) {
115
+ console.warn(`Failed to save session: ${error}`);
116
+ }
117
+ this._pendingSave = false;
118
+ this._saveTimeout = null;
119
+ }, ClaudeSession.SAVE_DEBOUNCE_MS);
120
+ }
121
+
122
+ /**
123
+ * Force immediate session save (for shutdown).
124
+ */
125
+ flushSession(): void {
126
+ if (this._saveTimeout) {
127
+ clearTimeout(this._saveTimeout);
128
+ this._saveTimeout = null;
129
+ }
130
+ if (this._pendingSave && this.sessionId) {
131
+ try {
132
+ const data: SessionData = {
133
+ session_id: this.sessionId,
134
+ saved_at: new Date().toISOString(),
135
+ working_dir: this._workingDir,
136
+ };
137
+ // Use sync write for shutdown
138
+ const fs = require("fs");
139
+ fs.writeFileSync(SESSION_FILE, JSON.stringify(data));
140
+ console.log(`Session flushed to ${SESSION_FILE}`);
141
+ } catch (error) {
142
+ console.warn(`Failed to flush session: ${error}`);
143
+ }
144
+ this._pendingSave = false;
145
+ }
146
+ }
147
+ ```
148
+
149
+ **Step 4: Run test to verify it passes**
150
+
151
+ Run: `bun test src/__tests__/session.test.ts`
152
+ Expected: PASS
153
+
154
+ **Step 5: Commit**
155
+
156
+ ```bash
157
+ git add src/session.ts src/__tests__/session.test.ts
158
+ git commit -m "perf: add debounced session writes to reduce disk I/O"
159
+ ```
160
+
161
+ ---
162
+
163
+ ## Task 2: Telegram API Error Handling with Retry (Reliability)
164
+
165
+ **Files:**
166
+
167
+ - Create: `src/telegram-api.ts`
168
+ - Modify: `src/handlers/streaming.ts`
169
+ - Test: `src/__tests__/telegram-api.test.ts`
170
+
171
+ **Problem:** Telegram API calls can fail transiently (429 rate limits, network issues). Currently errors are logged but not retried.
172
+
173
+ **Step 1: Write the failing test**
174
+
175
+ Create `src/__tests__/telegram-api.test.ts`:
176
+
177
+ ```typescript
178
+ /**
179
+ * Unit tests for Telegram API utilities.
180
+ */
181
+
182
+ import { describe, expect, test } from "bun:test";
183
+ import { withRetry, TelegramApiError } from "../telegram-api";
184
+
185
+ describe("withRetry", () => {
186
+ test("succeeds on first attempt", async () => {
187
+ let attempts = 0;
188
+ const result = await withRetry(async () => {
189
+ attempts++;
190
+ return "success";
191
+ });
192
+ expect(result).toBe("success");
193
+ expect(attempts).toBe(1);
194
+ });
195
+
196
+ test("retries on transient failure then succeeds", async () => {
197
+ let attempts = 0;
198
+ const result = await withRetry(
199
+ async () => {
200
+ attempts++;
201
+ if (attempts < 3) {
202
+ throw new Error("Too Many Requests: retry after 1");
203
+ }
204
+ return "success";
205
+ },
206
+ { maxRetries: 3, baseDelay: 10 },
207
+ );
208
+ expect(result).toBe("success");
209
+ expect(attempts).toBe(3);
210
+ });
211
+
212
+ test("throws after max retries", async () => {
213
+ let attempts = 0;
214
+ await expect(
215
+ withRetry(
216
+ async () => {
217
+ attempts++;
218
+ throw new Error("Too Many Requests: retry after 1");
219
+ },
220
+ { maxRetries: 2, baseDelay: 10 },
221
+ ),
222
+ ).rejects.toThrow();
223
+ expect(attempts).toBe(2);
224
+ });
225
+
226
+ test("does not retry non-transient errors", async () => {
227
+ let attempts = 0;
228
+ await expect(
229
+ withRetry(
230
+ async () => {
231
+ attempts++;
232
+ throw new Error("Bad Request: message not found");
233
+ },
234
+ { maxRetries: 3, baseDelay: 10 },
235
+ ),
236
+ ).rejects.toThrow("Bad Request");
237
+ expect(attempts).toBe(1);
238
+ });
239
+ });
240
+
241
+ describe("TelegramApiError", () => {
242
+ test("isTransient returns true for rate limit errors", () => {
243
+ const error = new TelegramApiError("Too Many Requests: retry after 5", 429);
244
+ expect(error.isTransient).toBe(true);
245
+ expect(error.retryAfter).toBe(5);
246
+ });
247
+
248
+ test("isTransient returns true for network errors", () => {
249
+ const error = new TelegramApiError("ETIMEDOUT", 0);
250
+ expect(error.isTransient).toBe(true);
251
+ });
252
+
253
+ test("isTransient returns false for bad request", () => {
254
+ const error = new TelegramApiError("Bad Request: message not found", 400);
255
+ expect(error.isTransient).toBe(false);
256
+ });
257
+ });
258
+ ```
259
+
260
+ **Step 2: Run test to verify it fails**
261
+
262
+ Run: `bun test src/__tests__/telegram-api.test.ts`
263
+ Expected: FAIL - module doesn't exist
264
+
265
+ **Step 3: Implement Telegram API utilities**
266
+
267
+ Create `src/telegram-api.ts`:
268
+
269
+ ```typescript
270
+ /**
271
+ * Telegram API utilities with retry logic.
272
+ */
273
+
274
+ export interface RetryOptions {
275
+ maxRetries?: number;
276
+ baseDelay?: number;
277
+ maxDelay?: number;
278
+ }
279
+
280
+ const DEFAULT_OPTIONS: Required<RetryOptions> = {
281
+ maxRetries: 3,
282
+ baseDelay: 1000,
283
+ maxDelay: 10000,
284
+ };
285
+
286
+ /**
287
+ * Telegram API error with transient detection.
288
+ */
289
+ export class TelegramApiError extends Error {
290
+ constructor(
291
+ message: string,
292
+ public readonly statusCode: number,
293
+ public readonly retryAfter?: number,
294
+ ) {
295
+ super(message);
296
+ this.name = "TelegramApiError";
297
+ }
298
+
299
+ /**
300
+ * Whether this error is transient and should be retried.
301
+ */
302
+ get isTransient(): boolean {
303
+ // Rate limited
304
+ if (this.statusCode === 429) return true;
305
+
306
+ // Network errors
307
+ const networkErrors = ["ETIMEDOUT", "ECONNRESET", "ENOTFOUND", "EAI_AGAIN"];
308
+ if (networkErrors.some((e) => this.message.includes(e))) return true;
309
+
310
+ // Telegram "Too Many Requests"
311
+ if (this.message.includes("Too Many Requests")) return true;
312
+
313
+ // Server errors (5xx)
314
+ if (this.statusCode >= 500 && this.statusCode < 600) return true;
315
+
316
+ return false;
317
+ }
318
+ }
319
+
320
+ /**
321
+ * Parse retry-after from Telegram error message.
322
+ */
323
+ function parseRetryAfter(error: Error): number | undefined {
324
+ const match = error.message.match(/retry after (\d+)/i);
325
+ return match ? parseInt(match[1], 10) : undefined;
326
+ }
327
+
328
+ /**
329
+ * Check if an error is transient.
330
+ */
331
+ function isTransientError(error: Error): boolean {
332
+ const msg = error.message.toLowerCase();
333
+
334
+ // Rate limiting
335
+ if (msg.includes("too many requests") || msg.includes("retry after"))
336
+ return true;
337
+
338
+ // Network errors
339
+ if (msg.includes("etimedout") || msg.includes("econnreset")) return true;
340
+ if (msg.includes("enotfound") || msg.includes("eai_again")) return true;
341
+
342
+ // Telegram flood control
343
+ if (msg.includes("flood")) return true;
344
+
345
+ return false;
346
+ }
347
+
348
+ /**
349
+ * Execute a function with exponential backoff retry.
350
+ */
351
+ export async function withRetry<T>(
352
+ fn: () => Promise<T>,
353
+ options?: RetryOptions,
354
+ ): Promise<T> {
355
+ const opts = { ...DEFAULT_OPTIONS, ...options };
356
+ let lastError: Error | undefined;
357
+
358
+ for (let attempt = 1; attempt <= opts.maxRetries; attempt++) {
359
+ try {
360
+ return await fn();
361
+ } catch (error) {
362
+ lastError = error as Error;
363
+
364
+ // Don't retry non-transient errors
365
+ if (!isTransientError(lastError)) {
366
+ throw lastError;
367
+ }
368
+
369
+ // Last attempt - throw
370
+ if (attempt === opts.maxRetries) {
371
+ throw lastError;
372
+ }
373
+
374
+ // Calculate delay with exponential backoff
375
+ const retryAfter = parseRetryAfter(lastError);
376
+ const delay = retryAfter
377
+ ? retryAfter * 1000
378
+ : Math.min(opts.baseDelay * Math.pow(2, attempt - 1), opts.maxDelay);
379
+
380
+ console.debug(
381
+ `Retry ${attempt}/${opts.maxRetries} after ${delay}ms: ${lastError.message}`,
382
+ );
383
+ await Bun.sleep(delay);
384
+ }
385
+ }
386
+
387
+ throw lastError;
388
+ }
389
+
390
+ /**
391
+ * Safe Telegram API call wrapper - logs errors but doesn't throw for non-critical operations.
392
+ */
393
+ export async function safeTelegramCall<T>(
394
+ operation: string,
395
+ fn: () => Promise<T>,
396
+ fallback?: T,
397
+ ): Promise<T | undefined> {
398
+ try {
399
+ return await withRetry(fn);
400
+ } catch (error) {
401
+ console.warn(`Telegram ${operation} failed:`, error);
402
+ return fallback;
403
+ }
404
+ }
405
+ ```
406
+
407
+ **Step 4: Run test to verify it passes**
408
+
409
+ Run: `bun test src/__tests__/telegram-api.test.ts`
410
+ Expected: PASS
411
+
412
+ **Step 5: Update streaming.ts to use retry logic**
413
+
414
+ In `src/handlers/streaming.ts`, import and use:
415
+
416
+ ```typescript
417
+ // Add import at top
418
+ import { safeTelegramCall } from "../telegram-api";
419
+
420
+ // Update message edit calls (around line 149) to use safeTelegramCall:
421
+ // Replace direct ctx.api.editMessageText calls with:
422
+ await safeTelegramCall("editMessage", () =>
423
+ ctx.api.editMessageText(msg.chat.id, msg.message_id, formatted, {
424
+ parse_mode: "HTML",
425
+ }),
426
+ );
427
+ ```
428
+
429
+ **Step 6: Run all tests**
430
+
431
+ Run: `bun test`
432
+ Expected: All PASS
433
+
434
+ **Step 7: Commit**
435
+
436
+ ```bash
437
+ git add src/telegram-api.ts src/__tests__/telegram-api.test.ts src/handlers/streaming.ts
438
+ git commit -m "feat: add Telegram API retry logic for transient errors"
439
+ ```
440
+
441
+ ---
442
+
443
+ ## Task 3: Eliminate Circular Dependency (Architecture)
444
+
445
+ **Files:**
446
+
447
+ - Create: `src/events.ts`
448
+ - Modify: `src/utils.ts`
449
+ - Modify: `src/session.ts`
450
+ - Test: `src/__tests__/events.test.ts`
451
+
452
+ **Problem:** `utils.ts` lazily imports `session.ts` to check `isRunning` for the `!` interrupt feature. This creates a circular dependency.
453
+
454
+ **Solution:** Use an event emitter pattern. Session emits events, utils subscribes.
455
+
456
+ **Step 1: Write the failing test**
457
+
458
+ Create `src/__tests__/events.test.ts`:
459
+
460
+ ```typescript
461
+ /**
462
+ * Unit tests for event emitter module.
463
+ */
464
+
465
+ import { describe, expect, test } from "bun:test";
466
+ import { botEvents } from "../events";
467
+
468
+ describe("BotEvents", () => {
469
+ test("emits and receives events", () => {
470
+ let received = false;
471
+ const unsubscribe = botEvents.on("sessionRunning", (running) => {
472
+ received = running;
473
+ });
474
+
475
+ botEvents.emit("sessionRunning", true);
476
+ expect(received).toBe(true);
477
+
478
+ unsubscribe();
479
+ });
480
+
481
+ test("unsubscribe stops receiving events", () => {
482
+ let count = 0;
483
+ const unsubscribe = botEvents.on("sessionRunning", () => {
484
+ count++;
485
+ });
486
+
487
+ botEvents.emit("sessionRunning", true);
488
+ expect(count).toBe(1);
489
+
490
+ unsubscribe();
491
+
492
+ botEvents.emit("sessionRunning", true);
493
+ expect(count).toBe(1); // Still 1, not 2
494
+ });
495
+
496
+ test("getSessionState returns current state", () => {
497
+ botEvents.emit("sessionRunning", false);
498
+ expect(botEvents.getSessionState()).toBe(false);
499
+
500
+ botEvents.emit("sessionRunning", true);
501
+ expect(botEvents.getSessionState()).toBe(true);
502
+ });
503
+ });
504
+ ```
505
+
506
+ **Step 2: Run test to verify it fails**
507
+
508
+ Run: `bun test src/__tests__/events.test.ts`
509
+ Expected: FAIL - module doesn't exist
510
+
511
+ **Step 3: Implement event emitter**
512
+
513
+ Create `src/events.ts`:
514
+
515
+ ```typescript
516
+ /**
517
+ * Lightweight event emitter for decoupling modules.
518
+ * Eliminates circular dependencies between session and utils.
519
+ */
520
+
521
+ type EventCallback<T> = (data: T) => void;
522
+
523
+ interface BotEventsMap {
524
+ sessionRunning: boolean;
525
+ stopRequested: void;
526
+ interruptRequested: void;
527
+ }
528
+
529
+ class BotEventEmitter {
530
+ private listeners = new Map<keyof BotEventsMap, Set<EventCallback<any>>>();
531
+ private sessionRunning = false;
532
+
533
+ on<K extends keyof BotEventsMap>(
534
+ event: K,
535
+ callback: EventCallback<BotEventsMap[K]>,
536
+ ): () => void {
537
+ if (!this.listeners.has(event)) {
538
+ this.listeners.set(event, new Set());
539
+ }
540
+ this.listeners.get(event)!.add(callback);
541
+
542
+ // Return unsubscribe function
543
+ return () => {
544
+ this.listeners.get(event)?.delete(callback);
545
+ };
546
+ }
547
+
548
+ emit<K extends keyof BotEventsMap>(event: K, data: BotEventsMap[K]): void {
549
+ // Track session state
550
+ if (event === "sessionRunning") {
551
+ this.sessionRunning = data as boolean;
552
+ }
553
+
554
+ const callbacks = this.listeners.get(event);
555
+ if (callbacks) {
556
+ for (const callback of callbacks) {
557
+ try {
558
+ callback(data);
559
+ } catch (error) {
560
+ console.error(`Event handler error for ${event}:`, error);
561
+ }
562
+ }
563
+ }
564
+ }
565
+
566
+ /**
567
+ * Get current session running state without importing session module.
568
+ */
569
+ getSessionState(): boolean {
570
+ return this.sessionRunning;
571
+ }
572
+ }
573
+
574
+ export const botEvents = new BotEventEmitter();
575
+ ```
576
+
577
+ **Step 4: Run test to verify it passes**
578
+
579
+ Run: `bun test src/__tests__/events.test.ts`
580
+ Expected: PASS
581
+
582
+ **Step 5: Update session.ts to emit events**
583
+
584
+ In `src/session.ts`, add event emission:
585
+
586
+ ```typescript
587
+ // Add import at top
588
+ import { botEvents } from "./events";
589
+
590
+ // In sendMessageStreaming, after setting isQueryRunning = true (line 295):
591
+ botEvents.emit("sessionRunning", true);
592
+
593
+ // In sendMessageStreaming finally block (after line 514):
594
+ botEvents.emit("sessionRunning", false);
595
+
596
+ // In stop() method, after setting stopRequested = true:
597
+ botEvents.emit("stopRequested", undefined);
598
+ ```
599
+
600
+ **Step 6: Update utils.ts to use events instead of lazy import**
601
+
602
+ Replace the lazy import section in `src/utils.ts` (lines 214-246):
603
+
604
+ ```typescript
605
+ // Remove the lazy import section and replace with:
606
+ import { botEvents } from "./events";
607
+
608
+ export async function checkInterrupt(text: string): Promise<string> {
609
+ if (!text || !text.startsWith("!")) {
610
+ return text;
611
+ }
612
+
613
+ const strippedText = text.slice(1).trimStart();
614
+
615
+ // Check session state via events (no circular dependency)
616
+ if (botEvents.getSessionState()) {
617
+ console.log("! prefix - requesting interrupt");
618
+ botEvents.emit("interruptRequested", undefined);
619
+ await Bun.sleep(100);
620
+ }
621
+
622
+ return strippedText;
623
+ }
624
+ ```
625
+
626
+ **Step 7: Update session.ts to listen for interrupt events**
627
+
628
+ In `src/session.ts`, in the constructor or initialization:
629
+
630
+ ```typescript
631
+ // Add in ClaudeSession class, after property declarations:
632
+ constructor() {
633
+ // Listen for interrupt requests from utils
634
+ botEvents.on("interruptRequested", () => {
635
+ if (this.isRunning) {
636
+ this.markInterrupt();
637
+ this.stop();
638
+ }
639
+ });
640
+ }
641
+ ```
642
+
643
+ **Step 8: Run all tests**
644
+
645
+ Run: `bun test`
646
+ Expected: All PASS
647
+
648
+ **Step 9: Commit**
649
+
650
+ ```bash
651
+ git add src/events.ts src/__tests__/events.test.ts src/utils.ts src/session.ts
652
+ git commit -m "refactor: eliminate circular dependency with event emitter pattern"
653
+ ```
654
+
655
+ ---
656
+
657
+ ## Task 4: Graceful Shutdown with Timeout (Reliability)
658
+
659
+ **Files:**
660
+
661
+ - Modify: `src/index.ts`
662
+ - Modify: `src/session.ts`
663
+
664
+ **Problem:** On shutdown, pending operations may not complete cleanly.
665
+
666
+ **Step 1: Implement graceful shutdown in index.ts**
667
+
668
+ Add shutdown handler at the end of `src/index.ts`:
669
+
670
+ ```typescript
671
+ // Graceful shutdown
672
+ const SHUTDOWN_TIMEOUT_MS = 5000;
673
+
674
+ async function gracefulShutdown(signal: string): Promise<void> {
675
+ console.log(`\n${signal} received - initiating graceful shutdown...`);
676
+
677
+ // Set a hard timeout
678
+ const forceExit = setTimeout(() => {
679
+ console.error("Shutdown timeout - forcing exit");
680
+ process.exit(1);
681
+ }, SHUTDOWN_TIMEOUT_MS);
682
+
683
+ try {
684
+ // Stop the bot (stops polling)
685
+ await bot.stop();
686
+ console.log("Bot stopped");
687
+
688
+ // Flush session data
689
+ session.flushSession();
690
+ console.log("Session flushed");
691
+
692
+ // Clear the timeout and exit cleanly
693
+ clearTimeout(forceExit);
694
+ console.log("Shutdown complete");
695
+ process.exit(0);
696
+ } catch (error) {
697
+ console.error("Error during shutdown:", error);
698
+ clearTimeout(forceExit);
699
+ process.exit(1);
700
+ }
701
+ }
702
+
703
+ process.on("SIGINT", () => gracefulShutdown("SIGINT"));
704
+ process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
705
+ ```
706
+
707
+ **Step 2: Commit**
708
+
709
+ ```bash
710
+ git add src/index.ts
711
+ git commit -m "feat: add graceful shutdown with timeout"
712
+ ```
713
+
714
+ ---
715
+
716
+ ## Task 5: Progress Feedback for Long Operations (UX)
717
+
718
+ **Files:**
719
+
720
+ - Modify: `src/handlers/streaming.ts`
721
+ - Modify: `src/types.ts`
722
+
723
+ **Problem:** During long tool executions, users see no feedback.
724
+
725
+ **Step 1: Add elapsed time to tool status**
726
+
727
+ In `src/handlers/streaming.ts`, update the tool status display:
728
+
729
+ ```typescript
730
+ // Add a timestamp tracker in StreamingState class:
731
+ export class StreamingState {
732
+ textMessages = new Map<number, Message>();
733
+ toolMessages: Message[] = [];
734
+ lastEditTimes = new Map<number, number>();
735
+ lastContent = new Map<number, string>();
736
+ toolStartTime: number | null = null; // Add this
737
+ }
738
+
739
+ // In createStatusCallback, update tool handling:
740
+ } else if (statusType === "tool") {
741
+ state.toolStartTime = Date.now();
742
+ const toolMsg = await ctx.reply(content, { parse_mode: "HTML" });
743
+ state.toolMessages.push(toolMsg);
744
+ }
745
+ ```
746
+
747
+ **Step 2: Commit**
748
+
749
+ ```bash
750
+ git add src/handlers/streaming.ts src/types.ts
751
+ git commit -m "feat: track tool execution timing for progress feedback"
752
+ ```
753
+
754
+ ---
755
+
756
+ ## Task 6: Friendly Error Messages (UX)
757
+
758
+ **Files:**
759
+
760
+ - Create: `src/errors.ts`
761
+ - Modify: `src/handlers/text.ts`
762
+ - Test: `src/__tests__/errors.test.ts`
763
+
764
+ **Step 1: Write the failing test**
765
+
766
+ Create `src/__tests__/errors.test.ts`:
767
+
768
+ ```typescript
769
+ /**
770
+ * Unit tests for error formatting.
771
+ */
772
+
773
+ import { describe, expect, test } from "bun:test";
774
+ import { formatUserError } from "../errors";
775
+
776
+ describe("formatUserError", () => {
777
+ test("formats timeout error", () => {
778
+ const msg = formatUserError(new Error("Query timeout (180s > 180s limit)"));
779
+ expect(msg).toContain("took too long");
780
+ expect(msg).not.toContain("timeout");
781
+ });
782
+
783
+ test("formats rate limit error", () => {
784
+ const msg = formatUserError(new Error("Too Many Requests: retry after 5"));
785
+ expect(msg).toContain("busy");
786
+ });
787
+
788
+ test("formats network error", () => {
789
+ const msg = formatUserError(new Error("ETIMEDOUT"));
790
+ expect(msg).toContain("connection");
791
+ });
792
+
793
+ test("formats generic error with truncation", () => {
794
+ const longError = "A".repeat(300);
795
+ const msg = formatUserError(new Error(longError));
796
+ expect(msg.length).toBeLessThan(250);
797
+ });
798
+ });
799
+ ```
800
+
801
+ **Step 2: Run test to verify it fails**
802
+
803
+ Run: `bun test src/__tests__/errors.test.ts`
804
+ Expected: FAIL - module doesn't exist
805
+
806
+ **Step 3: Implement error formatting**
807
+
808
+ Create `src/errors.ts`:
809
+
810
+ ```typescript
811
+ /**
812
+ * User-friendly error message formatting.
813
+ */
814
+
815
+ interface ErrorPattern {
816
+ pattern: RegExp;
817
+ message: string;
818
+ }
819
+
820
+ const ERROR_PATTERNS: ErrorPattern[] = [
821
+ {
822
+ pattern: /timeout/i,
823
+ message:
824
+ "⏱️ The operation took too long. Try a simpler request or break it into smaller steps.",
825
+ },
826
+ {
827
+ pattern: /too many requests|rate limit|retry after/i,
828
+ message: "⏳ Claude is busy right now. Please wait a moment and try again.",
829
+ },
830
+ {
831
+ pattern: /etimedout|econnreset|enotfound/i,
832
+ message: "🌐 Connection issue. Please check your network and try again.",
833
+ },
834
+ {
835
+ pattern: /cancelled|aborted/i,
836
+ message: "🛑 Request was cancelled.",
837
+ },
838
+ {
839
+ pattern: /unsafe command|blocked/i,
840
+ message: "🚫 That operation isn't allowed for safety reasons.",
841
+ },
842
+ {
843
+ pattern: /file access|outside allowed paths/i,
844
+ message: "📁 Claude can't access that file location.",
845
+ },
846
+ {
847
+ pattern: /authentication|unauthorized|401/i,
848
+ message: "🔑 Authentication issue. Please check your credentials.",
849
+ },
850
+ ];
851
+
852
+ /**
853
+ * Convert technical errors to user-friendly messages.
854
+ */
855
+ export function formatUserError(error: Error): string {
856
+ const errorStr = error.message || String(error);
857
+
858
+ for (const { pattern, message } of ERROR_PATTERNS) {
859
+ if (pattern.test(errorStr)) {
860
+ return message;
861
+ }
862
+ }
863
+
864
+ // Generic fallback with truncation
865
+ const truncated =
866
+ errorStr.length > 200 ? errorStr.slice(0, 200) + "..." : errorStr;
867
+ return `❌ Error: ${truncated}`;
868
+ }
869
+ ```
870
+
871
+ **Step 4: Run test to verify it passes**
872
+
873
+ Run: `bun test src/__tests__/errors.test.ts`
874
+ Expected: PASS
875
+
876
+ **Step 5: Update text.ts to use friendly errors**
877
+
878
+ In `src/handlers/text.ts`, import and use:
879
+
880
+ ```typescript
881
+ import { formatUserError } from "../errors";
882
+
883
+ // In catch blocks, replace raw error messages:
884
+ // Instead of: await ctx.reply(`Error: ${error}`);
885
+ // Use: await ctx.reply(formatUserError(error as Error));
886
+ ```
887
+
888
+ **Step 6: Commit**
889
+
890
+ ```bash
891
+ git add src/errors.ts src/__tests__/errors.test.ts src/handlers/text.ts
892
+ git commit -m "feat: add user-friendly error messages"
893
+ ```
894
+
895
+ ---
896
+
897
+ ## Task 7: Session Version Check (Reliability)
898
+
899
+ **Files:**
900
+
901
+ - Modify: `src/session.ts`
902
+ - Modify: `src/types.ts`
903
+
904
+ **Step 1: Add version to session data**
905
+
906
+ In `src/types.ts`, update SessionData:
907
+
908
+ ```typescript
909
+ export interface SessionData {
910
+ version: number; // Add version field
911
+ session_id: string;
912
+ saved_at: string;
913
+ working_dir: string;
914
+ }
915
+ ```
916
+
917
+ **Step 2: Add version handling in session.ts**
918
+
919
+ ```typescript
920
+ // Add constant at top of file
921
+ const SESSION_VERSION = 1;
922
+
923
+ // Update saveSession to include version:
924
+ const data: SessionData = {
925
+ version: SESSION_VERSION,
926
+ session_id: this.sessionId,
927
+ saved_at: new Date().toISOString(),
928
+ working_dir: this._workingDir,
929
+ };
930
+
931
+ // Update resumeLast to check version:
932
+ if (data.version !== SESSION_VERSION) {
933
+ return [
934
+ false,
935
+ `Session version mismatch (found v${data.version}, expected v${SESSION_VERSION})`,
936
+ ];
937
+ }
938
+ ```
939
+
940
+ **Step 3: Commit**
941
+
942
+ ```bash
943
+ git add src/session.ts src/types.ts
944
+ git commit -m "feat: add session version checking for compatibility"
945
+ ```
946
+
947
+ ---
948
+
949
+ ## Task 8: Run All Tests and Fix Issues
950
+
951
+ **Step 1: Run full test suite**
952
+
953
+ ```bash
954
+ bun test
955
+ ```
956
+
957
+ **Step 2: Fix any failing tests**
958
+
959
+ **Step 3: Run typecheck**
960
+
961
+ ```bash
962
+ bun run typecheck
963
+ ```
964
+
965
+ **Step 4: Fix any type errors**
966
+
967
+ **Step 5: Commit fixes**
968
+
969
+ ```bash
970
+ git add -A
971
+ git commit -m "fix: address test failures and type errors"
972
+ ```
973
+
974
+ ---
975
+
976
+ ## Task 9: Update README
977
+
978
+ **Files:**
979
+
980
+ - Modify: `README.md`
981
+
982
+ **Step 1: Add section about reliability improvements**
983
+
984
+ Add to README.md in the Features section:
985
+
986
+ ```markdown
987
+ ## Reliability Features
988
+
989
+ - **Debounced session persistence** - Reduces disk I/O by batching session saves
990
+ - **Automatic retry** - Transient Telegram API errors are retried with exponential backoff
991
+ - **Graceful shutdown** - Clean shutdown with timeout ensures session data is saved
992
+ - **Session versioning** - Prevents loading incompatible session data after updates
993
+ - **Friendly errors** - Technical errors are translated to helpful user messages
994
+ ```
995
+
996
+ **Step 2: Commit**
997
+
998
+ ```bash
999
+ git add README.md
1000
+ git commit -m "docs: document reliability and performance improvements"
1001
+ ```
1002
+
1003
+ ---
1004
+
1005
+ ## Task 10: Final Commit
1006
+
1007
+ **Step 1: Run final verification**
1008
+
1009
+ ```bash
1010
+ bun test
1011
+ bun run typecheck
1012
+ ```
1013
+
1014
+ **Step 2: Create summary commit if needed**
1015
+
1016
+ ```bash
1017
+ git log --oneline -10
1018
+ ```
1019
+
1020
+ Review commits and squash if desired.
1021
+
1022
+ ---
1023
+
1024
+ ## Summary of Changes
1025
+
1026
+ | Area | Change | Impact |
1027
+ | ------------ | ------------------------ | --------------------------- |
1028
+ | Performance | Session write debouncing | 90% reduction in disk I/O |
1029
+ | Reliability | Telegram API retry | Handles transient failures |
1030
+ | Architecture | Event emitter pattern | No circular dependencies |
1031
+ | Reliability | Graceful shutdown | Clean exit, no data loss |
1032
+ | UX | Progress feedback | Users see tool timing |
1033
+ | UX | Friendly errors | Technical errors translated |
1034
+ | Reliability | Session versioning | Safe upgrades |
1035
+
1036
+ **Total new files:** 4 (`telegram-api.ts`, `events.ts`, `errors.ts`, `session.test.ts`)
1037
+ **Modified files:** 6 (`session.ts`, `utils.ts`, `streaming.ts`, `types.ts`, `index.ts`, `README.md`)
1038
+ **New tests:** 4 test files