claude-code-tools 1.21.0__tar.gz → 1.22.0__tar.gz

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 (115) hide show
  1. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/PKG-INFO +1 -1
  2. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/__init__.py +1 -1
  3. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/aichat.py +9 -3
  4. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/export_session.py +60 -2
  5. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/node_menu_ui.py +103 -4
  6. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/search_index.py +79 -15
  7. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/tmux_cli_controller.py +5 -0
  8. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/pyproject.toml +2 -2
  9. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/.gitignore +0 -0
  10. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/LICENSE +0 -0
  11. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/README.md +0 -0
  12. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/action_rpc.py +0 -0
  13. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/agent_tunnel/__init__.py +0 -0
  14. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/agent_tunnel/backends.py +0 -0
  15. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/agent_tunnel/cli.py +0 -0
  16. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/agent_tunnel/config.py +0 -0
  17. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/agent_tunnel/convert.py +0 -0
  18. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/agent_tunnel/discord_bot.py +0 -0
  19. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/agent_tunnel/locking.py +0 -0
  20. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/agent_tunnel/paths.py +0 -0
  21. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/agent_tunnel/registry.py +0 -0
  22. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/agent_tunnel/session.py +0 -0
  23. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/agent_tunnel/store.py +0 -0
  24. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/agent_tunnel/tmux.py +0 -0
  25. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/agent_tunnel/trust.py +0 -0
  26. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/claude_continue.py +0 -0
  27. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/codex_continue.py +0 -0
  28. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/codex_server.py +0 -0
  29. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/codex_server_cli.py +0 -0
  30. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/codex_server_fingerprint.py +0 -0
  31. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/codex_server_generation.py +0 -0
  32. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/codex_server_legacy.py +0 -0
  33. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/codex_server_models.py +0 -0
  34. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/codex_server_process.py +0 -0
  35. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/codex_server_reservation.py +0 -0
  36. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/codex_server_retry.py +0 -0
  37. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/codex_server_reuse.py +0 -0
  38. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/codex_server_state.py +0 -0
  39. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/codex_server_supervisor.py +0 -0
  40. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/codex_server_worker.py +0 -0
  41. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/config.py +0 -0
  42. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/csv2gsheet.py +0 -0
  43. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/delete_session.py +0 -0
  44. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/dotenv_vault.py +0 -0
  45. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/env_safe.py +0 -0
  46. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/export_all.py +0 -0
  47. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/export_claude_session.py +0 -0
  48. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/export_codex_session.py +0 -0
  49. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/find_claude_session.py +0 -0
  50. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/find_codex_session.py +0 -0
  51. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/find_original_session.py +0 -0
  52. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/find_session.py +0 -0
  53. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/find_trimmed_sessions.py +0 -0
  54. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/fix_session.py +0 -0
  55. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/gdoc2docx.py +0 -0
  56. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/gdoc2md.py +0 -0
  57. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/gsheet2csv.py +0 -0
  58. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/md2gdoc.py +0 -0
  59. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/msg/__init__.py +0 -0
  60. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/msg/cli.py +0 -0
  61. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/msg/hooks.py +0 -0
  62. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/msg/models.py +0 -0
  63. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/msg/prompt_detect.py +0 -0
  64. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/msg/store.py +0 -0
  65. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/msg/watcher.py +0 -0
  66. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/port_claude_noise.py +0 -0
  67. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/port_claude_to_codex.py +0 -0
  68. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/port_codex_flatten.py +0 -0
  69. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/port_codex_to_claude.py +0 -0
  70. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/port_service.py +0 -0
  71. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/resolve_session.py +0 -0
  72. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/resolve_session_names.py +0 -0
  73. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/resolve_session_render.py +0 -0
  74. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/session_lineage.py +0 -0
  75. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/session_menu.py +0 -0
  76. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/session_menu_cli.py +0 -0
  77. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/session_resolution.py +0 -0
  78. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/session_utils.py +0 -0
  79. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/smart_trim.py +0 -0
  80. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/smart_trim_core.py +0 -0
  81. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/tmux_execution_helpers.py +0 -0
  82. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/tmux_remote_controller.py +0 -0
  83. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/trim_in_place.py +0 -0
  84. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/trim_session.py +0 -0
  85. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/trim_session_claude.py +0 -0
  86. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/trim_session_codex.py +0 -0
  87. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/workflow_cli.py +0 -0
  88. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/workflow_cli_contract.py +0 -0
  89. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/workflow_cli_formatting.py +0 -0
  90. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/workflow_cli_identity_policy.py +0 -0
  91. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/workflow_cli_manifest.py +0 -0
  92. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/workflow_cli_projection.py +0 -0
  93. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/workflow_cli_rendering.py +0 -0
  94. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/workflow_cli_snapshots.py +0 -0
  95. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/workflow_cli_store_backends.py +0 -0
  96. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/workflow_processes.py +0 -0
  97. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/workflow_runs.py +0 -0
  98. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/workflow_store_io.py +0 -0
  99. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/claude_code_tools/workflow_validation.py +0 -0
  100. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/docs/agent-tunnel-spec.md +0 -0
  101. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/docs/claude-code-tmux-tutorials.md +0 -0
  102. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/docs/dot-zshrc.md +0 -0
  103. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/docs/find-claude-session.md +0 -0
  104. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/docs/intercom-spec.md +0 -0
  105. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/docs/lmsh.md +0 -0
  106. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/docs/local-llm-setup.md +0 -0
  107. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/docs/msg-phase-a-plan.md +0 -0
  108. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/docs/msg-plan-v2.md +0 -0
  109. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/docs/tmux-cli-instructions.md +0 -0
  110. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/docs/vault-documentation.md +0 -0
  111. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/hatch_build.py +0 -0
  112. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/node_ui/action_config.js +0 -0
  113. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/node_ui/menu.js +0 -0
  114. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/node_ui/package-lock.json +0 -0
  115. {claude_code_tools-1.21.0 → claude_code_tools-1.22.0}/node_ui/package.json +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: claude-code-tools
