claude-code-tools 1.29.0__tar.gz → 1.29.2__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 (145) hide show
  1. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/PKG-INFO +1 -1
  2. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/__init__.py +1 -1
  3. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/agent_tunnel/config.py +8 -0
  4. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/agent_tunnel/mattermost_bot.py +100 -11
  5. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/tmux_cli_controller.py +2 -5
  6. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/tmux_remote_controller.py +166 -49
  7. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/docs/agent-tunnel-spec.md +8 -0
  8. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/docs/tmux-cli-instructions.md +29 -14
  9. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/pyproject.toml +2 -2
  10. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/.gitignore +0 -0
  11. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/LICENSE +0 -0
  12. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/README.md +0 -0
  13. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/action_rpc.py +0 -0
  14. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/agent_tunnel/__init__.py +0 -0
  15. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/agent_tunnel/backends.py +0 -0
  16. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/agent_tunnel/cli.py +0 -0
  17. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/agent_tunnel/convert.py +0 -0
  18. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/agent_tunnel/discord_bot.py +0 -0
  19. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/agent_tunnel/http_frontend.py +0 -0
  20. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/agent_tunnel/locking.py +0 -0
  21. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/agent_tunnel/paths.py +0 -0
  22. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/agent_tunnel/readonly.py +0 -0
  23. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/agent_tunnel/registry.py +0 -0
  24. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/agent_tunnel/relay.py +0 -0
  25. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/agent_tunnel/serve.py +0 -0
  26. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/agent_tunnel/session.py +0 -0
  27. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/agent_tunnel/store.py +0 -0
  28. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/agent_tunnel/tmux.py +0 -0
  29. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/agent_tunnel/trust.py +0 -0
  30. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/agent_tunnel/turn_log.py +0 -0
  31. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/aichat.py +0 -0
  32. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/amux/__init__.py +0 -0
  33. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/amux/__main__.py +0 -0
  34. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/amux/activity.py +0 -0
  35. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/amux/cache.py +0 -0
  36. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/amux/cli.py +0 -0
  37. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/amux/detect.py +0 -0
  38. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/amux/filters.py +0 -0
  39. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/amux/model.py +0 -0
  40. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/amux/render.py +0 -0
  41. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/amux/scan.py +0 -0
  42. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/claude_continue.py +0 -0
  43. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/cli_passthrough.py +0 -0
  44. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/codex_app_server_rpc.py +0 -0
  45. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/codex_continue.py +0 -0
  46. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/codex_server.py +0 -0
  47. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/codex_server_callback.py +0 -0
  48. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/codex_server_cli.py +0 -0
  49. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/codex_server_fingerprint.py +0 -0
  50. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/codex_server_generation.py +0 -0
  51. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/codex_server_legacy.py +0 -0
  52. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/codex_server_models.py +0 -0
  53. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/codex_server_process.py +0 -0
  54. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/codex_server_reservation.py +0 -0
  55. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/codex_server_resume.py +0 -0
  56. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/codex_server_retry.py +0 -0
  57. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/codex_server_reuse.py +0 -0
  58. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/codex_server_state.py +0 -0
  59. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/codex_server_supervisor.py +0 -0
  60. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/codex_server_wait.py +0 -0
  61. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/codex_server_worker.py +0 -0
  62. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/config.py +0 -0
  63. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/csv2gsheet.py +0 -0
  64. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/delete_session.py +0 -0
  65. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/dotenv_vault.py +0 -0
  66. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/env_safe.py +0 -0
  67. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/export_all.py +0 -0
  68. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/export_claude_session.py +0 -0
  69. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/export_codex_session.py +0 -0
  70. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/export_session.py +0 -0
  71. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/find_claude_session.py +0 -0
  72. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/find_codex_session.py +0 -0
  73. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/find_original_session.py +0 -0
  74. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/find_session.py +0 -0
  75. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/find_trimmed_sessions.py +0 -0
  76. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/fix_session.py +0 -0
  77. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/gdoc2docx.py +0 -0
  78. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/gdoc2md.py +0 -0
  79. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/github_wake.py +0 -0
  80. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/github_watch_daemon.py +0 -0
  81. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/github_watch_store.py +0 -0
  82. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/gsheet2csv.py +0 -0
  83. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/helper_sessions.py +0 -0
  84. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/issue_reply_delivery.py +0 -0
  85. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/legacy_session_wrappers.py +0 -0
  86. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/md2gdoc.py +0 -0
  87. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/move_account.py +0 -0
  88. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/msg/__init__.py +0 -0
  89. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/msg/cli.py +0 -0
  90. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/msg/hooks.py +0 -0
  91. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/msg/migrations.py +0 -0
  92. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/msg/models.py +0 -0
  93. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/msg/prompt_detect.py +0 -0
  94. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/msg/store.py +0 -0
  95. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/msg/watcher.py +0 -0
  96. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/node_menu_ui.py +0 -0
  97. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/port_claude_noise.py +0 -0
  98. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/port_claude_to_codex.py +0 -0
  99. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/port_codex_flatten.py +0 -0
  100. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/port_codex_to_claude.py +0 -0
  101. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/port_render.py +0 -0
  102. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/port_service.py +0 -0
  103. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/resolve_session.py +0 -0
  104. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/resolve_session_names.py +0 -0
  105. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/resolve_session_render.py +0 -0
  106. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/search_index.py +0 -0
  107. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/session_cli_resolution.py +0 -0
  108. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/session_lineage.py +0 -0
  109. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/session_menu.py +0 -0
  110. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/session_menu_cli.py +0 -0
  111. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/session_resolution.py +0 -0
  112. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/session_selection.py +0 -0
  113. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/session_utils.py +0 -0
  114. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/smart_trim.py +0 -0
  115. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/smart_trim_core.py +0 -0
  116. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/tmux_execution_helpers.py +0 -0
  117. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/trim_in_place.py +0 -0
  118. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/trim_session.py +0 -0
  119. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/trim_session_claude.py +0 -0
  120. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/trim_session_codex.py +0 -0
  121. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/workflow_cli.py +0 -0
  122. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/workflow_cli_contract.py +0 -0
  123. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/workflow_cli_formatting.py +0 -0
  124. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/workflow_cli_identity_policy.py +0 -0
  125. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/workflow_cli_manifest.py +0 -0
  126. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/workflow_cli_projection.py +0 -0
  127. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/workflow_cli_rendering.py +0 -0
  128. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/workflow_cli_snapshots.py +0 -0
  129. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/workflow_cli_store_backends.py +0 -0
  130. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/workflow_processes.py +0 -0
  131. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/workflow_runs.py +0 -0
  132. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/workflow_store_io.py +0 -0
  133. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/claude_code_tools/workflow_validation.py +0 -0
  134. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/docs/claude-code-tmux-tutorials.md +0 -0
  135. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/docs/dot-zshrc.md +0 -0
  136. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/docs/find-claude-session.md +0 -0
  137. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/docs/intercom-spec.md +0 -0
  138. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/docs/lmsh.md +0 -0
  139. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/docs/local-llm-setup.md +0 -0
  140. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/docs/vault-documentation.md +0 -0
  141. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/hatch_build.py +0 -0
  142. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/node_ui/action_config.js +0 -0
  143. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/node_ui/menu.js +0 -0
  144. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/node_ui/package-lock.json +0 -0
  145. {claude_code_tools-1.29.0 → claude_code_tools-1.29.2}/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.29.0
