claude-code-tools 1.28.0__tar.gz → 1.29.1__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.28.0 → claude_code_tools-1.29.1}/PKG-INFO +1 -1
  2. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/__init__.py +1 -1
  3. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/agent_tunnel/config.py +5 -0
  4. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/agent_tunnel/http_frontend.py +69 -2
  5. claude_code_tools-1.29.1/claude_code_tools/agent_tunnel/turn_log.py +164 -0
  6. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/tmux_cli_controller.py +2 -5
  7. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/tmux_remote_controller.py +166 -49
  8. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/docs/agent-tunnel-spec.md +3 -1
  9. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/docs/tmux-cli-instructions.md +29 -14
  10. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/pyproject.toml +2 -2
  11. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/.gitignore +0 -0
  12. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/LICENSE +0 -0
  13. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/README.md +0 -0
  14. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/action_rpc.py +0 -0
  15. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/agent_tunnel/__init__.py +0 -0
  16. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/agent_tunnel/backends.py +0 -0
  17. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/agent_tunnel/cli.py +0 -0
  18. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/agent_tunnel/convert.py +0 -0
  19. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/agent_tunnel/discord_bot.py +0 -0
  20. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/agent_tunnel/locking.py +0 -0
  21. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/agent_tunnel/mattermost_bot.py +0 -0
  22. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/agent_tunnel/paths.py +0 -0
  23. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/agent_tunnel/readonly.py +0 -0
  24. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/agent_tunnel/registry.py +0 -0
  25. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/agent_tunnel/relay.py +0 -0
  26. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/agent_tunnel/serve.py +0 -0
  27. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/agent_tunnel/session.py +0 -0
  28. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/agent_tunnel/store.py +0 -0
  29. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/agent_tunnel/tmux.py +0 -0
  30. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/agent_tunnel/trust.py +0 -0
  31. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/aichat.py +0 -0
  32. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/amux/__init__.py +0 -0
  33. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/amux/__main__.py +0 -0
  34. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/amux/activity.py +0 -0
  35. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/amux/cache.py +0 -0
  36. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/amux/cli.py +0 -0
  37. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/amux/detect.py +0 -0
  38. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/amux/filters.py +0 -0
  39. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/amux/model.py +0 -0
  40. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/amux/render.py +0 -0
  41. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/amux/scan.py +0 -0
  42. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/claude_continue.py +0 -0
  43. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/cli_passthrough.py +0 -0
  44. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/codex_app_server_rpc.py +0 -0
  45. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/codex_continue.py +0 -0
  46. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/codex_server.py +0 -0
  47. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/codex_server_callback.py +0 -0
  48. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/codex_server_cli.py +0 -0
  49. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/codex_server_fingerprint.py +0 -0
  50. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/codex_server_generation.py +0 -0
  51. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/codex_server_legacy.py +0 -0
  52. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/codex_server_models.py +0 -0
  53. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/codex_server_process.py +0 -0
  54. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/codex_server_reservation.py +0 -0
  55. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/codex_server_resume.py +0 -0
  56. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/codex_server_retry.py +0 -0
  57. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/codex_server_reuse.py +0 -0
  58. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/codex_server_state.py +0 -0
  59. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/codex_server_supervisor.py +0 -0
  60. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/codex_server_wait.py +0 -0
  61. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/codex_server_worker.py +0 -0
  62. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/config.py +0 -0
  63. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/csv2gsheet.py +0 -0
  64. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/delete_session.py +0 -0
  65. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/dotenv_vault.py +0 -0
  66. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/env_safe.py +0 -0
  67. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/export_all.py +0 -0
  68. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/export_claude_session.py +0 -0
  69. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/export_codex_session.py +0 -0
  70. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/export_session.py +0 -0
  71. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/find_claude_session.py +0 -0
  72. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/find_codex_session.py +0 -0
  73. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/find_original_session.py +0 -0
  74. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/find_session.py +0 -0
  75. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/find_trimmed_sessions.py +0 -0
  76. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/fix_session.py +0 -0
  77. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/gdoc2docx.py +0 -0
  78. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/gdoc2md.py +0 -0
  79. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/github_wake.py +0 -0
  80. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/github_watch_daemon.py +0 -0
  81. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/github_watch_store.py +0 -0
  82. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/gsheet2csv.py +0 -0
  83. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/helper_sessions.py +0 -0
  84. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/issue_reply_delivery.py +0 -0
  85. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/legacy_session_wrappers.py +0 -0
  86. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/md2gdoc.py +0 -0
  87. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/move_account.py +0 -0
  88. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/msg/__init__.py +0 -0
  89. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/msg/cli.py +0 -0
  90. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/msg/hooks.py +0 -0
  91. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/msg/migrations.py +0 -0
  92. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/msg/models.py +0 -0
  93. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/msg/prompt_detect.py +0 -0
  94. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/msg/store.py +0 -0
  95. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/msg/watcher.py +0 -0
  96. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/node_menu_ui.py +0 -0
  97. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/port_claude_noise.py +0 -0
  98. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/port_claude_to_codex.py +0 -0
  99. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/port_codex_flatten.py +0 -0
  100. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/port_codex_to_claude.py +0 -0
  101. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/port_render.py +0 -0
  102. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/port_service.py +0 -0
  103. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/resolve_session.py +0 -0
  104. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/resolve_session_names.py +0 -0
  105. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/resolve_session_render.py +0 -0
  106. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/search_index.py +0 -0
  107. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/session_cli_resolution.py +0 -0
  108. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/session_lineage.py +0 -0
  109. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/session_menu.py +0 -0
  110. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/session_menu_cli.py +0 -0
  111. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/session_resolution.py +0 -0
  112. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/session_selection.py +0 -0
  113. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/session_utils.py +0 -0
  114. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/smart_trim.py +0 -0
  115. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/smart_trim_core.py +0 -0
  116. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/tmux_execution_helpers.py +0 -0
  117. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/trim_in_place.py +0 -0
  118. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/trim_session.py +0 -0
  119. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/trim_session_claude.py +0 -0
  120. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/trim_session_codex.py +0 -0
  121. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/workflow_cli.py +0 -0
  122. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/workflow_cli_contract.py +0 -0
  123. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/workflow_cli_formatting.py +0 -0
  124. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/workflow_cli_identity_policy.py +0 -0
  125. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/workflow_cli_manifest.py +0 -0
  126. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/workflow_cli_projection.py +0 -0
  127. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/workflow_cli_rendering.py +0 -0
  128. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/workflow_cli_snapshots.py +0 -0
  129. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/workflow_cli_store_backends.py +0 -0
  130. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/workflow_processes.py +0 -0
  131. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/workflow_runs.py +0 -0
  132. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/workflow_store_io.py +0 -0
  133. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/workflow_validation.py +0 -0
  134. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/docs/claude-code-tmux-tutorials.md +0 -0
  135. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/docs/dot-zshrc.md +0 -0
  136. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/docs/find-claude-session.md +0 -0
  137. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/docs/intercom-spec.md +0 -0
  138. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/docs/lmsh.md +0 -0
  139. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/docs/local-llm-setup.md +0 -0
  140. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/docs/vault-documentation.md +0 -0
  141. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/hatch_build.py +0 -0
  142. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/node_ui/action_config.js +0 -0
  143. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/node_ui/menu.js +0 -0
  144. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/node_ui/package-lock.json +0 -0
  145. {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/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.28.0
3
+ Version: 1.29.1
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.28.0"
3
+ __version__ = "1.29.1"
@@ -176,6 +176,10 @@ class HttpConfig:
176
176
  token_file: str = ""
177
177
  # Name used in the fork's persona for questions arriving this way.
178
178
  platform: str = "the web"
179
+ # Append-only JSON-lines log of answered turns, readable at GET /turns.
180
+ # Empty = off. Relative paths sit beside the state file. The sender is
181
+ # never written to it.
182
+ turn_log: str = ""
179
183
 
180
184
 
181
185
  @dataclass
@@ -423,6 +427,7 @@ unset_api_key = true
423
427
  # port = 8766
424
428
  # token_file = "~/.config/agent-tunnel/http-token"
425
429
  # platform = "the web"
430
+ # turn_log = "http-turns.jsonl" # answered turns, no sender; GET /turns reads it
426
431
 
427
432
  [limits]
428
433
  max_concurrent = 2
@@ -9,7 +9,9 @@ refresh and logging are shared with Discord and Mattermost.
9
9
  Routes (``[http]`` in the config; off unless ``port`` is set):
10
10
 
11
11
  GET /health
12
- POST /ask {"handle": ..., "question": ..., "thread": ..., "sender": ...}
12
+ POST /ask {"handle": ..., "question": ..., "thread": ..., "sender": ...,
13
+ "metadata": {...}}
14
+ GET /turns ?handle=...&limit=...&meta.<key>=<value>
13
15
 
14
16
  Every route requires ``X-Ask-Token: <contents of http.token_file>``.
15
17
  ``thread`` is any caller-chosen id; questions with the same handle and
@@ -20,6 +22,11 @@ whatever access the handle was shared with, because the caller picks the
20
22
  handle. The response says whether a turn ran (``ran``) separately from what
21
23
  it produced, and a failed turn is an HTTP 502 carrying the relay's own
22
24
  error text, never an empty answer.
25
+
26
+ With ``[http] turn_log`` set, each answered turn is appended to that file
27
+ with the caller's flat ``metadata`` (a docs site sends page and heading) and
28
+ without the sender, and ``GET /turns`` returns the matching turns newest
29
+ first. See :mod:`.turn_log`.
23
30
  """
24
31
 
25
32
  from __future__ import annotations
@@ -37,6 +44,7 @@ from aiohttp import web
37
44
  from .readonly import HTTP_THREAD_PREFIX
38
45
  from .config import TunnelConfig
39
46
  from .relay import QUEUE_NOTICE, Relay
47
+ from .turn_log import MAX_LIMIT, TurnLog, resolve_log_path, validate_metadata
40
48
 
41
49
  logger = logging.getLogger("agent_tunnel.http")
42
50
 
@@ -148,6 +156,8 @@ async def _turn(
148
156
  thread: str,
149
157
  question: str,
150
158
  sender: str,
159
+ metadata: dict[str, str],
160
+ turn_log: Optional[TurnLog],
151
161
  ) -> web.Response:
152
162
  """Bind if new, run one relay turn, and shape the JSON reply.
153
163
 
@@ -166,6 +176,16 @@ async def _turn(
166
176
  error = problem or "the turn produced no answer"
167
177
  return _json(502, {"ran": False, "error": error, "thread": thread})
168
178
  record = relay.store.get(thread_key)
179
+ logged = False
180
+ if turn_log is not None:
181
+ try:
182
+ await turn_log.append(handle, thread, question, dest.text, metadata)
183
+ logged = True
184
+ except (OSError, ValueError):
185
+ # The reader still gets the answer; the response says it was not
186
+ # logged, and the daemon log says why.
187
+ logger.exception("Could not append to turn log %s", turn_log.path)
188
+ dest.notices.append("this turn could not be written to the turn log")
169
189
  return _json(
170
190
  200,
171
191
  {
@@ -176,6 +196,7 @@ async def _turn(
176
196
  "fork_session_id": record.fork_session_id if record else "",
177
197
  "files": [p.name for p in dest.files],
178
198
  "notices": dest.notices,
199
+ "logged": logged,
179
200
  },
180
201
  )
181
202
 
@@ -183,6 +204,8 @@ async def _turn(
183
204
  def make_app(cfg: TunnelConfig, relay: Relay) -> web.Application:
184
205
  """The aiohttp application; separated from serving so tests can drive it."""
185
206
  token = resolve_http_token(cfg).encode("utf-8")
207
+ log_path = resolve_log_path(cfg.http.turn_log, cfg.state_path)
208
+ turn_log = TurnLog(log_path) if log_path else None
186
209
 
187
210
  @web.middleware
188
211
  async def require_token(request: web.Request, handler: Any) -> web.Response:
@@ -225,6 +248,10 @@ def make_app(cfg: TunnelConfig, relay: Relay) -> web.Application:
225
248
  return _json(
226
249
  400, {"ran": False, "error": "thread must match [A-Za-z0-9_.:-]{1,80}"}
227
250
  )
251
+ metadata, err = validate_metadata(body.get("metadata"))
252
+ if err:
253
+ status = 413 if "longer" in err else 400
254
+ return _json(status, {"ran": False, "error": err})
228
255
  # The same per-user cooldown the chat front-ends apply, keyed by the
229
256
  # sender the caller vouches for, else by the caller's address.
230
257
  caller = sender or (request.remote or "unknown")
@@ -236,7 +263,17 @@ def make_app(cfg: TunnelConfig, relay: Relay) -> web.Application:
236
263
  )
237
264
  thread_key = f"{HTTP_THREAD_PREFIX}{handle}:{thread}"
238
265
  try:
239
- return await _turn(cfg, relay, thread_key, handle, thread, question, sender)
266
+ return await _turn(
267
+ cfg,
268
+ relay,
269
+ thread_key,
270
+ handle,
271
+ thread,
272
+ question,
273
+ sender,
274
+ metadata,
275
+ turn_log,
276
+ )
240
277
  except Exception: # noqa: BLE001 - keep the JSON contract on any failure
241
278
  logger.exception("HTTP turn failed [%s]", thread_key)
242
279
  return _json(
@@ -248,9 +285,39 @@ def make_app(cfg: TunnelConfig, relay: Relay) -> web.Application:
248
285
  },
249
286
  )
250
287
 
288
+ async def turns(request: web.Request) -> web.Response:
289
+ if turn_log is None:
290
+ return _json(404, {"ran": False, "error": "no [http] turn_log configured"})
291
+ query = request.query
292
+ handle = query.get("handle", "").strip().lower()
293
+ if not handle:
294
+ return _json(400, {"ran": False, "error": "handle is required"})
295
+ try:
296
+ limit = int(query.get("limit", "50"))
297
+ except ValueError:
298
+ return _json(400, {"ran": False, "error": "limit must be an integer"})
299
+ if not 1 <= limit <= MAX_LIMIT:
300
+ return _json(
301
+ 400, {"ran": False, "error": f"limit must be 1 to {MAX_LIMIT}"}
302
+ )
303
+ filters = {
304
+ key[len("meta.") :]: value
305
+ for key, value in query.items()
306
+ if key.startswith("meta.")
307
+ }
308
+ try:
309
+ found, skipped = await asyncio.to_thread(
310
+ turn_log.read, handle, filters, limit
311
+ )
312
+ except OSError:
313
+ logger.exception("Could not read turn log %s", turn_log.path)
314
+ return _json(502, {"ran": False, "error": "could not read the turn log"})
315
+ return _json(200, {"ran": True, "turns": found, "skipped": skipped})
316
+
251
317
  app = web.Application(client_max_size=256 * 1024, middlewares=[require_token])
252
318
  app.router.add_get("/health", health)
253
319
  app.router.add_post("/ask", ask)
320
+ app.router.add_get("/turns", turns)
254
321
  return app
255
322
 
256
323
 
@@ -0,0 +1,164 @@
1
+ """An append-only log of HTTP turns, and the reader behind ``GET /turns``.
2
+
3
+ The daemon's thread store keeps only a trimmed recap of each conversation,
4
+ so it cannot answer "what was asked about this page last month". When
5
+ ``[http] turn_log`` names a file, every answered HTTP turn is appended to it
6
+ as one JSON line: when, which handle and thread, the question, the answer,
7
+ and whatever flat ``metadata`` the caller attached (a docs site sends the
8
+ page and heading). The sender is never written. The fork is still told who
9
+ is asking and may repeat it in its answer, so a caller that shows the log to
10
+ other people sends a pseudonym as the sender rather than a name or email.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import json
17
+ import logging
18
+ import os
19
+ import re
20
+ from collections import deque
21
+ from dataclasses import dataclass, field
22
+ from datetime import datetime, timezone
23
+ from pathlib import Path
24
+ from typing import Any, Mapping, Optional
25
+
26
+ logger = logging.getLogger("agent_tunnel.turn_log")
27
+
28
+ META_KEY_RE = re.compile(r"^[A-Za-z0-9_.-]{1,40}$")
29
+ MAX_META_KEYS = 20
30
+ MAX_META_VALUE = 4000
31
+ MAX_LIMIT = 500
32
+
33
+
34
+ def validate_metadata(value: Any) -> tuple[dict[str, str], str]:
35
+ """A caller's ``metadata`` as a flat string map, or why it is not one.
36
+
37
+ Args:
38
+ value: The request's ``metadata`` field; absent or null is empty.
39
+
40
+ Returns:
41
+ ``(metadata, "")`` on success, else ``({}, error)``. An error that
42
+ mentions "longer" is an over-limit value (the caller answers 413).
43
+ """
44
+ if value is None:
45
+ return {}, ""
46
+ if not isinstance(value, dict):
47
+ return {}, "metadata must be a JSON object"
48
+ if len(value) > MAX_META_KEYS:
49
+ return {}, f"metadata has more than {MAX_META_KEYS} keys"
50
+ meta: dict[str, str] = {}
51
+ for key, item in value.items():
52
+ if not META_KEY_RE.match(key):
53
+ return {}, "metadata keys must match [A-Za-z0-9_.-]{1,40}"
54
+ if not isinstance(item, str):
55
+ return {}, f"metadata.{key} must be a string"
56
+ if len(item) > MAX_META_VALUE:
57
+ return {}, f"metadata.{key} is longer than {MAX_META_VALUE} characters"
58
+ meta[key] = item
59
+ return meta, ""
60
+
61
+
62
+ def resolve_log_path(configured: str, state_path: Path) -> Optional[Path]:
63
+ """The turn log's path, or None when logging is off.
64
+
65
+ A relative path sits beside the daemon's state file, like its other
66
+ runtime files.
67
+ """
68
+ if not configured:
69
+ return None
70
+ path = Path(configured).expanduser()
71
+ return path if path.is_absolute() else state_path.parent / path
72
+
73
+
74
+ @dataclass
75
+ class TurnLog:
76
+ """Append answered turns to a JSON-lines file and read them back."""
77
+
78
+ path: Path
79
+ _lock: asyncio.Lock = field(default_factory=asyncio.Lock)
80
+
81
+ async def append(
82
+ self,
83
+ handle: str,
84
+ thread: str,
85
+ question: str,
86
+ answer: str,
87
+ metadata: Mapping[str, str],
88
+ ) -> None:
89
+ """Write one turn durably; raises OSError if it could not be written."""
90
+ record = {
91
+ "ts": datetime.now(timezone.utc).isoformat(timespec="seconds"),
92
+ "handle": handle,
93
+ "thread": thread,
94
+ "question": question,
95
+ "answer": answer,
96
+ "metadata": dict(metadata),
97
+ }
98
+ # ASCII escapes: a lone surrogate from JSON input is a valid str but
99
+ # cannot be encoded as UTF-8, and must not cost the reader an answer.
100
+ line = json.dumps(record) + "\n"
101
+ async with self._lock:
102
+ await asyncio.to_thread(self._write, line)
103
+
104
+ def _write(self, line: str) -> None:
105
+ self.path.parent.mkdir(parents=True, exist_ok=True)
106
+ # Owner-only, like the thread store: the log holds every question
107
+ # and answer.
108
+ fd = os.open(self.path, os.O_RDWR | os.O_CREAT | os.O_APPEND, 0o600)
109
+ with os.fdopen(fd, "a+b") as fh:
110
+ if hasattr(os, "fchmod"): # POSIX only; also narrows an older file
111
+ os.fchmod(fh.fileno(), 0o600)
112
+ # A failed earlier write can leave a record without its newline;
113
+ # end it first so this record stays on a line of its own.
114
+ end = fh.seek(0, os.SEEK_END)
115
+ if end:
116
+ fh.seek(end - 1)
117
+ if fh.read(1) != b"\n":
118
+ fh.write(b"\n")
119
+ fh.write(line.encode("utf-8"))
120
+ fh.flush()
121
+ os.fsync(fh.fileno())
122
+
123
+ def read(
124
+ self, handle: str, meta_filters: Mapping[str, str], limit: int
125
+ ) -> tuple[list[dict[str, Any]], int]:
126
+ """Matching turns, newest first, and how many lines were unreadable.
127
+
128
+ Args:
129
+ handle: Only turns of this handle.
130
+ meta_filters: Each key must be present in a turn's metadata with
131
+ exactly this value.
132
+ limit: At most this many turns, the most recent ones.
133
+
134
+ Returns:
135
+ ``(turns, skipped)``; a missing file is no turns, not an error.
136
+ """
137
+ if not self.path.exists():
138
+ return [], 0
139
+ # Only the latest `limit` matches are kept while scanning, so memory
140
+ # stays bounded however long the log grows.
141
+ matches: deque[dict[str, Any]] = deque(maxlen=limit)
142
+ skipped = 0
143
+ # Bytes, decoded line by line, so one damaged line is skipped and
144
+ # counted rather than hiding every record after it.
145
+ with self.path.open("rb") as fh:
146
+ for raw in fh:
147
+ if not raw.strip():
148
+ continue
149
+ try:
150
+ turn = json.loads(raw.decode("utf-8"))
151
+ except (UnicodeDecodeError, json.JSONDecodeError):
152
+ skipped += 1
153
+ continue
154
+ meta = turn.get("metadata", {}) if isinstance(turn, dict) else None
155
+ if not isinstance(meta, dict):
156
+ skipped += 1 # valid JSON, but not a turn record
157
+ continue
158
+ if turn.get("handle") != handle:
159
+ continue
160
+ if all(meta.get(k) == v for k, v in meta_filters.items()):
161
+ matches.append(turn)
162
+ if skipped:
163
+ logger.warning("Turn log %s: %d unreadable line(s)", self.path, skipped)
164
+ return list(reversed(matches)), skipped
@@ -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 []
@@ -58,7 +58,9 @@ safe.
58
58
  forks are read-only whatever the handle's access, the configured tool
59
59
  lists, the backends' extra args, the configured permission mode, the
60
60
  settings' MCP servers, or `allow_skip_permissions`, since the caller picks
61
- the handle.
61
+ the handle. With `[http] turn_log` set, each answered turn is appended to
62
+ a JSON-lines log with the caller's flat `metadata` and never the sender;
63
+ `GET /turns` reads it back newest first, filtered by `meta.<key>=<value>`.
62
64
  Fields must be strings within their limits (over-limit is a 413); the
63
65
  per-user cooldown applies per `sender` (a 429 inside it). The
64
66
  response separates whether a turn ran from what it produced: a failed