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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ctb",
3
- "version": "1.2.1",
3
+ "version": "1.3.0",
4
4
  "description": "Control Claude Code from Telegram - run multiple bot instances per project",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Unit tests for error formatting.
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import { formatUserError } from "../errors";
7
+
8
+ describe("formatUserError", () => {
9
+ test("formats timeout error", () => {
10
+ const msg = formatUserError(new Error("Query timeout (180s > 180s limit)"));
11
+ expect(msg).toContain("took too long");
12
+ expect(msg).not.toContain("timeout");
13
+ });
14
+
15
+ test("formats rate limit error", () => {
16
+ const msg = formatUserError(new Error("Too Many Requests: retry after 5"));
17
+ expect(msg).toContain("busy");
18
+ });
19
+
20
+ test("formats network error", () => {
21
+ const msg = formatUserError(new Error("ETIMEDOUT"));
22
+ expect(msg.toLowerCase()).toContain("connection");
23
+ });
24
+
25
+ test("formats generic error with truncation", () => {
26
+ const longError = "A".repeat(300);
27
+ const msg = formatUserError(new Error(longError));
28
+ expect(msg.length).toBeLessThan(250);
29
+ });
30
+
31
+ test("formats cancelled/aborted error", () => {
32
+ const msg = formatUserError(new Error("Request was cancelled by user"));
33
+ expect(msg).toContain("cancelled");
34
+ });
35
+
36
+ test("formats unsafe command error", () => {
37
+ const msg = formatUserError(new Error("unsafe command detected"));
38
+ expect(msg).toContain("safety");
39
+ });
40
+
41
+ test("formats file access error", () => {
42
+ const msg = formatUserError(new Error("outside allowed paths"));
43
+ expect(msg).toContain("file location");
44
+ });
45
+
46
+ test("formats authentication error", () => {
47
+ const msg = formatUserError(new Error("401 unauthorized"));
48
+ expect(msg).toContain("Authentication");
49
+ });
50
+
51
+ test("formats ECONNRESET error", () => {
52
+ const msg = formatUserError(new Error("ECONNRESET"));
53
+ expect(msg).toContain("Connection");
54
+ });
55
+
56
+ test("handles error with empty message", () => {
57
+ const msg = formatUserError(new Error(""));
58
+ expect(msg).toContain("Error");
59
+ });
60
+ });
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Unit tests for event emitter module.
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import { botEvents } from "../events";
7
+
8
+ describe("BotEvents", () => {
9
+ test("emits and receives events", () => {
10
+ let received = false;
11
+ const unsubscribe = botEvents.on("sessionRunning", (running) => {
12
+ received = running;
13
+ });
14
+
15
+ botEvents.emit("sessionRunning", true);
16
+ expect(received).toBe(true);
17
+
18
+ unsubscribe();
19
+ });
20
+
21
+ test("unsubscribe stops receiving events", () => {
22
+ let count = 0;
23
+ const unsubscribe = botEvents.on("sessionRunning", () => {
24
+ count++;
25
+ });
26
+
27
+ botEvents.emit("sessionRunning", true);
28
+ expect(count).toBe(1);
29
+
30
+ unsubscribe();
31
+
32
+ botEvents.emit("sessionRunning", true);
33
+ expect(count).toBe(1);
34
+ });
35
+
36
+ test("getSessionState returns current state", () => {
37
+ botEvents.emit("sessionRunning", false);
38
+ expect(botEvents.getSessionState()).toBe(false);
39
+
40
+ botEvents.emit("sessionRunning", true);
41
+ expect(botEvents.getSessionState()).toBe(true);
42
+ });
43
+ });
@@ -396,4 +396,77 @@ describe("ClaudeSession", () => {
396
396
  expect(session.canUndo).toBe(false);
397
397
  });
398
398
  });
