claude-code-tools 1.0.6__py3-none-any.whl → 1.4.6__py3-none-any.whl

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.
Files changed (33) hide show
  1. claude_code_tools/__init__.py +1 -1
  2. claude_code_tools/action_rpc.py +16 -10
  3. claude_code_tools/aichat.py +793 -51
  4. claude_code_tools/claude_continue.py +4 -0
  5. claude_code_tools/codex_continue.py +48 -0
  6. claude_code_tools/export_session.py +94 -11
  7. claude_code_tools/find_claude_session.py +36 -12
  8. claude_code_tools/find_codex_session.py +33 -18
  9. claude_code_tools/find_session.py +30 -16
  10. claude_code_tools/gdoc2md.py +220 -0
  11. claude_code_tools/md2gdoc.py +549 -0
  12. claude_code_tools/search_index.py +119 -15
  13. claude_code_tools/session_menu_cli.py +1 -1
  14. claude_code_tools/session_utils.py +3 -3
  15. claude_code_tools/smart_trim.py +18 -8
  16. claude_code_tools/smart_trim_core.py +4 -2
  17. claude_code_tools/tmux_cli_controller.py +35 -25
  18. claude_code_tools/trim_session.py +28 -2
  19. claude_code_tools-1.4.6.dist-info/METADATA +1112 -0
  20. {claude_code_tools-1.0.6.dist-info → claude_code_tools-1.4.6.dist-info}/RECORD +31 -24
  21. {claude_code_tools-1.0.6.dist-info → claude_code_tools-1.4.6.dist-info}/entry_points.txt +2 -0
  22. docs/linked-in-20260102.md +32 -0
  23. docs/local-llm-setup.md +286 -0
  24. docs/reddit-aichat-resume-v2.md +80 -0
  25. docs/reddit-aichat-resume.md +29 -0
  26. docs/reddit-aichat.md +79 -0
  27. docs/rollover-details.md +67 -0
  28. node_ui/action_config.js +3 -3
  29. node_ui/menu.js +67 -113
  30. claude_code_tools/session_tui.py +0 -516
  31. claude_code_tools-1.0.6.dist-info/METADATA +0 -685
  32. {claude_code_tools-1.0.6.dist-info → claude_code_tools-1.4.6.dist-info}/WHEEL +0 -0
  33. {claude_code_tools-1.0.6.dist-info → claude_code_tools-1.4.6.dist-info}/licenses/LICENSE +0 -0
@@ -2,14 +2,25 @@
2
2
 
3
3
  import json
4
4
  import math
5
+ import shutil
5
6
  import sys
6
7
  from dataclasses import dataclass
7
8
  from datetime import datetime
9
+ from importlib.metadata import version as get_pkg_version
8
10
  from pathlib import Path
9
11
  from typing import Any, Optional
10
12
 
13
+ from claude_code_tools.export_session import _is_meta_user_message
11
14
  from claude_code_tools.session_utils import is_valid_session
12
15
 
16
+
17
+ def _get_package_version() -> str:
18
+ """Get installed package version for automatic index rebuilding."""
19
+ try:
20
+ return get_pkg_version("claude-code-tools")
21
+ except Exception:
22
+ return "unknown"
23
+
13
24
  # Lazy imports to allow module to load even if deps not installed
14
25
  try:
15
26
  import yaml
@@ -167,6 +178,7 @@ class SessionIndex:
167
178
  self.schema_builder.add_text_field("first_msg_content", stored=True)
168
179
  self.schema_builder.add_text_field("last_msg_role", stored=True)
169
180
  self.schema_builder.add_text_field("last_msg_content", stored=True)
181
+ self.schema_builder.add_text_field("first_user_msg_content", stored=True)
170
182
 
171
183
  # Session type fields (for filtering in TUI)
172
184
  self.schema_builder.add_text_field("derivation_type", stored=True)
@@ -176,6 +188,9 @@ class SessionIndex:
176
188
  # Use "raw" tokenizer so paths are indexed as single tokens for exact matching
