ctb 1.1.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.
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();