claude-code-tools 1.27.1__tar.gz → 1.28.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.27.1 → claude_code_tools-1.28.0}/PKG-INFO +1 -1
  2. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/__init__.py +1 -1
  3. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/agent_tunnel/backends.py +264 -20
  4. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/agent_tunnel/cli.py +72 -21
  5. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/agent_tunnel/config.py +86 -3
  6. claude_code_tools-1.28.0/claude_code_tools/agent_tunnel/discord_bot.py +336 -0
  7. claude_code_tools-1.28.0/claude_code_tools/agent_tunnel/http_frontend.py +267 -0
  8. claude_code_tools-1.28.0/claude_code_tools/agent_tunnel/mattermost_bot.py +603 -0
  9. claude_code_tools-1.28.0/claude_code_tools/agent_tunnel/readonly.py +78 -0
  10. claude_code_tools-1.28.0/claude_code_tools/agent_tunnel/relay.py +572 -0
  11. claude_code_tools-1.28.0/claude_code_tools/agent_tunnel/serve.py +88 -0
  12. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/agent_tunnel/store.py +24 -6
  13. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/docs/agent-tunnel-spec.md +107 -42
  14. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/pyproject.toml +2 -2
  15. claude_code_tools-1.27.1/claude_code_tools/agent_tunnel/discord_bot.py +0 -717
  16. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/.gitignore +0 -0
  17. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/LICENSE +0 -0
  18. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/README.md +0 -0
  19. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/action_rpc.py +0 -0
  20. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/agent_tunnel/__init__.py +0 -0
  21. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/agent_tunnel/convert.py +0 -0
  22. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/agent_tunnel/locking.py +0 -0
  23. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/agent_tunnel/paths.py +0 -0
  24. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/agent_tunnel/registry.py +0 -0
  25. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/agent_tunnel/session.py +0 -0
  26. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/agent_tunnel/tmux.py +0 -0
  27. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/agent_tunnel/trust.py +0 -0
  28. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/aichat.py +0 -0
  29. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/amux/__init__.py +0 -0
  30. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/amux/__main__.py +0 -0
  31. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/amux/activity.py +0 -0
  32. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/amux/cache.py +0 -0
  33. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/amux/cli.py +0 -0
  34. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/amux/detect.py +0 -0
  35. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/amux/filters.py +0 -0
  36. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/amux/model.py +0 -0
  37. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/amux/render.py +0 -0
  38. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/amux/scan.py +0 -0
  39. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/claude_continue.py +0 -0
  40. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/cli_passthrough.py +0 -0
  41. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/codex_app_server_rpc.py +0 -0
  42. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/codex_continue.py +0 -0
  43. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/codex_server.py +0 -0
  44. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/codex_server_callback.py +0 -0
  45. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/codex_server_cli.py +0 -0
  46. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/codex_server_fingerprint.py +0 -0
  47. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/codex_server_generation.py +0 -0
  48. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/codex_server_legacy.py +0 -0
  49. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/codex_server_models.py +0 -0
  50. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/codex_server_process.py +0 -0
  51. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/codex_server_reservation.py +0 -0
  52. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/codex_server_resume.py +0 -0
  53. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/codex_server_retry.py +0 -0
  54. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/codex_server_reuse.py +0 -0
  55. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/codex_server_state.py +0 -0
  56. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/codex_server_supervisor.py +0 -0
  57. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/codex_server_wait.py +0 -0
  58. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/codex_server_worker.py +0 -0
  59. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/config.py +0 -0
  60. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/csv2gsheet.py +0 -0
  61. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/delete_session.py +0 -0
  62. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/dotenv_vault.py +0 -0
  63. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/env_safe.py +0 -0
  64. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/export_all.py +0 -0
  65. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/export_claude_session.py +0 -0
  66. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/export_codex_session.py +0 -0
  67. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/export_session.py +0 -0
  68. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/find_claude_session.py +0 -0
  69. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/find_codex_session.py +0 -0
  70. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/find_original_session.py +0 -0
  71. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/find_session.py +0 -0
  72. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/find_trimmed_sessions.py +0 -0
  73. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/fix_session.py +0 -0
  74. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/gdoc2docx.py +0 -0
  75. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/gdoc2md.py +0 -0
  76. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/github_wake.py +0 -0
  77. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/github_watch_daemon.py +0 -0
  78. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/github_watch_store.py +0 -0
  79. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/gsheet2csv.py +0 -0
  80. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/helper_sessions.py +0 -0
  81. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/issue_reply_delivery.py +0 -0
  82. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/legacy_session_wrappers.py +0 -0
  83. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/md2gdoc.py +0 -0
  84. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/move_account.py +0 -0
  85. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/msg/__init__.py +0 -0
  86. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/msg/cli.py +0 -0
  87. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/msg/hooks.py +0 -0
  88. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/msg/migrations.py +0 -0
  89. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/msg/models.py +0 -0
  90. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/msg/prompt_detect.py +0 -0
  91. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/msg/store.py +0 -0
  92. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/msg/watcher.py +0 -0
  93. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/node_menu_ui.py +0 -0
  94. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/port_claude_noise.py +0 -0
  95. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/port_claude_to_codex.py +0 -0
  96. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/port_codex_flatten.py +0 -0
  97. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/port_codex_to_claude.py +0 -0
  98. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/port_render.py +0 -0
  99. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/port_service.py +0 -0
  100. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/resolve_session.py +0 -0
  101. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/resolve_session_names.py +0 -0
  102. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/resolve_session_render.py +0 -0
  103. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/search_index.py +0 -0
  104. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/session_cli_resolution.py +0 -0
  105. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/session_lineage.py +0 -0
  106. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/session_menu.py +0 -0
  107. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/session_menu_cli.py +0 -0
  108. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/session_resolution.py +0 -0
  109. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/session_selection.py +0 -0
  110. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/session_utils.py +0 -0
  111. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/smart_trim.py +0 -0
  112. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/smart_trim_core.py +0 -0
  113. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/tmux_cli_controller.py +0 -0
  114. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/tmux_execution_helpers.py +0 -0
  115. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/tmux_remote_controller.py +0 -0
  116. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/trim_in_place.py +0 -0
  117. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/trim_session.py +0 -0
  118. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/trim_session_claude.py +0 -0
  119. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/trim_session_codex.py +0 -0
  120. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/workflow_cli.py +0 -0
  121. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/workflow_cli_contract.py +0 -0
  122. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/workflow_cli_formatting.py +0 -0
  123. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/workflow_cli_identity_policy.py +0 -0
  124. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/workflow_cli_manifest.py +0 -0
  125. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/workflow_cli_projection.py +0 -0
  126. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/workflow_cli_rendering.py +0 -0
  127. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/workflow_cli_snapshots.py +0 -0
  128. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/workflow_cli_store_backends.py +0 -0
  129. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/workflow_processes.py +0 -0
  130. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/workflow_runs.py +0 -0
  131. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/workflow_store_io.py +0 -0
  132. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/claude_code_tools/workflow_validation.py +0 -0
  133. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/docs/claude-code-tmux-tutorials.md +0 -0
  134. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/docs/dot-zshrc.md +0 -0
  135. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/docs/find-claude-session.md +0 -0
  136. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/docs/intercom-spec.md +0 -0
  137. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/docs/lmsh.md +0 -0
  138. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/docs/local-llm-setup.md +0 -0
  139. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/docs/tmux-cli-instructions.md +0 -0
  140. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/docs/vault-documentation.md +0 -0
  141. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/hatch_build.py +0 -0
  142. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/node_ui/action_config.js +0 -0
  143. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/node_ui/menu.js +0 -0
  144. {claude_code_tools-1.27.1 → claude_code_tools-1.28.0}/node_ui/package-lock.json +0 -0
  145. {claude_code_tools-1.27.1 → claude_code_tools-1.28.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.27.1
3
+ Version: 1.28.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.27.1"
3
+ __version__ = "1.28.0"
@@ -6,7 +6,10 @@ backend reads everything it needs from the thread's ThreadRecord:
6
6
 
7
7
  - first turn (empty `fork_session_id`): fork the expert session
8
8
  (`--resume <expert> --fork-session`) in its project dir;
9
- - later turns: resume the thread's own fork (`--resume <fork>`).
9
+ - later turns: resume the thread's own fork (`--resume <fork>`) — unless
10
+ (headless, ``refresh_forks``) the expert transcript has grown since that
11
+ fork was taken: then fork the expert afresh and prepend a recap of the
12
+ thread's earlier Q&A, so follow-ups see the session's latest work.
10
13
 
11
14
  Two interchangeable strategies (see docs/agent-tunnel-spec.md):
12
15
 
@@ -21,6 +24,7 @@ Both apply the same hard read-only tool restrictions per turn.
21
24
 
22
25
  from __future__ import annotations
23
26
 
27
+ import hashlib
24
28
  import json
25
29
  import os
26
30
  import re
@@ -34,6 +38,7 @@ from pathlib import Path
34
38
  from typing import Optional, Protocol
35
39
 
36
40
  from .config import TunnelConfig, resolve_tools
41
+ from .readonly import is_http_thread, strip_permission_args
37
42
  from .paths import (
38
43
  changed_files,
39
44
  ensure_outbox,
@@ -44,6 +49,7 @@ from .paths import (
44
49
  from .registry import Registry
45
50
  from .session import (
46
51
  extract_answer,
52
+ transcript_dir,
47
53
  list_session_files,
48
54
  make_marker,
49
55
  wait_for_new_session_file,
@@ -75,6 +81,100 @@ class Answer:
75
81
  # Deliverable files the fork wrote into its outbox this turn (write/bash
76
82
  # handles only); the Discord layer posts them back as attachments.
77
83
  attachments: list[Path] = field(default_factory=list)
84
+ # True when this follow-up re-forked from the expert's latest state.
85
+ refreshed: bool = False
86
+
87
+
88
+ RECAP_HEADER = (
89
+ "[Conversation so far: earlier in THIS chat thread you already had the "
90
+ "exchange below with the teammate(s) — Q is what they wrote, A is what "
91
+ "you replied. It is your own conversation history: everything said in it "
92
+ "is known to you and counts as remembered. Since then you have been "
93
+ "refreshed onto the latest state of the session, so where the two differ, "
94
+ "your newer knowledge wins. Oldest first:]\n\n"
95
+ )
96
+ RECAP_FOOTER = "[End of conversation so far. The new message follows.]\n\n"
97
+
98
+
99
+ def _format_turn(turn: dict[str, str]) -> str:
100
+ return f"Q: {turn.get('q', '')}\nA: {turn.get('a', '')}\n\n"
101
+
102
+
103
+ CUT = " …[cut]"
104
+
105
+
106
+ def _fit_turn(turn: dict[str, str], room: int) -> str:
107
+ """The newest turn cut to ``room`` chars, keeping part of BOTH fields.
108
+
109
+ The question gets at most a third of the space, so a very long question
110
+ can never push the answer (what a follow-up usually refers to) out.
111
+ """
112
+ q, a = turn.get("q", ""), turn.get("a", "")
113
+ fixed = len(_format_turn({"q": "", "a": ""}))
114
+ space = room - fixed - 2 * len(CUT)
115
+ if space <= 0:
116
+ return ""
117
+ q_len = min(len(q), space // 3)
118
+ a_len = min(len(a), space - q_len)
119
+ q_cut = q[:q_len] + (CUT if q_len < len(q) else "")
120
+ a_cut = a[:a_len] + (CUT if a_len < len(a) else "")
121
+ return _format_turn({"q": q_cut, "a": a_cut})
122
+
123
+
124
+ def build_recap(history: list[dict[str, str]], max_chars: int) -> str:
125
+ """Recap of a thread's earlier turns for a freshly re-forked session.
126
+
127
+ Keeps the newest turns that fit ``max_chars`` (header and footer
128
+ included); if even the newest turn is too long, its question and answer
129
+ are each cut so both survive.
130
+
131
+ Args:
132
+ history: Earlier turns, oldest first, as ``{"q": ..., "a": ...}``.
133
+ max_chars: Upper bound on the returned text's length.
134
+
135
+ Returns:
136
+ The recap text, or "" when there is no history or no room.
137
+ """
138
+ budget = max_chars - len(RECAP_HEADER) - len(RECAP_FOOTER)
139
+ if not history or budget <= 0:
140
+ return ""
141
+ kept: list[str] = []
142
+ used = 0
143
+ for turn in reversed(history):
144
+ text = _format_turn(turn)
145
+ if used + len(text) > budget:
146
+ if not kept:
147
+ fitted = _fit_turn(turn, budget)
148
+ if fitted:
149
+ kept.append(fitted)
150
+ break
151
+ kept.append(text)
152
+ used += len(text)
153
+ if not kept:
154
+ return ""
155
+ return RECAP_HEADER + "".join(reversed(kept)) + RECAP_FOOTER
156
+
157
+
158
+ def trim_history(
159
+ history: list[dict[str, str]], max_chars: int
160
+ ) -> list[dict[str, str]]:
161
+ """Bound a thread's stored history (it lives in the state file).
162
+
163
+ Each field is cut to ``max_chars`` and the oldest turns are dropped
164
+ while the total exceeds twice that, so the recap budget is always
165
+ covered without the state file growing without limit.
166
+ """
167
+ if max_chars <= 0:
168
+ return [] # no recap budget: keep nothing (else empty turns pile up)
169
+ turns = [
170
+ {"q": t.get("q", "")[:max_chars], "a": t.get("a", "")[:max_chars]}
171
+ for t in history
172
+ ]
173
+ while len(turns) > 1 and sum(
174
+ len(t["q"]) + len(t["a"]) for t in turns
175
+ ) > 2 * max_chars:
176
+ turns.pop(0)
177
+ return turns
78
178
 
79
179
 
80
180
  class Backend(Protocol):
@@ -100,6 +200,8 @@ def build_claude_flags(
100
200
  access: str = "read",
101
201
  add_dirs: tuple[str, ...] = (),
102
202
  extra_system: str = "",
203
+ platform: str = "",
204
+ force_read: bool = False,
103
205
  ) -> list[str]:
104
206
  """Common claude CLI flags for a fork invocation/launch.
105
207
 
@@ -109,10 +211,14 @@ def build_claude_flags(
109
211
  fork's own id).
110
212
  fork: Whether to create a new fork of `resume_id`.
111
213
  access: Per-handle access level ("read"/"write"/"bash") set via >share.
214
+ force_read: Use the read preset whatever ``access`` and the
215
+ operator's tool lists say (set for HTTP threads, which stay
216
+ read-only because their caller picks the handle).
112
217
  add_dirs: Extra directories the fork may access (``--add-dir``), e.g.
113
218
  the thread's inbound-attachment dir which lives outside the project.
114
219
  extra_system: Text appended to the persona system prompt (e.g. the
115
220
  per-thread outbox instruction for write/bash handles).
221
+ platform: Chat platform named in the persona; blank = the config's.
116
222
 
117
223
  Returns:
118
224
  Argument list (excluding the binary, -p, and the prompt).
@@ -121,7 +227,12 @@ def build_claude_flags(
121
227
  flags = ["--resume", resume_id]
122
228
  if fork:
123
229
  flags.append("--fork-session")
124
- if access == "all" and claude.allow_skip_permissions:
230
+ if force_read:
231
+ # Only the MCP servers this invocation names, so a server configured
232
+ # in the project's or the user's settings cannot hand the fork a tool
233
+ # the read preset's deny list never names.
234
+ flags.append("--strict-mcp-config")
235
+ if access == "all" and claude.allow_skip_permissions and not force_read:
125
236
  # Full access: skip every permission prompt so the fork can use any
126
237
  # tool or MCP server the session has (web, browser, shell, edits). No
127
238
  # allow/deny lists — they would only restrict. The answer path refuses
@@ -130,20 +241,23 @@ def build_claude_flags(
130
241
  # would fall back to the restrictive read tools, never to "no limits".
131
242
  flags.append("--dangerously-skip-permissions")
132
243
  else:
133
- allowed, disallowed = resolve_tools(claude, access)
244
+ allowed, disallowed = resolve_tools(claude, access, force_read)
134
245
  if allowed:
135
246
  flags += ["--allowedTools", ",".join(allowed)]
136
247
  if disallowed:
137
248
  flags += ["--disallowedTools", ",".join(disallowed)]
138
- if claude.permission_mode:
139
- flags += ["--permission-mode", claude.permission_mode]
249
+ mode = "dontAsk" if force_read else claude.permission_mode
250
+ if mode:
251
+ # A pinned fork never takes the configured mode, which could be
252
+ # bypassPermissions.
253
+ flags += ["--permission-mode", mode]
140
254
  if claude.model:
141
255
  flags += ["--model", claude.model]
142
256
  for directory in add_dirs:
143
257
  flags += ["--add-dir", directory]
144
258
  # Fill the persona's {platform} placeholder from config (a custom persona
145
259
  # without it is unaffected by str.replace).
146
- system = claude.persona.replace("{platform}", cfg.platform)
260
+ system = claude.persona.replace("{platform}", platform or cfg.platform)
147
261
  if extra_system:
148
262
  system = f"{system}\n\n{extra_system}" if system else extra_system
149
263
  if system:
@@ -154,11 +268,15 @@ def build_claude_flags(
154
268
  def _window_name(handle: str, thread_key: str) -> str:
155
269
  """Readable, unique tmux window name: ``<handle>-<short>``.
156
270
 
157
- The handle makes it recognizable when attached; the thread-key suffix
158
- keeps it unique when the same handle opens more than one thread.
271
+ The handle makes it recognizable when attached; the suffix is a digest
272
+ of the whole thread key, so two threads of one handle never share a
273
+ window. Taking the key's last characters instead would collide, and
274
+ HTTP callers choose their own thread ids ("alice-1234", "bob-1234"),
275
+ which would put two conversations in one fork. The digest is wide
276
+ enough that a caller cannot search for a colliding id.
159
277
  """
160
278
  base = (re.sub(r"[^A-Za-z0-9-]", "", handle) or "s")[:24]
161
- suffix = re.sub(r"[^A-Za-z0-9]", "", thread_key)[-4:] or "0"
279
+ suffix = hashlib.sha256(thread_key.encode("utf-8")).hexdigest()[:16]
162
280
  return f"{base}-{suffix}"
163
281
 
164
282
 
@@ -219,6 +337,8 @@ class _BaseBackend:
219
337
  # already-running thread (same fork, context kept). Persisting it means
220
338
  # the change fires `_on_access_changed` once, not on every later turn.
221
339
  cur = self._current_access(rec)
340
+ if is_http_thread(thread_key):
341
+ cur = "read" # program callers never inherit write/bash/all
222
342
  if cur != rec.access:
223
343
  old = rec.access
224
344
  rec.access = cur
@@ -341,13 +461,43 @@ class HeadlessBackend(_BaseBackend):
341
461
 
342
462
  name = "headless"
343
463
 
464
+ def _expert_size(self, rec: ThreadRecord) -> int:
465
+ """Byte size of the expert's transcript, or -1 if it can't be read."""
466
+ path = (
467
+ transcript_dir(Path(rec.project_dir), self._home(rec))
468
+ / f"{rec.expert_session_id}.jsonl"
469
+ )
470
+ try:
471
+ return path.stat().st_size
472
+ except OSError:
473
+ return -1
474
+
344
475
  def ask(self, thread_key: str, question: str) -> Answer:
345
- """Run one headless turn in the thread's fork (creating it on the
346
- first turn) and return the result text."""
476
+ """Run one headless turn in the thread's fork and return the answer.
477
+
478
+ The first turn forks the expert. A follow-up resumes the thread's
479
+ fork, unless ``refresh_forks`` is on and the expert transcript has
480
+ grown since that fork was taken: then it forks the expert afresh and
481
+ prepends a recap of the thread's earlier Q&A.
482
+ """
347
483
  rec = self._require_binding(thread_key)
348
- fork = not rec.fork_session_id
484
+ size = self._expert_size(rec)
485
+ refresh = bool(
486
+ rec.fork_session_id
487
+ and self.cfg.refresh_forks
488
+ and rec.fork_base_size > 0
489
+ and size > rec.fork_base_size
490
+ )
491
+ fork = not rec.fork_session_id or refresh
349
492
  resume_id = rec.expert_session_id if fork else rec.fork_session_id
493
+ prompt = question
494
+ if refresh:
495
+ prompt = (
496
+ build_recap(rec.history, self.cfg.limits.recap_max_chars)
497
+ + question
498
+ )
350
499
  add_dirs, extra_system, outbox, snapshot = self._begin_turn(rec)
500
+ pinned = is_http_thread(rec.thread_key)
351
501
 
352
502
  argv = [
353
503
  self.cfg.claude.binary,
@@ -355,14 +505,25 @@ class HeadlessBackend(_BaseBackend):
355
505
  "--output-format",
356
506
  "json",
357
507
  *build_claude_flags(
358
- self.cfg, resume_id, fork, rec.access, add_dirs, extra_system
508
+ self.cfg,
509
+ resume_id,
510
+ fork,
511
+ rec.access,
512
+ add_dirs,
513
+ extra_system,
514
+ platform=rec.platform,
515
+ force_read=pinned,
516
+ ),
517
+ *(
518
+ strip_permission_args(self.cfg.claude.headless_extra_args)
519
+ if pinned
520
+ else self.cfg.claude.headless_extra_args
359
521
  ),
360
- *self.cfg.claude.headless_extra_args,
361
522
  ]
362
523
  try:
363
524
  result = subprocess.run(
364
525
  argv,
365
- input=question,
526
+ input=prompt,
366
527
  capture_output=True,
367
528
  text=True,
368
529
  cwd=rec.project_dir,
@@ -390,15 +551,26 @@ class HeadlessBackend(_BaseBackend):
390
551
 
391
552
  fork_id = data.get("session_id") or resume_id
392
553
  rec.fork_session_id = fork_id
393
- self.store.upsert(rec)
554
+ # The size measured before this turn is the fork's base: the fork
555
+ # copied at least that much. A fork of an unknown base (a thread
556
+ # from before this was tracked) gets its baseline now.
557
+ if fork or rec.fork_base_size <= 0:
558
+ rec.fork_base_size = max(size, 0)
394
559
  text = str(data.get("result", "")).strip()
560
+ if text:
561
+ rec.history = trim_history(
562
+ [*rec.history, {"q": question, "a": text}],
563
+ self.cfg.limits.recap_max_chars,
564
+ )
565
+ self.store.upsert(rec)
395
566
  if not text:
396
567
  raise BackendError("Empty answer from claude")
397
568
  return Answer(
398
569
  text=text,
399
570
  fork_session_id=fork_id,
400
- new_thread=fork,
571
+ new_thread=fork and not refresh,
401
572
  attachments=self._end_turn(outbox, snapshot),
573
+ refreshed=refresh,
402
574
  )
403
575
 
404
576
 
@@ -407,11 +579,58 @@ class TmuxBackend(_BaseBackend):
407
579
 
408
580
  name = "tmux"
409
581
 
582
+ # Windows this process launched under the HTTP read-only pin, shared by
583
+ # every instance because the relay builds a fresh backend for each turn.
584
+ # A warm window keeps the flags it launched with, and one left by an
585
+ # older build can hold wider tools while the thread's stored access
586
+ # already reads "read", so no access change fires to kill it. An HTTP
587
+ # window is reused only when it is in here.
588
+ _pinned_windows: set[str] = set()
589
+
410
590
  def __init__(self, cfg: TunnelConfig, store: TunnelStore) -> None:
411
591
  """Bind to the dedicated tmux session named in the config."""
412
592
  super().__init__(cfg, store)
413
593
  self.tmux = TmuxSession(cfg.tmux_session)
414
594
 
595
+ def _kill_stale_window(self, rec: ThreadRecord, window: str) -> bool:
596
+ """Kill a window this thread launched under an older naming scheme.
597
+
598
+ The turn overwrites ``rec.tmux_window``, so nothing would address
599
+ the old window afterwards and its fork would keep running.
600
+
601
+ Returns:
602
+ Whether a window was killed.
603
+ """
604
+ if not rec.tmux_window or rec.tmux_window == window:
605
+ return False
606
+ self.tmux.kill_window(rec.tmux_window)
607
+ return True
608
+
609
+ def _drop_unpinned_http_window(self, rec: ThreadRecord, window: str) -> bool:
610
+ """Kill a warm HTTP window this process did not launch read-only.
611
+
612
+ A warm window keeps the flags it launched with, and a window left by
613
+ an older build can hold wider tools while the thread's stored access
614
+ already reads ``read``, so no access change fires to kill it. Dropping
615
+ it sends the turn down the cold-relaunch path, which applies the pin.
616
+
617
+ Returns:
618
+ Whether a window was killed.
619
+
620
+ Raises:
621
+ BackendError: The window survived the kill, so reusing it would
622
+ answer with whatever tools it launched with.
623
+ """
624
+ if not is_http_thread(rec.thread_key) or window in self._pinned_windows:
625
+ return False
626
+ self.tmux.kill_window(window)
627
+ if self.tmux.window_alive(window):
628
+ raise BackendError(
629
+ f"Window {window} holds a fork this process did not launch "
630
+ "read-only, and it survived the kill. Close it by hand."
631
+ )
632
+ return True
633
+
415
634
  def _on_access_changed(self, rec: ThreadRecord, old: str, new: str) -> None:
416
635
  """Kill the warm window so the next turn cold-relaunches the fork.
417
636
 
@@ -421,7 +640,9 @@ class TmuxBackend(_BaseBackend):
421
640
  forces the next turn down the relaunch path, so the fork comes back
422
641
  with the new tools, same fork id, full context retained.
423
642
  """
424
- self.tmux.kill_window(_window_name(rec.handle, rec.thread_key))
643
+ self.tmux.kill_window(
644
+ rec.tmux_window or _window_name(rec.handle, rec.thread_key)
645
+ )
425
646
 
426
647
  def _launch(
427
648
  self,
@@ -434,6 +655,8 @@ class TmuxBackend(_BaseBackend):
434
655
  initial_prompt: Optional[str] = None,
435
656
  add_dirs: tuple[str, ...] = (),
436
657
  extra_system: str = "",
658
+ platform: str = "",
659
+ force_read: bool = False,
437
660
  ) -> None:
438
661
  """Launch an interactive fork in `window`.
439
662
 
@@ -455,10 +678,25 @@ class TmuxBackend(_BaseBackend):
455
678
  argv = [
456
679
  self.cfg.claude.binary,
457
680
  *build_claude_flags(
458
- self.cfg, resume_id, fork, access, add_dirs, extra_system
681
+ self.cfg,
682
+ resume_id,
683
+ fork,
684
+ access,
685
+ add_dirs,
686
+ extra_system,
687
+ platform=platform,
688
+ force_read=force_read,
689
+ ),
690
+ *(
691
+ strip_permission_args(self.cfg.claude.tmux_extra_args)
692
+ if force_read
693
+ else self.cfg.claude.tmux_extra_args
459
694
  ),
460
- *self.cfg.claude.tmux_extra_args,
461
695
  ]
696
+ if force_read:
697
+ self._pinned_windows.add(window)
698
+ else:
699
+ self._pinned_windows.discard(window)
462
700
  if initial_prompt is not None:
463
701
  # `--` ends option parsing so a prompt beginning with "-" (e.g.
464
702
  # "- how are you?") is taken as the positional prompt, not a CLI
@@ -538,6 +776,7 @@ class TmuxBackend(_BaseBackend):
538
776
  """
539
777
  rec = self._require_binding(thread_key)
540
778
  window = _window_name(rec.handle, thread_key)
779
+ self._kill_stale_window(rec, window)
541
780
  # Resolve to the canonical path so the three things keyed on it agree
542
781
  # with the directory Claude actually runs in (its process.cwd() is
543
782
  # canonical): the launch cwd, the trust-config entry, and the
@@ -568,9 +807,11 @@ class TmuxBackend(_BaseBackend):
568
807
  fork=True,
569
808
  config_dir=rec.config_dir,
570
809
  access=rec.access,
810
+ force_read=is_http_thread(rec.thread_key),
571
811
  initial_prompt=prompt,
572
812
  add_dirs=add_dirs,
573
813
  extra_system=extra_system,
814
+ platform=rec.platform,
574
815
  )
575
816
  fork_file = wait_for_new_session_file(
576
817
  project_dir,
@@ -589,6 +830,7 @@ class TmuxBackend(_BaseBackend):
589
830
  raise BackendError(
590
831
  f"Fork transcript {rec.fork_session_id} not found"
591
832
  )
833
+ self._drop_unpinned_http_window(rec, window)
592
834
  if self.tmux.window_alive(window) and not self.tmux.pane_dead(
593
835
  window
594
836
  ):
@@ -607,9 +849,11 @@ class TmuxBackend(_BaseBackend):
607
849
  fork=False,
608
850
  config_dir=rec.config_dir,
609
851
  access=rec.access,
852
+ force_read=is_http_thread(rec.thread_key),
610
853
  initial_prompt=prompt,
611
854
  add_dirs=add_dirs,
612
855
  extra_system=extra_system,
856
+ platform=rec.platform,
613
857
  )
614
858
 
615
859
  deadline = time.time() + self.cfg.limits.answer_timeout_s
@@ -82,7 +82,7 @@ def serve(
82
82
  channels: tuple[int, ...],
83
83
  token_env: Optional[str],
84
84
  ) -> None:
85
- """Run the Discord daemon (blocking).
85
+ """Run the chat daemon: Discord and/or Mattermost (blocking).
86
86
 
87
87
  Two server modes, set with --backend or [tunnel] backend (default
88
88
  headless):
@@ -102,17 +102,23 @@ def serve(
102
102
  store = TunnelStore(cfg.state_path)
103
103
  registry = Registry(cfg.registry_path)
104
104
  try:
105
- from .discord_bot import run_bot
105
+ from .serve import plan_frontends, run_serve
106
106
  except ImportError as exc:
107
107
  raise click.ClickException(
108
108
  f"discord.py is required for serve: {exc}"
109
109
  ) from exc
110
+ try:
111
+ frontends = plan_frontends(cfg)
112
+ except RuntimeError as exc:
113
+ raise click.ClickException(str(exc)) from exc
110
114
  click.echo(
111
115
  f"agent-tunnel: backend={cfg.backend} "
112
- f"registry={cfg.registry_path} channels={cfg.discord.channel_ids}"
116
+ f"registry={cfg.registry_path} frontends={','.join(frontends)} "
117
+ f"discord_channels={cfg.discord.channel_ids} "
118
+ f"mattermost_channels={cfg.mattermost.channel_ids}"
113
119
  )
114
120
  try:
115
- run_bot(cfg, store, registry)
121
+ run_serve(cfg, store, registry)
116
122
  except RuntimeError as exc:
117
123
  raise click.ClickException(str(exc)) from exc
118
124
 
@@ -192,11 +198,11 @@ def ask(
192
198
  except BackendError as exc:
193
199
  raise click.ClickException(str(exc)) from exc
194
200
  click.echo(answer.text)
195
- click.echo(
196
- f"\n[fork={answer.fork_session_id} "
197
- f"{'new' if answer.new_thread else 'follow-up'}]",
198
- err=True,
199
- )
201
+ if answer.refreshed:
202
+ kind = "follow-up, re-forked from latest session"
203
+ else:
204
+ kind = "new" if answer.new_thread else "follow-up"
205
+ click.echo(f"\n[fork={answer.fork_session_id} {kind}]", err=True)
200
206
 
201
207
 
202
208
  def _resolve_target(
@@ -647,23 +653,65 @@ def doctor(config: Optional[str]) -> None:
647
653
  import shutil
648
654
 
649
655
  from .convert import detect_converter
650
- from .discord_bot import resolve_token
656
+ from .discord_bot import discord_ready, resolve_token
657
+ from .mattermost_bot import mattermost_ready, resolve_mm_token
651
658
 
652
659
  cfg = _build(config)
653
- checks: list[tuple[bool, str]] = [
654
- (
655
- bool(resolve_token(cfg)),
656
- f"Discord token ({cfg.discord.token_env} or token_file)",
657
- ),
658
- (
659
- bool(cfg.discord.channel_ids),
660
- f"Watched channel(s): {cfg.discord.channel_ids or 'none set'}",
661
- ),
660
+ checks: list[tuple[bool, str]] = []
661
+ # Checks shown but not failing: an incomplete Mattermost while Discord
662
+ # runs (serve skips it with a warning; doctor mirrors that).
663
+ warn_only: list[tuple[bool, str]] = []
664
+ from .http_frontend import http_ready
665
+
666
+ discord_token = bool(resolve_token(cfg))
667
+ mm = cfg.mattermost
668
+ http_runnable = bool(cfg.http.port) and http_ready(cfg) is None
669
+ # Discord is optional once another front-end is configured: check it only
670
+ # when serve would run it, or when it is the only front-end.
671
+ if discord_ready(cfg) is None or not (mm.url or cfg.http.port):
672
+ checks += [
673
+ (
674
+ discord_token,
675
+ f"Discord token ({cfg.discord.token_env} or token_file)",
676
+ ),
677
+ (
678
+ bool(cfg.discord.channel_ids or cfg.discord.respond_to_dms),
679
+ f"Watched channel(s): {cfg.discord.channel_ids or 'none set'}"
680
+ + (" (+ DMs)" if cfg.discord.respond_to_dms else ""),
681
+ ),
682
+ ]
683
+ if mm.url:
684
+ mm_warn_only = mattermost_ready(cfg) is not None and (
685
+ discord_ready(cfg) is None or http_runnable
686
+ )
687
+ (warn_only if mm_warn_only else checks).extend([
688
+ (
689
+ bool(resolve_mm_token(cfg)),
690
+ f"Mattermost token ({mm.token_env} or token_file)",
691
+ ),
692
+ (
693
+ bool(mm.channel_ids),
694
+ "Mattermost channel(s): "
695
+ f"{mm.channel_ids or 'none set'} @ {mm.url}",
696
+ ),
697
+ ])
698
+ if cfg.http.port:
699
+ other_runs = discord_ready(cfg) is None or (
700
+ bool(mm.url) and mattermost_ready(cfg) is None
701
+ )
702
+ (warn_only if not http_runnable and other_runs else checks).append(
703
+ (
704
+ http_runnable,
705
+ f"HTTP front-end: {cfg.http.bind}:{cfg.http.port}, token in "
706
+ f"{cfg.http.token_file or 'unset'}",
707
+ )
708
+ )
709
+ checks.append(
662
710
  (
663
711
  shutil.which(cfg.claude.binary) is not None,
664
712
  f"claude binary on PATH ({cfg.claude.binary})",
665
- ),
666
- ]
713
+ )
714
+ )
667
715
  if cfg.backend == "tmux":
668
716
  checks.append(
669
717
  (shutil.which("tmux") is not None, "tmux on PATH (tmux backend)")
@@ -672,6 +720,9 @@ def doctor(config: Optional[str]) -> None:
672
720
  for ok, label in checks:
673
721
  click.echo(f" {'✓' if ok else '✗'} {label}")
674
722
  ok_all = ok_all and ok
723
+ for ok, label in warn_only:
724
+ suffix = "" if ok else " (serve skips this front-end until fixed)"
725
+ click.echo(f" {'✓' if ok else '!'} {label}{suffix}")
675
726
  n = len(Registry(cfg.registry_path).active())
676
727
  click.echo(f" • {n} published session(s) live")
677
728
  if cfg.attachments.convert == "off":