177
189
  self.schema_builder.add_text_field("claude_home", stored=True, tokenizer_name="raw")
178
190
 
191
+ # Custom title field (from /rename command)
192
+ self.schema_builder.add_text_field("custom_title", stored=True)
193
+
179
194
  # Searchable content field
180
195
  self.schema_builder.add_text_field("content", stored=True)
181
196
 
@@ -184,10 +199,43 @@ class SessionIndex:
184
199
  # Create or open index
185
200
  self.index_path.mkdir(parents=True, exist_ok=True)
186
201
 
187
- if (self.index_path / "meta.json").exists():
188
- self.index = tantivy.Index(self.schema, path=str(self.index_path))
189
- else:
190
- self.index = tantivy.Index(self.schema, path=str(self.index_path))
202
+ # Check index version - rebuild if package version changed
203
+ version_file = self.index_path / "VERSION"
204
+ current_version = _get_package_version()
205
+ needs_rebuild = False
206
+
207
+ if version_file.exists():
208
+ try:
209
+ stored_version = version_file.read_text().strip()
210
+ if stored_version != current_version:
211
+ needs_rebuild = True
212
+ print(
213
+ f"Package version changed ({stored_version} -> {current_version}), "
214
+ "rebuilding index..."
215
+ )
216
+ except IOError:
217
+ needs_rebuild = True
218
+ print("Index version file corrupted, rebuilding...")
219
+ elif (self.index_path / "meta.json").exists():
220
+ # Index exists but no VERSION file - legacy index, rebuild
221
+ needs_rebuild = True
222
+ print("Upgrading index to versioned format, rebuilding...")
223
+
224
+ if needs_rebuild:
225
+ # Clear index directory contents (but keep the directory itself)
226
+ for item in self.index_path.iterdir():
227
+ if item.is_file():
228
+ item.unlink()
229
+ elif item.is_dir():
230
+ shutil.rmtree(item)
231
+ # Reset state tracker
232
+ self.state = IndexState(self.index_path / "index_state.json")
233
+
234
+ # Create or open index
235
+ self.index = tantivy.Index(self.schema, path=str(self.index_path))
236
+
237
+ # Write current version
238
+ version_file.write_text(current_version)
191
239
 
192
240
  def _parse_export_file(self, export_path: Path) -> Optional[dict[str, Any]]:
193
241
  """
@@ -275,10 +323,12 @@ class SessionIndex:
275
323
  # First and last message fields
276
324
  first_msg = metadata.get("first_msg", {}) or {}
277
325
  last_msg = metadata.get("last_msg", {}) or {}
326
+ first_user_msg = metadata.get("first_user_msg", {}) or {}
278
327
  doc.add_text("first_msg_role", first_msg.get("role", ""))
279
328
  doc.add_text("first_msg_content", first_msg.get("content", ""))
280
329
  doc.add_text("last_msg_role", last_msg.get("role", ""))
281
330
  doc.add_text("last_msg_content", last_msg.get("content", ""))
331
+ doc.add_text("first_user_msg_content", first_user_msg.get("content", ""))
282
332
 
283
333
  # Session type fields
284
334
  doc.add_text("derivation_type", metadata.get("derivation_type", "") or "")
@@ -287,6 +337,9 @@ class SessionIndex:
287
337
  "true" if metadata.get("is_sidechain") else "false"
288
338
  )
289
339
 
340
+ # Custom title (from /rename command)
341
+ doc.add_text("custom_title", metadata.get("customTitle", "") or "")
342
+
290
343
  doc.add_text("content", parsed["content"])
291
344
 
292
345
  writer.add_document(doc)
@@ -356,10 +409,12 @@ class SessionIndex:
356
409
  # First and last message fields
357
410
  first_msg = metadata.get("first_msg", {}) or {}
358
411
  last_msg = metadata.get("last_msg", {}) or {}
412
+ first_user_msg = metadata.get("first_user_msg", {}) or {}
359
413
  doc.add_text("first_msg_role", first_msg.get("role", ""))
360
414
  doc.add_text("first_msg_content", first_msg.get("content", ""))
361
415
  doc.add_text("last_msg_role", last_msg.get("role", ""))
362
416
  doc.add_text("last_msg_content", last_msg.get("content", ""))
417
+ doc.add_text("first_user_msg_content", first_user_msg.get("content", ""))
363
418
 
364
419
  # Session type fields
365
420
  doc.add_text(
@@ -370,6 +425,9 @@ class SessionIndex:
370
425
  "true" if metadata.get("is_sidechain") else "false"
371
426
  )
372
427
 
428
+ # Custom title (from /rename command)
429
+ doc.add_text("custom_title", metadata.get("customTitle", "") or "")
430
+
373
431
  doc.add_text("content", parsed["content"])
374
432
 
375
433
  writer.add_document(doc)
@@ -426,7 +484,7 @@ class SessionIndex:
426
484
 
427
485
  def _extract_session_content(
428
486
  self, jsonl_path: Path, agent: str
429
- ) -> tuple[str, int]:
487
+ ) -> tuple[str, int, str]:
430
488
  """