3
+ Version: 1.29.2
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.29.0"
3
+ __version__ = "1.29.2"
@@ -104,6 +104,10 @@ class MattermostConfig:
104
104
  allowed_user_ids: list[str] = field(default_factory=list)
105
105
  # false only for a self-signed test server.
106
106
  verify_tls: bool = True
107
+ # Handle answered when a teammate just @-mentions the bot without naming
108
+ # a handle ("@bot how does X work?"). Empty = use the only shared session
109
+ # when exactly one is live, else ask which.
110
+ default_handle: str = ""
107
111
 
108
112
 
109
113
  @dataclass
@@ -385,6 +389,10 @@ respond_to_dms = false
385
389
  # channel_ids = [] # 26-char channel ids
386
390
  # allowed_user_ids = [] # empty = anyone in the watched channels
387
391
  # verify_tls = true
392
+ # Handle answered when someone just @-mentions the bot without naming one
393
+ # ("@bot how does X work?"). Unset = the only shared session when exactly
394
+ # one is live, else the bot asks which.
395
+ # default_handle = ""
388
396
 
389
397
  [claude]
390
398
  binary = "claude"
@@ -141,11 +141,23 @@ def parse_posted(event: dict[str, Any]) -> Optional[MMPost]:
141
141
  )
142
142
 
143
143
 
144
+ def resolve_default_handle(cfg: TunnelConfig, live: Sequence[str]) -> str:
145
+ """Handle to answer for a bare ``@bot`` mention.
146
+
147
+ The configured ``default_handle`` wins. With none set, a single shared
148
+ session is unambiguous; with several, the bot asks which one.
149
+ """
150
+ if cfg.mattermost.default_handle:
151
+ return cfg.mattermost.default_handle
152
+ return live[0] if len(live) == 1 else ""
153
+
154
+
144
155
  @dataclass
