claude-code-tools 1.27.1__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.
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/PKG-INFO +1 -1
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/__init__.py +1 -1
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/agent_tunnel/backends.py +264 -20
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/agent_tunnel/cli.py +72 -21
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/agent_tunnel/config.py +91 -3
- claude_code_tools-1.29.0/claude_code_tools/agent_tunnel/discord_bot.py +336 -0
- claude_code_tools-1.29.0/claude_code_tools/agent_tunnel/http_frontend.py +334 -0
- claude_code_tools-1.29.0/claude_code_tools/agent_tunnel/mattermost_bot.py +603 -0
- claude_code_tools-1.29.0/claude_code_tools/agent_tunnel/readonly.py +78 -0
- claude_code_tools-1.29.0/claude_code_tools/agent_tunnel/relay.py +572 -0
- claude_code_tools-1.29.0/claude_code_tools/agent_tunnel/serve.py +88 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/agent_tunnel/store.py +24 -6
- claude_code_tools-1.29.0/claude_code_tools/agent_tunnel/turn_log.py +164 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/docs/agent-tunnel-spec.md +109 -42
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/pyproject.toml +2 -2
- claude_code_tools-1.27.1/claude_code_tools/agent_tunnel/discord_bot.py +0 -717
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/.gitignore +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/LICENSE +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/README.md +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/action_rpc.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/agent_tunnel/__init__.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/agent_tunnel/convert.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/agent_tunnel/locking.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/agent_tunnel/paths.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/agent_tunnel/registry.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/agent_tunnel/session.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/agent_tunnel/tmux.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/agent_tunnel/trust.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/aichat.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/amux/__init__.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/amux/__main__.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/amux/activity.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/amux/cache.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/amux/cli.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/amux/detect.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/amux/filters.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/amux/model.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/amux/render.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/amux/scan.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/claude_continue.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/cli_passthrough.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/codex_app_server_rpc.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/codex_continue.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/codex_server.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/codex_server_callback.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/codex_server_cli.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/codex_server_fingerprint.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/codex_server_generation.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/codex_server_legacy.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/codex_server_models.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/codex_server_process.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/codex_server_reservation.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/codex_server_resume.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/codex_server_retry.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/codex_server_reuse.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/codex_server_state.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/codex_server_supervisor.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/codex_server_wait.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/codex_server_worker.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/config.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/csv2gsheet.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/delete_session.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/dotenv_vault.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/env_safe.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/export_all.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/export_claude_session.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/export_codex_session.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/export_session.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/find_claude_session.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/find_codex_session.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/find_original_session.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/find_session.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/find_trimmed_sessions.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/fix_session.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/gdoc2docx.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/gdoc2md.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/github_wake.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/github_watch_daemon.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/github_watch_store.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/gsheet2csv.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/helper_sessions.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/issue_reply_delivery.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/legacy_session_wrappers.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/md2gdoc.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/move_account.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/msg/__init__.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/msg/cli.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/msg/hooks.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/msg/migrations.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/msg/models.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/msg/prompt_detect.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/msg/store.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/msg/watcher.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/node_menu_ui.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/port_claude_noise.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/port_claude_to_codex.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/port_codex_flatten.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/port_codex_to_claude.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/port_render.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/port_service.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/resolve_session.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/resolve_session_names.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/resolve_session_render.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/search_index.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/session_cli_resolution.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/session_lineage.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/session_menu.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/session_menu_cli.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/session_resolution.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/session_selection.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/session_utils.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/smart_trim.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/smart_trim_core.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/tmux_cli_controller.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/tmux_execution_helpers.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/tmux_remote_controller.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/trim_in_place.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/trim_session.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/trim_session_claude.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/trim_session_codex.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/workflow_cli.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/workflow_cli_contract.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/workflow_cli_formatting.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/workflow_cli_identity_policy.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/workflow_cli_manifest.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/workflow_cli_projection.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/workflow_cli_rendering.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/workflow_cli_snapshots.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/workflow_cli_store_backends.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/workflow_processes.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/workflow_runs.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/workflow_store_io.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/workflow_validation.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/docs/claude-code-tmux-tutorials.md +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/docs/dot-zshrc.md +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/docs/find-claude-session.md +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/docs/intercom-spec.md +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/docs/lmsh.md +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/docs/local-llm-setup.md +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/docs/tmux-cli-instructions.md +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/docs/vault-documentation.md +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/hatch_build.py +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/node_ui/action_config.js +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/node_ui/menu.js +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/node_ui/package-lock.json +0 -0
- {claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/node_ui/package.json +0 -0
{claude_code_tools-1.27.1 → claude_code_tools-1.29.0}/claude_code_tools/agent_tunnel/backends.py
RENAMED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
158
|
-
|
|
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 =
|
|
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
|
|
346
|
-
|
|
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
|
-
|
|
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,
|
|
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=
|
|
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
|
-
|
|
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(
|
|
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,
|
|
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
|
|
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 .
|
|
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}
|
|
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
|
-
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
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":
|