3
- Version: 1.21.0
3
+ Version: 1.22.0
4
4
  Summary: Collection of tools for working with Claude Code
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.11
@@ -1,3 +1,3 @@
1
1
  """Claude Code Tools - Collection of utilities for Claude Code."""
2
2
 
3
- __version__ = "1.21.0"
3
+ __version__ = "1.22.0"
@@ -2710,6 +2710,8 @@ def index_stats(index, cwd, claude_home, codex_home):
2710
2710
  help='Limit number of results displayed')
2711
2711
  @click.option('--no-original', is_flag=True, help='Exclude original sessions')
2712
2712
  @click.option('--sub-agent', is_flag=True, help='Include sub-agent sessions (additive)')
2713
+ @click.option('--exec-runs', 'exec_runs', is_flag=True,
2714
+ help='Include headless `codex exec` runs (additive)')
2713
2715
  @click.option('--no-trimmed', is_flag=True, help='Exclude trimmed sessions')
2714
2716
  @click.option('--no-rollover', is_flag=True, help='Exclude rollover sessions')
2715
2717
  @click.option('--live', is_flag=True, help='Show only currently running sessions')
@@ -2725,14 +2727,14 @@ def index_stats(index, cwd, claude_home, codex_home):
2725
2727
  help='Output as JSONL for AI agents. Fields per line: session_id, '
2726
2728
  'agent, project, branch, cwd, lines, created, modified, '
2727
2729
  'first_msg, last_msg, file_path, derivation_type, '
2728
- 'is_sidechain, snippet')
2730
+ 'is_sidechain, is_exec_run, snippet')
2729
2731
  @click.option('--by-time', 'by_time', is_flag=True,
2730
2732
  help='Sort results by last-modified time (default: sort by relevance)')
