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.
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/PKG-INFO +1 -1
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/__init__.py +1 -1
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/agent_tunnel/config.py +5 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/agent_tunnel/http_frontend.py +69 -2
- claude_code_tools-1.29.1/claude_code_tools/agent_tunnel/turn_log.py +164 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/tmux_cli_controller.py +2 -5
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/tmux_remote_controller.py +166 -49
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/docs/agent-tunnel-spec.md +3 -1
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/docs/tmux-cli-instructions.md +29 -14
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/pyproject.toml +2 -2
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/.gitignore +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/LICENSE +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/README.md +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/action_rpc.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/agent_tunnel/__init__.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/agent_tunnel/backends.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/agent_tunnel/cli.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/agent_tunnel/convert.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/agent_tunnel/discord_bot.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/agent_tunnel/locking.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/agent_tunnel/mattermost_bot.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/agent_tunnel/paths.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/agent_tunnel/readonly.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/agent_tunnel/registry.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/agent_tunnel/relay.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/agent_tunnel/serve.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/agent_tunnel/session.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/agent_tunnel/store.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/agent_tunnel/tmux.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/agent_tunnel/trust.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/aichat.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/amux/__init__.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/amux/__main__.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/amux/activity.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/amux/cache.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/amux/cli.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/amux/detect.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/amux/filters.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/amux/model.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/amux/render.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/amux/scan.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/claude_continue.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/cli_passthrough.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/codex_app_server_rpc.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/codex_continue.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/codex_server.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/codex_server_callback.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/codex_server_cli.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/codex_server_fingerprint.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/codex_server_generation.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/codex_server_legacy.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/codex_server_models.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/codex_server_process.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/codex_server_reservation.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/codex_server_resume.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/codex_server_retry.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/codex_server_reuse.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/codex_server_state.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/codex_server_supervisor.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/codex_server_wait.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/codex_server_worker.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/config.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/csv2gsheet.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/delete_session.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/dotenv_vault.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/env_safe.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/export_all.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/export_claude_session.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/export_codex_session.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/export_session.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/find_claude_session.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/find_codex_session.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/find_original_session.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/find_session.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/find_trimmed_sessions.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/fix_session.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/gdoc2docx.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/gdoc2md.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/github_wake.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/github_watch_daemon.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/github_watch_store.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/gsheet2csv.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/helper_sessions.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/issue_reply_delivery.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/legacy_session_wrappers.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/md2gdoc.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/move_account.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/msg/__init__.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/msg/cli.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/msg/hooks.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/msg/migrations.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/msg/models.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/msg/prompt_detect.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/msg/store.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/msg/watcher.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/node_menu_ui.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/port_claude_noise.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/port_claude_to_codex.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/port_codex_flatten.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/port_codex_to_claude.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/port_render.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/port_service.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/resolve_session.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/resolve_session_names.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/resolve_session_render.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/search_index.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/session_cli_resolution.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/session_lineage.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/session_menu.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/session_menu_cli.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/session_resolution.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/session_selection.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/session_utils.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/smart_trim.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/smart_trim_core.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/tmux_execution_helpers.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/trim_in_place.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/trim_session.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/trim_session_claude.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/trim_session_codex.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/workflow_cli.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/workflow_cli_contract.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/workflow_cli_formatting.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/workflow_cli_identity_policy.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/workflow_cli_manifest.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/workflow_cli_projection.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/workflow_cli_rendering.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/workflow_cli_snapshots.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/workflow_cli_store_backends.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/workflow_processes.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/workflow_runs.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/workflow_store_io.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/workflow_validation.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/docs/claude-code-tmux-tutorials.md +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/docs/dot-zshrc.md +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/docs/find-claude-session.md +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/docs/intercom-spec.md +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/docs/lmsh.md +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/docs/local-llm-setup.md +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/docs/vault-documentation.md +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/hatch_build.py +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/node_ui/action_config.js +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/node_ui/menu.js +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/node_ui/package-lock.json +0 -0
- {claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/node_ui/package.json +0 -0
{claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/agent_tunnel/config.py
RENAMED
|
@@ -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(
|
|
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
|
{claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/tmux_cli_controller.py
RENAMED
|
@@ -914,11 +914,8 @@ class CLI:
|
|
|
914
914
|
print(str(e))
|
|
915
915
|
else:
|
|
916
916
|
# Remote mode - kill window
|
|
917
|
-
|
|
918
|
-
|
|
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):
|
{claude_code_tools-1.28.0 → claude_code_tools-1.29.1}/claude_code_tools/tmux_remote_controller.py
RENAMED
|
@@ -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
|
-
-
|
|
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
|
|
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., "
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
|
42
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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,
|
|
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.
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
args
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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 ==
|
|
392
|
+
if self.target_window == cached_target:
|
|
279
393
|
self.target_window = None
|
|
280
394
|
|
|
281
395
|
def attach_session(self):
|
|
282
|
-
self.
|
|
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
|