claude-code-tools 1.28.0__tar.gz → 1.29.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 (145) hide show
  1. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/PKG-INFO +1 -1
  2. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/__init__.py +1 -1
  3. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/agent_tunnel/config.py +5 -0
  4. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/agent_tunnel/http_frontend.py +69 -2
  5. claude_code_tools-1.29.0/claude_code_tools/agent_tunnel/turn_log.py +164 -0
  6. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/docs/agent-tunnel-spec.md +3 -1
  7. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/pyproject.toml +2 -2
  8. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/.gitignore +0 -0
  9. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/LICENSE +0 -0
  10. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/README.md +0 -0
  11. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/action_rpc.py +0 -0
  12. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/agent_tunnel/__init__.py +0 -0
  13. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/agent_tunnel/backends.py +0 -0
  14. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/agent_tunnel/cli.py +0 -0
  15. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/agent_tunnel/convert.py +0 -0
  16. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/agent_tunnel/discord_bot.py +0 -0
  17. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/agent_tunnel/locking.py +0 -0
  18. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/agent_tunnel/mattermost_bot.py +0 -0
  19. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/agent_tunnel/paths.py +0 -0
  20. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/agent_tunnel/readonly.py +0 -0
  21. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/agent_tunnel/registry.py +0 -0
  22. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/agent_tunnel/relay.py +0 -0
  23. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/agent_tunnel/serve.py +0 -0
  24. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/agent_tunnel/session.py +0 -0
  25. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/agent_tunnel/store.py +0 -0
  26. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/agent_tunnel/tmux.py +0 -0
  27. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/agent_tunnel/trust.py +0 -0
  28. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/aichat.py +0 -0
  29. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/amux/__init__.py +0 -0
  30. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/amux/__main__.py +0 -0
  31. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/amux/activity.py +0 -0
  32. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/amux/cache.py +0 -0
  33. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/amux/cli.py +0 -0
  34. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/amux/detect.py +0 -0
  35. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/amux/filters.py +0 -0
  36. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/amux/model.py +0 -0
  37. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/amux/render.py +0 -0
  38. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/amux/scan.py +0 -0
  39. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/claude_continue.py +0 -0
  40. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/cli_passthrough.py +0 -0
  41. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/codex_app_server_rpc.py +0 -0
  42. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/codex_continue.py +0 -0
  43. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/codex_server.py +0 -0
  44. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/codex_server_callback.py +0 -0
  45. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/codex_server_cli.py +0 -0
  46. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/codex_server_fingerprint.py +0 -0
  47. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/codex_server_generation.py +0 -0
  48. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/codex_server_legacy.py +0 -0
  49. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/codex_server_models.py +0 -0
  50. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/codex_server_process.py +0 -0
  51. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/codex_server_reservation.py +0 -0
  52. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/codex_server_resume.py +0 -0
  53. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/codex_server_retry.py +0 -0
  54. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/codex_server_reuse.py +0 -0
  55. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/codex_server_state.py +0 -0
  56. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/codex_server_supervisor.py +0 -0
  57. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/codex_server_wait.py +0 -0
  58. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/codex_server_worker.py +0 -0
  59. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/config.py +0 -0
  60. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/csv2gsheet.py +0 -0
  61. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/delete_session.py +0 -0
  62. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/dotenv_vault.py +0 -0
  63. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/env_safe.py +0 -0
  64. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/export_all.py +0 -0
  65. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/export_claude_session.py +0 -0
  66. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/export_codex_session.py +0 -0
  67. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/export_session.py +0 -0
  68. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/find_claude_session.py +0 -0
  69. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/find_codex_session.py +0 -0
  70. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/find_original_session.py +0 -0
  71. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/find_session.py +0 -0
  72. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/find_trimmed_sessions.py +0 -0
  73. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/fix_session.py +0 -0
  74. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/gdoc2docx.py +0 -0
  75. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/gdoc2md.py +0 -0
  76. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/github_wake.py +0 -0
  77. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/github_watch_daemon.py +0 -0
  78. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/github_watch_store.py +0 -0
  79. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/gsheet2csv.py +0 -0
  80. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/helper_sessions.py +0 -0
  81. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/issue_reply_delivery.py +0 -0
  82. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/legacy_session_wrappers.py +0 -0
  83. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/md2gdoc.py +0 -0
  84. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/move_account.py +0 -0
  85. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/msg/__init__.py +0 -0
  86. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/msg/cli.py +0 -0
  87. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/msg/hooks.py +0 -0
  88. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/msg/migrations.py +0 -0
  89. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/msg/models.py +0 -0
  90. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/msg/prompt_detect.py +0 -0
  91. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/msg/store.py +0 -0
  92. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/msg/watcher.py +0 -0
  93. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/node_menu_ui.py +0 -0
  94. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/port_claude_noise.py +0 -0
  95. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/port_claude_to_codex.py +0 -0
  96. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/port_codex_flatten.py +0 -0
  97. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/port_codex_to_claude.py +0 -0
  98. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/port_render.py +0 -0
  99. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/port_service.py +0 -0
  100. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/resolve_session.py +0 -0
  101. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/resolve_session_names.py +0 -0
  102. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/resolve_session_render.py +0 -0
  103. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/search_index.py +0 -0
  104. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/session_cli_resolution.py +0 -0
  105. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/session_lineage.py +0 -0
  106. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/session_menu.py +0 -0
  107. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/session_menu_cli.py +0 -0
  108. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/session_resolution.py +0 -0
  109. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/session_selection.py +0 -0
  110. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/session_utils.py +0 -0
  111. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/smart_trim.py +0 -0
  112. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/smart_trim_core.py +0 -0
  113. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/tmux_cli_controller.py +0 -0
  114. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/tmux_execution_helpers.py +0 -0
  115. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/tmux_remote_controller.py +0 -0
  116. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/trim_in_place.py +0 -0
  117. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/trim_session.py +0 -0
  118. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/trim_session_claude.py +0 -0
  119. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/trim_session_codex.py +0 -0
  120. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/workflow_cli.py +0 -0
  121. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/workflow_cli_contract.py +0 -0
  122. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/workflow_cli_formatting.py +0 -0
  123. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/workflow_cli_identity_policy.py +0 -0
  124. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/workflow_cli_manifest.py +0 -0
  125. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/workflow_cli_projection.py +0 -0
  126. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/workflow_cli_rendering.py +0 -0
  127. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/workflow_cli_snapshots.py +0 -0
  128. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/workflow_cli_store_backends.py +0 -0
  129. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/workflow_processes.py +0 -0
  130. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/workflow_runs.py +0 -0
  131. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/workflow_store_io.py +0 -0
  132. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/claude_code_tools/workflow_validation.py +0 -0
  133. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/docs/claude-code-tmux-tutorials.md +0 -0
  134. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/docs/dot-zshrc.md +0 -0
  135. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/docs/find-claude-session.md +0 -0
  136. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/docs/intercom-spec.md +0 -0
  137. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/docs/lmsh.md +0 -0
  138. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/docs/local-llm-setup.md +0 -0
  139. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/docs/tmux-cli-instructions.md +0 -0
  140. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/docs/vault-documentation.md +0 -0
  141. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/hatch_build.py +0 -0
  142. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/node_ui/action_config.js +0 -0
  143. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/node_ui/menu.js +0 -0
  144. {claude_code_tools-1.28.0 → claude_code_tools-1.29.0}/node_ui/package-lock.json +0 -0
  145. {claude_code_tools-1.28.0 → claude_code_tools-1.29.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.28.0
3
+ Version: 1.29.0
4
4
  Summary: Collection of tools for working with Claude Code
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.11
@@ -1,3 +1,3 @@
1
1
  """Claude Code Tools - Collection of utilities for Claude Code."""
2
2
 
3
- __version__ = "1.28.0"
3
+ __version__ = "1.29.0"
@@ -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
@@ -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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "claude-code-tools"
3
- version = "1.28.0"
3
+ version = "1.29.0"
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.28.0"
116
+ version = "1.29.0"
117
117
  tag_format = "v$version"
118
118
  version_files = [
119
119
  "pyproject.toml:version",