2731
2733
  @click.argument('query', required=False)
2732
2734
  def search(
2733
2735
  claude_home_arg, codex_home_arg, global_search, filter_dir, filter_branch,
2734
- num_results, no_original, sub_agent, no_trimmed, no_rollover, live, min_lines,
2735
- after, before, agent, json_output, by_time, query
2736
+ num_results, no_original, sub_agent, exec_runs, no_trimmed, no_rollover, live,
2737
+ min_lines, after, before, agent, json_output, by_time, query
2736
2738
  ):
2737
2739
  """Launch interactive TUI for full-text session search.
2738
2740
 
@@ -2828,6 +2830,8 @@ def search(
2828
2830
  rust_args.append("--no-original")
2829
2831
  if sub_agent:
2830
2832
  rust_args.append("--sub-agent")
2833
+ if exec_runs:
2834
+ rust_args.append("--exec-runs")
2831
2835
  if live:
2832
2836
  rust_args.append("--live")
2833
2837
  if no_trimmed:
@@ -3008,6 +3012,8 @@ def search(
3008
3012
  rust_args.append("--no-original")
3009
3013
  if filter_state.get("include_sub"):
3010
3014
  rust_args.append("--sub-agent")
3015
+ if filter_state.get("include_exec"):
3016
+ rust_args.append("--exec-runs")
3011
3017
  if filter_state.get("include_live_only"):
3012
3018
  rust_args.append("--live")
3013
3019
  if not filter_state.get("include_trimmed", True):
@@ -408,6 +408,45 @@ def extract_first_last_messages(
408
408
  return first_msg, last_msg, first_user_msg
409
409
 
410
410
 
411
+ def _is_codex_subagent_payload(payload: dict[str, Any]) -> bool:
412
+ """Return True when a Codex ``session_meta`` payload describes a sub-agent.
413
+
414
+ Codex names every thread ``rollout-*.jsonl``, so sub-agent threads can only
415
+ be told apart by their spawn metadata. A spawned thread records
416
+ ``thread_source: "subagent"``, a ``source`` object keyed by ``subagent``,
417
+ and the id of the thread that spawned it.
418
+
419
+ Args:
420
+ payload: The ``payload`` object of a ``session_meta`` record.
421
+
422
+ Returns:
423
+ True when the payload carries any sub-agent spawn marker.
424
+ """
425
+ if payload.get("thread_source") == "subagent":
426
+ return True
427
+ source = payload.get("source")
428
+ if isinstance(source, dict) and "subagent" in source:
429
+ return True
430
+ return _nonempty_str(payload.get("parent_thread_id"))
431
+
432
+
433
+ def _is_codex_exec_payload(payload: dict[str, Any]) -> bool:
434
+ """Return True when a Codex ``session_meta`` payload describes a headless run.
435
+
436
+ Codex records how a thread was launched: ``"exec"`` for a headless
437
+ ``codex exec`` run (typically spawned by an orchestrating agent or script),
438
+ ``"cli"`` for the interactive TUI. Sub-agent spawns carry an object here
439
+ instead, and are reported by :func:`_is_codex_subagent_payload`.
440
+
441
+ Args:
442
+ payload: The ``payload`` object of a ``session_meta`` record.
443
+
444
+ Returns:
445
+ True when the thread was launched headlessly.
446
+ """
447
+ return payload.get("source") == "exec"
448
+
449
+
411
450
  def extract_session_metadata(session_file: Path, agent: str) -> dict[str, Any]:
412
451
  """
413
452
  Extract metadata from a session JSONL file.
@@ -425,9 +464,12 @@ def extract_session_metadata(session_file: Path, agent: str) -> dict[str, Any]:
425
464
  Returns:
426
465
  Dict with extracted metadata
