claude-code-tools 1.21.0__tar.gz → 1.23.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 (116) hide show
  1. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/PKG-INFO +1 -1
  2. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/__init__.py +1 -1
  3. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/aichat.py +44 -38
  4. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/export_session.py +60 -2
  5. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/node_menu_ui.py +103 -4
  6. claude_code_tools-1.23.0/claude_code_tools/port_render.py +363 -0
  7. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/port_service.py +46 -4
  8. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/search_index.py +79 -15
  9. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/session_resolution.py +34 -9
  10. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/tmux_cli_controller.py +5 -0
  11. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/pyproject.toml +2 -2
  12. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/.gitignore +0 -0
  13. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/LICENSE +0 -0
  14. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/README.md +0 -0
  15. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/action_rpc.py +0 -0
  16. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/agent_tunnel/__init__.py +0 -0
  17. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/agent_tunnel/backends.py +0 -0
  18. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/agent_tunnel/cli.py +0 -0
  19. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/agent_tunnel/config.py +0 -0
  20. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/agent_tunnel/convert.py +0 -0
  21. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/agent_tunnel/discord_bot.py +0 -0
  22. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/agent_tunnel/locking.py +0 -0
  23. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/agent_tunnel/paths.py +0 -0
  24. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/agent_tunnel/registry.py +0 -0
  25. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/agent_tunnel/session.py +0 -0
  26. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/agent_tunnel/store.py +0 -0
  27. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/agent_tunnel/tmux.py +0 -0
  28. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/agent_tunnel/trust.py +0 -0
  29. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/claude_continue.py +0 -0
  30. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/codex_continue.py +0 -0
  31. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/codex_server.py +0 -0
  32. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/codex_server_cli.py +0 -0
  33. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/codex_server_fingerprint.py +0 -0
  34. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/codex_server_generation.py +0 -0
  35. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/codex_server_legacy.py +0 -0
  36. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/codex_server_models.py +0 -0
  37. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/codex_server_process.py +0 -0
  38. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/codex_server_reservation.py +0 -0
  39. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/codex_server_retry.py +0 -0
  40. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/codex_server_reuse.py +0 -0
  41. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/codex_server_state.py +0 -0
  42. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/codex_server_supervisor.py +0 -0
  43. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/codex_server_worker.py +0 -0
  44. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/config.py +0 -0
  45. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/csv2gsheet.py +0 -0
  46. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/delete_session.py +0 -0
  47. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/dotenv_vault.py +0 -0
  48. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/env_safe.py +0 -0
  49. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/export_all.py +0 -0
  50. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/export_claude_session.py +0 -0
  51. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/export_codex_session.py +0 -0
  52. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/find_claude_session.py +0 -0
  53. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/find_codex_session.py +0 -0
  54. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/find_original_session.py +0 -0
  55. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/find_session.py +0 -0
  56. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/find_trimmed_sessions.py +0 -0
  57. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/fix_session.py +0 -0
  58. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/gdoc2docx.py +0 -0
  59. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/gdoc2md.py +0 -0
  60. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/gsheet2csv.py +0 -0
  61. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/md2gdoc.py +0 -0
  62. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/msg/__init__.py +0 -0
  63. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/msg/cli.py +0 -0
  64. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/msg/hooks.py +0 -0
  65. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/msg/models.py +0 -0
  66. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/msg/prompt_detect.py +0 -0
  67. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/msg/store.py +0 -0
  68. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/msg/watcher.py +0 -0
  69. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/port_claude_noise.py +0 -0
  70. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/port_claude_to_codex.py +0 -0
  71. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/port_codex_flatten.py +0 -0
  72. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/port_codex_to_claude.py +0 -0
  73. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/resolve_session.py +0 -0
  74. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/resolve_session_names.py +0 -0
  75. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/resolve_session_render.py +0 -0
  76. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/session_lineage.py +0 -0
  77. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/session_menu.py +0 -0
  78. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/session_menu_cli.py +0 -0
  79. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/session_utils.py +0 -0
  80. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/smart_trim.py +0 -0
  81. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/smart_trim_core.py +0 -0
  82. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/tmux_execution_helpers.py +0 -0
  83. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/tmux_remote_controller.py +0 -0
  84. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/trim_in_place.py +0 -0
  85. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/trim_session.py +0 -0
  86. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/trim_session_claude.py +0 -0
  87. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/trim_session_codex.py +0 -0
  88. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/workflow_cli.py +0 -0
  89. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/workflow_cli_contract.py +0 -0
  90. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/workflow_cli_formatting.py +0 -0
  91. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/workflow_cli_identity_policy.py +0 -0
  92. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/workflow_cli_manifest.py +0 -0
  93. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/workflow_cli_projection.py +0 -0
  94. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/workflow_cli_rendering.py +0 -0
  95. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/workflow_cli_snapshots.py +0 -0
  96. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/workflow_cli_store_backends.py +0 -0
  97. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/workflow_processes.py +0 -0
  98. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/workflow_runs.py +0 -0
  99. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/workflow_store_io.py +0 -0
  100. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/claude_code_tools/workflow_validation.py +0 -0
  101. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/docs/agent-tunnel-spec.md +0 -0
  102. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/docs/claude-code-tmux-tutorials.md +0 -0
  103. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/docs/dot-zshrc.md +0 -0
  104. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/docs/find-claude-session.md +0 -0
  105. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/docs/intercom-spec.md +0 -0
  106. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/docs/lmsh.md +0 -0
  107. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/docs/local-llm-setup.md +0 -0
  108. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/docs/msg-phase-a-plan.md +0 -0
  109. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/docs/msg-plan-v2.md +0 -0
  110. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/docs/tmux-cli-instructions.md +0 -0
  111. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/docs/vault-documentation.md +0 -0
  112. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/hatch_build.py +0 -0
  113. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/node_ui/action_config.js +0 -0
  114. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/node_ui/menu.js +0 -0
  115. {claude_code_tools-1.21.0 → claude_code_tools-1.23.0}/node_ui/package-lock.json +0 -0
  116. {claude_code_tools-1.21.0 → claude_code_tools-1.23.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.23.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.23.0"
@@ -1768,7 +1768,9 @@ def port_session(
1768
1768
  """
1769
1769
  import sys
1770
1770
 
1771
+ from claude_code_tools.port_render import PortDisplay
1771
1772
  from claude_code_tools.port_service import (
1773
+ PortAmbiguityError,
1772
1774
  PortSessionError,
1773
1775
  port_claude_session,
1774
1776
  port_codex_session,
@@ -1782,56 +1784,54 @@ def port_session(
1782
1784
  claude_home = claude_home or root_obj.get("claude_home")
1783
1785
  codex_home = codex_home or root_obj.get("codex_home")
1784
1786
 
1787
+ display = PortDisplay(session)
1788
+ display.start()
1785
1789
  try:
1786
- resolved = resolve_port_session(
1787
- session, claude_home=claude_home, codex_home=codex_home
1788
- )
1790
+ with display.resolving():
1791
+ resolved = resolve_port_session(
1792
+ session,
1793
+ claude_home=claude_home,
1794
+ codex_home=codex_home,
1795
+ )
1796
+ except PortAmbiguityError as error:
1797
+ try:
1798
+ resolved = display.choose_candidate(error)
1799
+ except (EOFError, KeyboardInterrupt):
1800
+ display.error(str(error))
1801
+ sys.exit(1)
1802
+ if resolved is None:
1803
+ display.cancelled()
1804
+ sys.exit(1)
1789
1805
  except PortSessionError as e:
1790
- print(f"Error: {e}", file=sys.stderr)
1806
+ display.error(str(e))
1791
1807
  sys.exit(1)
1792
1808
 
1809
+ display.resolved(resolved)
1793
1810
  if resolved.agent == "claude":
1794
- print("Detected source agent: claude — porting to Codex")
1795
1811
  try:
1796
- result = port_claude_session(
1797
- resolved.session_file, codex_home=codex_home
1798
- )
1812
+ with display.porting(resolved.agent):
1813
+ result = port_claude_session(
1814
+ resolved.session_file,
1815
+ codex_home=codex_home,
1816
+ )
1799
1817
  except PortSessionError as e:
1800
- print(f"Error: {e}", file=sys.stderr)
1818
+ display.error(str(e))
1801
1819
  sys.exit(1)
1802
-
1803
- print()
1804
- print(f"New Codex session id: {result.new_session_id}")
1805
- print(f"Output file: {result.output_file}")
1806
- print(f"Session cwd: {result.cwd}")
1807
- print()
1808
- print("To resume:")
1809
- print(f" {result.resume_hint}")
1810
- print()
1811
- print(
1812
- "Tip: codex's /import can also import Claude sessions "
1813
- "natively (interactive alternative)."
1814
- )
1820
+ display.complete(result, resolved.agent)
1815
1821
  return
1816
1822
 
1817
- print("Detected source agent: codex — porting to Claude Code")
1818
1823
  try:
1819
- result = port_codex_session(
1820
- resolved.session_file, claude_home=claude_home
1821
- )
1824
+ with display.porting(resolved.agent):
1825
+ result = port_codex_session(
1826
+ resolved.session_file,
1827
+ claude_home=claude_home,
1828
+ )
1822
1829
  except PortSessionError as e:
1823
1830
  # Expected failure modes (missing/empty session, filesystem
1824
1831
  # or encoding errors) become a clean error, not a traceback.
1825
- print(f"Error: {e}", file=sys.stderr)
1832
+ display.error(str(e))
1826
1833
  sys.exit(1)
1827
-
1828
- print()
1829
- print(f"New Claude session id: {result.new_session_id}")
1830
- print(f"Output file: {result.output_file}")
1831
- print(f"Session cwd: {result.cwd}")
1832
- print()
1833
- print("To resume:")
1834
- print(f" {result.resume_hint}")
1834
+ display.complete(result, resolved.agent)
1835
1835
 
1836
1836
 
1837
1837
  @main.command("query")
@@ -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