145
156
  class Route:
146
157
  """What the bot should do with one post."""
147
158
 
148
- action: str # ignore | list | open | unknown_handle | followup | close
159
+ # ignore | list | open | unknown_handle | which_handle | followup | close
160
+ action: str
149
161
  thread_key: str = ""
150
162
  root_id: str = ""
151
163
  handle: str = ""
@@ -161,6 +173,7 @@ def route_post(
161
173
  allowed_user_ids: Sequence[str],
162
174
  is_bound: Callable[[str], bool],
163
175
  handle_live: Callable[[str], bool],
176
+ default_handle: str = "",
164
177
  ) -> Route:
165
178
  """Decide what to do with a post (pure; no I/O).
166
179
 
@@ -172,6 +185,8 @@ def route_post(
172
185
  allowed_user_ids: If non-empty, only these users are answered.
173
186
  is_bound: Whether a thread key is bound to a session.
174
187
  handle_live: Whether a handle is currently shared.
188
+ default_handle: Handle to use when the bot is @-mentioned without
189
+ one (see ``resolve_default_handle``); "" = ask which.
175
190
 
176
191
  Returns:
177
192
  The routing decision.
@@ -194,7 +209,18 @@ def route_post(
194
209
  if post.root_id:
195
210
  key = f"mm:{post.root_id}"
196
211
  if not is_bound(key):
197
- return ignore
212
+ # Not our thread yet. Teammates still address the bot inside one
213
+ # (e.g. answering its "which session?" question), so an explicit
214
+ # handle or @bot mention opens a binding on that root; anything
215
+ # else stays ignored.
216
+ return _open_route(
217
+ text,
218
+ post_id=post.root_id,
219
+ bot_username=bot_username,
220
+ handle_live=handle_live,
221
+ default_handle=default_handle,
222
+ complain_unknown=False,
223
+ )
198
224
  # A reply that opens with @someone-else (or @all/@channel/@here) is
199
225
  # teammates talking among themselves. A leading @bot is stripped.
200
226
  who = leading_mention(text)
@@ -208,21 +234,74 @@ def route_post(
208
234
  return Route("close", thread_key=key, root_id=post.root_id)
209
235
  return Route("followup", thread_key=key, root_id=post.root_id, text=text)
210
236
 
237
+ return _open_route(
238
+ text,
239
+ post_id=post.id,
240
+ bot_username=bot_username,
241
+ handle_live=handle_live,
242
+ default_handle=default_handle,
243
+ )
244
+
245
+
246
+ def _open_route(
247
+ text: str,
248
+ *,
249
+ post_id: str,
250
+ bot_username: str,
251
+ handle_live: Callable[[str], bool],
252
+ default_handle: str,
253
+ complain_unknown: bool = True,
254
+ ) -> Route:
255
+ """Route a message that may open a thread on ``post_id`` (pure).
256
+
257
+ Used for a root post and for a message inside a thread the bot has not
258
+ bound yet: both are "someone addressing the bot in the channel".
259
+ ``complain_unknown`` is off in the latter case, so ordinary chatter
260
+ there stays unanswered.
261
+ """
262
+ ignore = Route("ignore")
263
+ # An @bot mention is how a teammate naturally addresses the bot. Strip
264
+ # it; whatever follows is the question (a handle may still lead it).
265
+ mentioned = False
266
+ who = leading_mention(text)
267
+ if who is not None:
268
+ if who in BROADCAST_MENTIONS or who != bot_username.lower():
269
+ return ignore # teammates talking to each other
270
+ mentioned = True
271
+ text = _MENTION_RE.sub("", text.lstrip(), count=1).strip()
272
+
211
273
  if is_list_command(text):
212
- return Route("list", root_id=post.id)
213
- token, _, remainder = text.partition(" ")
214
- handle = token.strip().lower()
274
+ return Route("list", root_id=post_id)
275
+ # Split on ANY whitespace: a handle followed by a newline is still a
276
+ # handle, and must not fall through to the default.
277
+ parts = text.split(None, 1)
278
+ handle = parts[0].strip().lower() if parts else ""
279
+ remainder = parts[1].strip() if len(parts) > 1 else ""
215
280
  if handle_live(handle):
216
281
  return Route(
217
282
  "open",
218
- thread_key=f"mm:{post.id}",
219
- root_id=post.id,
283
+ thread_key=f"mm:{post_id}",
284
+ root_id=post_id,
220
285
  handle=handle,
221
- text=remainder.strip(),
286
+ text=remainder,
222
287
  )
223
- # Only complain if it clearly looks like a handle attempt.
224
- if HANDLE_RE.match(handle) and not remainder:
225
- return Route("unknown_handle", root_id=post.id, handle=handle)
288
+ if mentioned:
289
+ # Addressed to the bot but no handle named: answer the default one,
290
+ # but only while it is actually shared.
291
+ if not default_handle or not handle_live(default_handle):
292
+ return Route("which_handle", root_id=post_id)
293
+ return Route(
294
+ "open",
295
+ thread_key=f"mm:{post_id}",
296
+ root_id=post_id,
297
+ handle=default_handle,
298
+ text=text,
299
+ )
300
+ # Only complain if it clearly looks like a handle attempt — and never
301
+ # inside someone else's thread, where a one-word reply ("thanks") would
302
+ # otherwise draw a "no live session" answer.
303
+ if complain_unknown and HANDLE_RE.match(handle) and not remainder:
304
+ return Route("unknown_handle", root_id=post_id, handle=handle)
226
305
  return ignore
227
306
 
228
307
 
@@ -553,6 +632,9 @@ class _Router:
553
632
  allowed_user_ids=self.cfg.mattermost.allowed_user_ids,
554
633
  is_bound=lambda key: relay.store.get(key) is not None,
555
634
  handle_live=lambda h: self.registry.get(h) is not None,
635
+ default_handle=resolve_default_handle(
636
+ self.cfg, [r.handle for r in self.registry.active()]
637
+ ),
556
638
  )
557
639
  if route.action == "ignore":
558
640
  return
@@ -560,6 +642,13 @@ class _Router:
560
642
  sender = post.sender or "A teammate"
561
643
  if route.action == "list":
562
644
  await dest.send(relay.handles_text())
645
+ elif route.action == "which_handle":
646
+ live = self.registry.active()
647
+ example = live[0].handle if live else "handle"
648
+ await dest.send(
649
+ "Which session should I ask? Put its handle first, e.g. "
650
+ f"`{example} your question`.\n\n" + relay.handles_text()
651
+ )
563
652
  elif route.action == "unknown_handle":
564
653
  await dest.send(
565
654
  f"No live session for handle `{route.handle}`. "
@@ -914,11 +914,8 @@ class CLI:
914
914
  print(str(e))
915
915
  else:
916
916
  # Remote mode - kill window
917
- try:
918
- self.controller.kill_window(window_id=pane)
919
- print("Window killed")
920
- except ValueError as e:
921
- print(str(e))
917
+ self.controller.kill_window(window_id=pane)
918
+ print("Window killed")
922
919
 
923
920
  def wait_idle(self, pane: Optional[str] = None, idle_time: float = 2.0,
924
921
  timeout: Optional[int] = None):
@@ -3,7 +3,7 @@
3
3
  Remote Tmux Controller
4
4
 
5
5
  Enables tmux-cli to work when run outside of tmux by:
6
- - Auto-creating a detached tmux session on first use
6
+ - Creating a detached tmux session when launching a managed window
7
7
  - Managing commands in separate tmux windows (not panes)
8
8
  - Providing an API compatible with the local (pane) controller
9
9
  """