427
466
  """
428
- # Detect sidechain from filename pattern (agent-* prefix)
467
+ # Detect sidechain from filename pattern (agent-* prefix).
429
468
  # This is more reliable than checking isSidechain field in JSON,
430
- # which can be set on individual messages within main sessions
469
+ # which can be set on individual messages within main sessions.
470
+ # Claude sub-agent transcripts are written as agent-*.jsonl; Codex writes
471
+ # every thread as rollout-*.jsonl, so Codex sub-agents are detected from
472
+ # the session_meta record below instead.
431
473
  is_sidechain = session_file.name.startswith("agent-")
432
474
 
433
475
  metadata: dict[str, Any] = {
@@ -438,6 +480,7 @@ def extract_session_metadata(session_file: Path, agent: str) -> dict[str, Any]:
438
480
  "branch": None,
439
481
  "derivation_type": None,
440
482
  "is_sidechain": is_sidechain,
483
+ "is_exec_run": False, # Codex: launched headlessly via `codex exec`
441
484
  "session_type": None, # "helper" for SDK/headless sessions
442
485
  "parent_session_id": None,
443
486
  "parent_session_file": None,
@@ -451,6 +494,13 @@ def extract_session_metadata(session_file: Path, agent: str) -> dict[str, Any]:
451
494
  # Track session start timestamp from JSON metadata
452
495
  session_start_timestamp: str | None = None
453
496
 
497
+ # A forked Codex rollout replays its ancestors' session_meta records into
498
+ # the same file (1951 of 6880 real rollouts carry more than one, up to 29).
499
+ # Only the first one describes THIS session, so the launch-kind flags are
500
+ # taken from it alone -- otherwise an interactive fork of a headless
501
+ # ancestor inherits is_exec_run and vanishes from default search results.
502
+ codex_meta_seen = False
503
+
454
504
  try:
455
505
  with open(
456
506
  session_file, "r", encoding="utf-8", errors="replace"
@@ -554,6 +604,12 @@ def extract_session_metadata(session_file: Path, agent: str) -> dict[str, Any]:
554
604
  metadata["cwd"] = payload["cwd"]
555
605
  if _nonempty_str(payload.get("id")):
556
606
  metadata["session_id"] = payload["id"]
607
+ if not codex_meta_seen:
608
+ codex_meta_seen = True
609
+ if _is_codex_subagent_payload(payload):
610
+ metadata["is_sidechain"] = True
611
+ if _is_codex_exec_payload(payload):
612
+ metadata["is_exec_run"] = True
557
613
  if session_start_timestamp is None and _nonempty_str(
558
614
  data.get("timestamp")
559
615
  ):
@@ -701,6 +757,8 @@ def generate_yaml_frontmatter(metadata: dict[str, Any]) -> str:
701
757
  yaml_data["derivation_type"] = metadata["derivation_type"]
702
758
  if metadata.get("is_sidechain"):
703
759
  yaml_data["is_sidechain"] = metadata["is_sidechain"]
760
+ if metadata.get("is_exec_run"):
761
+ yaml_data["is_exec_run"] = metadata["is_exec_run"]
704
762
  if metadata.get("parent_session_id"):
705
763
  yaml_data["parent_session_id"] = metadata["parent_session_id"]
706
764
  if metadata.get("parent_session_file"):
@@ -9,6 +9,8 @@ from __future__ import annotations
9
9
 
10
10
  import json
11
11
  import os
12
+ import shlex
13
+ import shutil
12
14
  import subprocess
13
15
  import sys
14
16
  import tempfile
@@ -17,11 +19,90 @@ from typing import Any, Callable, Dict, Iterable, List
17
19
 
18
20
  SessionDict = Dict[str, Any]
19
21
 
22
+ #: Runtime npm packages ``menu.js`` imports directly. A checkout that is
23
+ #: missing any of them cannot render a menu, so we check before spawning Node
24
+ #: rather than letting Node print a raw ``ERR_MODULE_NOT_FOUND`` stack trace.
25
+ NODE_UI_REQUIRED_PACKAGES = (
26
+ "chalk",
27
+ "figures",
28
+ "ink",
29
+ "ink-select-input",
30
+ "meow",
31
+ "react",
32
+ )
33
+
34
+
35
+ def _node_ui_dir() -> Path:
36
+ """Return the directory holding the bundled Node UI."""
37
+ here = Path(__file__).resolve()
38
+ return here.parent.parent / "node_ui"
39
+
20
40
 
21
41
  def _node_script_path() -> Path:
22
42
  """Return path to the bundled Node UI script."""
23
- here = Path(__file__).resolve()
24
- return here.parent.parent / "node_ui" / "menu.js"
43
+ return _node_ui_dir() / "menu.js"
44
+
45
+
46
+ def _missing_node_ui_packages(node_ui_dir: Path | None = None) -> List[str]:
47
+ """Return the required Node UI packages that are not installed.
48
+
49
+ Args:
50
+ node_ui_dir: Directory to inspect. Defaults to the bundled ``node_ui``.
51
+
52
+ Returns:
53
+ Names of missing packages, empty when every requirement is present.
54
+ """
55
+ root = node_ui_dir if node_ui_dir is not None else _node_ui_dir()
56
+ node_modules = root / "node_modules"
57
+ if not node_modules.is_dir():
58
+ return list(NODE_UI_REQUIRED_PACKAGES)
59
+ return [
60
+ name
61
+ for name in NODE_UI_REQUIRED_PACKAGES
62
+ if not (node_modules / name).is_dir()
63
+ ]
64
+
65
+
66
+ def _quote_path(path: Path) -> str:
67
+ """Quote a path for the shell the user is most likely pasting into.
68
+
69
+ ``shlex.quote`` emits POSIX single quotes, which ``cmd.exe`` treats as
70
+ literal characters, so a Windows path containing spaces would be split.
71
+
72
+ Args:
73
+ path: Path to embed in a suggested command line.
74
+
75
+ Returns:
76
+ The path, quoted only when it needs to be.
77
+ """
78
+ text = str(path)
79
+ if os.name == "nt":
80
+ # cmd.exe splits on & | ^ < > as well as spaces, so always quote.
81
+ return f'"{text}"'
82
+ return shlex.quote(text)
83
+
84
+
85
+ def _no_node_message() -> str:
86
+ """Build the message shown when the Node runtime is unavailable."""
87
+ return (
88
+ "aichat could not start its interactive menu: 'node' was not found "
89
+ "on PATH.\nInstall Node.js (>=18) and try again."
90
+ )
91
+
92
+
93
+ def _node_ui_setup_message(missing: List[str], node_ui_dir: Path) -> str:
94
+ """Build an actionable message for a Node UI with missing dependencies."""
95
+ return (
96
+ "aichat could not start its interactive menu: the Node UI "
97
+ f"dependencies are missing ({', '.join(missing)}).\n"
98
+ f"Expected them in: {node_ui_dir / 'node_modules'}\n"
99
+ "\n"
100
+ "This happens when aichat runs from a source checkout (an editable\n"
101
+ "install) whose Node dependencies were never installed. Install them\n"
102
+ "with either of:\n"
103
+ f" npm ci --prefix {_quote_path(node_ui_dir)} --omit=dev\n"
104
+ " make install # from that checkout (needs GNU make)\n"
105
+ )
25
106
 
26
107
 
27
108
  def _write_payload(
@@ -63,15 +144,33 @@ def _write_payload(
63
144
  def _run_node(data_path: Path, out_path: Path, stderr_mode: bool = False) -> int:
64
145
  """Invoke the Node UI process.
