claude-code-tools 1.30.4__tar.gz → 1.31.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 (146) hide show
  1. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/PKG-INFO +1 -1
  2. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/__init__.py +1 -1
  3. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/aichat.py +53 -2
  4. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/port_claude_to_codex.py +5 -1
  5. claude_code_tools-1.31.0/claude_code_tools/port_codex_provider.py +268 -0
  6. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/port_render.py +29 -17
  7. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/port_service.py +140 -5
  8. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/docs/local-llm-setup.md +18 -0
  9. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/pyproject.toml +2 -2
  10. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/.gitignore +0 -0
  11. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/LICENSE +0 -0
  12. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/README.md +0 -0
  13. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/action_rpc.py +0 -0
  14. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/agent_tunnel/__init__.py +0 -0
  15. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/agent_tunnel/backends.py +0 -0
  16. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/agent_tunnel/cli.py +0 -0
  17. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/agent_tunnel/config.py +0 -0
  18. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/agent_tunnel/convert.py +0 -0
  19. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/agent_tunnel/discord_bot.py +0 -0
  20. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/agent_tunnel/http_frontend.py +0 -0
  21. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/agent_tunnel/locking.py +0 -0
  22. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/agent_tunnel/mattermost_bot.py +0 -0
  23. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/agent_tunnel/paths.py +0 -0
  24. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/agent_tunnel/readonly.py +0 -0
  25. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/agent_tunnel/registry.py +0 -0
  26. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/agent_tunnel/relay.py +0 -0
  27. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/agent_tunnel/serve.py +0 -0
  28. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/agent_tunnel/session.py +0 -0
  29. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/agent_tunnel/store.py +0 -0
  30. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/agent_tunnel/tmux.py +0 -0
  31. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/agent_tunnel/trust.py +0 -0
  32. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/agent_tunnel/turn_log.py +0 -0
  33. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/amux/__init__.py +0 -0
  34. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/amux/__main__.py +0 -0
  35. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/amux/activity.py +0 -0
  36. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/amux/cache.py +0 -0
  37. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/amux/cli.py +0 -0
  38. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/amux/detect.py +0 -0
  39. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/amux/filters.py +0 -0
  40. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/amux/model.py +0 -0
  41. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/amux/render.py +0 -0
  42. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/amux/scan.py +0 -0
  43. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/claude_continue.py +0 -0
  44. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/cli_passthrough.py +0 -0
  45. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/codex_app_server_rpc.py +0 -0
  46. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/codex_continue.py +0 -0
  47. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/codex_server.py +0 -0
  48. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/codex_server_callback.py +0 -0
  49. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/codex_server_cli.py +0 -0
  50. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/codex_server_fingerprint.py +0 -0
  51. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/codex_server_generation.py +0 -0
  52. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/codex_server_legacy.py +0 -0
  53. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/codex_server_models.py +0 -0
  54. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/codex_server_process.py +0 -0
  55. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/codex_server_reservation.py +0 -0
  56. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/codex_server_resume.py +0 -0
  57. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/codex_server_retry.py +0 -0
  58. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/codex_server_reuse.py +0 -0
  59. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/codex_server_state.py +0 -0
  60. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/codex_server_supervisor.py +0 -0
  61. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/codex_server_wait.py +0 -0
  62. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/codex_server_worker.py +0 -0
  63. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/config.py +0 -0
  64. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/csv2gsheet.py +0 -0
  65. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/delete_session.py +0 -0
  66. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/dotenv_vault.py +0 -0
  67. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/env_safe.py +0 -0
  68. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/export_all.py +0 -0
  69. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/export_claude_session.py +0 -0
  70. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/export_codex_session.py +0 -0
  71. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/export_session.py +0 -0
  72. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/find_claude_session.py +0 -0
  73. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/find_codex_session.py +0 -0
  74. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/find_original_session.py +0 -0
  75. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/find_session.py +0 -0
  76. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/find_trimmed_sessions.py +0 -0
  77. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/fix_session.py +0 -0
  78. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/gdoc2docx.py +0 -0
  79. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/gdoc2md.py +0 -0
  80. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/github_wake.py +0 -0
  81. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/github_watch_daemon.py +0 -0
  82. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/github_watch_store.py +0 -0
  83. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/gsheet2csv.py +0 -0
  84. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/helper_sessions.py +0 -0
  85. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/issue_reply_delivery.py +0 -0
  86. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/legacy_session_wrappers.py +0 -0
  87. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/md2gdoc.py +0 -0
  88. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/move_account.py +0 -0
  89. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/msg/__init__.py +0 -0
  90. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/msg/cli.py +0 -0
  91. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/msg/hooks.py +0 -0
  92. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/msg/migrations.py +0 -0
  93. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/msg/models.py +0 -0
  94. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/msg/prompt_detect.py +0 -0
  95. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/msg/store.py +0 -0
  96. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/msg/watcher.py +0 -0
  97. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/node_menu_ui.py +0 -0
  98. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/port_claude_noise.py +0 -0
  99. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/port_codex_flatten.py +0 -0
  100. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/port_codex_to_claude.py +0 -0
  101. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/resolve_session.py +0 -0
  102. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/resolve_session_names.py +0 -0
  103. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/resolve_session_render.py +0 -0
  104. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/search_index.py +0 -0
  105. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/session_cli_resolution.py +0 -0
  106. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/session_lineage.py +0 -0
  107. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/session_menu.py +0 -0
  108. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/session_menu_cli.py +0 -0
  109. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/session_resolution.py +0 -0
  110. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/session_selection.py +0 -0
  111. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/session_utils.py +0 -0
  112. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/smart_trim.py +0 -0
  113. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/smart_trim_core.py +0 -0
  114. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/tmux_cli_controller.py +0 -0
  115. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/tmux_execution_helpers.py +0 -0
  116. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/tmux_remote_controller.py +0 -0
  117. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/trim_in_place.py +0 -0
  118. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/trim_session.py +0 -0
  119. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/trim_session_claude.py +0 -0
  120. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/trim_session_codex.py +0 -0
  121. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/workflow_cli.py +0 -0
  122. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/workflow_cli_contract.py +0 -0
  123. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/workflow_cli_formatting.py +0 -0
  124. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/workflow_cli_identity_policy.py +0 -0
  125. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/workflow_cli_manifest.py +0 -0
  126. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/workflow_cli_projection.py +0 -0
  127. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/workflow_cli_rendering.py +0 -0
  128. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/workflow_cli_snapshots.py +0 -0
  129. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/workflow_cli_store_backends.py +0 -0
  130. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/workflow_processes.py +0 -0
  131. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/workflow_runs.py +0 -0
  132. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/workflow_store_io.py +0 -0
  133. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/claude_code_tools/workflow_validation.py +0 -0
  134. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/docs/agent-tunnel-spec.md +0 -0
  135. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/docs/claude-code-tmux-tutorials.md +0 -0
  136. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/docs/dot-zshrc.md +0 -0
  137. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/docs/find-claude-session.md +0 -0
  138. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/docs/intercom-spec.md +0 -0
  139. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/docs/lmsh.md +0 -0
  140. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/docs/tmux-cli-instructions.md +0 -0
  141. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/docs/vault-documentation.md +0 -0
  142. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/hatch_build.py +0 -0
  143. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/node_ui/action_config.js +0 -0
  144. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/node_ui/menu.js +0 -0
  145. {claude_code_tools-1.30.4 → claude_code_tools-1.31.0}/node_ui/package-lock.json +0 -0
  146. {claude_code_tools-1.30.4 → claude_code_tools-1.31.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.30.4
3
+ Version: 1.31.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.30.4"
3
+ __version__ = "1.31.0"
@@ -1858,12 +1858,31 @@ def _write_lines_atomically(
1858
1858
  @click.argument("session", required=True)
1859
1859
  @click.option("--claude-home", help="Path to Claude home directory")
1860
1860
  @click.option("--codex-home", help="Path to Codex home directory")
1861
+ @click.option(
1862
+ "--provider",
1863
+ help=(
1864
+ "Codex model provider for the new session (e.g. openai, "
1865
+ "local-fractal). With a Codex session: copy it onto this "
1866
+ "provider instead of porting to Claude. With a Claude session: "
1867
+ "the new Codex session's provider (default openai)."
1868
+ ),
1869
+ )
1870
+ @click.option(
1871
+ "--model",
1872
+ help=(
1873
+ "Model to resume the new Codex session with; added to the "
1874
+ "printed resume command as -m (Codex otherwise resumes with "
1875
+ "the session's last model)."
1876
+ ),
1877
+ )
1861
1878
  @click.pass_context
1862
1879
  def port_session(
1863
1880
  ctx: click.Context,
1864
1881
  session: str,
1865
1882
  claude_home: "str | None",
1866
1883
  codex_home: "str | None",
1884
+ provider: "str | None",
1885
+ model: "str | None",
1867
1886
  ) -> None:
1868
1887
  """Port a session to the OTHER agent (codex <-> claude).
1869
1888
 
@@ -1872,6 +1891,14 @@ def port_session(
1872
1891
  Claude sessions are converted into a flattened, resumable Codex
1873
1892
  rollout.
1874
1893
 
1894
+ With --provider, a Codex session is instead copied to a new Codex
1895
+ session bound to that model provider (Codex only resumes a thread
1896
+ under the provider it was created with). OpenAI-only encrypted
1897
+ reasoning and compaction summaries (and a local model's reasoning)
1898
+ are dropped; everything else is kept. Use --provider openai to move
1899
+ a local-model session back, with --model naming the model to resume
1900
+ with.
1901
+
1875
1902
  SESSION may be a full session id, a partial id (prefix, middle,
1876
1903
  or suffix fragment), a session name (set with /rename), a
1877
1904
  rollout filename fragment (e.g. 2026-03-25T14-50), or a session
@@ -1883,6 +1910,8 @@ def port_session(
1883
1910
  aichat port abc123 --claude-home ~/.claude-alt
1884
1911
  aichat port my-session-name
1885
1912
  aichat port 2026-03-25T14-50
1913
+ aichat port 01a1a29a --provider local-fractal
1914
+ aichat port 01a1a2aa --provider openai --model gpt-6.1-sol
1886
1915
  """
1887
1916
  import sys
1888
1917
 
@@ -1893,6 +1922,7 @@ def port_session(
1893
1922
  port_claude_session,
1894
1923
  port_codex_session,
1895
1924
  resolve_port_session,
1925
+ retarget_codex_session,
1896
1926
  )
1897
1927
 
1898
1928
  # Group-level --claude-home/--codex-home (given BEFORE the
@@ -1924,13 +1954,34 @@ def port_session(
1924
1954
  display.error(str(e))
1925
1955
  sys.exit(1)
1926
1956
 
1927
- display.resolved(resolved)
1957
+ if model and resolved.agent == "codex" and not provider:
1958
+ display.error("--model only applies to a Codex target: add --provider")
1959
+ sys.exit(1)
1960
+ target_agent = "codex" if (resolved.agent == "claude" or provider) else "claude"
1961
+ display.resolved(resolved, target_agent)
1928
1962
  if resolved.agent == "claude":
1929
1963
  try:
1930
- with display.porting(resolved.agent):
1964
+ with display.porting(resolved.agent, target_agent):
1931
1965
  result = port_claude_session(
1932
1966
  resolved.session_file,
1933
1967
  codex_home=codex_home,
1968
+ provider=provider,
1969
+ model=model or "",
1970
+ )
1971
+ except PortSessionError as e:
1972
+ display.error(str(e))
1973
+ sys.exit(1)
1974
+ display.complete(result, resolved.agent)
1975
+ return
1976
+
1977
+ if provider:
1978
+ try:
1979
+ with display.porting(resolved.agent, target_agent):
1980
+ result = retarget_codex_session(
1981
+ resolved.session_file,
1982
+ provider,
1983
+ codex_home=codex_home,
1984
+ model=model or "",
1934
1985
  )
1935
1986
  except PortSessionError as e:
1936
1987
  display.error(str(e))
@@ -756,6 +756,7 @@ def _append_history_transactional(
756
756
  def port_claude_session_to_codex(
757
757
  claude_session_file: Union[str, Path],
758
758
  codex_home: Optional[Union[str, Path]] = None,
759
+ model_provider: str = CODEX_MODEL_PROVIDER,
759
760
  ) -> tuple[str, Path]:
760
761
  """Convert a Claude Code session into a new Codex rollout file.
761
762
 
@@ -771,6 +772,9 @@ def port_claude_session_to_codex(
771
772
  claude_session_file: Path to the Claude session JSONL file.
772
773
  codex_home: Optional Codex home directory (defaults to
773
774
  ``~/.codex``).
775
+ model_provider: Codex model provider the new rollout is bound
776
+ to (Codex only resumes a thread under the provider it
777
+ records). Defaults to ``openai``.
774
778
 
775
779
  Returns:
776
780
  Tuple ``(new_session_id, new_file_path)``.
@@ -829,7 +833,7 @@ def port_claude_session_to_codex(
829
833
  "cwd": cwd,
830
834
  "originator": PORT_ORIGINATOR,
831
835
  "cli_version": "aichat-port",
832
- "model_provider": CODEX_MODEL_PROVIDER,
836
+ "model_provider": model_provider,
833
837
  }
834
838
  if meta["branch"]:
835
839
  payload["git"] = {"branch": meta["branch"]}
@@ -0,0 +1,268 @@
1
+ """Re-target a Codex rollout at another model provider.
2
+
3
+ Codex records the model provider a thread was created with in the
4
+ rollout's ``session_meta`` line (``"model_provider": "openai"``), and the
5
+ Codex TUI refuses to resume a thread under a different provider
6
+ ("This chat uses openai, but your current provider is ..."). This module
7
+ writes a NEW rollout, forked from the original, that is bound to the
8
+ requested provider instead, so a session can move between OpenAI and a
9
+ local model (or between any two providers) in either direction.
10
+
11
+ Everything in the transcript is kept except what one provider cannot
12
+ take from another: reasoning items (OpenAI's are encrypted; a local
13
+ model's carry raw ``reasoning_text`` content, which OpenAI rejects as
14
+ input) and any item carrying ``encrypted_content``, such as OpenAI's
15
+ encrypted compaction summaries. They are dropped from the top-level
16
+ history and from the ``replacement_history`` / ``guardian_history``
17
+ lists inside ``compacted`` records. Visible messages, tool calls, tool
18
+ output and plaintext summaries all survive.
19
+
20
+ Codex also resumes with the model a session last used, which belongs to
21
+ the old provider; :class:`RetargetStats` reports it so the caller can
22
+ tell the user to pick a model with ``-m``.
23
+
24
+ The source rollout is never modified. The new rollout goes to the same
25
+ Codex home as the source (or ``codex_home`` when given) and is appended
26
+ to that home's ``history.jsonl`` like every other ported session.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import datetime
32
+ import json
33
+ import re
34
+ from collections import Counter
35
+ from dataclasses import dataclass, field
36
+ from pathlib import Path
37
+ from typing import Any, Iterator, Optional, Union
38
+
39
+ from claude_code_tools.port_claude_to_codex import (
40
+ _append_history_transactional,
41
+ _new_codex_session_id,
42
+ _utc_now_codex_timestamp,
43
+ )
44
+ from claude_code_tools.port_codex_to_claude import _write_transcript_atomic
45
+ from claude_code_tools.session_utils import get_codex_home
46
+
47
+ # Provider ids are TOML table keys in Codex's config ([model_providers.X])
48
+ # and appear unquoted on resume command lines.
49
+ _PROVIDER_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]*$")
50
+
51
+ # History lists inside a ``compacted`` record that hold response items.
52
+ _COMPACTED_HISTORY_KEYS = ("replacement_history", "guardian_history")
53
+
54
+
55
+ @dataclass
56
+ class RetargetStats:
57
+ """What changed while re-targeting a rollout.
58
+
59
+ Attributes:
60
+ source_provider: Provider recorded in the source rollout.
61
+ dropped: Count of dropped provider-only items, by item type
62
+ (e.g. ``{"reasoning": 192, "compaction": 2}``).
63
+ last_model: Model of the session's last turn ("" if none).
64
+ """
65
+
66
+ source_provider: str
67
+ dropped: Counter = field(default_factory=Counter)
68
+ last_model: str = ""
69
+
70
+
71
+ def validate_provider(provider: str) -> str:
72
+ """Check a provider id and return it.
73
+
74
+ Args:
75
+ provider: Provider id such as ``openai`` or ``local-fractal``.
76
+
77
+ Returns:
78
+ The provider id, unchanged.
79
+
80
+ Raises:
81
+ ValueError: If the id is empty or not a plain identifier.
82
+ """
83
+ if not _PROVIDER_RE.match(provider or ""):
84
+ raise ValueError(
85
+ f"Invalid provider {provider!r}: use letters, digits, '-' or '_' "
86
+ "(e.g. openai, local-fractal)"
87
+ )
88
+ return provider
89
+
90
+
91
+ def _is_provider_only(item: Any) -> bool:
92
+ """True for a response item another provider cannot take as input."""
93
+ return isinstance(item, dict) and (
94
+ item.get("type") == "reasoning" or bool(item.get("encrypted_content")))
95
+
96
+
97
+ def _codex_home_of(rollout: Path) -> Optional[Path]:
98
+ """Codex home holding ``rollout``.
99
+
100
+ Live rollouts sit in ``<home>/sessions/Y/M/D/``; archived ones in
101
+ ``<home>/archived_sessions/``.
102
+ """
103
+ parents = rollout.parents
104
+ if len(parents) >= 5 and parents[3].name == "sessions":
105
+ return parents[4]
106
+ if len(parents) >= 2 and parents[0].name == "archived_sessions":
107
+ return parents[1]
108
+ return None
109
+
110
+
111
+ def _read_session_meta(rollout: Path) -> dict[str, Any]:
112
+ """Return the first line's session_meta payload, or raise."""
113
+ with open(rollout, "r", encoding="utf-8") as f:
114
+ first = f.readline()
115
+ try:
116
+ record = json.loads(first)
117
+ except ValueError:
118
+ record = None
119
+ if not isinstance(record, dict) or record.get("type") != "session_meta" \
120
+ or not isinstance(record.get("payload"), dict):
121
+ raise ValueError(f"Not a Codex rollout (no session_meta first): {rollout}")
122
+ return record["payload"]
123
+
124
+
125
+ def _first_user_text(record: dict[str, Any]) -> Optional[str]:
126
+ """The text of a user_message event, if ``record`` is one."""
127
+ payload = record.get("payload")
128
+ if record.get("type") == "event_msg" and isinstance(payload, dict) \
129
+ and payload.get("type") == "user_message":
130
+ message = payload.get("message")
131
+ if isinstance(message, str) and message.strip():
132
+ return message
133
+ return None
134
+
135
+
136
+ def _retarget_lines(
137
+ rollout: Path,
138
+ *,
139
+ provider: str,
140
+ new_id: str,
141
+ now_iso: str,
142
+ stats: RetargetStats,
143
+ preview: dict[str, Optional[str]],
144
+ ) -> Iterator[dict[str, Any]]:
145
+ """Stream the source rollout's records, re-targeted at ``provider``."""
146
+ first = True
147
+ with open(rollout, "r", encoding="utf-8") as f:
148
+ for raw in f:
149
+ if not raw.strip():
150
+ continue
151
+ record = json.loads(raw)
152
+ if not isinstance(record, dict):
153
+ yield record
154
+ continue
155
+ kind = record.get("type")
156
+ payload = record.get("payload")
157
+ if kind == "session_meta" and isinstance(payload, dict):
158
+ payload = {**payload, "model_provider": provider}
159
+ if first:
160
+ old_id = payload.get("id")
161
+ payload.update(id=new_id, session_id=new_id,
162
+ timestamp=now_iso, forked_from_id=old_id)
163
+ record = {
164
+ **record,
165
+ "timestamp": now_iso,
166
+ "payload": payload,
167
+ "continue_metadata": {
168
+ "parent_session_id": old_id,
169
+ "parent_session_file": str(rollout.absolute()),
170
+ "continued_at": now_iso,
171
+ "retargeted_from_provider": stats.source_provider,
172
+ "retargeted_to_provider": provider,
173
+ },
174
+ }
175
+ else: # lineage line of the thread this one forked from
176
+ record = {**record, "payload": payload}
177
+ first = False
178
+ elif kind == "turn_context" and isinstance(payload, dict):
179
+ if isinstance(payload.get("model"), str):
180
+ stats.last_model = payload["model"]
181
+ elif kind == "response_item" and _is_provider_only(payload):
182
+ stats.dropped[str(payload.get("type"))] += 1
183
+ continue
184
+ elif kind == "compacted" and isinstance(payload, dict):
185
+ payload = dict(payload)
186
+ for key in _COMPACTED_HISTORY_KEYS:
187
+ items = payload.get(key)
188
+ if isinstance(items, list):
189
+ kept = []
190
+ for x in items:
191
+ if isinstance(x, dict) and _is_provider_only(x):
192
+ stats.dropped[str(x.get("type"))] += 1
193
+ else:
194
+ kept.append(x)
195
+ payload[key] = kept
196
+ record = {**record, "payload": payload}
197
+ elif kind == "event_msg" and isinstance(payload, dict) \
198
+ and payload.get("type") == "thread_settings_applied":
199
+ settings = payload.get("thread_settings")
200
+ if isinstance(settings, dict) and "model_provider_id" in settings:
201
+ record = {**record, "payload": {
202
+ **payload,
203
+ "thread_settings": {**settings,
204
+ "model_provider_id": provider}}}
205
+ if preview["text"] is None:
206
+ preview["text"] = _first_user_text(record)
207
+ yield record
208
+
209
+
210
+ def retarget_codex_session(
211
+ rollout_file: Union[str, Path],
212
+ provider: str,
213
+ codex_home: Optional[Union[str, Path]] = None,
214
+ ) -> tuple[str, Path, RetargetStats]:
215
+ """Write a copy of a Codex rollout bound to another model provider.
216
+
217
+ Args:
218
+ rollout_file: Path to the source Codex rollout JSONL file.
219
+ provider: Target provider id (``openai``, ``local-fractal``, ...).
220
+ codex_home: Codex home for the new rollout. Defaults to the
221
+ source rollout's own home, else ``~/.codex``.
222
+
223
+ Returns:
224
+ Tuple ``(new_session_id, new_file_path, stats)``.
225
+
226
+ Raises:
227
+ FileNotFoundError: If the source rollout does not exist.
228
+ ValueError: If the provider id is invalid, the file is not a
229
+ Codex rollout, or it already uses ``provider``.
230
+ OSError: On filesystem failures (no partial file is left, and
231
+ the history entry is rolled back).
232
+ """
233
+ validate_provider(provider)
234
+ source = Path(rollout_file).expanduser()
235
+ if not source.is_file():
236
+ raise FileNotFoundError(f"Codex session not found: {source}")
237
+ meta = _read_session_meta(source)
238
+ source_provider = str(meta.get("model_provider") or "openai")
239
+ if source_provider == provider:
240
+ raise ValueError(
241
+ f"Session already uses provider {provider!r}; nothing to change"
242
+ )
243
+
244
+ if codex_home is not None:
245
+ home_dir = get_codex_home(str(codex_home))
246
+ else:
247
+ home_dir = _codex_home_of(source.absolute()) or get_codex_home(None)
248
+ new_id = _new_codex_session_id()
249
+ now_iso = _utc_now_codex_timestamp()
250
+ now_local = datetime.datetime.now()
251
+ day_dir = home_dir / "sessions" / now_local.strftime("%Y/%m/%d")
252
+ day_dir.mkdir(parents=True, exist_ok=True)
253
+ stamp = now_local.strftime("%Y-%m-%dT%H-%M-%S")
254
+ out_path = day_dir / f"rollout-{stamp}-{new_id}.jsonl"
255
+
256
+ stats = RetargetStats(source_provider=source_provider)
257
+ preview: dict[str, Optional[str]] = {"text": None}
258
+ _write_transcript_atomic(
259
+ out_path,
260
+ _retarget_lines(source, provider=provider, new_id=new_id,
261
+ now_iso=now_iso, stats=stats, preview=preview),
262
+ )
263
+ _append_history_transactional(
264
+ home_dir, new_id,
265
+ preview["text"] or f"Codex session moved to provider {provider}",
266
+ out_path,
267
+ )
268
+ return new_id, out_path, stats
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import os
6
+ import shlex
6
7
  import shutil
7
8
  import subprocess
8
9
  import sys
@@ -106,9 +107,9 @@ def _target_agent(source_agent: str) -> str:
106
107
  return "codex" if source_agent == "claude" else "claude"
107
108
 
108
109
 
109
- def _target_label(source_agent: str) -> str:
110
+ def _target_label(source_agent: str, target_agent: str = "") -> str:
110
111
  """Return the product label for a destination agent."""
111
- target = _target_agent(source_agent)
112
+ target = target_agent or _target_agent(source_agent)
112
113
  return "Claude Code" if target == "claude" else "Codex"
113
114
 
114
115
 
@@ -161,15 +162,16 @@ class PortDisplay:
161
162
  session_file=Path(record.session_file),
162
163
  )
163
164
 
164
- def resolved(self, session: "ResolvedSession") -> None:
165
+ def resolved(self, session: "ResolvedSession", target_agent: str = "") -> None:
165
166
  """Print the chosen source and detected direction."""
166
167
  source = _agent_label(session.agent)
167
- target = _target_label(session.agent)
168
+ target = _target_label(session.agent, target_agent)
168
169
  self.console.print(
169
170
  Text.assemble(
170
171
  (" ✓ ", "bold green"),
171
172
  (f"Detected source agent: {session.agent}", "bold"),
172
- f" — porting to {target}",
173
+ " — copying to a new Codex session on another provider"
174
+ if target_agent == session.agent else f" — porting to {target}",
173
175
  )
174
176
  )
175
177
  self.console.print(
@@ -177,9 +179,9 @@ class PortDisplay:
177
179
  )
178
180
  self.console.print(f"[bold cyan]2/3[/] Porting {source} → {target}")
179
181
 
180
- def porting(self, source_agent: str) -> Status:
182
+ def porting(self, source_agent: str, target_agent: str = "") -> Status:
181
183
  """Return the conversion-phase spinner."""
182
- target = _target_label(source_agent)
184
+ target = _target_label(source_agent, target_agent)
183
185
  return self.console.status(
184
186
  f"[dim]Converting transcript and creating {target} session...[/dim]",
185
187
  spinner="dots",
@@ -187,7 +189,7 @@ class PortDisplay:
187
189
 
188
190
  def complete(self, result: "PortResult", source_agent: str) -> None:
189
191
  """Render the final destination details and resume command."""
190
- target_agent = _target_agent(source_agent)
192
+ target_agent = result.target_agent or _target_agent(source_agent)
191
193
  target = _agent_label(target_agent)
192
194
  self.console.print("[bold cyan]3/3[/] Ready")
193
195
 
@@ -200,6 +202,8 @@ class PortDisplay:
200
202
  )
201
203
  table.add_row("Session cwd:", Text(result.cwd or "Unknown"))
202
204
  table.add_row("Output file:", Text(str(result.output_file)))
205
+ if result.provider:
206
+ table.add_row("Model provider:", Text(result.provider))
203
207
  self.console.print(
204
208
  Panel(
205
209
  table,
@@ -215,18 +219,29 @@ class PortDisplay:
215
219
  overflow="ignore",
216
220
  )
217
221
  self.console.print(command, soft_wrap=True)
218
- if target_agent == "codex":
222
+ for note in result.notes:
223
+ self.console.print(Text(f" {note}", style="dim"))
224
+ if target_agent == "codex" and source_agent == "claude":
219
225
  self.console.print()
220
226
  self.console.print(
221
227
  "[dim]Tip: Codex's /import is also available as an "
222
228
  "interactive alternative.[/dim]"
223
229
  )
224
- self.offer_session_id_copy(result.new_session_id, target_agent)
230
+ # The follow-up command keeps every flag of the resume hint
231
+ # (provider, model, CODEX_HOME), with the id left to paste. The
232
+ # services build the hint as `cd <quoted cwd> && <command>`.
233
+ prefix = f"cd {shlex.quote(result.cwd)} && "
234
+ command = ""
235
+ if result.resume_hint.startswith(prefix):
236
+ command = result.resume_hint[len(prefix):].replace(
237
+ result.new_session_id, "<paste session ID>")
238
+ self.offer_session_id_copy(result.new_session_id, target_agent, command)
225
239
 
226
240
  def offer_session_id_copy(
227
241
  self,
228
242
  session_id: str,
229
243
  target_agent: str,
244
+ command: str = "",
230
245
  ) -> None:
231
246
  """Offer to copy a newly created session ID in interactive terminals."""
232
247
  if not self.console.is_terminal or not sys.stdin.isatty():
@@ -250,14 +265,11 @@ class PortDisplay:
250
265
  self.console.print(
251
266
  "[bold green]✓ Session ID copied to clipboard.[/bold green]"
252
267
  )
253
- command = "codex resume" if target_agent == "codex" else "claude --resume"
268
+ if not command:
269
+ command = ("codex resume" if target_agent == "codex"
270
+ else "claude --resume") + " <paste session ID>"
254
271
  self.console.print("[bold]Now you can run:[/bold]")
255
- self.console.print(
256
- Text(
257
- f" {command} <paste session ID>",
258
- style="bold cyan",
259
- )
260
- )
272
+ self.console.print(Text(f" {command}", style="bold cyan"))
261
273
  return
262
274
  self.console.print(
263
275
  "[yellow]Could not find a working system clipboard command. "