431
489
  Extract searchable content from a session file.
432
490
 
@@ -437,10 +495,11 @@ class SessionIndex:
437
495
  agent: Agent type ('claude' or 'codex')
438
496
 
439
497
  Returns:
440
- Tuple of (content_string, user_message_count)
498
+ Tuple of (content_string, user_message_count, custom_title)
441
499
  """
442
500
  messages = []
443
501
  user_count = 0 # Count only user messages for the "lines" metric
502
+ custom_title = "" # Session name from /rename command
444
503
 
445
504
  try:
446
505
  with open(jsonl_path, "r", encoding="utf-8") as f:
@@ -454,6 +513,11 @@ class SessionIndex:
454
513
  except json.JSONDecodeError:
455
514
  continue
456
515
 
516
+ # Check for custom-title (from /rename command)
517
+ if data.get("type") == "custom-title":
518
+ custom_title = data.get("customTitle", "")
519
+ continue
520
+
457
521
  role: Optional[str] = None
458
522
  text: str = ""
459
523
 
@@ -467,9 +531,10 @@ class SessionIndex:
467
531
  message = data.get("message", {})
468
532
  content = message.get("content")
469
533
 
470
- # Count user messages, but exclude tool results
471
- # Tool results have content as list with {"type": "tool_result"}
472
- # Real user messages have content as string or list with text
534
+ # Count user messages, but exclude:
535
+ # - Tool results (content is list with {"type": "tool_result"})
536
+ # - Meta messages (isMeta: true, e.g., "Caveat:" warnings)
537
+ # - Local command messages (<command-name>, <local-command-stdout>)
473
538
  if role == "user":
474
539
  is_tool_result = (
475
540
  isinstance(content, list)
@@ -477,7 +542,12 @@ class SessionIndex:
477
542
  and isinstance(content[0], dict)
478
543
  and content[0].get("type") == "tool_result"
479
544
  )
480
- if not is_tool_result:
545
+ # Check for meta/non-genuine messages
546
+ text_content = content if isinstance(content, str) else ""
547
+ if (
548
+ not is_tool_result
549
+ and not _is_meta_user_message(data, text_content)
550
+ ):
481
551
  user_count += 1
482
552
 
483
553
  if not content:
@@ -496,6 +566,12 @@ class SessionIndex:
496
566
  elif block.get("type") == "tool_use":
497
567
  tool_name = block.get("name", "")
498
568
  text += f"[Tool: {tool_name}]\n"
569
+ # Index tool input content
570
+ tool_input = block.get("input", {})
571
+ if isinstance(tool_input, dict):
572
+ for value in tool_input.values():
573
+ if isinstance(value, str) and value:
574
+ text += f"{value}\n"
499
575
  elif block.get("type") == "tool_result":
500
576
  result = block.get("content", "")
501
577
  if isinstance(result, str):
@@ -511,13 +587,23 @@ class SessionIndex:
511
587
  continue
512
588
 
513
589
  role = payload.get("role")
514
- if role == "user":
515
- user_count += 1
516
590
  content = payload.get("content", [])
517
591
 
518
592
  if not isinstance(content, list):
519
593
  continue
520
594
 
595
+ # Extract text first to check for meta patterns
596
+ codex_text = ""
597
+ for block in content:
598
+ if isinstance(block, dict):
599
+ if block.get("type") in ("input_text", "output_text"):
600
+ codex_text += block.get("text", "")
601
+
602
+ # Count user messages, but exclude non-genuine messages
603
+ if role == "user":
604
+ if not _is_meta_user_message({}, codex_text):
605
+ user_count += 1
606
+
521
607
  for block in content:
522
608
  if not isinstance(block, dict):
523
609
  continue
@@ -532,7 +618,7 @@ class SessionIndex:
532
618
  except (OSError, IOError):
533
619
  pass
534
620
 
535
- return "\n\n".join(messages), user_count
621
+ return "\n\n".join(messages), user_count, custom_title
536
622
 
537
623
  def _parse_jsonl_session(self, jsonl_path: Path) -> Optional[dict[str, Any]]:
538
624
  """
