claude-code-tools 1.24.0__tar.gz → 1.25.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 (132) hide show
  1. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/PKG-INFO +18 -2
  2. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/README.md +17 -1
  3. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/__init__.py +1 -1
  4. claude_code_tools-1.25.0/claude_code_tools/amux/__init__.py +5 -0
  5. claude_code_tools-1.25.0/claude_code_tools/amux/__main__.py +6 -0
  6. claude_code_tools-1.25.0/claude_code_tools/amux/cache.py +80 -0
  7. claude_code_tools-1.25.0/claude_code_tools/amux/cli.py +206 -0
  8. claude_code_tools-1.25.0/claude_code_tools/amux/detect.py +236 -0
  9. claude_code_tools-1.25.0/claude_code_tools/amux/model.py +90 -0
  10. claude_code_tools-1.25.0/claude_code_tools/amux/render.py +86 -0
  11. claude_code_tools-1.25.0/claude_code_tools/amux/scan.py +259 -0
  12. claude_code_tools-1.25.0/claude_code_tools/codex_app_server_rpc.py +372 -0
  13. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/codex_server.py +4 -7
  14. claude_code_tools-1.25.0/claude_code_tools/codex_server_callback.py +39 -0
  15. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/codex_server_cli.py +16 -6
  16. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/codex_server_fingerprint.py +180 -42
  17. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/codex_server_models.py +1 -0
  18. claude_code_tools-1.25.0/claude_code_tools/codex_server_resume.py +275 -0
  19. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/export_session.py +8 -0
  20. claude_code_tools-1.25.0/claude_code_tools/github_wake.py +413 -0
  21. claude_code_tools-1.25.0/claude_code_tools/github_watch_daemon.py +486 -0
  22. claude_code_tools-1.25.0/claude_code_tools/github_watch_store.py +390 -0
  23. claude_code_tools-1.25.0/claude_code_tools/issue_reply_delivery.py +197 -0
  24. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/port_claude_to_codex.py +93 -5
  25. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/port_codex_to_claude.py +286 -8
  26. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/pyproject.toml +4 -2
  27. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/.gitignore +0 -0
  28. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/LICENSE +0 -0
  29. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/action_rpc.py +0 -0
  30. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/agent_tunnel/__init__.py +0 -0
  31. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/agent_tunnel/backends.py +0 -0
  32. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/agent_tunnel/cli.py +0 -0
  33. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/agent_tunnel/config.py +0 -0
  34. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/agent_tunnel/convert.py +0 -0
  35. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/agent_tunnel/discord_bot.py +0 -0
  36. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/agent_tunnel/locking.py +0 -0
  37. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/agent_tunnel/paths.py +0 -0
  38. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/agent_tunnel/registry.py +0 -0
  39. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/agent_tunnel/session.py +0 -0
  40. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/agent_tunnel/store.py +0 -0
  41. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/agent_tunnel/tmux.py +0 -0
  42. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/agent_tunnel/trust.py +0 -0
  43. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/aichat.py +0 -0
  44. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/claude_continue.py +0 -0
  45. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/codex_continue.py +0 -0
  46. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/codex_server_generation.py +0 -0
  47. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/codex_server_legacy.py +0 -0
  48. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/codex_server_process.py +0 -0
  49. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/codex_server_reservation.py +0 -0
  50. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/codex_server_retry.py +0 -0
  51. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/codex_server_reuse.py +0 -0
  52. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/codex_server_state.py +0 -0
  53. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/codex_server_supervisor.py +0 -0
  54. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/codex_server_worker.py +0 -0
  55. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/config.py +0 -0
  56. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/csv2gsheet.py +0 -0
  57. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/delete_session.py +0 -0
  58. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/dotenv_vault.py +0 -0
  59. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/env_safe.py +0 -0
  60. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/export_all.py +0 -0
  61. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/export_claude_session.py +0 -0
  62. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/export_codex_session.py +0 -0
  63. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/find_claude_session.py +0 -0
  64. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/find_codex_session.py +0 -0
  65. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/find_original_session.py +0 -0
  66. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/find_session.py +0 -0
  67. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/find_trimmed_sessions.py +0 -0
  68. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/fix_session.py +0 -0
  69. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/gdoc2docx.py +0 -0
  70. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/gdoc2md.py +0 -0
  71. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/gsheet2csv.py +0 -0
  72. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/md2gdoc.py +0 -0
  73. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/move_account.py +0 -0
  74. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/msg/__init__.py +0 -0
  75. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/msg/cli.py +0 -0
  76. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/msg/hooks.py +0 -0
  77. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/msg/models.py +0 -0
  78. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/msg/prompt_detect.py +0 -0
  79. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/msg/store.py +0 -0
  80. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/msg/watcher.py +0 -0
  81. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/node_menu_ui.py +0 -0
  82. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/port_claude_noise.py +0 -0
  83. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/port_codex_flatten.py +0 -0
  84. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/port_render.py +0 -0
  85. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/port_service.py +0 -0
  86. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/resolve_session.py +0 -0
  87. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/resolve_session_names.py +0 -0
  88. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/resolve_session_render.py +0 -0
  89. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/search_index.py +0 -0
  90. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/session_lineage.py +0 -0
  91. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/session_menu.py +0 -0
  92. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/session_menu_cli.py +0 -0
  93. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/session_resolution.py +0 -0
  94. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/session_utils.py +0 -0
  95. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/smart_trim.py +0 -0
  96. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/smart_trim_core.py +0 -0
  97. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/tmux_cli_controller.py +0 -0
  98. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/tmux_execution_helpers.py +0 -0
  99. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/tmux_remote_controller.py +0 -0
  100. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/trim_in_place.py +0 -0
  101. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/trim_session.py +0 -0
  102. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/trim_session_claude.py +0 -0
  103. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/trim_session_codex.py +0 -0
  104. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/workflow_cli.py +0 -0
  105. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/workflow_cli_contract.py +0 -0
  106. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/workflow_cli_formatting.py +0 -0
  107. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/workflow_cli_identity_policy.py +0 -0
  108. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/workflow_cli_manifest.py +0 -0
  109. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/workflow_cli_projection.py +0 -0
  110. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/workflow_cli_rendering.py +0 -0
  111. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/workflow_cli_snapshots.py +0 -0
  112. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/workflow_cli_store_backends.py +0 -0
  113. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/workflow_processes.py +0 -0
  114. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/workflow_runs.py +0 -0
  115. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/workflow_store_io.py +0 -0
  116. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/claude_code_tools/workflow_validation.py +0 -0
  117. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/docs/agent-tunnel-spec.md +0 -0
  118. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/docs/claude-code-tmux-tutorials.md +0 -0
  119. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/docs/dot-zshrc.md +0 -0
  120. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/docs/find-claude-session.md +0 -0
  121. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/docs/intercom-spec.md +0 -0
  122. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/docs/lmsh.md +0 -0
  123. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/docs/local-llm-setup.md +0 -0
  124. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/docs/msg-phase-a-plan.md +0 -0
  125. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/docs/msg-plan-v2.md +0 -0
  126. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/docs/tmux-cli-instructions.md +0 -0
  127. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/docs/vault-documentation.md +0 -0
  128. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/hatch_build.py +0 -0
  129. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/node_ui/action_config.js +0 -0
  130. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/node_ui/menu.js +0 -0
  131. {claude_code_tools-1.24.0 → claude_code_tools-1.25.0}/node_ui/package-lock.json +0 -0
  132. {claude_code_tools-1.24.0 → claude_code_tools-1.25.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.24.0
3
+ Version: 1.25.0
4
4
  Summary: Collection of tools for working with Claude Code
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.11
@@ -84,6 +84,11 @@ docs. Click a card below to jump to a feature, or
84
84
  </tr>
85
85
  <tr>
86
86
  <td align="center">
87
+ <a href="https://pchalasani.github.io/claude-code-tools/tools/amux/">
88
+ <img src="assets/card-amux.svg" alt="amux" width="200"/>
89
+ </a>
90
+ </td>
91
+ <td align="center">
87
92
  <a href="https://pchalasani.github.io/claude-code-tools/tools/agent-tunnel/">
88
93
  <img src="assets/card-agent-tunnel.svg" alt="agent-tunnel" width="200"/>
89
94
  </a>
@@ -152,8 +157,19 @@ docs. Click a card below to jump to a feature, or
152
157
  </tr>
153
158
  <tr>
154
159
  <td align="center">
160
+ <a href="https://pchalasani.github.io/claude-code-tools/tools/aichat/port/">
161
+ <img src="assets/card-session-port.svg" alt="session porting" width="200"/>
162
+ </a>
163
+ </td>
164
+ <td align="center">
155
165
  <a href="https://pchalasani.github.io/claude-code-tools/guides/claude-to-codex/">
156
- <img src="assets/card-claude-to-codex.svg" alt="Claude to Codex" width="200"/>
166
+ <img src="assets/card-claude-to-codex.svg" alt="Codex dynamic workflows" width="200"/>
167
+ </a>
168
+ </td>
169
+ <td align="center">
170
+ <a href="https://pchalasani.github.io/claude-code-tools/tools/github-wake/">
171
+ <img src="assets/card-github-watch.svg"
172
+ alt="github-watch: wake on github comment" width="200"/>
157
173
  </a>
158
174
  </td>
159
175
  </tr>
@@ -58,6 +58,11 @@ docs. Click a card below to jump to a feature, or
58
58
  </tr>
59
59
  <tr>
60
60
  <td align="center">
61
+ <a href="https://pchalasani.github.io/claude-code-tools/tools/amux/">
62
+ <img src="assets/card-amux.svg" alt="amux" width="200"/>
63
+ </a>
64
+ </td>
65
+ <td align="center">
61
66
  <a href="https://pchalasani.github.io/claude-code-tools/tools/agent-tunnel/">
62
67
  <img src="assets/card-agent-tunnel.svg" alt="agent-tunnel" width="200"/>
63
68
  </a>
@@ -126,8 +131,19 @@ docs. Click a card below to jump to a feature, or
126
131
  </tr>
127
132
  <tr>
128
133
  <td align="center">
134
+ <a href="https://pchalasani.github.io/claude-code-tools/tools/aichat/port/">
135
+ <img src="assets/card-session-port.svg" alt="session porting" width="200"/>
136
+ </a>
137
+ </td>
138
+ <td align="center">
129
139
  <a href="https://pchalasani.github.io/claude-code-tools/guides/claude-to-codex/">
130
- <img src="assets/card-claude-to-codex.svg" alt="Claude to Codex" width="200"/>
140
+ <img src="assets/card-claude-to-codex.svg" alt="Codex dynamic workflows" width="200"/>
141
+ </a>
142
+ </td>
143
+ <td align="center">
144
+ <a href="https://pchalasani.github.io/claude-code-tools/tools/github-wake/">
145
+ <img src="assets/card-github-watch.svg"
146
+ alt="github-watch: wake on github comment" width="200"/>
131
147
  </a>
132
148
  </td>
133
149
  </tr>
@@ -1,3 +1,3 @@
1
1
  """Claude Code Tools - Collection of utilities for Claude Code."""
2
2
 
3
- __version__ = "1.24.0"
3
+ __version__ = "1.25.0"
@@ -0,0 +1,5 @@
1
+ """amux: see and jump between coding agents running in tmux panes."""
2
+
3
+ from .model import Agent, State, Kind
4
+
5
+ __all__ = ["Agent", "State", "Kind"]
@@ -0,0 +1,6 @@
1
+ """Allow ``python -m claude_code_tools.amux`` (used by fzf reload bindings)."""
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
@@ -0,0 +1,80 @@
1
+ """Cache of the last scan, so the picker opens instantly.
2
+
3
+ The picker shows cached rows immediately and kicks off a fresh scan in the
4
+ background, replacing the list when it lands. With hundreds of panes, a cold
5
+ scan is noticeable; a warm one is not.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import math
12
+ import os
13
+ import time
14
+ from pathlib import Path
15
+
16
+ from .model import Agent
17
+
18
+ _DEFAULT = Path.home() / ".cache" / "cc-tools" / "amux.json"
19
+
20
+
21
+ def cache_path() -> Path:
22
+ """Location of the cache file (``AMUX_CACHE`` overrides)."""
23
+ override = os.environ.get("AMUX_CACHE")
24
+ return Path(override) if override else _DEFAULT
25
+
26
+
27
+ def read(max_age: float | None = None) -> tuple[list[Agent], float]:
28
+ """Load cached agents.
29
+
30
+ Args:
31
+ max_age: If set, return an empty list when the cache is older than
32
+ this many seconds.
33
+
34
+ Returns:
35
+ ``(agents, age_seconds)``; ``age_seconds`` is ``-1`` when no usable
36
+ cache exists.
37
+ """
38
+ path = cache_path()
39
+ try:
40
+ raw = json.loads(path.read_text())
41
+ except (OSError, ValueError):
42
+ return [], -1.0
43
+
44
+ # Syntactically valid JSON can still be the wrong shape (hand-edited file,
45
+ # a truncated write from an older version). Never crash over a cache.
46
+ try:
47
+ stamp = float(raw.get("time", 0))
48
+ age = time.time() - stamp
49
+ # Python's json accepts the NaN literal, and every comparison against
50
+ # nan is False -- so a NaN age passed both the negative check and the
51
+ # max_age check, making the cache permanently "fresh".
52
+ if not math.isfinite(age) or age < 0:
53
+ return [], -1.0
54
+ if max_age is not None and age > max_age:
55
+ return [], age
56
+ parsed = (
57
+ Agent.from_dict(d) for d in raw.get("agents", []) if isinstance(d, dict)
58
+ )
59
+ agents = [a for a in parsed if a is not None]
60
+ except (AttributeError, TypeError, ValueError):
61
+ return [], -1.0
62
+ return agents, age
63
+
64
+
65
+ def write(agents: list[Agent]) -> None:
66
+ """Persist *agents* atomically, creating the cache directory if needed."""
67
+ path = cache_path()
68
+ try:
69
+ path.parent.mkdir(parents=True, exist_ok=True)
70
+ payload = {
71
+ "time": time.time(),
72
+ "agents": [a.to_dict() for a in agents],
73
+ }
74
+ # Per-process temp name: two concurrent `amux scan` runs sharing one
75
+ # ".tmp" would race, and the loser died on a missing file.
76
+ tmp = path.with_suffix(f".{os.getpid()}.tmp")
77
+ tmp.write_text(json.dumps(payload))
78
+ tmp.replace(path)
79
+ except OSError:
80
+ pass # a missing cache only costs speed, never correctness
@@ -0,0 +1,206 @@
1
+ """amux -- see and jump between the coding agents running in your tmux panes.
2
+
3
+ amux # interactive picker (fzf): jump to an agent
4
+ amux list # table of every live agent
5
+ amux list --json # machine-readable
6
+ amux scan # refresh the cache (for cron / tmux hooks)
7
+
8
+ The picker opens on cached rows and refreshes in the background, so it is
9
+ instant even with hundreds of panes.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import math
16
+ import os
17
+ import shlex
18
+ import shutil
19
+ import subprocess
20
+ import sys
21
+
22
+ from . import cache, render, scan
23
+ from .model import Agent
24
+
25
+ _FZF_HEADER = (
26
+ "enter=jump ctrl-r=refresh esc=quit "
27
+ "input=asking you busy=working bg=monitors idle"
28
+ )
29
+
30
+
31
+ def _fresh(write_cache: bool = True) -> list[Agent]:
32
+ """Scan live panes and refresh the cache."""
33
+ agents = scan.scan()
34
+ if write_cache:
35
+ cache.write(agents)
36
+ return agents
37
+
38
+
39
+ def _agents_for_display(max_age: float) -> tuple[list[Agent], bool]:
40
+ """Return agents to show now, plus whether they came from cache."""
41
+ cached, age = cache.read(max_age=max_age)
42
+ if cached:
43
+ return cached, True
44
+ return _fresh(), False
45
+
46
+
47
+ def cmd_list(args: argparse.Namespace) -> int:
48
+ """Print every live agent as a table or JSON."""
49
+ agents = cache.read(max_age=args.max_age)[0] if args.cached else _fresh()
50
+ if not agents and args.cached:
51
+ agents = _fresh()
52
+ if args.json:
53
+ print(render.as_json(agents))
54
+ else:
55
+ colour = sys.stdout.isatty() and not args.no_color
56
+ print(render.table(agents, colour=colour))
57
+ return 0
58
+
59
+
60
+ def cmd_scan(args: argparse.Namespace) -> int:
61
+ """Refresh the cache and report what was found."""
62
+ agents = _fresh()
63
+ print(f"cached {len(agents)} agents -> {cache.cache_path()}")
64
+ return 0
65
+
66
+
67
+ def cmd_rows(args: argparse.Namespace) -> int:
68
+ """Emit picker rows (internal: used by fzf's reload binding)."""
69
+ agents = _fresh() if args.refresh else _agents_for_display(args.max_age)[0]
70
+ print(render.picker_lines(agents, colour=True))
71
+ return 0
72
+
73
+
74
+ def cmd_pick(args: argparse.Namespace) -> int:
75
+ """Interactive picker; jumps to the chosen agent's pane."""
76
+ if not shutil.which("fzf"):
77
+ print("amux: fzf not found (brew install fzf)", file=sys.stderr)
78
+ return 1
79
+ if not sys.stdin.isatty() or not sys.stdout.isatty():
80
+ print("amux: no tty -- use 'amux list' for scripted output", file=sys.stderr)
81
+ return 1
82
+
83
+ agents, from_cache = _agents_for_display(args.max_age)
84
+ if not agents:
85
+ print("no agents running")
86
+ return 0
87
+
88
+ # fzf runs bind commands through a shell, so the interpreter path must be
89
+ # quoted: a venv at "/tmp/my venv/bin/python" would otherwise run "/tmp/my".
90
+ self_cmd = f"{shlex.quote(sys.executable)} -m claude_code_tools.amux"
91
+ binds = [
92
+ f"ctrl-r:reload({self_cmd} rows --refresh)",
93
+ # Opening on cached rows is what makes this instant; refresh the
94
+ # moment the list is up so stale states self-correct without a keypress.
95
+ f"load:reload-sync({self_cmd} rows --refresh)" if from_cache else "",
96
+ ]
97
+ cmd = [
98
+ "fzf",
99
+ "--ansi",
100
+ "--no-sort",
101
+ # Field 1 is the pane target, hidden from view; see picker_lines().
102
+ "--delimiter",
103
+ "\t",
104
+ "--with-nth",
105
+ "2..",
106
+ "--header",
107
+ _FZF_HEADER,
108
+ "--preview",
109
+ "tmux capture-pane -ep -S -200 -t {1} | grep '[^[:space:]]' | tail -60",
110
+ "--preview-window",
111
+ "right:55%:wrap:follow",
112
+ "--prompt",
113
+ "agent> ",
114
+ ]
115
+ for bind in binds:
116
+ if bind:
117
+ cmd += ["--bind", bind]
118
+
119
+ # Capture ONLY stdout. capture_output=True also pipes stderr, which is
120
+ # where fzf draws its interface -- the UI never reaches the terminal and
121
+ # the picker looks hung, waiting for keys against an invisible prompt.
122
+ # (fzf reads keystrokes from /dev/tty, so feeding the list on stdin is
123
+ # fine.)
124
+ proc = subprocess.run(
125
+ cmd,
126
+ input=render.picker_lines(agents),
127
+ stdout=subprocess.PIPE,
128
+ text=True,
129
+ )
130
+ if proc.returncode != 0 or not proc.stdout.strip():
131
+ return 0
132
+
133
+ pane = render.pane_from_selection(proc.stdout)
134
+ if not pane:
135
+ return 0
136
+ scan.switch_to(pane)
137
+ return 0
138
+
139
+
140
+ def _finite_seconds(value: str) -> float:
141
+ """Parse a --max-age value, rejecting nan/inf.
142
+
143
+ argparse's ``type=float`` happily accepts "nan", and every comparison
144
+ against NaN is False -- so ``--max-age nan`` disabled cache expiry
145
+ entirely and served arbitrarily stale data.
146
+ """
147
+ seconds = float(value)
148
+ if not math.isfinite(seconds) or seconds < 0:
149
+ raise argparse.ArgumentTypeError(
150
+ f"must be a non-negative finite number of seconds, got {value!r}"
151
+ )
152
+ return seconds
153
+
154
+
155
+ def build_parser() -> argparse.ArgumentParser:
156
+ """Construct the argument parser."""
157
+ parser = argparse.ArgumentParser(
158
+ prog="amux",
159
+ description="See and jump between coding agents running in tmux panes.",
160
+ )
161
+ parser.add_argument(
162
+ "--max-age",
163
+ type=_finite_seconds,
164
+ default=30.0,
165
+ help="seconds a cached scan stays usable (default: 30)",
166
+ )
167
+ sub = parser.add_subparsers(dest="command")
168
+
169
+ pick = sub.add_parser("pick", help="interactive picker (default)")
170
+ pick.set_defaults(func=cmd_pick)
171
+
172
+ lst = sub.add_parser("list", help="print all live agents")
173
+ lst.add_argument("--json", action="store_true", help="machine-readable output")
174
+ lst.add_argument("--cached", action="store_true", help="use the cache if fresh")
175
+ lst.add_argument("--no-color", action="store_true", help="disable colour")
176
+ lst.set_defaults(func=cmd_list)
177
+
178
+ scan_cmd = sub.add_parser("scan", help="refresh the cache")
179
+ scan_cmd.set_defaults(func=cmd_scan)
180
+
181
+ rows = sub.add_parser("rows", help=argparse.SUPPRESS)
182
+ rows.add_argument("--refresh", action="store_true")
183
+ rows.set_defaults(func=cmd_rows)
184
+
185
+ return parser
186
+
187
+
188
+ def main(argv: list[str] | None = None) -> int:
189
+ """Entry point for the ``amux`` console script."""
190
+ parser = build_parser()
191
+ raw = list(sys.argv[1:] if argv is None else argv)
192
+ # Default to the picker, but insert the subcommand rather than reparsing
193
+ # only ["pick"] -- reparsing dropped global options, so `amux --max-age 0`
194
+ # silently used the 30s default.
195
+ known = {"pick", "list", "scan", "rows"}
196
+ if not any(tok in known for tok in raw):
197
+ raw.append("pick")
198
+ args = parser.parse_args(raw)
199
+ if not scan.tmux_available():
200
+ print("amux: no tmux server running", file=sys.stderr)
201
+ return 1
202
+ return int(args.func(args) or 0)
203
+
204
+
205
+ if __name__ == "__main__":
206
+ raise SystemExit(main())
@@ -0,0 +1,236 @@
1
+ """Screen-scraping heuristics: what harness is this, and what is it doing.
2
+
3
+ Everything here is deliberately pure (string in, verdict out) so it can be
4
+ unit-tested against captured fixtures without a live tmux server.
5
+
6
+ Why screen-scraping at all: ``pane_current_command`` is useless for
7
+ identifying these harnesses -- Claude Code reports a bare version string like
8
+ ``2.1.220`` because it runs a versioned binary, and Codex reports ``node``.
9
+ Process *argv* identifies the harness reliably; the *screen* is the only place
10
+ that reveals what it is currently doing.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+
17
+ from .model import Kind, State
18
+
19
+ # --- harness identification (from the pane's child process argv) -----------
20
+
21
+ #: Interpreters that launch an agent via a script path rather than being the
22
+ #: agent themselves (Codex ships as JS run under node).
23
+ _INTERPRETERS = {"node", "node.exe", "python", "python3", "bun", "deno"}
24
+
25
+ #: Script-path fragments that identify a harness when run under an interpreter.
26
+ _CODEX_PATH = re.compile(r"@openai/codex|/codex(\.js|-cli)?\b")
27
+ _CLAUDE_PATH = re.compile(r"/claude(\.js|-code)?\b|/\.local/share/claude/")
28
+
29
+
30
+ def _basename(token: str) -> str:
31
+ """Executable name from an argv[0] token."""
32
+ return token.rsplit("/", 1)[-1]
33
+
34
+
35
+ def classify_argv(argv: str) -> Kind | None:
36
+ """Identify the harness from a single process command line.
37
+
38
+ Only the EXECUTABLE decides -- argv[0], or the script path when argv[0] is
39
+ an interpreter. Searching the whole command line matched arguments too, so
40
+ an ordinary ``rg claude .`` in a pane was listed as an idle Claude agent.
41
+
42
+ Args:
43
+ argv: One process's command line.
44
+
45
+ Returns:
46
+ ``"codex"``, ``"claude"``, or ``None`` when this is not an agent.
47
+ """
48
+ for line in argv.splitlines():
49
+ tokens = line.split()
50
+ if not tokens:
51
+ continue
52
+ exe = _basename(tokens[0])
53
+ if exe in ("codex", "codex-cli"):
54
+ return "codex"
55
+ if exe in ("claude", "claude-code"):
56
+ return "claude"
57
+ # Versioned Claude binaries are named after the version (2.1.220).
58
+ if re.fullmatch(r"\d+\.\d+\.\d+", exe):
59
+ return "claude"
60
+ if exe in _INTERPRETERS and len(tokens) > 1:
61
+ script = tokens[1]
62
+ if _CODEX_PATH.search(script):
63
+ return "codex"
64
+ if _CLAUDE_PATH.search(script):
65
+ return "claude"
66
+ return None
67
+
68
+
69
+ # --- state detection (from the pane's visible screen) ----------------------
70
+
71
+ #: Claude renders AskUserQuestion and permission prompts as numbered choices.
72
+ _ASKING = re.compile(
73
+ r"❯\s*1\.|Do you want|Would you like|^\s*1\.\s*Yes|\(y/n\)|"
74
+ r"Select an option|Choose an option",
75
+ re.MULTILINE,
76
+ )
77
+ #: The MAIN agent is mid-turn. Two forms seen live:
78
+ #: "✻ Cooked for 14m 17s · esc to interrupt"
79
+ #: "· Razzmatazzing… (1m 42s · ↓ 4.8k tokens · thought for 14s)"
80
+ #: The second has no "esc to interrupt" at all, so matching only that phrase
81
+ #: reported an actively working pane as idle. The parenthesised
82
+ #: elapsed-time-plus-token-counter is what distinguishes it from a subagent
83
+ #: row, which carries the same counter WITHOUT parentheses.
84
+ _BUSY = re.compile(
85
+ r"esc to interrupt|\(\d+[ms][^)]*↓[^)]*tokens",
86
+ re.IGNORECASE,
87
+ )
88
+ #: Background work. Two forms, both taken from live panes:
89
+ #: "✻ Baked for 16m 24s · 1 monitor still running"
90
+ #: " ◯ cache-incremental-evidence Grepping ... 1m 16s · ↓ 873.5k tokens"
91
+ #: The second is Claude's subagent tree: ◯ marks a subagent still RUNNING
92
+ #: (✓ marks a finished one, and ⏺ is ordinary assistant output, so neither of
93
+ #: those can be used). Without this a pane with a working subagent but a free
94
+ #: main prompt reported as idle.
95
+ _BG = re.compile(
96
+ r"\b\d+\s+monitors?\b|^\s*◯\s+\S",
97
+ re.MULTILINE,
98
+ )
99
+
100
+ #: Codex chrome to ignore when looking for its last content line.
101
+ _CODEX_CHROME = re.compile(
102
+ r"^\s*[›❯]|·|gpt-[\d.]|^\s*─|esc to|tab to|^\s*$|^\s*[▌│]"
103
+ )
104
+
105
+
106
+ #: A pending prompt is at the BOTTOM of the screen. Searching all retained
107
+ #: text made any earlier sentence containing e.g. "Would you like" pin the
108
+ #: pane to `input` until it scrolled off.
109
+ _PROMPT_TAIL_LINES = 12
110
+
111
+
112
+ def detect_state(screen: str, kind: Kind) -> State:
113
+ """Classify what the agent is doing from its visible screen.
114
+
115
+ Args:
116
+ screen: The pane's captured text (escape sequences stripped).
117
+ kind: Which harness is running, from :func:`classify_argv`.
118
+
119
+ Returns:
120
+ One of ``input`` (waiting on the user), ``busy`` (mid-turn),
121
+ ``bg`` (background monitors running), or ``idle``.
122
+ """
123
+ tail_lines = screen.splitlines()[-_PROMPT_TAIL_LINES:]
124
+ tail = "\n".join(tail_lines)
125
+ # An ANSWERED prompt keeps its choices on screen, with Claude's footer
126
+ # rendered below them. Only treat a prompt as pending when nothing from
127
+ # the footer appears after it -- otherwise a just-answered
128
+ # AskUserQuestion stays flagged as blocking until it scrolls off.
129
+ if _ASKING.search(tail) and not _footer_below_prompt(tail_lines):
130
+ return "input"
131
+ # BUSY must be decided before the Codex question heuristic. A visible
132
+ # spinner is proof the agent is working, whereas the heuristic can only
133
+ # guess from an older question still on screen: Codex asks, you answer,
134
+ # it starts working -- and the question is still up there.
135
+ if _BUSY.search(screen):
136
+ return "busy"
137
+ if kind == "codex" and _codex_awaiting_answer(tail):
138
+ return "input"
139
+ if _BG.search(screen):
140
+ return "bg"
141
+ return "idle"
142
+
143
+
144
+ #: Footer chrome Claude renders BELOW a completed exchange. Anchored to the
145
+ #: start of a line: an option in a PENDING prompt can quote this text
146
+ #: ("2. Explain why the status says bypass permissions on") and must not be
147
+ #: mistaken for the footer itself.
148
+ _FOOTER = re.compile(
149
+ r"^\s*("
150
+ r"⏵⏵|ctx [█░]|new task\?|✻|※" # Claude chrome
151
+ r"|gpt-[\w.\-]+\s|›" # Codex status line and input box
152
+ r")"
153
+ )
154
+
155
+
156
+ def _footer_below_prompt(lines: list[str]) -> bool:
157
+ """Whether harness footer chrome appears after the last prompt line."""
158
+ last_prompt = -1
159
+ for index, line in enumerate(lines):
160
+ if _ASKING.search(line):
161
+ last_prompt = index
162
+ if last_prompt < 0:
163
+ return False
164
+ return any(_FOOTER.search(line) for line in lines[last_prompt + 1 :])
165
+
166
+ def _codex_awaiting_answer(screen: str) -> bool:
167
+ """Heuristic: Codex asked a question and stopped.
168
+
169
+ Codex has no structured prompt UI, so the only available signal is that
170
+ the last real content line -- ignoring the input box and status chrome --
171
+ ends in a question mark. This has false positives (an answer that merely
172
+ ends in a question) and misses (a question phrased as a statement).
173
+
174
+ Only the screen TAIL is considered, and callers must rule out "busy"
175
+ first: a question further up the scrollback says nothing about whether
176
+ the agent is still waiting for you.
177
+ """
178
+ for line in reversed(screen.splitlines()):
179
+ if not line.strip() or _CODEX_CHROME.search(line):
180
+ continue
181
+ return line.rstrip().endswith("?")
182
+ return False
183
+
184
+
185
+ # --- context extraction ----------------------------------------------------
186
+
187
+ _CODEX_STATUS = re.compile(r"(gpt-[\w.\-]+)\s+(\w+)?\s*·\s*([^·]+)·\s*([^·\n]+)")
188
+ _CLAUDE_MODEL = re.compile(
189
+ r"\b(opus[\w.\-\[\]]*|sonnet[\w.\-\[\]]*|haiku[\w.\-\[\]]*|fable[\w.\-\[\]]*)"
190
+ )
191
+ _SEPARATOR_NAME = re.compile(r"─\s([A-Za-z0-9][A-Za-z0-9._-]{3,})\s─")
192
+ _ARGV_NAME = re.compile(r"--(?:resume|name)[= ]([^\s]+)")
193
+ _SPINNER = re.compile(r"^[✳*⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏\s]+")
194
+
195
+
196
+ def extract_model(screen: str, kind: Kind) -> str:
197
+ """Pull the model identifier out of the harness footer, if present."""
198
+ if kind == "codex":
199
+ match = _CODEX_STATUS.search(screen)
200
+ return match.group(1) if match else ""
201
+ match = _CLAUDE_MODEL.search(screen)
202
+ return match.group(1) if match else ""
203
+
204
+
205
+ def extract_name(argv: str, pane_title: str, screen: str) -> str:
206
+ """Best-effort agent session name, most reliable source first.
207
+
208
+ Order: the harness's own argv (``--resume``/``--name``), then the tmux
209
+ pane title (Claude sets it to ``✳ <name>``), then an on-screen separator
210
+ line. Codex rarely carries a name in any of these and returns ``""``.
211
+ """
212
+ match = _ARGV_NAME.search(argv)
213
+ if match:
214
+ return match.group(1)
215
+
216
+ title = _SPINNER.sub("", pane_title).strip()
217
+ # Shell-set titles are paths, ~[dir] forms, or hostnames -- not names.
218
+ if title and not re.search(r"[/\[\]]|\.lan$|\.local$", title):
219
+ return title
220
+
221
+ names = _SEPARATOR_NAME.findall(screen)
222
+ return names[-1] if names else ""
223
+
224
+
225
+ def extract_info(screen: str, kind: Kind) -> str:
226
+ """One line of context for the list view (model, repo, branch, …)."""
227
+ if kind == "codex":
228
+ match = _CODEX_STATUS.search(screen)
229
+ if match:
230
+ parts = [p.strip() for p in match.groups() if p]
231
+ return " · ".join(parts)
232
+ lines = [ln.strip() for ln in screen.splitlines() if ln.strip()]
233
+ for line in reversed(lines):
234
+ if _CLAUDE_MODEL.search(line):
235
+ return re.sub(r"\s{2,}", " ", line)[:80]
236
+ return lines[-1][:80] if lines else ""