claude-code-tools 1.0.6__py3-none-any.whl → 1.4.1__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 (32) 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 +9 -5
  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 +83 -9
  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.1.dist-info/METADATA +1113 -0
  20. {claude_code_tools-1.0.6.dist-info → claude_code_tools-1.4.1.dist-info}/RECORD +30 -24
  21. {claude_code_tools-1.0.6.dist-info → claude_code_tools-1.4.1.dist-info}/entry_points.txt +2 -0
  22. docs/local-llm-setup.md +286 -0
  23. docs/reddit-aichat-resume-v2.md +80 -0
  24. docs/reddit-aichat-resume.md +29 -0
  25. docs/reddit-aichat.md +79 -0
  26. docs/rollover-details.md +67 -0
  27. node_ui/action_config.js +3 -3
  28. node_ui/menu.js +67 -113
  29. claude_code_tools/session_tui.py +0 -516
  30. claude_code_tools-1.0.6.dist-info/METADATA +0 -685
  31. {claude_code_tools-1.0.6.dist-info → claude_code_tools-1.4.1.dist-info}/WHEEL +0 -0
  32. {claude_code_tools-1.0.6.dist-info → claude_code_tools-1.4.1.dist-info}/licenses/LICENSE +0 -0
@@ -32,8 +32,18 @@ class SessionIDGroup(click.Group):
32
32
 
33
33
  @click.group(cls=SessionIDGroup, invoke_without_command=True)
34
34
  @click.version_option()
35
+ @click.option(
36
+ '--claude-home',
37
+ type=click.Path(exists=True, file_okay=False, dir_okay=True),
38
+ help='Claude home directory (default: ~/.claude or CLAUDE_CONFIG_DIR)',
39
+ )
40
+ @click.option(
41
+ '--codex-home',
42
+ type=click.Path(exists=True, file_okay=False, dir_okay=True),
43
+ help='Codex home directory (default: ~/.codex or CODEX_HOME)',
44
+ )
35
45
  @click.pass_context
36
- def main(ctx):
46
+ def main(ctx, claude_home, codex_home):
37
47
  """
38
48
  Session management tools for Claude Code and Codex.
39
49
 
@@ -56,7 +66,17 @@ def main(ctx):
56
66
  aichat resume abc123-def456 # Resume specific session
57
67
  aichat menu abc123-def456
58
68
  aichat trim session-id.jsonl
69
+
70
+ \b
71
+ Environment variables:
72
+ CLAUDE_CONFIG_DIR - Default Claude home (overridden by --claude-home)
73
+ CODEX_HOME - Default Codex home (overridden by --codex-home)
59
74
  """
75
+ # Store home dirs in context for subcommands to access
76
+ ctx.ensure_object(dict)
77
+ ctx.obj['claude_home'] = claude_home
78
+ ctx.obj['codex_home'] = codex_home
79
+
60
80
  # Auto-index sessions on every aichat command (incremental, fast if up-to-date)
61
81
  # Skip for build-index/clear-index to avoid double-indexing or state conflicts
62
82
  # In JSON mode (-j/--json), suppress all output for clean parsing
@@ -68,10 +88,10 @@ def main(ctx):
68
88
  try:
69
89
  from claude_code_tools.search_index import auto_index
70
90
  from claude_code_tools.session_utils import get_claude_home, get_codex_home
71
- # Respect CLAUDE_CONFIG_DIR and CODEX_HOME environment variables
91
+ # Respect CLI args, then env vars, then defaults
72
92
  auto_index(
73
- claude_home=get_claude_home(),
74
- codex_home=get_codex_home(),
93
+ claude_home=get_claude_home(cli_arg=claude_home),
94
+ codex_home=get_codex_home(cli_arg=codex_home),
75
95
  verbose=False,
76
96
  silent=json_mode,
77
97
  )