399
+
400
+ describe("session debouncing", () => {
401
+ test("multiple saveSession calls within debounce window only write once", async () => {
402
+ let writeCount = 0;
403
+ // biome-ignore lint/suspicious/noExplicitAny: test mock requires any
404
+ const originalWrite = (Bun as any).write;
405
+
406
+ // Mock Bun.write to count session.json writes
407
+ // biome-ignore lint/suspicious/noExplicitAny: test mock requires any
408
+ (Bun as any).write = async (...args: unknown[]) => {
409
+ if (String(args[0]).includes("session.json")) {
410
+ writeCount++;
411
+ }
412
+ return originalWrite.apply(Bun, args);
413
+ };
414
+
415
+ try {
416
+ // Set up a session ID to trigger saves
417
+ session.sessionId = "test-session-debounce-1";
418
+
419
+ // Call saveSession multiple times rapidly
420
+ // @ts-expect-error - accessing private for test
421
+ session.saveSession();
422
+ // @ts-expect-error - accessing private for test
423
+ session.saveSession();
424
+ // @ts-expect-error - accessing private for test
425
+ session.saveSession();
426
+
427
+ // Immediately after calls, write should not have happened yet (debounced)
428
+ expect(writeCount).toBe(0);
429
+
430
+ // Wait for debounce timeout (500ms + buffer)
431
+ await Bun.sleep(600);
432
+
433
+ // Now exactly one write should have occurred
434
+ expect(writeCount).toBe(1);
435
+ } finally {
436
+ // Restore original Bun.write
437
+ // biome-ignore lint/suspicious/noExplicitAny: test mock requires any
438
+ (Bun as any).write = originalWrite;
439
+ }
440
+ });
441
+
442
+ test("flushSession writes immediately", async () => {
443
+ let writeCount = 0;
444
+ // biome-ignore lint/suspicious/noExplicitAny: test mock requires any
445
+ const originalWrite = (Bun as any).write;
446
+
447
+ // Mock Bun.write to count session.json writes
448
+ // biome-ignore lint/suspicious/noExplicitAny: test mock requires any
449
+ (Bun as any).write = async (...args: unknown[]) => {
450
+ if (String(args[0]).includes("session.json")) {
451
+ writeCount++;
452
+ }
453
+ return originalWrite.apply(Bun, args);
454
+ };
455
+
456
+ try {
457
+ // Set up a session ID to trigger saves
458
+ session.sessionId = "test-session-flush-1";
459
+
460
+ // Call flushSession for immediate write
461
+ session.flushSession();
462
+
463
+ // Write should happen immediately (synchronously)
464
+ expect(writeCount).toBe(1);
465
+ } finally {
466
+ // Restore original Bun.write
467
+ // biome-ignore lint/suspicious/noExplicitAny: test mock requires any
468
+ (Bun as any).write = originalWrite;
469
+ }
470
+ });
471
+ });
399
472
  });
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Unit tests for Telegram API utilities.
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import { TelegramApiError, withRetry } from "../telegram-api";
7
+
8
+ describe("withRetry", () => {
9
+ test("succeeds on first attempt", async () => {
10
+ let attempts = 0;
11
+ const result = await withRetry(async () => {
12
+ attempts++;
13
+ return "success";
14
+ });
15
+ expect(result).toBe("success");
16
+ expect(attempts).toBe(1);
17
+ });
18
+
19
+ test("retries on transient failure then succeeds", async () => {
20
+ let attempts = 0;
21
+ const result = await withRetry(
22
+ async () => {
23
+ attempts++;
24
+ if (attempts < 3) {
25
+ throw new Error("Too Many Requests: retry after 1");
26
+ }
27
+ return "success";
28
+ },
29
+ { maxRetries: 3, baseDelay: 10 },
30
+ );
31
+ expect(result).toBe("success");
32
+ expect(attempts).toBe(3);
33
+ });
34
+
35
+ test("throws after max retries", async () => {
36
+ let attempts = 0;
37
+ await expect(
38
+ withRetry(
39
+ async () => {
40
+ attempts++;
41
+ throw new Error("Too Many Requests: retry after 1");
42
+ },
43
+ { maxRetries: 2, baseDelay: 10 },
44
+ ),
45
+ ).rejects.toThrow();
46
+ expect(attempts).toBe(2);
47
+ });
48
+
49
+ test("does not retry non-transient errors", async () => {
50
+ let attempts = 0;
51
+ await expect(
52
+ withRetry(
53
+ async () => {
54
+ attempts++;
55
+ throw new Error("Bad Request: message not found");
56
+ },
57
+ { maxRetries: 3, baseDelay: 10 },
58
+ ),
59
+ ).rejects.toThrow("Bad Request");
60
+ expect(attempts).toBe(1);
61
+ });
62
+ });
63
+
64
+ describe("TelegramApiError", () => {
65
+ test("isTransient returns true for rate limit errors", () => {
66
+ const error = new TelegramApiError("Too Many Requests: retry after 5", 429);
67
+ expect(error.isTransient).toBe(true);
68
+ expect(error.retryAfter).toBe(5);
69
+ });
70
+
71
+ test("isTransient returns true for network errors", () => {
72
+ const error = new TelegramApiError("ETIMEDOUT", 0);
73
+ expect(error.isTransient).toBe(true);
74
+ });
75
+
76
+ test("isTransient returns false for bad request", () => {
77
+ const error = new TelegramApiError("Bad Request: message not found", 400);
78
+ expect(error.isTransient).toBe(false);
79
+ });
80
+ });
package/src/errors.ts ADDED
@@ -0,0 +1,58 @@
1
+ /**
2
+ * User-friendly error message formatting.
3
+ */
4
+
5
+ interface ErrorPattern {
6
+ pattern: RegExp;
7
+ message: string;
8
+ }
9
+
10
+ const ERROR_PATTERNS: ErrorPattern[] = [
11
+ {
12
+ pattern: /timeout/i,
13
+ message:
14
+ "The operation took too long. Try a simpler request or break it into smaller steps.",
15
+ },
16
+ {
17
+ pattern: /too many requests|rate limit|retry after/i,
18
+ message: "Claude is busy right now. Please wait a moment and try again.",
19
+ },
20
+ {
21
+ pattern: /etimedout|econnreset|enotfound/i,
22
+ message: "Connection issue. Please check your network and try again.",
23
+ },
24
+ {
25
+ pattern: /cancelled|aborted/i,
26
+ message: "Request was cancelled.",
27
+ },
28
+ {
29
+ pattern: /unsafe command|blocked/i,
30
+ message: "That operation isn't allowed for safety reasons.",
31
+ },
32
+ {
33
+ pattern: /file access|outside allowed paths/i,
34
+ message: "Claude can't access that file location.",
35
+ },
36
+ {
37
+ pattern: /authentication|unauthorized|401/i,
38
+ message: "Authentication issue. Please check your credentials.",
39
+ },
40
+ ];
41
+
42
+ /**
43
+ * Convert technical errors to user-friendly messages.
44
+ */
45
+ export function formatUserError(error: Error): string {
46
+ const errorStr = error.message || String(error);
47
+
48
+ for (const { pattern, message } of ERROR_PATTERNS) {
49
+ if (pattern.test(errorStr)) {
50
+ return message;
51
+ }
52
+ }
53
+
54
+ // Generic fallback with truncation
55
+ const truncated =
56
+ errorStr.length > 200 ? errorStr.slice(0, 200) + "..." : errorStr;
57
+ return `Error: ${truncated || "An unexpected error occurred"}`;
58
+ }
package/src/events.ts ADDED
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Lightweight event emitter for decoupling modules.
3
+ * Eliminates circular dependencies between session and utils.
4
+ */
5
+
6
+ type EventCallback<T> = (data: T) => void;
7
+
8
+ interface BotEventsMap {
9
+ sessionRunning: boolean;
10
+ stopRequested: undefined;
11
+ interruptRequested: undefined;
12
+ }
13
+
14
+ class BotEventEmitter {
15
+ private listeners = new Map<
16
+ keyof BotEventsMap,
17
+ Set<EventCallback<unknown>>
18
+ >();
19
+ private sessionRunning = false;
20
+
21
+ on<K extends keyof BotEventsMap>(
22
+ event: K,
23
+ callback: EventCallback<BotEventsMap[K]>,
24
+ ): () => void {
25
+ if (!this.listeners.has(event)) {
26
+ this.listeners.set(event, new Set());
27
+ }
28
+ this.listeners.get(event)?.add(callback as EventCallback<unknown>);
29
+
30
+ return () => {
31
+ this.listeners.get(event)?.delete(callback as EventCallback<unknown>);
32
+ };
33
+ }
34
+
35
+ emit<K extends keyof BotEventsMap>(event: K, data: BotEventsMap[K]): void {
36
+ if (event === "sessionRunning") {
37
+ this.sessionRunning = data as boolean;
38
+ }
39
+
40
+ const callbacks = this.listeners.get(event);
41
+ if (callbacks) {
42
+ for (const callback of callbacks) {
43
+ try {
44
+ callback(data);
45
+ } catch (error) {
46
+ console.error(`Event handler error for ${event}:`, error);
47
+ }
48
+ }
49
+ }
50
+ }
51
+
52
+ getSessionState(): boolean {
53
+ return this.sessionRunning;
54
+ }
55
+ }
56
+
57
+ export const botEvents = new BotEventEmitter();
@@ -14,6 +14,7 @@ import {
14
14
  TELEGRAM_SAFE_LIMIT,
15
15
  } from "../config";