65
146
 
66
- Returns the process return code.
147
+ Returns the process return code. A missing Node runtime or missing Node UI
148
+ dependencies are reported as an actionable message and return code 1.
67
149
  """
150
+ # Check the runtime before its packages: on a machine without Node, telling
151
+ # the user to run npm is useless advice.
152
+ if shutil.which("node") is None:
153
+ print(_no_node_message(), file=sys.stderr)
154
+ return 1
155
+
156
+ node_ui_dir = _node_ui_dir()
157
+ missing = _missing_node_ui_packages(node_ui_dir)
158
+ if missing:
159
+ print(_node_ui_setup_message(missing, node_ui_dir), file=sys.stderr)
160
+ return 1
161
+
68
162
  script = _node_script_path()
69
163
  cmd = ["node", str(script), "--data", str(data_path), "--out", str(out_path)]
70
164
  env = os.environ.copy()
71
165
  if stderr_mode:
72
166
  env["NODE_UI_STDERR"] = "1"
73
167
 
74
- proc = subprocess.run(cmd, env=env)
168
+ try:
169
+ proc = subprocess.run(cmd, env=env)
170
+ except FileNotFoundError:
171
+ # Node disappeared between the check above and the spawn.
172
+ print(_no_node_message(), file=sys.stderr)
173
+ return 1
75
174
  return proc.returncode
76
175
 
77
176
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  import json
4
4
  import math
5
+ import os
5
6
  import shutil
6
7
  import sys
7
8
  from dataclasses import dataclass
@@ -14,12 +15,26 @@ from claude_code_tools.export_session import _is_meta_user_message
14
15
  from claude_code_tools.session_utils import is_valid_session
15
16
 
16
17
 
17
- def _get_package_version() -> str:
18
- """Get installed package version for automatic index rebuilding."""
18
+ #: Revision of the indexing logic itself. Bump this whenever a change alters
19
+ #: what gets stored for a session (new field, corrected detection), so existing
20
+ #: indexes rebuild even when the package version has not changed -- an editable
21
+ #: install keeps the same version across every code change.
22
+ #: rev 2: Codex sub-agent (is_sidechain) detection.
23
+ #: rev 3: headless `codex exec` run (is_exec_run) detection.
24
+ #: rev 4: launch flags read from the session's own session_meta record only.
25
+ INDEX_LOGIC_REVISION = 4
26
+
27
+
28
+ def _get_index_version() -> str:
29
+ """Return the index identity: package version plus indexing-logic revision.
30
+
31
+ A mismatch against the stored value triggers a full rebuild.
32
+ """
19
33
  try:
20
- return get_pkg_version("claude-code-tools")
34
+ version = get_pkg_version("claude-code-tools")
21
35
  except Exception:
22
- return "unknown"
36
+ version = "unknown"
37
+ return f"{version}+idx{INDEX_LOGIC_REVISION}"
23
38
 
24
39
  # Lazy imports to allow module to load even if deps not installed
25
40
  try:
@@ -129,9 +144,18 @@ class IndexState:
129
144
  or stat.st_size != stored.get("size")
130
145
  )
131
146
 
132
- def mark_indexed(self, file_path: Path):
133
- """Mark file as indexed with current metadata."""
134
- stat = file_path.stat()
147
+ def mark_indexed(
148
+ self, file_path: Path, stat_result: Optional[os.stat_result] = None
149
+ ):
150
+ """Mark file as indexed.
151
+
152
+ Args:
153
+ file_path: Session file that was processed.
154
+ stat_result: File state to record. Pass the state captured BEFORE
155
+ reading the file, so content appended while it was being read
156
+ is not recorded as already indexed. Defaults to current state.
157
+ """
158
+ stat = stat_result if stat_result is not None else file_path.stat()
135
159
  self.indexed_files[str(file_path)] = {
136
160
  "mtime": stat.st_mtime,
137
161
  "size": stat.st_size,
@@ -183,6 +207,9 @@ class SessionIndex:
183
207
  # Session type fields (for filtering in TUI)
184
208
  self.schema_builder.add_text_field("derivation_type", stored=True)
185
209
  self.schema_builder.add_text_field("is_sidechain", stored=True) # "true"/"false"
210
+ # Codex sessions launched headlessly via `codex exec` (agent-spawned
211
+ # workers). Excluded from results by default, like sub-agents.
212
+ self.schema_builder.add_text_field("is_exec_run", stored=True) # "true"/"false"
186
213
 
187
214
  # Claude home field (for filtering by source Claude home directory)
188
215
  # Use "raw" tokenizer so paths are indexed as single tokens for exact matching
@@ -201,7 +228,7 @@ class SessionIndex:
201
228
 
202
229
  # Check index version - rebuild if package version changed
203
230
  version_file = self.index_path / "VERSION"
204
- current_version = _get_package_version()
231
+ current_version = _get_index_version()
205
232
  needs_rebuild = False
206
233
 
207
234
  if version_file.exists():
@@ -209,17 +236,23 @@ class SessionIndex:
209
236
  stored_version = version_file.read_text().strip()
210
237
  if stored_version != current_version:
211
238
  needs_rebuild = True
239
+ # stderr, not stdout: `aichat search --json` emits JSONL on
240
+ # stdout, and an upgrade notice there is invalid output.
212
241
  print(
213
- f"Package version changed ({stored_version} -> {current_version}), "
214
- "rebuilding index..."
242
+ f"Index version changed ({stored_version} -> {current_version}), "
243
+ "rebuilding index...",
244
+ file=sys.stderr,
215
245
  )
216
246
  except IOError:
217
247
  needs_rebuild = True
218
- print("Index version file corrupted, rebuilding...")
248
+ print("Index version file corrupted, rebuilding...", file=sys.stderr)
219
249
  elif (self.index_path / "meta.json").exists():
220
250
  # Index exists but no VERSION file - legacy index, rebuild
221
251
  needs_rebuild = True
222
- print("Upgrading index to versioned format, rebuilding...")
252
+ print(
253
+ "Upgrading index to versioned format, rebuilding...",
254
+ file=sys.stderr,
255
+ )
223
256
 
224
257
  if needs_rebuild:
225
258
  # Clear index directory contents (but keep the directory itself)
@@ -336,6 +369,10 @@ class SessionIndex:
336
369
  "is_sidechain",
337
370
  "true" if metadata.get("is_sidechain") else "false"
338
371
  )
372
+ doc.add_text(
373
+ "is_exec_run",
374
+ "true" if metadata.get("is_exec_run") else "false"
375
+ )
339
376
 
340
377
  # Custom title (from /rename command)
341
378
  doc.add_text("custom_title", metadata.get("customTitle", "") or "")
@@ -424,6 +461,10 @@ class SessionIndex:
424
461
  "is_sidechain",
425
462
  "true" if metadata.get("is_sidechain") else "false"
426
463
  )
464
+ doc.add_text(
465
+ "is_exec_run",
466
+ "true" if metadata.get("is_exec_run") else "false"
467
+ )
427
468
 
428
469
  # Custom title (from /rename command)
429
470
  doc.add_text("custom_title", metadata.get("customTitle", "") or "")
@@ -685,6 +726,7 @@ class SessionIndex:
685
726
  "created": metadata.get("created", "") or "",
686
727
  "modified": metadata.get("modified", "") or "",
687
728
  "is_sidechain": metadata.get("is_sidechain", False),
729
+ "is_exec_run": metadata.get("is_exec_run", False),
688
730
  "derivation_type": metadata.get("derivation_type", "") or "",
689
731
  "session_type": metadata.get("session_type"),
690
732
  "customTitle": metadata.get("customTitle", "") or "",
@@ -753,11 +795,21 @@ class SessionIndex:
753
795
  stats["skipped"] += 1
754
796
  continue
755
797
 
798
+ # Snapshot the file state BEFORE reading it. A live session can
799
+ # append while we read; recording the post-read state would mark
800
+ # that new content as already indexed and hide the session until
801
+ # its next write.
802
+ try:
803
+ file_stat = jsonl_path.stat()
804
+ except OSError:
805
+ stats["skipped"] += 1
806
+ continue
807
+
756
808
  # Skip invalid sessions (metadata-only files like file-history-snapshot)
757
809
  if not is_valid_session(jsonl_path):
758
810
  stats["skipped"] += 1
759
811
  # Mark as indexed so we don't re-check next time
760
- self.state.mark_indexed(jsonl_path)
812
+ self.state.mark_indexed(jsonl_path, file_stat)
761
813
  continue
762
814
 
763
815
  # Parse JSONL file
@@ -768,6 +820,10 @@ class SessionIndex:
768
820
  reason = parsed.get("_skip_reason", "unknown")
769
821
  if reason == "empty":
770
822
  stats["empty"] += 1
823
+ # Mark as indexed so we don't re-parse it every launch.
824
+ # If the session later gains messages its mtime/size
825
+ # changes, which re-triggers indexing.
826
+ self.state.mark_indexed(jsonl_path, file_stat)
771
827
  elif reason == "parse_error":
772
828
  stats["parse_error"] += 1
773
829
  continue
@@ -781,7 +837,7 @@ class SessionIndex:
781
837
  if metadata.get("session_type") == "helper":
782
838
  stats["skipped"] += 1
783
839
  # Mark as indexed so we don't re-check next time
784
- self.state.mark_indexed(jsonl_path)
840
+ self.state.mark_indexed(jsonl_path, file_stat)
785
841
  continue
786
842
 
787
843
  # Skip sessions run from inside claude_home or codex_home directories
@@ -791,6 +847,10 @@ class SessionIndex:
791
847
  or (codex_home_str and cwd.startswith(codex_home_str))
792
848
  ):
793
849
  stats["skipped"] += 1
850
+ # Deliberately NOT recorded in the index state: this exclusion
851
+ # depends on the claude_home/codex_home passed on the command
852
+ # line, so caching it would keep the session out of the index
853
+ # after a later run with different homes.
794
854
  continue
795
855
 
796
856
  try:
@@ -829,6 +889,10 @@ class SessionIndex:
829
889
  "is_sidechain",
830
890
  "true" if metadata.get("is_sidechain") else "false"
831
891
  )
892
+ doc.add_text(
893
+ "is_exec_run",
894
+ "true" if metadata.get("is_exec_run") else "false"
895
+ )
832
896
 
833
897
  # Custom title (from /rename command)
834
898
  doc.add_text("custom_title", metadata.get("customTitle", "") or "")
@@ -846,7 +910,7 @@ class SessionIndex:
846
910
  doc.add_text("content", parsed["content"])
847
911
 
848
912
  writer.add_document(doc)
849
- self.state.mark_indexed(jsonl_path)
913
+ self.state.mark_indexed(jsonl_path, file_stat)
850
914
  stats["indexed"] += 1
851
915
  except Exception:
852
916
  stats["failed"] += 1
@@ -254,6 +254,11 @@ class TmuxCLIController:
254
254
  for line in output.split('\n'):
255
255
  if line:
256
256
  parts = line.split('|')
257
+ # tmux emits six fields; anything shorter is not a pane line
258
+ # (an error string, a truncated read) and is skipped rather
259
+ # than indexed into.
260
+ if len(parts) < 5:
261
+ continue
257
262
  pane_id = parts[0]
258
263
  panes.append({
259
264
  'id': pane_id,
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "claude-code-tools"
3
- version = "1.21.0"
3
+ version = "1.22.0"
4
4
  description = "Collection of tools for working with Claude Code"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -111,7 +111,7 @@ markers = [
111
111
 
112
112
  [tool.commitizen]
113
113
  name = "cz_conventional_commits"
114
- version = "1.21.0"
114
+ version = "1.22.0"
115
115
  tag_format = "v$version"
116
116
  version_files = [
117
117
  "pyproject.toml:version",