@@ -556,8 +642,14 @@ class SessionIndex:
556
642
  from claude_code_tools.export_session import extract_session_metadata
557
643
  metadata = extract_session_metadata(jsonl_path, agent)
558
644
 
559
- # Extract content for full-text search
560
- content, msg_count = self._extract_session_content(jsonl_path, agent)
645
+ # Extract content for full-text search (also extracts custom_title)
646
+ content, msg_count, custom_title = self._extract_session_content(
647
+ jsonl_path, agent
648
+ )
649
+
650
+ # Add custom_title to metadata (extracted during content scan)
651
+ if custom_title:
652
+ metadata["customTitle"] = custom_title
561
653
 
562
654
  if msg_count == 0:
563
655
  return {"_skip_reason": "empty"}
@@ -565,6 +657,7 @@ class SessionIndex:
565
657
  # Map metadata fields to expected format
566
658
  first_msg = metadata.get("first_msg") or {"role": "", "content": ""}
567
659
  last_msg = metadata.get("last_msg") or {"role": "", "content": ""}
660
+ first_user_msg = metadata.get("first_user_msg") or {"role": "", "content": ""}
568
661
 
569
662
  # Always use filename-derived session_id (the canonical identifier)
570
663
  # Internal sessionId field can be stale in forked sessions
@@ -585,10 +678,12 @@ class SessionIndex:
585
678
  "is_sidechain": metadata.get("is_sidechain", False),
586
679
  "derivation_type": metadata.get("derivation_type", "") or "",
587
680
  "session_type": metadata.get("session_type"),
681
+ "customTitle": metadata.get("customTitle", "") or "",
588
682
  },
589
683
  "content": content,
590
684
  "first_msg": first_msg,
591
685
  "last_msg": last_msg,
686
+ "first_user_msg": first_user_msg,
592
687
  "lines": msg_count,
593
688
  "file_path": str(jsonl_path),
594
689
  }
@@ -671,6 +766,7 @@ class SessionIndex:
671
766
  metadata = parsed["metadata"]
672
767
  first_msg = parsed["first_msg"]
673
768
  last_msg = parsed["last_msg"]
769
+ first_user_msg = parsed.get("first_user_msg", {}) or {}
674
770
 
675
771
  # Skip helper sessions (SDK/headless sessions used for analysis)
676
772
  if metadata.get("session_type") == "helper":
@@ -714,6 +810,7 @@ class SessionIndex:
714
810
  doc.add_text("first_msg_content", first_msg.get("content", ""))
715
811
  doc.add_text("last_msg_role", last_msg.get("role", ""))
716
812
  doc.add_text("last_msg_content", last_msg.get("content", ""))
813
+ doc.add_text("first_user_msg_content", first_user_msg.get("content", ""))
717
814
 