@@ -157,7 +177,75 @@ def _find_and_run_session_ui(
157
177
  from claude_code_tools.session_utils import default_export_path
158
178
 
159
179
  if session_id:
160
- # Route to session_menu_cli with appropriate start screen
180
+ if direct_action:
181
+ # Direct action mode: bypass session_menu_cli for proper context
182
+ # This allows direct_action to be passed to Node UI for correct
183
+ # escape behavior (exit to shell vs. go to resume menu)
184
+ from claude_code_tools.session_utils import (
185
+ find_session_file,
186
+ default_export_path,
187
+ detect_agent_from_path,
188
+ )
189
+
190
+ # Find session file
191
+ input_path = Path(session_id).expanduser()
192
+ if input_path.exists() and input_path.is_file():
193
+ session_file = input_path
194
+ agent = detect_agent_from_path(session_file)
195
+ project_path = str(session_file.parent)
196
+ git_branch = None
197
+ else:
198
+ result = find_session_file(session_id)
199
+ if not result:
200
+ print(f"Error: Session not found: {session_id}", file=sys.stderr)
201
+ sys.exit(1)
202
+ agent, session_file, project_path, git_branch = result
203
+
204
+ # Build session dict for Node UI
205
+ session_dict = {
206
+ "agent": agent,
207
+ "agent_display": "Claude" if agent == "claude" else "Codex",
208
+ "session_id": session_file.stem,
209
+ "mod_time": session_file.stat().st_mtime,
210
+ "create_time": session_file.stat().st_ctime,
211
+ "lines": 0, # Not needed for direct action
212
+ "project": Path(project_path).name,
213
+ "preview": "",
214
+ "cwd": project_path,
215
+ "branch": git_branch or "",
216
+ "file_path": str(session_file),
217
+ "default_export_path": str(default_export_path(session_file, agent)),
218
+ "is_trimmed": False,
219
+ "derivation_type": None,
220
+ "is_sidechain": False,
221
+ }
222
+
223
+ # Handler for actions
224
+ def handler(sess, action, kwargs=None):
225
+ merged_kwargs = {**(action_kwargs or {}), **(kwargs or {})}
226
+ return execute_action(
227
+ action,
228
+ sess["agent"],
229
+ Path(sess["file_path"]),
230
+ sess["cwd"],
231
+ action_kwargs=merged_kwargs if merged_kwargs else None,
232
+ session_id=sess.get("session_id"),
233
+ )
234
+
235
+ rpc_path = str(Path(__file__).parent / "action_rpc.py")
236
+
237
+ # Run Node UI directly with direct_action for proper escape behavior
238
+ run_node_menu_ui(
239
+ [session_dict],
240
+ [],
241
+ handler,
242
+ start_screen=start_screen,
243
+ rpc_path=rpc_path,
244
+ direct_action=direct_action,
245
+ )
246
+ return
247
+
248
+ # Normal mode (no direct_action): route through session_menu_cli
161
249
  sys.argv = [
162
250
  sys.argv[0].replace('aichat', 'session-menu'),
163
251
  '--start-screen', start_screen,
@@ -459,22 +547,71 @@ def menu(ctx):
459
547
 
460
548
  @main.command("trim")
461
549
  @click.argument("session", required=False)
462
- @click.option("--simple-ui", is_flag=True, help="Use simple CLI trim instead of Node UI")
463
- def trim(session, simple_ui):
464
- """Trim/resume session - shows menu of trim options.
550
+ @click.option("--tools", "-t",
551
+ help="Comma-separated tools to trim (e.g., 'bash,read,edit'). "
552
+ "Default: all tools.")
553
+ @click.option("--len", "-l", "threshold", type=int, default=500,
554
+ help="Minimum length threshold in chars for trimming (default: 500)")
555
+ @click.option("--trim-assistant", "-a", "trim_assistant", type=int,
556
+ help="Trim assistant messages: positive N trims first N over threshold, "
557
+ "negative N trims all except last |N| over threshold")
558
+ @click.option("--output-dir", "-o",
559
+ help="Output directory (default: same as input file)")
560
+ @click.option("--agent", type=click.Choice(["claude", "codex"], case_sensitive=False),
561
+ help="Force agent type (auto-detected if not specified)")
562
+ @click.option("--claude-home", help="Path to Claude home directory")
563
+ @click.option("--simple-ui", is_flag=True,
564
+ help="Use simple CLI trim instead of Node UI (requires session ID)")
565
+ def trim(session, tools, threshold, trim_assistant, output_dir, agent, claude_home, simple_ui):
566
+ """Trim session to reduce size by truncating large tool outputs.
567
+
568
+ Trims tool call results and optionally assistant messages that exceed
569
+ the length threshold. Creates a new trimmed session file with lineage
570
+ metadata linking back to the original.
571
+
572
+ If no session ID provided, finds latest session for current project/branch
573
+ and opens the interactive trim menu.
465
574
 
466
- If no session ID provided, finds latest session for current project/branch.
467
- Opens Node UI with trim/resume options (trim+resume, smart-trim).
468
- Use --simple-ui for direct CLI trim.
575
+ \b
576
+ Examples:
577
+ aichat trim # Interactive menu for latest session
578
+ aichat trim abc123 # Interactive menu for specific session
579
+ aichat trim abc123 --simple-ui # Direct CLI trim with defaults
580
+ aichat trim abc123 -t bash,read -l 1000 # Trim specific tools, custom threshold
581
+ aichat trim abc123 -a 5 # Also trim first 5 long assistant msgs
582
+
583
+ \b
584
+ Options:
585
+ --tools, -t Which tool outputs to trim (default: all)
586
+ --len, -l Character threshold for trimming (default: 500)
587
+ --trim-assistant, -a Trim assistant messages too
588
+ --output-dir, -o Where to write trimmed file
469
589
  """
470
590
  import sys
471
591
 
472
- if simple_ui:
592
+ if simple_ui or tools or trim_assistant or output_dir:
593
+ # Direct CLI mode - need a session
473
594
  if not session:
474
- print("Error: --simple-ui requires a session ID", file=sys.stderr)
595
+ print("Error: Direct trim options require a session ID", file=sys.stderr)
596
+ print("Use 'aichat trim' without options for interactive mode", file=sys.stderr)
475
597
  sys.exit(1)
476
- # Fall back to old trim-session CLI
477
- sys.argv = [sys.argv[0].replace('aichat', 'trim-session'), session]
598
+
599
+ # Build args for trim-session CLI
600
+ args = [session]
601
+ if tools:
602
+ args.extend(["--tools", tools])
603
+ if threshold != 500:
604
+ args.extend(["--len", str(threshold)])
605
+ if trim_assistant is not None:
606
+ args.extend(["--trim-assistant-messages", str(trim_assistant)])
607
+ if output_dir:
608
+ args.extend(["--output-dir", output_dir])
609
+ if agent:
610
+ args.extend(["--agent", agent])
611
+ if claude_home:
612
+ args.extend(["--claude-home", claude_home])
613
+
614
+ sys.argv = [sys.argv[0].replace('aichat', 'trim-session')] + args
478
615
  from claude_code_tools.trim_session import main as trim_main
479
616
  trim_main()
480
617
  return
@@ -488,32 +625,122 @@ def trim(session, simple_ui):
488
625
  )
489
626
 
490
627
 
491
- @main.command("smart-trim", context_settings={"ignore_unknown_options": True, "allow_extra_args": True, "allow_interspersed_args": False})
492
- @click.pass_context
493
- def smart_trim(ctx):
628
+ @main.command("smart-trim")
629
+ @click.argument("session", required=False)
630
+ @click.option("--instructions", "-i",
631
+ help="Custom instructions for what to prioritize when trimming")
632
+ @click.option("--exclude-types", "-e",
633
+ help="Comma-separated message types to never trim (default: user)")
634
+ @click.option("--preserve-recent", "-p", type=int, default=10,
635
+ help="Always preserve last N messages (default: 10)")
636
+ @click.option("--len", "-l", "content_threshold", type=int, default=200,
637
+ help="Minimum chars for content extraction (default: 200)")
638
+ @click.option("--output-dir", "-o",
639
+ help="Output directory (default: same as input)")
640
+ @click.option("--dry-run", "-n", is_flag=True,
641
+ help="Show what would be trimmed without doing it")
642
+ @click.option("--claude-home", help="Path to Claude home directory")
643
+ def smart_trim(session, instructions, exclude_types, preserve_recent, content_threshold,
644
+ output_dir, dry_run, claude_home):
494
645
  """Smart trim using Claude SDK agents (EXPERIMENTAL).
495
646
 
647
+ Uses an LLM to intelligently analyze the session and decide what can
648
+ be safely trimmed while preserving important context. Creates a new
649
+ trimmed session with lineage metadata.
650
+
496
651
  If no session ID provided, finds latest session for current project/branch.
652
+
653
+ \b
654
+ Examples:
655
+ aichat smart-trim # Interactive for latest session
656
+ aichat smart-trim abc123 # Smart trim specific session
657
+ aichat smart-trim abc123 --dry-run # Preview what would be trimmed
658
+ aichat smart-trim abc123 -p 20 # Preserve last 20 messages
659
+ aichat smart-trim abc123 -e user,system # Never trim user or system msgs
660
+ aichat smart-trim abc123 -i "preserve auth-related messages"
661
+
662
+ \b
663
+ Options:
664
+ --instructions, -i Custom instructions for what to prioritize
665
+ --exclude-types, -e Message types to EXCLUDE from trimming
666
+ --preserve-recent, -p Keep last N messages untouched (default: 10)
667
+ --len, -l Min chars for content extraction (default: 200)
668
+ --dry-run, -n Preview only, don't actually trim
497
669
  """
498
670
  import sys
499
- args = ctx.args
500
- session_id = args[0] if args and not args[0].startswith('-') else None
671
+ from pathlib import Path
672
+
673
+ from claude_code_tools.session_utils import find_session_file, detect_agent_from_path
674
+
675
+ # If --instructions provided, use the handler function (same as TUI)
676
+ if instructions and session:
677
+ input_path = Path(session).expanduser()
678
+ if input_path.exists() and input_path.is_file():
679
+ session_file = input_path
680
+ detected_agent = detect_agent_from_path(session_file)
681
+ session_id = session_file.stem
682
+ project_path = str(session_file.parent)
683
+ else:
684
+ result = find_session_file(session)
685
+ if not result:
686
+ print(f"Error: Session not found: {session}", file=sys.stderr)
687
+ sys.exit(1)
688
+ detected_agent, session_file, project_path, _ = result
689
+ session_id = session_file.stem
690
+
691
+ # Use the handler function which supports custom_instructions
692
+ if detected_agent == "claude":
693
+ from claude_code_tools.find_claude_session import handle_smart_trim_resume_claude
694
+ handle_smart_trim_resume_claude(
695
+ session_id, project_path, claude_home,
696
+ custom_instructions=instructions,
697
+ )
698
+ else:
699
+ from claude_code_tools.find_codex_session import handle_smart_trim_resume_codex
700
+ from claude_code_tools.session_utils import get_codex_home
701
+ handle_smart_trim_resume_codex(
702
+ {"file_path": str(session_file), "cwd": project_path, "session_id": session_id},
703
+ Path(get_codex_home(cli_arg=None)),
704
+ custom_instructions=instructions,
705
+ )
706
+ return
707
+
708
+ # If any direct CLI options specified (but not instructions), use smart_trim.py
709
+ if exclude_types or preserve_recent != 10 or content_threshold != 200 or output_dir or dry_run:
710
+ if not session:
711
+ print("Error: Direct smart-trim options require a session ID", file=sys.stderr)
712
+ print("Use 'aichat smart-trim' without options for interactive mode", file=sys.stderr)
713
+ sys.exit(1)
714
+
715
+ # Build args for smart-trim CLI
716
+ args = [session]
717
+ if exclude_types:
718
+ args.extend(["--exclude-types", exclude_types])
719
+ if preserve_recent != 10:
720
+ args.extend(["--preserve-recent", str(preserve_recent)])
721
+ if content_threshold != 200:
722
+ args.extend(["--content-threshold", str(content_threshold)])
723
+ if output_dir:
724
+ args.extend(["--output-dir", output_dir])
725
+ if dry_run:
726
+ args.append("--dry-run")
727
+ if claude_home:
728
+ args.extend(["--claude-home", claude_home])
501
729
 
502
- if session_id:
503
- # Pass to existing smart_trim CLI
504
730
  sys.argv = [sys.argv[0].replace('aichat', 'smart-trim')] + args
505
731
  from claude_code_tools.smart_trim import main as smart_trim_main
506
732
  smart_trim_main()
507
- else:
508
- # Find latest session and execute smart_trim_resume directly
509
- _find_and_run_session_ui(
510
- session_id=None,
511
- agent_constraint='both',
512
- start_screen='trim_menu', # Fallback if multiple sessions
513
- select_target='trim_menu',
514
- results_title=' Which session to smart-trim? ',
515
- direct_action='smart_trim_resume',
516
- )
733
+ return
734
+
735
+ # Show interactive UI for entering instructions
736
+ # (whether session provided or not - find latest if not)
737
+ _find_and_run_session_ui(
738
+ session_id=session,
739
+ agent_constraint='both',
740
+ start_screen='smart_trim_form',
741
+ select_target='smart_trim_form',
742
+ results_title=' Which session to smart-trim? ',
743
+ )
517
744
 
518
745
 
519
746
  @main.command("export", context_settings={"ignore_unknown_options": True, "allow_extra_args": True, "allow_interspersed_args": False})
@@ -660,15 +887,528 @@ def delete(ctx):
660
887
  delete_main()
661
888
 
662
889
 
890
+ @main.command("info")
891
+ @click.argument("session", required=False)
892
+ @click.option("--agent", type=click.Choice(["claude", "codex"], case_sensitive=False),
893
+ help="Force agent type (auto-detected if not specified)")
894
+ @click.option("--json", "json_output", is_flag=True, help="Output as JSON")
895
+ def info(session, agent, json_output):
896
+ """Show information about a session.
897
+
898
+ Displays session metadata including file path, agent type, project,
899
+ branch, timestamps, message counts, and lineage (parent sessions).
900
+
901
+ If no session ID provided, shows info for latest session in current
902
+ project/branch.
903
+
904
+ \b
905
+ Examples:
906
+ aichat info # Info for latest session
907
+ aichat info abc123-def456 # Info for specific session
908
+ aichat info --json abc123 # Output as JSON
909
+ """
910
+ import json as json_lib
911
+ import sys
912
+ from pathlib import Path
913
+ from datetime import datetime
914
+
915
+ from claude_code_tools.session_utils import (
916
+ find_session_file,
917
+ detect_agent_from_path,
918
+ extract_cwd_from_session,
919
+ count_user_messages,
920
+ default_export_path,
921
+ )
922
+ from claude_code_tools.session_lineage import get_continuation_lineage
923
+
924
+ # Find session file
925
+ if session:
926
+ input_path = Path(session).expanduser()
927
+ if input_path.exists() and input_path.is_file():
928
+ session_file = input_path
929
+ detected_agent = agent or detect_agent_from_path(session_file)
930
+ else:
931
+ result = find_session_file(session)
932
+ if not result:
933
+ print(f"Error: Session not found: {session}", file=sys.stderr)
934
+ sys.exit(1)
935
+ detected_agent, session_file, _, _ = result
936
+ if agent:
937
+ detected_agent = agent
938
+ else:
939
+ # No session provided - find latest
940
+ _find_and_run_session_ui(
941
+ session_id=None,
942
+ agent_constraint='both',
943
+ start_screen='action',
944
+ direct_action='path', # Just show path for now
945
+ )
946
+ return
947
+
948
+ # Gather session info
949
+ session_id = session_file.stem
950
+ mod_time = datetime.fromtimestamp(session_file.stat().st_mtime)
951
+ create_time = datetime.fromtimestamp(session_file.stat().st_ctime)
952
+ file_size = session_file.stat().st_size
953
+ line_count = sum(1 for _ in open(session_file))
954
+
955
+ # Extract metadata from session
956
+ from claude_code_tools.export_session import extract_session_metadata
957
+ metadata = extract_session_metadata(session_file, detected_agent)
958
+ cwd = metadata.get("cwd") or extract_cwd_from_session(session_file)
959
+ project = Path(cwd).name if cwd else "unknown"
960
+ custom_title = metadata.get("customTitle", "")
961
+ user_msg_count = count_user_messages(session_file, detected_agent)
962
+
963
+ # Get lineage
964
+ lineage = get_continuation_lineage(session_file, export_missing=False)
965
+ lineage_info = []
966
+ for node in lineage:
967
+ lineage_info.append({
968
+ "file": str(node.session_file),
969
+ "type": node.derivation_type or "original",
970
+ })
971
+
972
+ info_data = {
973
+ "session_id": session_id,
974
+ "agent": detected_agent,
975
+ "custom_title": custom_title,
976
+ "file_path": str(session_file),
977
+ "project": project,
978
+ "cwd": cwd,
979
+ "created": create_time.isoformat(),
980
+ "modified": mod_time.isoformat(),
981
+ "file_size_bytes": file_size,
982
+ "line_count": line_count,
983
+ "user_message_count": user_msg_count,
984
+ "export_path": str(default_export_path(session_file, detected_agent)),
985
+ "lineage": lineage_info,
986
+ }
987
+
988
+ if json_output:
989
+ print(json_lib.dumps(info_data, indent=2))
990
+ else:
991
+ print(f"\n{'='*60}")
992
+ print(f"Session: {session_id}")
993
+ if custom_title:
994
+ print(f"Title: {custom_title}")
995
+ print(f"{'='*60}")
996
+ print(f"Agent: {detected_agent}")
997
+ print(f"Project: {project}")
998
+ print(f"CWD: {cwd}")
999
+ print(f"File: {session_file}")
1000
+ print(f"Created: {create_time.strftime('%Y-%m-%d %H:%M:%S')}")
1001
+ print(f"Modified: {mod_time.strftime('%Y-%m-%d %H:%M:%S')}")
1002
+ print(f"Size: {file_size:,} bytes")
1003
+ print(f"Lines: {line_count:,}")
1004
+ print(f"User msgs: {user_msg_count}")
1005
+ if lineage_info:
1006
+ print(f"\nLineage ({len(lineage_info)} sessions):")
1007
+ for i, node in enumerate(lineage_info):
1008
+ prefix = " └─" if i == len(lineage_info) - 1 else " ├─"
1009
+ fname = Path(node["file"]).name
1010
+ print(f"{prefix} {fname} ({node['type']})")
1011
+
1012
+
1013
+ @main.command("copy")
1014
+ @click.argument("session", required=False)
1015
+ @click.option("--dest", "-d", help="Destination path (default: prompted)")
1016
+ @click.option("--agent", type=click.Choice(["claude", "codex"], case_sensitive=False),
1017
+ help="Force agent type (auto-detected if not specified)")
1018
+ def copy_session(session, dest, agent):
1019
+ """Copy a session file to a new location.
1020
+
1021
+ If no session ID provided, finds latest session for current project/branch.
1022
+
1023
+ \b
1024
+ Examples:
1025
+ aichat copy abc123-def456 # Copy with prompted destination
1026
+ aichat copy abc123 -d ~/backups/ # Copy to specific directory
1027
+ aichat copy abc123 -d ./my-session.jsonl # Copy with specific filename
1028
+ """
1029
+ import sys
1030
+ from pathlib import Path
1031
+
1032
+ from claude_code_tools.session_utils import find_session_file, detect_agent_from_path
1033
+
1034
+ if not session:
1035
+ _find_and_run_session_ui(
1036
+ session_id=None,
1037
+ agent_constraint='both',
1038
+ start_screen='action',
1039
+ direct_action='copy',
1040
+ )
1041
+ return
1042
+
1043
+ # Find session file
1044
+ input_path = Path(session).expanduser()
1045
+ if input_path.exists() and input_path.is_file():
1046
+ session_file = input_path
1047
+ detected_agent = agent or detect_agent_from_path(session_file)
1048
+ else:
1049
+ result = find_session_file(session)
1050
+ if not result:
1051
+ print(f"Error: Session not found: {session}", file=sys.stderr)
1052
+ sys.exit(1)
1053
+ detected_agent, session_file, _, _ = result
1054
+ if agent:
1055
+ detected_agent = agent
1056
+
1057
+ # Import agent-specific copy function
1058
+ if detected_agent == "claude":
1059
+ from claude_code_tools.find_claude_session import copy_session_file
1060
+ else:
1061
+ from claude_code_tools.find_codex_session import copy_session_file
1062
+
1063
+ copy_session_file(str(session_file), dest)
1064
+
1065
+
1066
+ @main.command("query")
1067
+ @click.argument("session", required=False)
1068
+ @click.argument("question", required=False)
1069
+ @click.option("--agent", type=click.Choice(["claude", "codex"], case_sensitive=False),
1070
+ help="Force agent type (auto-detected if not specified)")
1071
+ def query_session(session, question, agent):
1072
+ """Query a session with a question using an AI agent.
1073
+
1074
+ Exports the session and uses Claude to answer questions about its content.
1075
+ If no question provided, opens interactive query mode.
1076
+
1077
+ \b
1078
+ Examples:
1079
+ aichat query abc123 "What was the main bug fixed?"
1080
+ aichat query abc123 "Summarize the changes made"
1081
+ aichat query # Interactive mode for latest session
1082
+ """
1083
+ import sys
1084
+ from pathlib import Path
1085
+
1086
+ from claude_code_tools.session_utils import find_session_file, detect_agent_from_path
1087
+
1088
+ if not session:
1089
+ _find_and_run_session_ui(
1090
+ session_id=None,
1091
+ agent_constraint='both',
1092
+ start_screen='query',
1093
+ direct_action='query',
1094
+ )
1095
+ return
1096
+
1097
+ # Find session file
1098
+ input_path = Path(session).expanduser()
1099
+ if input_path.exists() and input_path.is_file():
1100
+ session_file = input_path
1101
+ detected_agent = agent or detect_agent_from_path(session_file)
1102
+ else:
1103
+ result = find_session_file(session)
1104
+ if not result:
1105
+ print(f"Error: Session not found: {session}", file=sys.stderr)
1106
+ sys.exit(1)
1107
+ detected_agent, session_file, cwd, _ = result
1108
+ if agent:
1109
+ detected_agent = agent
1110
+
1111
+ # If no question, show interactive query UI
1112
+ if not question:
1113
+ from claude_code_tools.node_menu_ui import run_node_menu_ui
1114
+ from claude_code_tools.session_menu_cli import execute_action
1115
+
1116
+ rpc_path = str(Path(__file__).parent / "action_rpc.py")
1117
+ session_data = {
1118
+ "session_id": session_file.stem,
1119
+ "agent": detected_agent,
1120
+ "file_path": str(session_file),
1121
+ "cwd": str(session_file.parent),
1122
+ }
1123
+
1124
+ def handler(sess, action, kwargs=None):
1125
+ return execute_action(
1126
+ action, sess["agent"], Path(sess["file_path"]),
1127
+ sess["cwd"], action_kwargs=kwargs
1128
+ )
1129
+
1130
+ run_node_menu_ui(
1131
+ [session_data], [], handler,
1132
+ start_screen="query",
1133
+ rpc_path=rpc_path,
1134
+ )
1135
+ return
1136
+
1137
+ # Direct query with provided question
1138
+ from claude_code_tools.session_utils import default_export_path
1139
+
1140
+ # Export session first
1141
+ if detected_agent == "claude":
1142
+ from claude_code_tools.find_claude_session import handle_export_session
1143
+ else:
1144
+ from claude_code_tools.find_codex_session import handle_export_session
1145
+
1146
+ export_path = default_export_path(session_file, detected_agent)
1147
+ handle_export_session(str(session_file))
1148
+
1149
+ # Query using Claude
1150
+ import subprocess
1151
+ prompt = f"Read the session transcript at {export_path} and answer: {question}"
1152
+ subprocess.run(["claude", "-p", prompt])
1153
+
1154
+
1155
+ @main.command("clone")
1156
+ @click.argument("session", required=False)
1157
+ @click.option("--agent", type=click.Choice(["claude", "codex"], case_sensitive=False),
1158
+ help="Force agent type (auto-detected if not specified)")
1159
+ def clone_session_cmd(session, agent):
1160
+ """Clone a session and resume the clone.
1161
+
1162
+ Creates a copy of the session with a new ID and resumes it,
1163
+ leaving the original session untouched.
1164
+
1165
+ If no session ID provided, finds latest session for current project/branch.
1166
+
1167
+ \b
1168
+ Examples:
1169
+ aichat clone abc123-def456 # Clone specific session
1170
+ aichat clone # Clone latest session
1171
+ """
1172
+ import sys
1173
+ from pathlib import Path
1174
+
1175
+ from claude_code_tools.session_utils import find_session_file, detect_agent_from_path
1176
+
1177
+ if not session:
1178
+ _find_and_run_session_ui(
1179
+ session_id=None,
1180
+ agent_constraint='both',
1181
+ start_screen='resume',
1182
+ direct_action='clone',
1183
+ )
1184
+ return
1185
+
1186
+ # Find session file
1187
+ input_path = Path(session).expanduser()
1188
+ if input_path.exists() and input_path.is_file():
1189
+ session_file = input_path
1190
+ detected_agent = agent or detect_agent_from_path(session_file)
1191
+ cwd = str(session_file.parent)
1192
+ else:
1193
+ result = find_session_file(session)
1194
+ if not result:
1195
+ print(f"Error: Session not found: {session}", file=sys.stderr)
1196
+ sys.exit(1)
1197
+ detected_agent, session_file, cwd, _ = result
1198
+ if agent:
1199
+ detected_agent = agent
1200
+
1201
+ session_id = session_file.stem
1202
+
1203
+ # Execute clone
1204
+ if detected_agent == "claude":
1205
+ from claude_code_tools.find_claude_session import clone_session
1206
+ clone_session(session_id, cwd, shell_mode=False)
1207
+ else:
1208
+ from claude_code_tools.find_codex_session import clone_session
1209
+ clone_session(str(session_file), session_id, cwd, shell_mode=False)
1210
+
1211
+
1212
+ @main.command("rollover")
1213
+ @click.argument("session", required=False)
1214
+ @click.option("--quick", is_flag=True,
1215
+ help="Quick rollover without context extraction (just preserve lineage)")
1216
+ @click.option("--prompt", "-p", help="Custom prompt for context extraction")
1217
+ @click.option("--agent", type=click.Choice(["claude", "codex"], case_sensitive=False),
1218
+ help="Force agent type (auto-detected if not specified)")
1219
+ def rollover(session, quick, prompt, agent):
1220
+ """Rollover: hand off work to a fresh session with preserved lineage.
1221
+
1222
+ Creates a new session with a summary of the current work and links back
1223
+ to the parent session. The new session starts with full context available
1224
+ while the parent session is preserved intact.
1225
+
1226
+ \b
1227
+ Options:
1228
+ --quick Skip context extraction, just preserve lineage pointers
1229
+ --prompt Custom instructions for what context to extract
1230
+
1231
+ \b
1232
+ Examples:
1233
+ aichat rollover abc123 # Rollover with context extraction
1234
+ aichat rollover abc123 --quick # Quick rollover (lineage only)
1235
+ aichat rollover abc123 -p "Focus on the auth changes"
1236
+ aichat rollover # Rollover latest session
1237
+ """
1238
+ import sys
1239
+ from pathlib import Path
1240
+
1241
+ from claude_code_tools.session_utils import (
1242
+ find_session_file,
1243
+ detect_agent_from_path,
1244
+ continue_with_options,
1245
+ )
1246
+
1247
+ # If CLI options provided, use direct handler (backward compatible)
1248
+ if quick or prompt or agent:
1249
+ if not session:
1250
+ print("Error: --quick, --prompt, or --agent require a session ID",
1251
+ file=sys.stderr)
1252
+ print("Use 'aichat rollover' without options for interactive mode",
1253
+ file=sys.stderr)
1254
+ sys.exit(1)
1255
+
1256
+ # Find session file
1257
+ input_path = Path(session).expanduser()
1258
+ if input_path.exists() and input_path.is_file():
1259
+ session_file = input_path
1260
+ detected_agent = agent or detect_agent_from_path(session_file)
1261
+ else:
1262
+ result = find_session_file(session)
1263
+ if not result:
1264
+ print(f"Error: Session not found: {session}", file=sys.stderr)
1265
+ sys.exit(1)
1266
+ detected_agent, session_file, _, _ = result
1267
+ if agent:
1268
+ detected_agent = agent
1269
+
1270
+ # Execute rollover directly
1271
+ rollover_type = "quick" if quick else "context"
1272
+ continue_with_options(
1273
+ str(session_file),
1274
+ detected_agent,
1275
+ preset_prompt=prompt,
1276
+ rollover_type=rollover_type,
1277
+ )
1278
+ return
1279
+
1280
+ # Show interactive Node UI for rollover options
1281
+ # (whether session provided or not - find latest if not)
1282
+ # Start at lineage screen, with direct_action for proper escape behavior
1283
+ _find_and_run_session_ui(
1284
+ session_id=session,
1285
+ agent_constraint='both',
1286
+ start_screen='lineage',
1287
+ select_target='lineage',
1288
+ results_title=' Which session to rollover? ',
1289
+ direct_action='continue',
1290
+ )
1291
+
1292
+
1293
+ @main.command("lineage")
1294
+ @click.argument("session", required=False)
1295
+ @click.option("--agent", type=click.Choice(["claude", "codex"], case_sensitive=False),
1296
+ help="Force agent type (auto-detected if not specified)")
1297
+ @click.option("--json", "json_output", is_flag=True, help="Output as JSON")
1298
+ def lineage(session, agent, json_output):
1299
+ """Show the parent lineage chain of a session.
1300
+
1301
+ Traces back through continue_metadata and trim_metadata to find
1302
+ all ancestor sessions, from the current session back to the original.
1303
+
1304
+ \b
1305
+ Examples:
1306
+ aichat lineage abc123-def456 # Show lineage for specific session
1307
+ aichat lineage # Show lineage for latest session
1308
+ aichat lineage abc123 --json # Output as JSON
1309
+ """
1310
+ import json as json_lib
1311
+ import sys
1312
+ from pathlib import Path
1313
+ from datetime import datetime
1314
+
1315
+ from claude_code_tools.session_utils import find_session_file, detect_agent_from_path
1316
+ from claude_code_tools.session_lineage import get_full_lineage_chain
1317
+
1318
+ if not session:
1319
+ _find_and_run_session_ui(
1320
+ session_id=None,
1321
+ agent_constraint='both',
1322
+ start_screen='lineage',
1323
+ )
1324
+ return
1325
+
1326
+ # Find session file
1327
+ input_path = Path(session).expanduser()
1328
+ if input_path.exists() and input_path.is_file():
1329
+ session_file = input_path
1330
+ detected_agent = agent or detect_agent_from_path(session_file)
1331
+ else:
1332
+ result = find_session_file(session)
1333
+ if not result:
1334
+ print(f"Error: Session not found: {session}", file=sys.stderr)
1335
+ sys.exit(1)
1336
+ detected_agent, session_file, _, _ = result
1337
+ if agent:
1338
+ detected_agent = agent
1339
+
1340
+ # Get lineage (returns newest-first, ending with original)
1341
+ lineage_chain = get_full_lineage_chain(session_file)
1342
+
1343
+ # Check if this is an original session (only one item with type "original")
1344
+ if len(lineage_chain) == 1 and lineage_chain[0][1] == "original":
1345
+ print("No lineage found (this is an original session).")
1346
+ return
1347
+
1348
+ lineage_data = []
1349
+ for path, derivation_type in lineage_chain:
1350
+ mod_time = datetime.fromtimestamp(path.stat().st_mtime)
1351
+ lineage_data.append({
1352
+ "session_id": path.stem,
1353
+ "file_path": str(path),
1354
+ "derivation_type": derivation_type,
1355
+ "modified": mod_time.isoformat(),
1356
+ })
1357
+
1358
+ if json_output:
1359
+ print(json_lib.dumps(lineage_data, indent=2))
1360
+ else:
1361
+ print(f"\nLineage for: {session_file.stem}")
1362
+ print(f"{'='*60}")
1363
+ print(f"Chain has {len(lineage_data)} session(s):\n")
1364
+
1365
+ for i, node in enumerate(lineage_data):
1366
+ # Current session or ancestor
1367
+ if i == 0:
1368
+ marker = "► "
1369
+ else:
1370
+ marker = " "
1371
+
1372
+ dtype = node["derivation_type"]
1373
+ fname = Path(node["file_path"]).name
1374
+ mod = node["modified"][:10]
1375
+
1376
+ if i == len(lineage_data) - 1:
1377
+ prefix = f"{marker}└─"
1378
+ else:
1379
+ prefix = f"{marker}├─"
1380
+
1381
+ print(f"{prefix} [{dtype:10}] {fname} ({mod})")
1382
+
1383
+
663
1384
  @main.command("resume", context_settings={"ignore_unknown_options": True, "allow_extra_args": True, "allow_interspersed_args": False})
1385
+ @click.option(
1386
+ '--claude-home',
1387
+ type=click.Path(exists=True, file_okay=False, dir_okay=True),
1388
+ help='Claude home directory (default: ~/.claude or CLAUDE_CONFIG_DIR)',
1389
+ )
1390
+ @click.option(
1391
+ '--codex-home',
1392
+ type=click.Path(exists=True, file_okay=False, dir_okay=True),
1393
+ help='Codex home directory (default: ~/.codex or CODEX_HOME)',
1394
+ )
664
1395
  @click.pass_context
665
- def resume_session(ctx):
1396
+ def resume_session(ctx, claude_home, codex_home):
666
1397
  """Resume a session with various options (resume, clone, trim, continue).
667
1398
 
668
1399
  If no session ID provided, finds latest session for current project/branch.
669
1400
  Shows resume menu with options: resume as-is, clone, trim+resume,
670
1401
  smart-trim, or continue with context.
1402
+
1403
+ \b
1404
+ Environment variables:
1405
+ CLAUDE_CONFIG_DIR - Default Claude home (overridden by --claude-home)
1406
+ CODEX_HOME - Default Codex home (overridden by --codex-home)
671
1407
  """
1408
+ # Merge with parent context options (CLI arg takes precedence)
1409
+ claude_home = claude_home or (ctx.obj or {}).get('claude_home')
1410
+ codex_home = codex_home or (ctx.obj or {}).get('codex_home')
1411
+
672
1412
  args = ctx.args
673
1413
  session_id = args[0] if args and not args[0].startswith('-') else None
674
1414
  _find_and_run_session_ui(
@@ -1202,10 +1942,10 @@ def index_stats(index, cwd, claude_home, codex_home):
1202
1942
  help='Filter to specific git branch (only effective when not global)')
1203
1943
  @click.option('-n', '--num-results', type=int, default=None,
1204
1944
  help='Limit number of results displayed')
1205
- @click.option('--original', is_flag=True, help='Include original sessions')
1206
- @click.option('--sub-agent', is_flag=True, help='Include sub-agent sessions')
1207
- @click.option('--trimmed', is_flag=True, help='Include trimmed sessions')
1208
- @click.option('--rollover', is_flag=True, help='Include rollover sessions')
1945
+ @click.option('--no-original', is_flag=True, help='Exclude original sessions')
1946
+ @click.option('--sub-agent', is_flag=True, help='Include sub-agent sessions (additive)')
1947
+ @click.option('--no-trimmed', is_flag=True, help='Exclude trimmed sessions')
1948
+ @click.option('--no-rollover', is_flag=True, help='Exclude rollover sessions')
1209
1949
  @click.option('--min-lines', type=int, default=None,
1210
1950
  help='Only show sessions with at least N lines')
1211
1951
  @click.option('--after', metavar='DATE',
@@ -1224,7 +1964,7 @@ def index_stats(index, cwd, claude_home, codex_home):
1224
1964
  @click.argument('query', required=False)
1225
1965
  def search(
1226
1966
  claude_home_arg, codex_home_arg, global_search, filter_dir, filter_branch,
1227
- num_results, original, sub_agent, trimmed, rollover, min_lines,
1967
+ num_results, no_original, sub_agent, no_trimmed, no_rollover, min_lines,
1228
1968
  after, before, agent, json_output, by_time, query
1229
1969
  ):
1230
1970
  """Launch interactive TUI for full-text session search.
@@ -1317,14 +2057,14 @@ def search(
1317
2057
  rust_args.extend(["--branch", filter_branch])
1318
2058
  if num_results:
1319
2059
  rust_args.extend(["--num-results", str(num_results)])
1320
- if original:
1321
- rust_args.append("--original")
2060
+ if no_original:
2061
+ rust_args.append("--no-original")
1322
2062
  if sub_agent:
1323
2063
  rust_args.append("--sub-agent")
1324
- if trimmed:
1325
- rust_args.append("--trimmed")
1326
- if rollover:
1327
- rust_args.append("--rollover")
2064
+ if no_trimmed:
2065
+ rust_args.append("--no-trimmed")
2066
+ if no_rollover:
2067
+ rust_args.append("--no-rollover")
1328
2068
  if min_lines:
1329
2069
  rust_args.extend(["--min-lines", str(min_lines)])
1330
2070
  if after:
@@ -1490,15 +2230,17 @@ def search(
1490
2230
  if filter_state.get("filter_branch"):
1491
2231
  rust_args.extend(["--branch", filter_state["filter_branch"]])
1492
2232
 
1493
- # Session type filters (only add if true)
1494
- if filter_state.get("include_original"):
1495
- rust_args.append("--original")
2233
+ # Session type filters
2234
+ # Subtractive: add --no-* when type is excluded
2235
+ # Additive: add --sub-agent when sub-agents are included
2236
+ if not filter_state.get("include_original", True):
2237
+ rust_args.append("--no-original")
1496
2238
  if filter_state.get("include_sub"):
1497
2239
  rust_args.append("--sub-agent")
1498
- if filter_state.get("include_trimmed"):
1499
- rust_args.append("--trimmed")
1500
- if filter_state.get("include_continued"):
1501
- rust_args.append("--rollover")
2240
+ if not filter_state.get("include_trimmed", True):
2241
+ rust_args.append("--no-trimmed")
2242
+ if not filter_state.get("include_continued", True):
2243
+ rust_args.append("--no-rollover")
1502
2244
 
1503
2245
  # Other filters
1504
2246
  if filter_state.get("filter_min_lines"):