@@ -16,65 +16,141 @@ from typing import Optional, List, Dict, Tuple, Union, Any
16
16
 
17
17
  class RemoteTmuxController:
18
18
  """Remote controller that manages a dedicated tmux session and windows."""
19
+
20
+ _ready_option = '@tmux_cli_ready'
19
21
 
20
22
  def __init__(self, session_name: str = "remote-cli-session"):
21
- """Initialize with session name and ensure the session exists."""
23
+ """Initialize with a managed session name without creating it."""
22
24
  self.session_name = session_name
23
- self.target_window: Optional[str] = None # e.g., "session:0" (active pane in that window)
25
+ self.target_window: Optional[str] = None # Stable window ID, e.g., "@1"
24
26
  print(f"Note: tmux-cli is running outside tmux. Managing windows in session '{session_name}'.")
25
27
  print("For better integration, consider running from inside a tmux session.")
26
28
  print("Use 'tmux-cli attach' to view the remote session.")
27
- self._ensure_session()
28
29
 
29
30
  # ----------------------------
30
31
  # Internal utilities
31
32
  # ----------------------------
32
- def _run_tmux(self, args: List[str]) -> Tuple[str, int]:
33
- result = subprocess.run(
34
- ['tmux'] + args,
35
- capture_output=True,
36
- text=True
33
+ def _run_tmux(self, args: List[str], include_stderr: bool = False) -> Tuple[str, int]:
34
+ try:
35
+ result = subprocess.run(
36
+ ['tmux'] + args,
37
+ capture_output=True,
38
+ text=True
39
+ )
40
+ except OSError as exc:
41
+ raise RuntimeError(f"Could not run tmux {args[0]}: {exc}") from exc
42
+ output = result.stdout.strip()
43
+ if include_stderr and result.returncode != 0:
44
+ output = "\n".join(part for part in (output, result.stderr.strip()) if part)
45
+ return output, result.returncode
46
+
47
+ @staticmethod
48
+ def _session_is_missing(error: str) -> bool:
49
+ """Recognize tmux's absent-session and absent-server diagnostics only."""
50
+ return (
51
+ "can't find session:" in error
52
+ or error.startswith("no server running on ")
53
+ or (error.startswith("error connecting to ") and "(No such file or directory)" in error)
37
54
  )
38
- return result.stdout.strip(), result.returncode
55
+
56
+ @staticmethod
57
+ def _tmux_failure(action: str, output: str, code: int) -> RuntimeError:
58
+ detail = f": {output}" if output else ""
59
+ return RuntimeError(f"tmux {action} failed (exit {code}){detail}")
60
+
61
+ @staticmethod
62
+ def _window_is_missing(error: str, window_id: str) -> bool:
63
+ """Only classify a diagnostic for this exact stable ID as a vanished window."""
64
+ return error.strip() in (
65
+ f"no such window: {window_id}",
66
+ f"can't find window: {window_id}",
67
+ )
68
+
69
+ def _managed_session_exists(self) -> bool:
70
+ output, code = self._run_tmux(['has-session', '-t', f'={self.session_name}'], include_stderr=True)
71
+ if code == 0:
72
+ return True
73
+ if self._session_is_missing(output):
74
+ return False
75
+ raise self._tmux_failure('has-session', output, code)
39
76
 
40
77
  def _ensure_session(self) -> None:
41
- """Create the session if it doesn't exist (detached)."""
42
- _, code = self._run_tmux(['has-session', '-t', self.session_name])
43
- if code != 0:
78
+ """Create the managed session if needed for a new window."""
79
+ if not self._managed_session_exists():
44
80
  # Create a detached session using user's default shell
45
81
  # Return the session name just to force creation
46
- self._run_tmux([
82
+ output, code = self._run_tmux([
47
83
  'new-session', '-d', '-s', self.session_name, '-P', '-F', '#{session_name}'
48
- ])
49
- # Remember first window as default target
50
- self.target_window = f"{self.session_name}:0"
51
- else:
52
- # If already exists and we don't have a target, set to active window
53
- if not self.target_window:
54
- win, code2 = self._run_tmux(['display-message', '-p', '-t', self.session_name, '#{session_name}:#{window_index}'])
55
- if code2 == 0 and win:
56
- self.target_window = win
84
+ ], include_stderr=True)
85
+ if code != 0:
86
+ # Another launcher may have won the check/create race.
87
+ if output != f'duplicate session: {self.session_name}' or not self._managed_session_exists():
88
+ raise self._tmux_failure('new-session', output, code)
89
+ elif not output:
90
+ raise self._tmux_failure('new-session', output, code)
57
91
 
58
92
  def _window_target(self, pane: Optional[str]) -> str:
59
93
  """Resolve user-provided pane/window hint to a tmux target.
60
94
  Accepts:
61
- - None -> use last target window if set else active window in session
95
+ - None -> use last target window if set else a ready active window in session
62
96
  - digits (e.g., "1") -> session:index
63
97
  - full tmux target (e.g., "name:1" or "name:1.0" or "%12") -> pass-through
64
98
  """
65
- self._ensure_session()
66
99
  if pane is None:
67
100
  if self.target_window:
68
- return self.target_window
69
- # Fallback to active window in session
70
- win, code = self._run_tmux(['display-message', '-p', '-t', self.session_name, '#{session_name}:#{window_index}'])
71
- if code == 0 and win:
72
- self.target_window = win
73
- return win
74
- # Final fallback: session:0
75
- return f"{self.session_name}:0"
101
+ # Window IDs can be reused after a server restart. Check both
102
+ # membership and readiness before trusting a cached default.
103
+ windows, code = self._run_tmux(
104
+ ['list-windows', '-t', f'={self.session_name}',
105
+ '-F', '#{window_id}|#{@tmux_cli_ready}'],
106
+ include_stderr=True,
107
+ )
108
+ if code != 0 and not self._session_is_missing(windows):
109
+ raise self._tmux_failure('list-windows', windows, code)
110
+ expected = f'{self.target_window}|{self.target_window}'
111
+ if code == 0 and expected in windows.splitlines():
112
+ return self.target_window
113
+ self.target_window = None
114
+ if code != 0:
115
+ raise ValueError(
116
+ "No target pane/window specified; managed session "
117
+ f"'{self.session_name}' does not exist. "
118
+ "Launch a window or pass --pane."
119
+ )
120
+ # An existing managed session may have been created by an earlier CLI call.
121
+ win, code = self._run_tmux(
122
+ ['display-message', '-p', '-t', f'={self.session_name}:',
123
+ '#{window_id}|#{@tmux_cli_ready}'],
124
+ include_stderr=True,
125
+ )
126
+ if code == 0:
127
+ if not win:
128
+ raise self._tmux_failure('display-message', win, code)
129
+ window_id, separator, ready = win.partition('|')
130
+ if not separator or not window_id.startswith('@'):
131
+ raise self._tmux_failure('display-message', win, code)
132
+ if ready == window_id:
133
+ self.target_window = window_id
134
+ return window_id
135
+ raise ValueError(
136
+ f"No target pane/window specified; managed session '{self.session_name}' "
137
+ "has no successfully launched active window. Launch a window or pass --pane."
138
+ )
139
+ if code != 0 and not (self._session_is_missing(win) or "can't find window:" in win):
140
+ raise self._tmux_failure('display-message', win, code)
141
+ raise ValueError(
142
+ f"No target pane/window specified; managed session '{self.session_name}' "
143
+ "does not exist or has no active window. Launch a window or pass --pane."
144
+ )
76
145
  # If user supplied a simple index
77
- if isinstance(pane, str) and pane.isdigit():
146
+ if isinstance(pane, bool):
147
+ raise ValueError("Boolean --pane is not a window index")
148
+ if type(pane) is int or (isinstance(pane, str) and pane.isdigit()):
149
+ if not self._managed_session_exists():
150
+ raise ValueError(
151
+ f"Managed session '{self.session_name}' does not exist; "
152
+ "launch a window or pass a full --pane target."
153
+ )
78
154
  return f"{self.session_name}:{pane}"
79
155
  # Otherwise assume user provided a pane/window target or pane id
80
156
  return pane
@@ -92,7 +168,6 @@ class RemoteTmuxController:
92
168
  Returns a list shaped similarly to local list_panes, with keys:
93
169
  id (window target), index, title (window name), active (bool), size (N/A)
94
170
  """
95
- self._ensure_session()
96
171
  out, code = self._run_tmux([
97
172
  'list-windows', '-t', self.session_name,
98
173
  '-F', '#{window_index}|#{window_name}|#{window_active}|#{window_width}x#{window_height}'
@@ -117,17 +192,49 @@ class RemoteTmuxController:
117
192
  """Launch a command in a new window within the managed session.
118
193
  Returns the window target (e.g., "session:1").
119
194
  """
120
- self._ensure_session()
121
- args = ['new-window', '-t', self.session_name, '-P', '-F', '#{session_name}:#{window_index}']
122
- if name:
123
- args.extend(['-n', name])
124
- if command:
125
- args.append(command)
126
- out, code = self._run_tmux(args)
127
- if code == 0 and out:
128
- self.target_window = out
129
- return out
130
- return None
195
+ previous_target = self.target_window
196
+ try:
197
+ self._ensure_session()
198
+ args = ['new-window', '-t', f'={self.session_name}:', '-P', '-F',
199
+ '#{session_name}:#{window_index}|#{window_id}']
200
+ if name:
201
+ args.extend(['-n', name])
202
+ if command:
203
+ args.append(command)
204
+ out, code = self._run_tmux(args, include_stderr=True)
205
+ if code != 0 or not out:
206
+ raise self._tmux_failure('new-window', out, code)
207
+ target, separator, window_id = out.partition('|')
208
+ if not separator or not target or not window_id.startswith('@'):
209
+ raise self._tmux_failure('new-window', out, code)
210
+ try:
211
+ marked, mark_code = self._run_tmux(
212
+ ['set-option', '-w', '-t', window_id, self._ready_option, window_id],
213
+ include_stderr=True,
214
+ )
215
+ except RuntimeError as exc:
216
+ raise RuntimeError(
217
+ f"Ready mark for created window {window_id} could not be set; "
218
+ f"command may have started. {exc}"
219
+ ) from exc
220
+ if mark_code != 0:
221
+ if self._window_is_missing(marked, window_id):
222
+ raise RuntimeError(
223
+ f"Command was launched in window {window_id}, but that window "
224
+ f"disappeared before its ready mark; no live target is available "
225
+ f"(tmux set-option exit {mark_code}: {marked}). "
226
+ "The command may have run; do not retry automatically."
227
+ )
228
+ raise RuntimeError(
229
+ f"{self._tmux_failure('set-option', marked, mark_code)}; "
230
+ f"created window {window_id}; command may have started. "
231
+ "Inspect the window before retrying."
232
+ )
233
+ self.target_window = window_id
234
+ return target
235
+ except Exception:
236
+ self.target_window = previous_target
237
+ raise
131
238
 
132
239
  def send_keys(self, text: str, pane_id: Optional[str] = None, enter: bool = True,
133
240
  delay_enter: Union[bool, float] = True, verify_enter: bool = True,
@@ -272,14 +379,25 @@ class RemoteTmuxController:
272
379
 
273
380
  def kill_window(self, window_id: Optional[str] = None):
274
381
  target = self._window_target(window_id)
382
+ cached_target = target
383
+ if self.target_window and target != self.target_window:
384
+ resolved, code = self._run_tmux(
385
+ ['display-message', '-p', '-t', target, '#{window_id}']
386
+ )
387
+ if code == 0 and resolved:
388
+ cached_target = resolved
275
389
  # Ensure the target refers to a window (not a %pane id)
276
390
  # If user passed a pane id like %12, tmux can still resolve to its window
277
391
  self._run_tmux(['kill-window', '-t', target])
278
- if self.target_window == target:
392
+ if self.target_window == cached_target:
279
393
  self.target_window = None
280
394
 
281
395
  def attach_session(self):
282
- self._ensure_session()
396
+ if not self._managed_session_exists():
397
+ raise ValueError(
398
+ f"Managed session '{self.session_name}' does not exist; "
399
+ "launch a window before attaching."
400
+ )
283
401
  # Attach will replace the current terminal view until the user detaches
284
402
  subprocess.run(['tmux', 'attach-session', '-t', self.session_name])
285
403
 
@@ -289,7 +407,6 @@ class RemoteTmuxController:
289
407
 
290
408
  def list_windows(self) -> List[Dict[str, str]]:
291
409
  """List all windows in the managed session with basic info."""
292
- self._ensure_session()
293
410
  out, code = self._run_tmux(['list-windows', '-t', self.session_name, '-F', '#{window_index}|#{window_name}|#{window_active}'])
294
411
  if code != 0 or not out:
295
412
  return []
@@ -236,6 +236,14 @@ Daemon + core — `claude_code_tools/agent_tunnel/`:
236
236
  Mattermost routes the same way, with Mattermost's thread model: a root post
237
237
  `\<handle\> [question]` in a watched channel (26-char `channel_ids`) opens a
238
238
  thread keyed `mm:<root post id>`, and the bot answers as replies under it.
239
+ A root post that simply **@-mentions the bot** works too — the mention is
240
+ stripped and what follows is the question, answered against
241
+ `[mattermost] default_handle` (unset: the only shared session when exactly
242
+ one is live, else the bot replies asking which handle to use). A handle may
243
+ still lead the question after the mention.
244
+ A message inside a thread the bot has NOT bound is routed the same way as a
245
+ root post (so a teammate can answer the bot's "which session?" question
246
+ in place); anything else there is left alone.
239
247
  Replies in a bound thread are follow-ups; a reply opening with
240
248
  `@someone-else`, `@all`, `@channel` or `@here` is side-chat and ignored, and
241
249
  a leading `@bot` is stripped. `!list`/`!handles` and `!done`/`!close`/`!end`
@@ -5,27 +5,40 @@ Automatically detects whether you're inside or outside tmux and uses the appropr
5
5
 
6
6
  ## Auto-Detection
7
7
  - **Inside tmux (Local Mode)**: Manages panes in your current tmux window
8
- - **Outside tmux (Remote Mode)**: Creates and manages a separate tmux session with windows
8
+ - **Outside tmux (Remote Mode)**: Manages windows in a separate tmux session, creating it when `launch` first needs it
9
+
10
+ Remote `status`, `list_panes`, and `list_windows` do not create a session.
11
+ An explicit full pane target (such as `%12` or `myapp:1.2`) works without a
12
+ managed session. Commands without a target require an active window from a
13
+ successful managed `launch`; otherwise, launch a window first or pass `--pane`.
14
+ `attach` requires the managed session to already exist.
9
15
 
10
16
  ## Prerequisites
11
17
  - tmux must be installed
12
18
  - The `tmux-cli` command must be available (installed via `uv tool install`)
13
19
 
14
- ## Pane Identification
20
+ ## Target Identification
15
21
 
16
- Panes can be specified in two simple ways:
22
+ In local mode, panes can be specified in two simple ways:
17
23
  - Just the pane number (e.g., `2`) - refers to pane 2 in the current window
18
24
  - Full format: `session:window.pane` (e.g., `myapp:1.2`) - for any pane in any session
19
25
 
26
+ In remote mode, `launch` returns a window target such as `remote-cli-session:1`.
27
+ Use the returned target as `--pane`. A bare number such as `1` means a managed
28
+ window index, not a pane index; window indices can change if windows move.
29
+ Numeric `--pane=2` examples below show local mode unless labeled as remote.
30
+
20
31
  ## ⚠️ IMPORTANT: Always Launch a Shell First!
21
32
 
22
33
  **Always launch zsh first** to prevent losing output when commands fail:
23
34
  ```bash
24
- tmux-cli launch "zsh" # Do this FIRST
25
- tmux-cli send "your-command" --pane=2 # Then run commands
35
+ tmux-cli launch "zsh" # Do this FIRST; remote example returns remote-cli-session:1
36
+ tmux-cli send "your-command" --pane=remote-cli-session:1 # Use the actual returned target
26
37
  ```
27
38
 
28
- If you launch a command directly and it errors, the pane closes immediately and you lose all output!
39
+ If a directly launched command exits before its remote window is marked ready,
40
+ `launch` reports that the command was launched but has no live target. It does
41
+ not retry the command. Launch a shell first to keep output available.
29
42
 
30
43
  ## Core Commands
31
44
 
@@ -33,7 +46,8 @@ If you launch a command directly and it errors, the pane closes immediately and
33
46
  ```bash
34
47
  tmux-cli launch "command"
35
48
  # Example: tmux-cli launch "python3"
36
- # Returns: pane identifier (e.g., session:window.pane format like myapp:1.2)
49
+ # Local return: pane identifier (e.g., myapp:1.2)
50
+ # Remote return: window target (e.g., remote-cli-session:1)
37
51
  ```
38
52
 
39
53
  ### Send input to a pane
@@ -159,23 +173,23 @@ tmux-cli help
159
173
 
160
174
  1. **ALWAYS launch a shell first** (prefer zsh) - this prevents losing output on errors:
161
175
  ```bash
162
- tmux-cli launch "zsh" # Returns pane identifier - DO THIS FIRST!
176
+ tmux-cli launch "zsh" # Remote: returns a window target - DO THIS FIRST!
163
177
  ```
164
178
 
165
179
  2. Run your command in the shell:
166
180
  ```bash
167
- tmux-cli send "python script.py" --pane=2
181
+ tmux-cli send "python script.py" --pane=remote-cli-session:1 # Use the actual returned target
168
182
  ```
169
183
 
170
184
  3. Interact with the program:
171
185
  ```bash
172
- tmux-cli send "user input" --pane=2
173
- tmux-cli capture --pane=2 # Check output
186
+ tmux-cli send "user input" --pane=remote-cli-session:1
187
+ tmux-cli capture --pane=remote-cli-session:1 # Check output
174
188
  ```
175
189
 
176
190
  4. Clean up when done:
177
191
  ```bash
178
- tmux-cli kill --pane=2
192
+ tmux-cli kill --pane=remote-cli-session:1
179
193
  ```
180
194
 
181
195
  ## Remote Mode Specific Commands
@@ -205,7 +219,8 @@ tmux-cli list_windows
205
219
  - Use `capture` to check the current state before sending input
206
220
  - Use `status` to see all available panes and their current state
207
221
  - In local mode: Pane identifiers can be session:window.pane format (like `myapp:1.2`) or just pane indices like `1`, `2`
208
- - In remote mode: Window IDs can be indices like `0`, `1` or full form like `session:0.0`
222
+ - In remote mode: Bare numbers like `0`, `1` are window indices; `launch` returns
223
+ a `session:window_index` target, such as `remote-cli-session:1`
209
224
  - If you launch a command directly (not via shell), the pane/window closes when
210
225
  the command exits
211
226
  - **IMPORTANT**: The tool prevents you from killing your own pane/window to avoid
@@ -222,4 +237,4 @@ tmux-cli wait_idle --pane=2 --idle-time=3.0
222
237
 
223
238
  # Now capture the result
224
239
  tmux-cli capture --pane=2
225
- ```
240
+ ```
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "claude-code-tools"
3
- version = "1.29.0"
3
+ version = "1.29.2"
4
4
  description = "Collection of tools for working with Claude Code"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -113,7 +113,7 @@ markers = [
113
113
 
114
114
  [tool.commitizen]
115
115
  name = "cz_conventional_commits"
116
- version = "1.29.0"
116
+ version = "1.29.2"
117
117
  tag_format = "v$version"
118
118
  version_files = [
119
119
  "pyproject.toml:version",