718
815
  # Session type fields
719
816
  doc.add_text(
@@ -724,6 +821,9 @@ class SessionIndex:
724
821
  "true" if metadata.get("is_sidechain") else "false"
725
822
  )
726
823
 
824
+ # Custom title (from /rename command)
825
+ doc.add_text("custom_title", metadata.get("customTitle", "") or "")
826
+
727
827
  # Source home (for filtering by source directory)
728
828
  # Detect from path whether this is a Claude or Codex session
729
829
  agent = metadata.get("agent", "")
@@ -933,6 +1033,7 @@ class SessionIndex:
933
1033
  first_msg_content = doc.get_first("first_msg_content") or ""
934
1034
  last_msg_role = doc.get_first("last_msg_role") or ""
935
1035
  last_msg_content = doc.get_first("last_msg_content") or ""
1036
+ first_user_msg_content = doc.get_first("first_user_msg_content") or ""
936
1037
 
937
1038
  results.append({
938
1039
  "session_id": session_id,
@@ -950,6 +1051,7 @@ class SessionIndex:
950
1051
  "first_msg_content": first_msg_content,
951
1052
  "last_msg_role": last_msg_role,
952
1053
  "last_msg_content": last_msg_content,
1054
+ "first_user_msg_content": first_user_msg_content,
953
1055
  })
954
1056
 
955
1057
  if len(results) >= limit:
@@ -1013,6 +1115,7 @@ class SessionIndex:
1013
1115
  "first_msg_content": doc.get_first("first_msg_content") or "",
1014
1116
  "last_msg_role": doc.get_first("last_msg_role") or "",
1015
1117
  "last_msg_content": doc.get_first("last_msg_content") or "",
1118
+ "first_user_msg_content": doc.get_first("first_user_msg_content") or "",
1016
1119
  "claude_home": doc.get_first("claude_home") or "",
1017
1120
  })
1018
1121
 
@@ -1112,6 +1215,7 @@ class SessionIndex:
1112
1215
  "first_msg_content": doc.get_first("first_msg_content") or "",
1113
1216
  "last_msg_role": doc.get_first("last_msg_role") or "",
1114
1217
  "last_msg_content": doc.get_first("last_msg_content") or "",
1218
+ "first_user_msg_content": doc.get_first("first_user_msg_content") or "",
1115
1219
  "derivation_type": doc.get_first("derivation_type") or "",
1116
1220
  "is_sidechain": doc.get_first("is_sidechain") or "false",
1117
1221
  }
@@ -424,7 +424,7 @@ Examples:
424
424
  }
425
425
 
426
426
  def handler(session_dict_in, action, kwargs=None):
427
- execute_action(
427
+ return execute_action(
428
428
  action,
429
429
  agent,
430
430
  session_file,
@@ -604,7 +604,7 @@ def is_valid_session(filepath: Path) -> bool:
604
604
 
605
605
  # Whitelist of resumable message types
606
606
  # Claude Code types (require sessionId)
607
- claude_valid_types = {"user", "assistant", "tool_result", "tool_use"}
607
+ claude_valid_types = {"user", "assistant", "tool_result", "tool_use", "system"}
608
608
  # Codex types (conversation content types)
609
609
  codex_valid_types = {"event_msg", "response_item", "turn_context"}
610
610
 
@@ -1167,7 +1167,7 @@ This session continues from a previous conversation. The prior session log is:
1167
1167
 
1168
1168
  {session_file}
1169
1169
 
1170
- The file is in JSONL format. You can use sub-agents to explore it if needed.
1170
+ The file is in JSONL format. Since it can be large, use appropriate strategies (such as sub-agents if available) to carefully explore it if you need context.
1171
1171
 
1172
1172
  Do not do anything yet. Simply greet the user and await instructions on how they want to continue the work based on the above session."""
1173
1173
  else:
@@ -1182,7 +1182,7 @@ This session continues from a chain of prior conversations. Here are the JSONL s
1182
1182
  - "trimmed" = Long messages were truncated to free up context. Full content in parent session.
1183
1183
  - "rolled over" = Work handed off to fresh session.
1184
1184
 
1185
- You can use sub-agents to explore these files if you need context.
1185
+ Since these files can be large, use appropriate strategies (such as sub-agents if available) to carefully explore them if you need context.
1186
1186
 
1187
1187
  Do not do anything yet. Simply greet the user and await instructions on how they want to continue the work based on the above sessions."""
1188
1188
 
@@ -12,7 +12,7 @@ from typing import List, Optional
12
12
 
13
13
  from claude_code_tools.smart_trim_core import identify_trimmable_lines_cli, SMART_TRIM_THRESHOLD
14
14
  from claude_code_tools.trim_session import detect_agent, inject_lineage_into_first_user_message
15
- from claude_code_tools.session_utils import get_claude_home, resolve_session_path
15
+ from claude_code_tools.session_utils import get_claude_home, get_session_uuid, resolve_session_path
16
16
 
17
17
 
18
18
  def trim_lines(
@@ -334,18 +334,28 @@ def main():
334
334
  line_indices = [item[0] for item in trimmable]
335
335
 
336
336
  # Determine output file
337
- output_dir = args.output_dir or session_file.parent
338
- output_dir.mkdir(parents=True, exist_ok=True)
339
-
340
337
  # Use agent type already detected earlier
341
338
  if agent == "claude":
342
- # Generate new UUID for trimmed session
339
+ # Claude: output in same directory or specified output_dir
340
+ output_dir = args.output_dir or session_file.parent
341
+ output_dir.mkdir(parents=True, exist_ok=True)
343
342
  new_uuid = str(uuid.uuid4())
344
343
  output_file = output_dir / f"{new_uuid}.jsonl"
345
344
  else:
346
- # Codex style
347
- timestamp = datetime.datetime.now().strftime("%Y-%m-%dT%H-%M-%S")
345
+ # Codex: new session goes in today's date folder (YYYY/MM/DD)
346
+ now = datetime.datetime.now()
347
+ timestamp = now.strftime("%Y-%m-%dT%H-%M-%S")
348
+ date_path = now.strftime("%Y/%m/%d")
348
349
  new_uuid = str(uuid.uuid4())
350
+
351
+ if args.output_dir:
352
+ output_dir = args.output_dir / date_path
353
+ else:
354
+ # Find sessions root by going up from input file (sessions/YYYY/MM/DD/file.jsonl)
355
+ sessions_root = session_file.parent.parent.parent.parent
356
+ output_dir = sessions_root / date_path
357
+
358
+ output_dir.mkdir(parents=True, exist_ok=True)
349
359
  output_file = output_dir / f"rollout-{timestamp}-{new_uuid}.jsonl"
350
360
 
351
361
  # Perform trimming (pass descriptions for truncation summaries)
@@ -407,7 +417,7 @@ def main():
407
417
  print(f" Tokens saved (est): ~{stats['tokens_saved']:,}")
408
418
  print()
409
419
  print(f"📄 Output: {output_file}")
410
- print(f" Session ID: {output_file.stem}")
420
+ print(f" Session ID: {get_session_uuid(output_file.name)}")
411
421
 
412
422
 
413
423
  if __name__ == "__main__":
@@ -8,6 +8,7 @@ from typing import List, Optional, Any, Dict
8
8
  from claude_code_tools.session_utils import (
9
9
  get_codex_home,
10
10
  get_claude_home,
11
+ get_session_uuid,
11
12
  encode_claude_project_path,
12
13
  mark_session_as_helper,
13
14
  )
@@ -181,8 +182,8 @@ def analyze_session_with_cli(
181
182
 
182
183
  session_content = "\n".join(content_parts)
183
184
 
184
- # Extract session_id from filename
185
- session_id = session_file.stem
185
+ # Extract session_id (UUID) from filename - works for both Claude and Codex formats
186
+ session_id = get_session_uuid(session_file.name)
186
187
 
187
188
  # Determine if this is a Codex session (for output directory placement)
188
189
  # Resolve paths to handle ~ and relative paths correctly
@@ -224,6 +225,7 @@ def analyze_session_with_cli(
224
225
  result = subprocess.run(
225
226
  [
226
227
  "claude", "-p",
228
+ "--no-session-persistence",
227
229
  "--output-format", "json",
228
230
  "--permission-mode", "bypassPermissions",
229
231
  prompt,
@@ -145,13 +145,19 @@ class TmuxCLIController:
145
145
 
146
146
  def format_pane_identifier(self, pane_id: str) -> str:
147
147
  """Convert pane ID to session:window.pane format."""
148
+ # Validate pane_id is not empty
149
+ if not pane_id:
150
+ return pane_id
151
+
148
152
  try:
149
153
  # Get session, window index, and pane index for this pane
150
154
  session_output, session_code = self._run_tmux_command(['display-message', '-t', pane_id, '-p', '#{session_name}'])
151
155
  window_output, window_code = self._run_tmux_command(['display-message', '-t', pane_id, '-p', '#{window_index}'])
152
156
  pane_output, pane_code = self._run_tmux_command(['display-message', '-t', pane_id, '-p', '#{pane_index}'])
153
-
154
- if session_code == 0 and window_code == 0 and pane_code == 0:
157
+
158
+ # Check both return codes AND that outputs are non-empty
159
+ if (session_code == 0 and window_code == 0 and pane_code == 0 and
160
+ session_output and window_output and pane_output):
155
161
  return f"{session_output}:{window_output}.{pane_output}"
156
162
  else:
157
163
  # Fallback to pane ID
@@ -275,31 +281,36 @@ class TmuxCLIController:
275
281
  """
276
282
  # Get the current window ID to ensure pane is created in this window
277
283
  current_window_id = self.get_current_window_id()
278
-
279
- cmd = ['split-window']
280
-
284
+
285
+ # Build base command
286
+ base_cmd = ['split-window']
287
+
281
288
  # Target the specific window where tmux-cli was called from
282
289
  if current_window_id:
283
- cmd.extend(['-t', current_window_id])
284
-
290
+ base_cmd.extend(['-t', current_window_id])
291
+
285
292
  if vertical:
286
- cmd.append('-h')
293
+ base_cmd.append('-h')
287
294
  else:
288
- cmd.append('-v')
289
-
290
- if size:
291
- cmd.extend(['-p', str(size)])
292
-
293
- cmd.extend(['-P', '-F', '#{pane_id}'])
294
-
295
- if start_command:
296
- cmd.append(start_command)
297
-
298
- output, code = self._run_tmux_command(cmd)
299
-
300
- if code == 0:
301
- self.target_pane = output
302
- return output
295
+ base_cmd.append('-v')
296
+
297
+ # Try -l first (tmux 3.4+), fall back to -p (older versions)
298
+ # tmux 3.4 removed -p, but 3.5+ has both; older versions only have -p
299
+ for size_flag in (['-l', f'{size}%'], ['-p', str(size)]) if size else [[]]:
300
+ cmd = base_cmd.copy()
301
+ if size_flag:
302
+ cmd.extend(size_flag)
303
+ cmd.extend(['-P', '-F', '#{pane_id}'])
304
+ if start_command:
305
+ cmd.append(start_command)
306
+
307
+ output, code = self._run_tmux_command(cmd)
308
+
309
+ # Validate: code must be 0 and output must be a valid pane ID (starts with %)
310
+ if code == 0 and output and output.startswith('%'):
311
+ self.target_pane = output
312
+ return output
313
+
303
314
  return None
304
315
 
305
316
  def select_pane(self, pane_id: Optional[str] = None, pane_index: Optional[int] = None):
@@ -686,7 +697,6 @@ class CLI:
686
697
  else:
687
698
  # Remote mode - pass pane_id directly
688
699
  content = self.controller.capture_pane(pane_id=pane, lines=lines)
689
- print(content)
690
700
  return content
691
701
 
692
702
  def interrupt(self, pane: Optional[str] = None):
@@ -925,4 +935,4 @@ def main():
925
935
 
926
936
 
927
937
  if __name__ == '__main__':
928
- main()
938
+ main()
@@ -365,6 +365,9 @@ def create_placeholder(tool_name: str, original_length: int) -> str:
365
365
  )
366
366
 
367
367
 
368
+ MIN_TOKEN_SAVINGS = 300 # Minimum tokens saved to consider trim worthwhile
369
+
370
+
368
371
  def trim_and_create_session(
369
372
  agent: Optional[str],
370
373
  input_file: Path,
@@ -372,6 +375,7 @@ def trim_and_create_session(
372
375
  threshold: int,
373
376
  output_dir: Optional[Path] = None,
374
377
  trim_assistant_messages: Optional[int] = None,
378
+ min_token_savings: int = MIN_TOKEN_SAVINGS,
375
379
  ) -> dict:
376
380
  """
377
381
  Trim tool results and assistant messages, creating a new session file.
@@ -386,16 +390,19 @@ def trim_and_create_session(
386
390
  - Positive N: trim first N assistant messages exceeding threshold
387
391
  - Negative N: trim all except last abs(N) assistant messages exceeding threshold
388
392
  - None: don't trim assistant messages
393
+ min_token_savings: Minimum tokens saved to consider trim worthwhile.
394
+ If savings are below this, deletes output file and sets nothing_to_trim=True.
389
395
 
390
396
  Returns:
391
397
  Dict with:
392
- - session_id: New session UUID
393
- - output_file: Path to new session file
398
+ - session_id: New session UUID (None if nothing_to_trim)
399
+ - output_file: Path to new session file (None if nothing_to_trim)
394
400
  - num_tools_trimmed: Number of tool results trimmed
395
401
  - num_assistant_trimmed: Number of assistant messages trimmed
396
402
  - chars_saved: Characters saved
397
403
  - tokens_saved: Estimated tokens saved
398
404
  - detected_agent: Detected agent type
405
+ - nothing_to_trim: True if savings below min_token_savings threshold
399
406
  """
400
407
  import json
401
408
  from datetime import datetime, timezone
@@ -471,6 +478,9 @@ def trim_and_create_session(
471
478
  try:
472
479
  # Parse first line and add metadata fields
473
480
  first_line_data = json.loads(lines[0])
481
+ # Remove continue_metadata if present - a session is either trimmed OR
482
+ # continued, not both. The trim_metadata.parent_file preserves ancestry.
483
+ first_line_data.pop("continue_metadata", None)
474
484
  first_line_data.update(metadata_fields)
475
485
  lines[0] = json.dumps(first_line_data) + "\n"
476
486
 
@@ -481,6 +491,21 @@ def trim_and_create_session(
481
491
  # If first line is not valid JSON, leave file as-is
482
492
  pass
483
493
 
494
+ # Check if savings are worth it
495
+ if tokens_saved < min_token_savings:
496
+ # Not worth trimming - delete output file and return nothing_to_trim flag
497
+ output_path.unlink(missing_ok=True)
498
+ return {
499
+ "session_id": None,
500
+ "output_file": None,
501
+ "num_tools_trimmed": num_tools_trimmed,
502
+ "num_assistant_trimmed": num_assistant_trimmed,
503
+ "chars_saved": chars_saved,
504
+ "tokens_saved": tokens_saved,
505
+ "detected_agent": agent,
506
+ "nothing_to_trim": True,
507
+ }
508
+
484
509
  # Inject parent session lineage into first user message
485
510
  inject_lineage_into_first_user_message(output_path, input_file, agent)
486
511
 
@@ -492,6 +517,7 @@ def trim_and_create_session(
492
517
  "chars_saved": chars_saved,
493
518
  "tokens_saved": tokens_saved,
494
519
  "detected_agent": agent,
520
+ "nothing_to_trim": False,
495
521
  }
496
522
 
497
523