16
16
  import { convertMarkdownToHtml, escapeHtml } from "../formatting";
17
+ import { safeTelegramCall } from "../telegram-api";
17
18
  import type { StatusCallback } from "../types";
18
19
 
19
20
  /**
@@ -87,6 +88,7 @@ export class StreamingState {
87
88
  toolMessages: Message[] = []; // ephemeral tool status messages
88
89
  lastEditTimes = new Map<number, number>(); // segment_id -> last edit time
89
90
  lastContent = new Map<number, string>(); // segment_id -> last sent content
91
+ toolStartTime: number | null = null; // timestamp when current tool started
90
92
  }
91
93
 
92
94
  /**
@@ -108,6 +110,7 @@ export function createStatusCallback(
108
110
  });
109
111
  state.toolMessages.push(thinkingMsg);
110
112
  } else if (statusType === "tool") {
113
+ state.toolStartTime = Date.now();
111
114
  const toolMsg = await ctx.reply(content, { parse_mode: "HTML" });
112
115
  state.toolMessages.push(toolMsg);
113
116
  } else if (statusType === "text" && segmentId !== undefined) {
@@ -145,28 +148,28 @@ export function createStatusCallback(
145
148
  if (formatted === state.lastContent.get(segmentId)) {
146
149
  return;
147
150
  }
148
- try {
149
- await ctx.api.editMessageText(
150
- msg.chat.id,
151
- msg.message_id,
152
- formatted,
153
- {
154
- parse_mode: "HTML",
155
- },
156
- );
157
- state.lastContent.set(segmentId, formatted);
158
- } catch (htmlError) {
159
- console.debug("HTML edit failed, trying plain text:", htmlError);
151
+ const editResult = await safeTelegramCall("editMessage", async () => {
160
152
  try {
161
153
  await ctx.api.editMessageText(
162
154
  msg.chat.id,
163
155
  msg.message_id,
164
156
  formatted,
157
+ { parse_mode: "HTML" },
165
158
  );
166
- state.lastContent.set(segmentId, formatted);
167
- } catch (editError) {
168
- console.debug("Edit message failed:", editError);
159
+ return true;
160
+ } catch (htmlError) {
161
+ // HTML parse failed, try plain text
162
+ console.debug("HTML edit failed, trying plain text:", htmlError);
163
+ await ctx.api.editMessageText(
164
+ msg.chat.id,
165
+ msg.message_id,
166
+ formatted,
167
+ );
168
+ return true;
169
169
  }
170
+ });
171
+ if (editResult) {
172
+ state.lastContent.set(segmentId, formatted);
170
173
  }
171
174
  state.lastEditTimes.set(segmentId, now);
172
175
  }
@@ -181,18 +184,11 @@ export function createStatusCallback(
181
184
  }
182
185
 
183
186
  if (formatted.length <= TELEGRAM_MESSAGE_LIMIT) {
184
- try {
185
- await ctx.api.editMessageText(
186
- msg.chat.id,
187
- msg.message_id,
188
- formatted,
189
- {
190
- parse_mode: "HTML",
191
- },
192
- );
193
- } catch (error) {
194
- console.debug("Failed to edit final message:", error);
195
- }
187
+ await safeTelegramCall("editFinalMessage", () =>
188
+ ctx.api.editMessageText(msg.chat.id, msg.message_id, formatted, {
189
+ parse_mode: "HTML",
190
+ }),
191
+ );
196
192
  } else {
197
193
  // Too long - delete and split
198
194
  try {
@@ -5,6 +5,7 @@
5
5
  import { spawn } from "node:child_process";
6
6
  import type { Context } from "grammy";
7
7
  import { ALLOWED_USERS } from "../config";
8
+ import { formatUserError } from "../errors";
8
9
  import { isAuthorized, rateLimiter } from "../security";
9
10
  import { session } from "../session";
10
11
  import {
@@ -194,7 +195,7 @@ export async function handleText(ctx: Context): Promise<void> {
194
195
  await ctx.reply("🛑 Query stopped.");
195
196
  }
196
197
  } else {
197
- await ctx.reply(`❌ Error: ${errorStr.slice(0, 200)}`);
198
+ await ctx.reply(`❌ ${formatUserError(error as Error)}`);
198
199
  }
199
200
  break; // Exit loop after handling error
200
201
  }
package/src/index.ts CHANGED
@@ -37,6 +37,7 @@ import {
37
37
  handleUndo,
38
38
  handleVoice,
39
39
  } from "./handlers";
40
+ import { session } from "./session";
40
41
 
41
42
  // Create bot instance
42
43
  const bot = new Bot(TELEGRAM_TOKEN);
@@ -149,21 +150,38 @@ if (existsSync(RESTART_FILE)) {
149
150
  const runner = run(bot);
150
151
 
151
152
  // Graceful shutdown
152
- const stopRunner = () => {
153
- if (runner.isRunning()) {
154
- console.log("Stopping bot...");
155
- runner.stop();
156
- }
157
- };
153
+ const SHUTDOWN_TIMEOUT_MS = 5000;
158
154
 
159
- process.on("SIGINT", () => {
160
- console.log("Received SIGINT");
161
- stopRunner();
162
- process.exit(0);
163
- });
155
+ async function gracefulShutdown(signal: string): Promise<void> {
156
+ console.log(`\n${signal} received - initiating graceful shutdown...`);
164
157
 
165
- process.on("SIGTERM", () => {
166
- console.log("Received SIGTERM");
167
- stopRunner();
168
- process.exit(0);
169
- });
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
@@ -25,11 +25,14 @@ import {
25
25
  THINKING_KEYWORDS,
26
26
  WORKING_DIR,
27
27
  } from "./config";
28
+ import { botEvents } from "./events";
28
29
  import { formatToolStatus } from "./formatting";
29
30
  import { checkPendingAskUserRequests } from "./handlers/streaming";
30
31
  import { checkCommandSafety, isPathAllowed } from "./security";
31
32
  import type { SessionData, StatusCallback, TokenUsage } from "./types";
32
33
 
34
+ const SESSION_VERSION = 1;
35
+
33
36
  /**
34
37
  * Determine thinking token budget based on message keywords.
35
38
  * Exported for testing.
@@ -96,6 +99,11 @@ class ClaudeSession {
96
99
  private _queryInstance: Query | null = null;
97
100
  private _userMessageUuids: string[] = [];
98
101
 
102
+ // Debounced session saving
103
+ private _saveTimeout: ReturnType<typeof setTimeout> | null = null;
104
+ private _pendingSave = false;
105
+ private readonly SAVE_DEBOUNCE_MS = 500;
106
+
99
107
  // Mutable working directory (can be changed with /cd)
100
108
  private _workingDir: string = WORKING_DIR;
101
109
 
@@ -105,6 +113,15 @@ class ClaudeSession {
105
113
  private _isProcessing = false;
106
114
  private _wasInterruptedByNewMessage = false;
107
115
 
116
+ constructor() {
117
+ botEvents.on("interruptRequested", () => {
118
+ if (this.isRunning) {
119
+ this.markInterrupt();
120
+ this.stop();
121
+ }
122
+ });
123
+ }
124
+
108
125
  get workingDir(): string {
109
126
  return this._workingDir;
110
127
  }
@@ -293,6 +310,7 @@ class ClaudeSession {
293
310
  // Create abort controller for cancellation
294
311
  this.abortController = new AbortController();
295
312
  this.isQueryRunning = true;
313
+ botEvents.emit("sessionRunning", true);
296
314
  this.stopRequested = false;
297
315
  this.queryStarted = new Date();
298
316
  this.currentTool = null;
@@ -511,6 +529,7 @@ class ClaudeSession {
511
529
  }
512
530
  } finally {
513
531
  this.isQueryRunning = false;
532
+ botEvents.emit("sessionRunning", false);
514
533
  this.abortController = null;
515
534
  this.queryStarted = null;
516
535
  this.currentTool = null;
@@ -630,13 +649,34 @@ class ClaudeSession {
630
649
  }
631
650
 
632
651
  /**
633
- * Save session to disk for resume after restart.
652
+ * Save session to disk for resume after restart (debounced).
653
+ * Multiple calls within SAVE_DEBOUNCE_MS will only result in one write.
634
654
  */
635
655
  private saveSession(): void {
636
656
  if (!this.sessionId) return;
637
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
+
638
677
  try {
639
678
  const data: SessionData = {
679
+ version: SESSION_VERSION,
640
680
  session_id: this.sessionId,
641
681
  saved_at: new Date().toISOString(),
642
682
  working_dir: this._workingDir,
@@ -645,6 +685,25 @@ class ClaudeSession {
645
685
  console.log(`Session saved to ${SESSION_FILE}`);
646
686
  } catch (error) {
647
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();
648
707
  }
649
708
  }
650
709
 
@@ -665,6 +724,13 @@ class ClaudeSession {
665
724
  return [false, "Saved session file is empty"];
666
725
  }
667
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
+
668
734
  if (data.working_dir && data.working_dir !== this._workingDir) {
669
735
  return [
670
736
  false,