-
Notifications
You must be signed in to change notification settings - Fork 3
Expand file tree
/
Copy pathsession.py
More file actions
1786 lines (1579 loc) · 72.9 KB
/
session.py
File metadata and controls
1786 lines (1579 loc) · 72.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""Claude Code session management."""
import json
import os
import time
from typing import Optional, List, Dict, Callable
import sublime
from .rpc import JsonRpcClient
from .output import OutputView
BRIDGE_SCRIPT = os.path.join(os.path.dirname(__file__), "bridge", "main.py")
CODEX_BRIDGE_SCRIPT = os.path.join(os.path.dirname(__file__), "bridge", "codex_main.py")
COPILOT_BRIDGE_SCRIPT = os.path.join(os.path.dirname(__file__), "bridge", "copilot_main.py")
SESSIONS_FILE = os.path.join(os.path.dirname(__file__), ".sessions.json")
def load_saved_sessions() -> List[Dict]:
"""Load saved sessions from disk."""
if os.path.exists(SESSIONS_FILE):
try:
with open(SESSIONS_FILE, "r") as f:
return json.load(f)
except:
pass
return []
def save_sessions(sessions: List[Dict]) -> None:
"""Save sessions to disk."""
try:
with open(SESSIONS_FILE, "w") as f:
json.dump(sessions, f, indent=2)
except Exception as e:
print(f"[Claude] Failed to save sessions: {e}")
class ContextItem:
"""A pending context item to attach to next query."""
def __init__(self, kind: str, name: str, content: str):
self.kind = kind # "file", "selection"
self.name = name # Display name
self.content = content # Actual content
class Session:
def __init__(self, window: sublime.Window, resume_id: Optional[str] = None, fork: bool = False, profile: Optional[Dict] = None, initial_context: Optional[Dict] = None, backend: str = "claude"):
self.window = window
self.backend = backend
self.client: Optional[JsonRpcClient] = None
self.output = OutputView(window)
self.initialized = False
self.working = False
self.current_tool: Optional[str] = None
self.spinner_frame = 0
# Session identity
# When resuming (not forking), use resume_id as session_id immediately
# so renames/saves work before first query completes
self.session_id: Optional[str] = resume_id if resume_id and not fork else None
self.resume_id: Optional[str] = resume_id # ID to resume from
self.fork: bool = fork # Fork from resume_id instead of continuing it
self.profile: Optional[Dict] = profile # Profile config (model, betas, system_prompt, preload_docs)
self.initial_context: Optional[Dict] = initial_context # Initial context (subsession_id, parent_view_id, etc.)
self.name: Optional[str] = None
self.total_cost: float = 0.0
self.query_count: int = 0
self.context_usage: Optional[Dict] = None # Latest usage/context stats
# Pending context for next query
self.pending_context: List[ContextItem] = []
# Profile docs available for reading (paths only, not content)
self.profile_docs: List[str] = []
# Draft prompt (persists across input panel open/close)
self.draft_prompt: str = ""
self._pending_resume_at: Optional[str] = None # Set by undo, consumed by next query
# Track if we've entered input mode after last query
self._input_mode_entered: bool = False
# Callback for channel mode responses
self._response_callback: Optional[Callable[[str], None]] = None
# Queue of prompts to send after current query completes
self._queued_prompts: List[str] = []
# Track if inject was sent (to skip "done" status until inject query completes)
self._inject_pending: bool = False
# Extract subsession_id and parent_view_id if provided
if initial_context:
self.subsession_id = initial_context.get("subsession_id")
self.parent_view_id = initial_context.get("parent_view_id")
else:
self.subsession_id = None
self.parent_view_id = None
# Persona info (for release on close)
if profile:
self.persona_id = profile.get("persona_id")
self.persona_session_id = profile.get("persona_session_id")
self.persona_url = profile.get("persona_url")
else:
self.persona_id = None
self.persona_session_id = None
self.persona_url = None
# Activity tracking for auto-sleep
self.last_activity: float = time.time()
# Terminal mode state
self.terminal_mode: bool = False
self._terminal_tag: Optional[str] = None
self._terminal_poll_active: bool = False
# Plan mode state
self.plan_mode: bool = False
self.plan_file: Optional[str] = None
# Pending retain content (set by compact_boundary, sent after interrupt)
self._pending_retain: Optional[str] = None
def start(self, resume_session_at: str = None) -> None:
self._show_connecting_phantom()
settings = sublime.load_settings("ClaudeCode.sublime-settings")
python_path = settings.get("python_path", "python3")
# Build profile docs list early (before init) so we can add to system prompt
self._build_profile_docs_list()
# Load environment variables from settings and profile
env = self._load_env(settings)
# Sync sublime project retain content to file for hook
self._sync_project_retain()
if self.backend == "codex":
bridge_script = CODEX_BRIDGE_SCRIPT
elif self.backend == "copilot":
bridge_script = COPILOT_BRIDGE_SCRIPT
else:
bridge_script = BRIDGE_SCRIPT
self.client = JsonRpcClient(self._on_notification)
self.client.start([python_path, bridge_script], env=env)
self._status("connecting...")
permission_mode = settings.get("permission_mode", "acceptEdits")
# In default mode, don't auto-allow any tools - prompt for all
if permission_mode == "default":
allowed_tools = []
else:
allowed_tools = settings.get("allowed_tools", [])
# Per-backend default model, fallback to legacy default_model
default_models = settings.get("default_models", {})
default_model = default_models.get(self.backend) or settings.get("default_model")
print(f"[Claude] initialize: permission_mode={permission_mode}, allowed_tools={allowed_tools}, resume={self.resume_id}, fork={self.fork}, profile={self.profile}, default_model={default_model}, subsession_id={getattr(self, 'subsession_id', None)}")
# Get additional working directories from project folders + project settings
additional_dirs = self.window.folders()[1:] if len(self.window.folders()) > 1 else []
project_data = self.window.project_data() or {}
project_settings = project_data.get("settings", {})
extra_dirs = project_settings.get("claude_additional_dirs", [])
if extra_dirs:
expanded = [os.path.expanduser(d) for d in extra_dirs]
additional_dirs = additional_dirs + expanded
print(f"[Claude] extra additional_dirs from project: {expanded}")
init_params = {
"cwd": self._cwd(),
"additional_dirs": additional_dirs,
"allowed_tools": allowed_tools,
"permission_mode": permission_mode,
"view_id": str(self.output.view.id()) if self.output and self.output.view else None,
}
if self.resume_id:
init_params["resume"] = self.resume_id
if self.fork:
init_params["fork_session"] = True
# Use saved session's project dir as cwd (session may belong to different project)
for saved in load_saved_sessions():
if saved.get("session_id") == self.resume_id:
saved_project = saved.get("project", "")
if saved_project and saved_project != init_params["cwd"]:
print(f"[Claude] resume: using saved project {saved_project}")
init_params["cwd"] = saved_project
break
if resume_session_at:
init_params["resume_session_at"] = resume_session_at
# Pass subsession_id if this is a subsession
if hasattr(self, 'subsession_id') and self.subsession_id:
init_params["subsession_id"] = self.subsession_id
# Apply profile config or default model
if self.profile:
if self.profile.get("model"):
init_params["model"] = self.profile["model"]
if self.profile.get("betas"):
init_params["betas"] = self.profile["betas"]
if self.profile.get("pre_compact_prompt"):
init_params["pre_compact_prompt"] = self.profile["pre_compact_prompt"]
# Build system prompt with profile docs info
system_prompt = self.profile.get("system_prompt", "")
if self.profile_docs:
docs_info = f"\n\nProfile Documentation: {len(self.profile_docs)} files available. Use list_profile_docs to see them and read_profile_doc(path) to read their contents."
system_prompt = system_prompt + docs_info if system_prompt else docs_info.strip()
if system_prompt:
init_params["system_prompt"] = system_prompt
else:
# No profile - use default_model setting if available
if default_model:
init_params["model"] = default_model
self.client.send("initialize", init_params, self._on_init)
def _cwd(self) -> str:
if self.window.folders():
return self.window.folders()[0]
view = self.window.active_view()
if view and view.file_name():
return os.path.dirname(view.file_name())
# Fallback: use ~/.claude/scratch for sessions without a project
# This ensures consistent cwd for session resume
scratch_dir = os.path.expanduser("~/.claude/scratch")
os.makedirs(scratch_dir, exist_ok=True)
return scratch_dir
def _on_init(self, result: dict) -> None:
if "error" in result:
self._clear_overlay_phantom()
error_msg = result['error'].get('message', str(result['error']))
print(f"[Claude] init error: {error_msg}")
self._status("error")
# Show user-friendly message in view
is_session_error = (
"No conversation found" in error_msg or
"Command failed" in error_msg
)
if is_session_error:
self.output.text("\n*Session expired or not found.*\n\nUse `Claude: Restart Session` (Cmd+Shift+R) to start fresh.\n")
else:
self.output.text(f"\n*Failed to connect: {error_msg}*\n\nTry `Claude: Restart Session` (Cmd+Shift+R).\n")
return
self._clear_overlay_phantom()
self.initialized = True
self.working = False
self.current_tool = None
self.last_activity = time.time()
if self._pending_resume_at:
self._pending_resume_at = None
self._input_mode_entered = False # Reset for fresh start after init
# Capture session_id from initialize response (set via --session-id CLI arg)
if result.get("session_id"):
self.session_id = result["session_id"]
print(f"[Claude] session_id={self.session_id}")
# Show loaded MCP servers and agents
mcp_servers = result.get("mcp_servers", [])
agents = result.get("agents", [])
parts = []
if mcp_servers:
print(f"[Claude] MCP servers: {mcp_servers}")
parts.append(f"MCP: {', '.join(mcp_servers)}")
if agents:
print(f"[Claude] Agents: {agents}")
parts.append(f"agents: {', '.join(agents)}")
if parts:
self._status(f"ready ({'; '.join(parts)})")
else:
self._status("ready")
# Persist "open" state (so plugin_loaded can track which sessions had views)
self._save_session()
# Auto-enter input mode when ready
self._enter_input_with_draft()
def _load_env(self, settings) -> dict:
"""Load environment variables from settings and project profile."""
import os
env = {}
# From user settings (ClaudeCode.sublime-settings)
settings_env = settings.get("env", {})
if isinstance(settings_env, dict):
env.update(settings_env)
# From sublime project settings (.sublime-project -> settings -> claude_env)
project_data = self.window.project_data() or {}
project_settings = project_data.get("settings", {})
project_env = project_settings.get("claude_env", {})
if isinstance(project_env, dict):
env.update(project_env)
# From project .claude/settings.json
cwd = self._cwd()
if cwd:
project_settings_path = os.path.join(cwd, ".claude", "settings.json")
if os.path.exists(project_settings_path):
try:
with open(project_settings_path, "r") as f:
import json
project_settings = json.load(f)
claude_env = project_settings.get("env", {})
if isinstance(claude_env, dict):
env.update(claude_env)
except Exception as e:
print(f"[Claude] Failed to load project env: {e}")
# From profile (highest priority)
if self.profile:
profile_env = self.profile.get("env", {})
if isinstance(profile_env, dict):
env.update(profile_env)
if env:
print(f"[Claude] Custom env vars: {env}")
return env
def _sync_project_retain(self):
"""Sync sublime project retain setting to file for hook."""
cwd = self._cwd()
if not cwd:
return
project_data = self.window.project_data() or {}
project_settings = project_data.get("settings", {})
retain_content = project_settings.get("claude_retain", "")
retain_path = os.path.join(cwd, ".claude", "sublime_project_retain.md")
if retain_content:
os.makedirs(os.path.dirname(retain_path), exist_ok=True)
with open(retain_path, "w") as f:
f.write(retain_content)
elif os.path.exists(retain_path):
os.remove(retain_path)
def _get_retain_path(self) -> Optional[str]:
"""Get path to session's dynamic retain file."""
if not self.session_id:
return None
cwd = self._cwd()
if not cwd:
return None
return os.path.join(cwd, ".claude", "sessions", f"{self.session_id}_retain.md")
def retain(self, content: str = None, append: bool = False) -> Optional[str]:
"""Write to or read session's retain file for compaction.
Args:
content: Content to write (None to read current)
append: If True, append to existing content
Returns:
Current retain content if reading, None if writing
"""
path = self._get_retain_path()
if not path:
print("[Claude] Cannot access retain file - no session_id yet")
return None
if content is None:
# Read mode
if os.path.exists(path):
with open(path, "r") as f:
return f.read()
return ""
# Write mode - ensure directory exists
os.makedirs(os.path.dirname(path), exist_ok=True)
mode = "a" if append else "w"
with open(path, mode) as f:
if append and os.path.exists(path):
f.write("\n")
f.write(content)
print(f"[Claude] Retain file updated: {path}")
return None
def clear_retain(self):
"""Clear session's retain file."""
path = self._get_retain_path()
if path and os.path.exists(path):
os.remove(path)
print(f"[Claude] Retain file cleared: {path}")
def _strip_comment_only_content(self, content: str) -> str:
"""Strip lines that are only comments or whitespace."""
lines = content.split('\n')
filtered = [line for line in lines if line.strip() and not line.strip().startswith('#')]
return '\n'.join(filtered).strip()
def _gather_retain_content(self) -> Optional[str]:
"""Gather all retain content from various sources.
Returns combined retain content string, or None if no content found.
"""
prompts = []
cwd = self._cwd()
# 1. Static retain file (.claude/RETAIN.md)
if cwd:
static_path = os.path.join(cwd, ".claude", "RETAIN.md")
if os.path.exists(static_path):
try:
with open(static_path, "r") as f:
content = self._strip_comment_only_content(f.read())
if content:
prompts.append(content)
except Exception as e:
print(f"[Claude] Error reading static retain: {e}")
# 2. Sublime project retain file
if cwd:
sublime_retain_path = os.path.join(cwd, ".claude", "sublime_project_retain.md")
if os.path.exists(sublime_retain_path):
try:
with open(sublime_retain_path, "r") as f:
content = self._strip_comment_only_content(f.read())
if content:
prompts.append(content)
except Exception as e:
print(f"[Claude] Error reading sublime project retain: {e}")
# 3. Session retain file
session_retain = self._strip_comment_only_content(self.retain() or "")
if session_retain:
prompts.append(session_retain)
# 4. Profile pre_compact_prompt
if self.profile and self.profile.get("pre_compact_prompt"):
prompts.append(self.profile["pre_compact_prompt"])
if prompts:
return "\n\n---\n\n".join(prompts)
return None
def _inject_retain_midquery(self) -> None:
"""Inject retain content by interrupting and restarting with retain prompt."""
content = self._gather_retain_content()
if content:
print(f"[Claude] Interrupting to inject retain content ({len(content)} chars)")
# Store retain content to send after interrupt completes
self._pending_retain = f"[retain context]\n\n{content}"
self.interrupt(break_channel=False)
def _record_edit(self, tool_name: str):
"""Record an Edit/Write operation to the order table's edit log."""
from .order_table import get_table, refresh_order_table
# Get tool input from current conversation
if not self.output.current:
return
tools = self.output.current.tools
if not tools:
return
# Find the most recent tool of this type that's still pending
tool_input = None
for tool in reversed(tools):
if tool.name == tool_name and tool.status == "pending":
tool_input = tool.tool_input
break
if not tool_input:
return
file_path = tool_input.get("file_path") or tool_input.get("notebook_path")
if not file_path:
return
# Calculate line number, diff stats, and context
if tool_name == "Edit":
old = tool_input.get("old_string", "")
new = tool_input.get("new_string", "")
line_num = self._find_edit_line(file_path, new or old)
lines_added = len(new.splitlines()) if new else 0
lines_removed = len(old.splitlines()) if old else 0
context = self._extract_edit_context(new)
else: # Write
content = tool_input.get("content", "")
line_num = 1
lines_added = len(content.splitlines())
lines_removed = 0
context = os.path.basename(file_path)
table = get_table(self.window)
if table:
agent_name = self.name or f"view_{self.output.view.id()}" if self.output.view else "unknown"
view_id = self.output.view.id() if self.output.view else 0
table.add_edit(agent_name, view_id, file_path, line_num or 1,
lines_added, lines_removed, tool_name, context)
refresh_order_table(self.window)
def _extract_edit_context(self, text: str) -> str:
"""Extract first meaningful line as context."""
if not text:
return ""
for line in text.split('\n'):
line = line.strip()
if line and not line.startswith('#') and not line.startswith('//'):
# Truncate and clean
return line[:50].strip()
return ""
def _find_edit_line(self, file_path: str, search: str) -> int:
"""Find line number where content occurs in file."""
if not search or not os.path.exists(file_path):
return None
try:
with open(file_path, 'r', encoding='utf-8', errors='replace') as f:
content = f.read()
pos = content.find(search)
if pos >= 0:
return content[:pos].count('\n') + 1
except Exception:
pass
return None
def _build_profile_docs_list(self) -> None:
"""Build list of available docs from profile preload_docs patterns (no reading yet)."""
if not self.profile or not self.profile.get("preload_docs"):
return
import glob as glob_module
patterns = self.profile["preload_docs"]
if isinstance(patterns, str):
patterns = [patterns]
cwd = self._cwd()
try:
for pattern in patterns:
# Make pattern relative to cwd
full_pattern = os.path.join(cwd, pattern)
for filepath in glob_module.glob(full_pattern, recursive=True):
if os.path.isfile(filepath):
rel_path = os.path.relpath(filepath, cwd)
self.profile_docs.append(rel_path)
if self.profile_docs:
print(f"[Claude] Profile docs available: {len(self.profile_docs)} files")
except Exception as e:
print(f"[Claude] preload_docs error: {e}")
def add_context_file(self, path: str, content: str) -> None:
"""Add a file to pending context."""
name = os.path.basename(path)
self.pending_context.append(ContextItem("file", name, f"File: {path}\n```\n{content}\n```"))
# print(f"[Claude] add_context_file: added {name}, pending_context={[c.name for c in self.pending_context]}")
self._update_context_display()
def add_context_selection(self, path: str, content: str) -> None:
"""Add a selection to pending context."""
name = os.path.basename(path) if path else "selection"
self.pending_context.append(ContextItem("selection", name, f"Selection from {path}:\n```\n{content}\n```"))
self._update_context_display()
def add_context_folder(self, path: str) -> None:
"""Add a folder path to pending context."""
name = os.path.basename(path) + "/"
self.pending_context.append(ContextItem("folder", name, f"Folder: {path}"))
self._update_context_display()
def add_context_image(self, image_data: bytes, mime_type: str) -> None:
"""Add an image to pending context."""
import base64
import tempfile
# Save to temp file for reference
ext = ".png" if "png" in mime_type else ".jpg" if "jpeg" in mime_type or "jpg" in mime_type else ".img"
with tempfile.NamedTemporaryFile(suffix=ext, delete=False) as f:
f.write(image_data)
temp_path = f.name
# Store base64 encoded image
encoded = base64.b64encode(image_data).decode('utf-8')
# Use special format that query() will detect
name = f"image{ext}"
self.pending_context.append(ContextItem(
"image",
name,
f"__IMAGE__:{mime_type}:{encoded}" # Special marker for image data
))
print(f"[Claude] Added image to context: {name} ({len(image_data)} bytes, saved to {temp_path})")
self._update_context_display()
def clear_context(self) -> None:
"""Clear pending context."""
self.pending_context = []
self._update_context_display()
def _update_context_display(self) -> None:
"""Update output view with pending context."""
self.output.set_pending_context(self.pending_context)
def _build_prompt_with_context(self, prompt: str) -> tuple:
"""Build full prompt with pending context.
Returns:
(full_prompt, images) where images is list of {"mime_type": str, "data": str}
"""
if not self.pending_context:
return prompt, []
parts = []
images = []
for item in self.pending_context:
if item.content.startswith("__IMAGE__:"):
# Extract image data: __IMAGE__:mime_type:base64data
_, mime_type, data = item.content.split(":", 2)
images.append({"mime_type": mime_type, "data": data})
else:
parts.append(item.content)
parts.append(prompt)
return "\n\n".join(parts), images
def undo_message(self) -> None:
"""Undo last conversation turn by rewinding the CLI session."""
if not self.session_id:
return
# Allow undo even while "working" (bridge reconnecting from previous undo)
if self.working and self.current_tool != "rewinding...":
return
rewind_id, undone_prompt = self._find_rewind_point()
if not rewind_id:
print(f"[Claude] undo_message: no rewind point found")
return
saved_id = self.session_id
print(f"[Claude] undo_message: rewinding {saved_id} to {rewind_id}")
# Exit input mode
if self.output._input_mode:
self.output.exit_input_mode(keep_text=False)
# Erase last prompt turn from view (prompt = "◎ ... ▶", not input marker "◎ ")
view = self.output.view
content = view.substr(sublime.Region(0, view.size()))
# Find last prompt line (contains " ▶")
import re as _re
last_prompt = None
for m in _re.finditer(r'\n◎ .+? ▶', content):
last_prompt = m
if not last_prompt and content.startswith("◎ ") and " ▶" in content.split("\n")[0]:
print(f"[Claude] undo: erasing entire view (first turn)")
self.output._replace(0, view.size(), "")
elif last_prompt:
erase_from = last_prompt.start()
print(f"[Claude] undo: erasing from {erase_from} to {view.size()}, matched={last_prompt.group()[:40]!r}")
self.output._replace(erase_from, view.size(), "")
else:
print(f"[Claude] undo: no prompt found to erase")
# Update conversation state
if self.output.current:
self.output.current = None
view.erase_regions("claude_conversation")
# Kill bridge synchronously (may already be dead from previous undo)
if self.client:
self.client.stop()
self.client = None
self.initialized = False
# Restart bridge with rewind
self.session_id = saved_id
self.resume_id = saved_id
self.fork = False
self.draft_prompt = undone_prompt
self._input_mode_entered = True # Block auto input mode until bridge ready
self._pending_resume_at = rewind_id
self._save_session() # Persist rewind point for restart survival
self.working = True
self.current_tool = "rewinding..."
self._animate()
self.start(resume_session_at=rewind_id)
# _on_init will reset _input_mode_entered and call _enter_input_with_draft
def _find_rewind_point(self) -> tuple:
"""Find the assistant entry uuid to rewind to (before last visible turn).
Respects current _pending_resume_at to support consecutive undos.
Returns (uuid, undone_prompt) or (None, "") if can't rewind."""
jsonl_path = self._find_jsonl_path()
if not jsonl_path:
return None, ""
# Collect user prompt turns and their preceding assistant uuid
turns = [] # [(prompt, prev_assistant_uuid)]
last_assistant_uuid = None
try:
with open(jsonl_path, "r") as f:
for line in f:
line = line.strip()
if not line:
continue
entry = json.loads(line)
if entry.get("isSidechain") or entry.get("isMeta"):
continue
etype = entry.get("type")
if etype == "assistant":
uuid = entry.get("uuid")
if uuid:
last_assistant_uuid = uuid
elif etype == "user":
msg = entry.get("message", {})
content = msg.get("content", [])
has_tool_result = (
isinstance(content, list) and
any(isinstance(b, dict) and b.get("type") == "tool_result" for b in content)
)
if has_tool_result:
continue
prompt = ""
if isinstance(content, str):
prompt = content
elif isinstance(content, list):
for block in content:
if isinstance(block, dict) and block.get("type") == "text":
prompt += block.get("text", "")
turns.append((prompt, last_assistant_uuid))
except Exception as e:
print(f"[Claude] _find_rewind_point error: {e}")
return None, ""
# If already rewound, find the turn whose prev_assistant_uuid == current rewind point
# and rewind one step further back
if self._pending_resume_at:
# Find which turn we're currently rewound to
for i, (prompt, asst_uuid) in enumerate(turns):
if asst_uuid == self._pending_resume_at:
# This turn starts after the current rewind point
# We want to undo the turn BEFORE this one
if i < 2:
return None, "" # Can't undo further
undone_prompt = turns[i - 1][0]
rewind_to = turns[i - 1][1]
if not rewind_to:
return None, ""
return rewind_to, undone_prompt
# Fallback: current rewind point not found in turns
return None, ""
# Normal case: undo the last turn
if len(turns) < 2:
return None, ""
undone_prompt = turns[-1][0]
rewind_to = turns[-1][1]
if not rewind_to:
return None, ""
return rewind_to, undone_prompt
def _find_jsonl_path(self) -> Optional[str]:
"""Find the JSONL file for this session."""
if not self.session_id:
return None
fname = f"{self.session_id}.jsonl"
projects_dir = os.path.expanduser("~/.claude/projects")
# Try exact cwd match first
cwd = self._cwd()
project_key = cwd.replace("/", "-").lstrip("-")
exact = os.path.join(projects_dir, project_key, fname)
if os.path.exists(exact):
return exact
# Search all project directories
if os.path.isdir(projects_dir):
for d in os.listdir(projects_dir):
candidate = os.path.join(projects_dir, d, fname)
if os.path.exists(candidate):
return candidate
return None
def query(self, prompt: str, display_prompt: str = None, silent: bool = False) -> None:
"""
Start a new query.
Args:
prompt: The full prompt to send to the agent
display_prompt: Optional shorter prompt to display in the UI (defaults to prompt)
silent: If True, skip UI updates (for channel mode)
"""
if not self.client or not self.initialized:
sublime.error_message("Claude not initialized")
return
self.working = True
self.query_count += 1
self.draft_prompt = "" # Clear draft — query submitted
self._input_mode_entered = False # Reset so input mode can be entered when query completes
# Mark this session as the currently executing session for MCP tools
# MCP tools should operate on the executing session, not the UI-active session
# Only set if not already set (don't overwrite parent session when spawning subsessions)
self._is_executing_session = False # Track if we set the marker
if self.output.view and not self.window.settings().has("claude_executing_view"):
self.window.settings().set("claude_executing_view", self.output.view.id())
self._is_executing_session = True
# Build prompt with context (may include images)
full_prompt, images = self._build_prompt_with_context(prompt)
context_names = [item.name for item in self.pending_context]
self.pending_context = [] # Clear after use
self._update_context_display()
# Store images for RPC call
self._pending_images = images
# Use display_prompt for UI if provided, otherwise use full prompt
ui_prompt = display_prompt if display_prompt else prompt
# Check if bridge is alive before sending
if not self.client.is_alive():
self._status("error: bridge died")
if not silent:
self.output.text("\n\n*Bridge process died. Please restart the session.*\n")
return
if not silent:
self.output.show()
# Auto-name session from first prompt if not already named
if not self.name:
self._set_name(ui_prompt[:30].strip() + ("..." if len(ui_prompt) > 30 else ""))
self.output.prompt(ui_prompt, context_names)
self._animate()
query_params = {"prompt": full_prompt}
if hasattr(self, '_pending_images') and self._pending_images:
query_params["images"] = self._pending_images
self._pending_images = []
if not self.client.send("query", query_params, self._on_done):
self._status("error: bridge died")
self.working = False
self.output.text("\n\n*Failed to send query. Bridge process died.*\n")
def send_message_with_callback(self, message: str, callback: Callable[[str], None], silent: bool = False, display_prompt: str = None) -> None:
"""Send message and call callback with Claude's response.
Used by channel mode for sync request-response communication.
Args:
message: The message to send to Claude
callback: Function to call with the response text when complete
silent: If True, skip UI updates
display_prompt: Optional display text for UI (ignored if silent=True)
"""
# Validate session state before setting callback
if not self.client or not self.initialized:
print(f"[Claude] send_message_with_callback: session not initialized")
callback("Error: session not initialized")
return
if not self.client.is_alive():
print(f"[Claude] send_message_with_callback: bridge not running")
callback("Error: bridge not running")
return
print(f"[Claude] send_message_with_callback: sending message")
self._response_callback = callback
ui_prompt = display_prompt if display_prompt else (message[:50] + "..." if len(message) > 50 else message)
self.query(message, display_prompt=ui_prompt, silent=silent)
# Check if query() failed (working is False if send failed)
if not self.working and self._response_callback:
print(f"[Claude] send_message_with_callback: query failed, calling callback with error")
cb = self._response_callback
self._response_callback = None
cb("Error: failed to send query")
def _on_done(self, result: dict) -> None:
self.current_tool = None
# Clear executing session marker - MCP tools should no longer target this session
if self.output.view and getattr(self, '_is_executing_session', False):
self.window.settings().erase("claude_executing_view")
self._is_executing_session = False
# 1. Determine completion type
if "error" in result:
completion = "error"
elif result.get("status") == "interrupted":
completion = "interrupted"
else:
completion = "success"
# 2. Handle UI for each completion type
if completion == "error":
error_msg = result['error'].get('message', str(result['error'])) if isinstance(result['error'], dict) else str(result['error'])
self._status("error")
self.output.text(f"\n\n*Error: {error_msg}*\n")
if self.output.current:
self.output.current.working = False
self.output._render_current()
elif completion == "interrupted":
self._status("interrupted")
self.output.interrupted()
else:
self._status("ready")
self.output.set_name(self.name or "Claude")
self.output.clear_all_permissions()
# 3. Response callback fires for ALL completions (channel mode needs to know)
if self._response_callback:
callback = self._response_callback
self._response_callback = None
response_text = ""
if self.output.current:
response_text = "".join(self.output.current.text_chunks)
try:
callback(response_text)
except Exception as e:
print(f"[Claude] response callback error: {e}")
# Notify subsession completion (for notalone2)
if self.output.view:
view_id = str(self.output.view.id())
for session in sublime._claude_sessions.values():
if session.client:
session.client.send("subsession_complete", {"subsession_id": view_id})
# 4. Check for pending retain (interrupt was triggered by compact_boundary)
if completion == "interrupted" and self._pending_retain:
retain_content = self._pending_retain
self._pending_retain = None
self.output.text(f"\n◎ [retain] ▶\n\n")
self.query(retain_content, display_prompt="[retain context]")
return
# 5. GATE: Only process deferred actions on success
if completion != "success":
self.working = False
self._clear_deferred_state()
sublime.set_timeout(lambda: self._enter_input_with_draft() if not self.working else None, 100)
return
# 5. Process queued prompts (keep working=True, animation continues)
if self._queued_prompts:
prompt = self._queued_prompts.pop(0)
self.output.text(f"\n**[queued]** {prompt}\n\n")
self.query(prompt)
return
# 6. Clear inject_pending - if inject was mid-query, it's done now
# If inject was queued, queued_inject notification will start new query
self._inject_pending = False
# 7. Now set working=False and enter input mode
self.working = False
self.last_activity = time.time()
sublime.set_timeout(lambda: self._enter_input_with_draft() if not self.working else None, 100)
def _clear_deferred_state(self) -> None:
"""Clear deferred action state. Called on error/interrupt."""
self._queued_prompts.clear()
self._inject_pending = False
self._pending_retain = None
self._input_mode_entered = False # Allow re-entry to input mode
def _enter_input_with_draft(self) -> None:
"""Enter input mode and restore draft with cursor at end."""
# Skip if already in input mode or session is working
if self.output.is_input_mode() or self.working:
return
# Skip if we've already entered input mode after the last query
# This prevents duplicate entries from multiple callers (on_activated, _on_done, etc.)
if self._input_mode_entered:
return
self.output.enter_input_mode()
# Check if enter_input_mode actually succeeded (might have deferred)
if not self.output.is_input_mode():
return
self._input_mode_entered = True
if self.draft_prompt and self.output.view:
self.output.view.run_command("append", {"characters": self.draft_prompt})
end = self.output.view.size()
self.output.view.sel().clear()
self.output.view.sel().add(sublime.Region(end, end))
def queue_prompt(self, prompt: str) -> None:
"""Inject a prompt into the current query stream."""
self._status(f"injected: {prompt[:30]}...")
if self.working and self.client:
# Mid-query: show prompt and inject via bridge
short = prompt[:100] + "..." if len(prompt) > 100 else prompt
self.output.text(f"\n◎ [injected] {short} ▶\n\n")
self._inject_pending = True # Don't show "done" until inject query completes
self.client.send("inject_message", {"message": prompt})
elif self.client:
# Not working: start query directly (no round-trip delay)
self.query(prompt)
else:
# No client - queue locally for later
self._queued_prompts.append(prompt)
def show_queue_input(self) -> None:
"""Show input panel to queue a prompt while session is working."""
if not self.working:
# Not working, just enter normal input mode
self._enter_input_with_draft()
return
def on_done(text: str) -> None:
text = text.strip()
if text:
self.queue_prompt(text)
self.window.show_input_panel(
"Queue prompt:",
self.draft_prompt,
on_done,
None, # on_change
None # on_cancel
)
def interrupt(self, break_channel: bool = True) -> None:
"""Interrupt current query.
Args:
break_channel: If True, also breaks any active channel connection.
Set to False when interrupt is from channel message.
"""
if self.client: