tmux-agent-control 0.1.0__py3-none-any.whl
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.
- daemon/__init__.py +0 -0
- daemon/adapter/__init__.py +0 -0
- daemon/adapter/tmux.py +96 -0
- daemon/agent_aware/__init__.py +0 -0
- daemon/agent_aware/auto_approve.py +51 -0
- daemon/agent_aware/classify.py +73 -0
- daemon/agent_aware/push_watcher.py +139 -0
- daemon/api/__init__.py +0 -0
- daemon/api/auth.py +29 -0
- daemon/api/main.py +73 -0
- daemon/api/pairing.py +34 -0
- daemon/api/panes.py +17 -0
- daemon/api/push.py +104 -0
- daemon/api/read.py +240 -0
- daemon/api/vapid.py +72 -0
- daemon/api/write.py +252 -0
- daemon/registry/__init__.py +0 -0
- daemon/registry/push_store.py +61 -0
- daemon/registry/reaper.py +25 -0
- daemon/registry/store.py +52 -0
- launcher/__init__.py +0 -0
- launcher/launch.py +329 -0
- tmux_agent_control-0.1.0.dist-info/METADATA +19 -0
- tmux_agent_control-0.1.0.dist-info/RECORD +27 -0
- tmux_agent_control-0.1.0.dist-info/WHEEL +4 -0
- tmux_agent_control-0.1.0.dist-info/entry_points.txt +5 -0
- tmux_agent_control-0.1.0.dist-info/licenses/LICENSE +21 -0
daemon/__init__.py
ADDED
|
File without changes
|
|
File without changes
|
daemon/adapter/tmux.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Thin wrapper over libtmux — the only module allowed to touch tmux directly."""
|
|
2
|
+
import libtmux
|
|
3
|
+
|
|
4
|
+
# Pinned to a fixed socket name rather than the ambient $TMUX var. Without
|
|
5
|
+
# this, a daemon (re)started from inside a tmux pane inherits that pane's
|
|
6
|
+
# server socket and can only see sessions on it — sessions launched from a
|
|
7
|
+
# different pane, a different socket, or no tmux at all silently vanish
|
|
8
|
+
# from find_pane(), and the reaper deletes their registry entries as if
|
|
9
|
+
# the tmux session itself were gone. launcher/launch.py uses the same
|
|
10
|
+
# socket name when creating sessions, so both sides always agree.
|
|
11
|
+
TMUX_SOCKET_NAME = "tmux-agent-control"
|
|
12
|
+
|
|
13
|
+
_server = libtmux.Server(socket_name=TMUX_SOCKET_NAME)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def list_sessions():
|
|
17
|
+
return _server.sessions
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def find_session(session_name):
|
|
21
|
+
return _server.sessions.get(session_name=session_name, default=None)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def find_pane(session_name, window_index=0, pane_index=0):
|
|
25
|
+
session = find_session(session_name)
|
|
26
|
+
if session is None:
|
|
27
|
+
return None
|
|
28
|
+
window = session.windows[window_index]
|
|
29
|
+
return window.panes[pane_index]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def create_session(session_name, cwd, command):
|
|
33
|
+
"""command is a single binary name (e.g. "claude"), never a shell
|
|
34
|
+
string with arguments — callers must not build up an argv/command line
|
|
35
|
+
here, that's exactly the "arbitrary shell execution over HTTP" this
|
|
36
|
+
project's AGENTS.md forbids."""
|
|
37
|
+
_server.new_session(session_name=session_name, start_directory=cwd, window_command=command)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def kill_session(session_name):
|
|
41
|
+
session = find_session(session_name)
|
|
42
|
+
if session is not None:
|
|
43
|
+
session.kill()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def capture_screen(pane):
|
|
47
|
+
# join_wrapped (tmux capture-pane -J) merges lines tmux only split
|
|
48
|
+
# because they hard-wrapped at the terminal's current width, back into
|
|
49
|
+
# one real line — without it, a real terminal (e.g. Warp) attached at
|
|
50
|
+
# whatever width it happens to be open to bakes those wrap points into
|
|
51
|
+
# the captured text as literal newlines, which a browser client then
|
|
52
|
+
# wraps *again* to fit its own narrower container, turning normal
|
|
53
|
+
# paragraphs into short, choppy multi-line fragments. This is the
|
|
54
|
+
# actual fix for that double-wrap; it doesn't require pinning the tmux
|
|
55
|
+
# window to any particular size, so a real terminal attaching to the
|
|
56
|
+
# session keeps resizing/wrapping normally on its own.
|
|
57
|
+
return "\n".join(pane.capture_pane(join_wrapped=True))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
DEFAULT_HISTORY_LINES = 100
|
|
61
|
+
MAX_HISTORY_LINES = 2000
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def capture_screen_ansi(pane, lines=DEFAULT_HISTORY_LINES):
|
|
65
|
+
"""Same as capture_screen but preserves ANSI escape codes (colors,
|
|
66
|
+
styles) and includes scrollback history, for clients that render a
|
|
67
|
+
real terminal (e.g. xterm.js) with scrollable history. classify.py
|
|
68
|
+
must keep using capture_screen (current screen only, no history,
|
|
69
|
+
plain text) — escape codes and extra history would break its
|
|
70
|
+
pattern matching and slow down the auto-approve poll loop.
|
|
71
|
+
|
|
72
|
+
`lines` defaults to a small window (just enough to cover a screen's
|
|
73
|
+
worth) since this gets re-fetched on every ~2s poll tick — the full
|
|
74
|
+
MAX_HISTORY_LINES is only worth the bandwidth when a client explicitly
|
|
75
|
+
asks for it (e.g. the user scrolled toward the top and needs more
|
|
76
|
+
history than the default window covers). join_wrapped=True for the
|
|
77
|
+
same reason as capture_screen — see that docstring."""
|
|
78
|
+
lines = max(1, min(lines, MAX_HISTORY_LINES))
|
|
79
|
+
return "\n".join(pane.capture_pane(escape_sequences=True, start=-lines, join_wrapped=True))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def send_keys(pane, keys, enter=True):
|
|
83
|
+
pane.send_keys(keys, enter=enter)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def send_key_name(pane, key):
|
|
87
|
+
"""Sends a single tmux key name (e.g. "Escape", "C-o", "Up") rather than
|
|
88
|
+
literal text — libtmux's literal=False passes it straight to `tmux
|
|
89
|
+
send-keys` unquoted, which is what makes tmux interpret it as a key
|
|
90
|
+
rather than typing the characters "C-o" out. No enter: a named key is
|
|
91
|
+
already the whole input, unlike send_keys's text-then-Enter shape."""
|
|
92
|
+
pane.send_keys(key, enter=False, literal=False)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def resize_pane(pane, width, height):
|
|
96
|
+
pane.resize(width=width, height=height)
|
|
File without changes
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Background auto-approve loop. Opt-in (see AGENTS.md) — disabled unless
|
|
2
|
+
explicitly enabled, and only ever acts on a freshly re-classified state via
|
|
3
|
+
try_approve(), never a cached/assumed one."""
|
|
4
|
+
import asyncio
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
from daemon.adapter import tmux
|
|
9
|
+
from daemon.agent_aware import classify
|
|
10
|
+
from daemon.api.write import try_approve
|
|
11
|
+
from daemon.registry import store
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger("tmux_agent_control.auto_approve")
|
|
14
|
+
|
|
15
|
+
# Subset of APPROVABLE_STATES that's safe to approve without a human in the
|
|
16
|
+
# loop: repetitive, low-stakes confirmations. waiting_for_question_answer
|
|
17
|
+
# and plan_review require actually thinking about the answer, never auto-approved.
|
|
18
|
+
AUTO_APPROVE_STATES = {
|
|
19
|
+
classify.WAITING_FOR_TOOL_PERMISSION,
|
|
20
|
+
classify.WAITING_FOR_WORKSPACE_TRUST,
|
|
21
|
+
classify.WAITING_FOR_MCP_APPROVAL,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
POLL_INTERVAL_SECONDS = float(os.environ.get("TAC_AUTO_APPROVE_INTERVAL", "0.5"))
|
|
25
|
+
|
|
26
|
+
# Runtime-toggleable via POST /auto-approve (daemon/api/write.py) — the loop
|
|
27
|
+
# below is always running; this flag just gates whether it acts. Seeded from
|
|
28
|
+
# TAC_AUTO_APPROVE at import time so existing env-var-based startup behavior
|
|
29
|
+
# is unchanged, but a client with the write token can flip it without a
|
|
30
|
+
# daemon restart.
|
|
31
|
+
enabled = os.environ.get("TAC_AUTO_APPROVE") == "1"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
async def run_loop():
|
|
35
|
+
logger.info("auto-approve loop started (states=%s)", AUTO_APPROVE_STATES)
|
|
36
|
+
while True:
|
|
37
|
+
if enabled:
|
|
38
|
+
for entry in store.list_entries():
|
|
39
|
+
try:
|
|
40
|
+
pane = tmux.find_pane(entry["tmux_session"])
|
|
41
|
+
if pane is None:
|
|
42
|
+
continue
|
|
43
|
+
state = classify.classify(tmux.capture_screen(pane))
|
|
44
|
+
if state in AUTO_APPROVE_STATES:
|
|
45
|
+
try_approve(pane)
|
|
46
|
+
logger.info("auto-approved session_id=%s state=%s", entry["session_id"], state)
|
|
47
|
+
except Exception:
|
|
48
|
+
logger.exception(
|
|
49
|
+
"auto-approve check failed for session_id=%s, skipping", entry["session_id"]
|
|
50
|
+
)
|
|
51
|
+
await asyncio.sleep(POLL_INTERVAL_SECONDS)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Classifies a captured pane screen into a coarse agent state.
|
|
2
|
+
|
|
3
|
+
Signatures are empirically verified for Claude Code (see docs/design.md).
|
|
4
|
+
Order matters: check the most specific dialog footers before falling back
|
|
5
|
+
to busy/idle, since dialog and busy/error text can co-occur on screen.
|
|
6
|
+
"""
|
|
7
|
+
import re
|
|
8
|
+
|
|
9
|
+
WAITING_FOR_TOOL_PERMISSION = "waiting_for_tool_permission"
|
|
10
|
+
WAITING_FOR_QUESTION_ANSWER = "waiting_for_question_answer"
|
|
11
|
+
WAITING_FOR_WORKSPACE_TRUST = "waiting_for_workspace_trust"
|
|
12
|
+
WAITING_FOR_MCP_APPROVAL = "waiting_for_mcp_approval"
|
|
13
|
+
PLAN_REVIEW = "plan_review"
|
|
14
|
+
ERROR = "error"
|
|
15
|
+
BUSY = "busy"
|
|
16
|
+
IDLE = "idle"
|
|
17
|
+
|
|
18
|
+
_TOOL_PERMISSION_RE = re.compile(r"Tab\s+to\s+amend.*ctrl\+e\s+to\s+explain", re.DOTALL)
|
|
19
|
+
_TOOL_PERMISSION_GENERIC_RE = re.compile(
|
|
20
|
+
r"Do\s+you\s+want\s+to.*?\n\s*❯?\s*1\.\s*Yes", re.DOTALL
|
|
21
|
+
)
|
|
22
|
+
_QUESTION_ANSWER_RE = re.compile(r"↑/↓\s+to\s+navigate")
|
|
23
|
+
# AskUserQuestion's multi-step tabbed questionnaire (a "☐ step ☐ step ✔
|
|
24
|
+
# Submit" tab bar above each step's own numbered options) — a distinct
|
|
25
|
+
# dialog shape from the single free-text question _QUESTION_ANSWER_RE
|
|
26
|
+
# matches. Without this, classify() fell through every other pattern and
|
|
27
|
+
# misreported this as idle even while it was actively waiting on an
|
|
28
|
+
# answer. Matched on the tab bar's own ☐/✔ icons rather than footer
|
|
29
|
+
# wording ("Tab/Arrow keys to navigate") — the icons are this dialog's own
|
|
30
|
+
# structural marker (every step renders one), where footer text is just
|
|
31
|
+
# a hint string that could plausibly get reworded across Claude Code
|
|
32
|
+
# versions independent of the dialog's actual shape.
|
|
33
|
+
_MULTI_STEP_QUESTION_RE = re.compile(r"☐.*✔\s*Submit", re.DOTALL)
|
|
34
|
+
_WORKSPACE_TRUST_RE = re.compile(r"Enter\s+to\s+confirm")
|
|
35
|
+
_MCP_APPROVAL_RE = re.compile(r"MCP\s+server")
|
|
36
|
+
_PLAN_REVIEW_RE = re.compile(r"How\s+would\s+you\s+like\s+to\s+proceed\?")
|
|
37
|
+
_ERROR_RE = re.compile(r"API\s+Error:")
|
|
38
|
+
_BUSY_RE = re.compile(r"esc\s+to\s+interrupt")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def classify(screen_text):
|
|
42
|
+
if _TOOL_PERMISSION_RE.search(screen_text):
|
|
43
|
+
return WAITING_FOR_TOOL_PERMISSION
|
|
44
|
+
if _TOOL_PERMISSION_GENERIC_RE.search(screen_text):
|
|
45
|
+
return WAITING_FOR_TOOL_PERMISSION
|
|
46
|
+
if _QUESTION_ANSWER_RE.search(screen_text):
|
|
47
|
+
return WAITING_FOR_QUESTION_ANSWER
|
|
48
|
+
if _MULTI_STEP_QUESTION_RE.search(screen_text):
|
|
49
|
+
return WAITING_FOR_QUESTION_ANSWER
|
|
50
|
+
if _MCP_APPROVAL_RE.search(screen_text) and _WORKSPACE_TRUST_RE.search(screen_text):
|
|
51
|
+
return WAITING_FOR_MCP_APPROVAL
|
|
52
|
+
if _WORKSPACE_TRUST_RE.search(screen_text):
|
|
53
|
+
return WAITING_FOR_WORKSPACE_TRUST
|
|
54
|
+
if _PLAN_REVIEW_RE.search(screen_text):
|
|
55
|
+
return PLAN_REVIEW
|
|
56
|
+
if _ERROR_RE.search(screen_text):
|
|
57
|
+
return ERROR
|
|
58
|
+
if _BUSY_RE.search(screen_text):
|
|
59
|
+
return BUSY
|
|
60
|
+
return IDLE
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# States where sending "1" + Enter is a meaningful approve action.
|
|
64
|
+
# Anything outside this set means there's no dialog to approve — approve()
|
|
65
|
+
# must refuse rather than blindly injecting keystrokes (see AGENTS.md:
|
|
66
|
+
# approve must be explicit, never inferred from generic input injection).
|
|
67
|
+
APPROVABLE_STATES = {
|
|
68
|
+
WAITING_FOR_TOOL_PERMISSION,
|
|
69
|
+
WAITING_FOR_QUESTION_ANSWER,
|
|
70
|
+
WAITING_FOR_WORKSPACE_TRUST,
|
|
71
|
+
WAITING_FOR_MCP_APPROVAL,
|
|
72
|
+
PLAN_REVIEW,
|
|
73
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Always-on background push notification watcher. Fires a Web Push for each
|
|
2
|
+
session that transitions into a pushable state requiring a human decision.
|
|
3
|
+
Unlike auto_approve.py this is unconditional (not opt-in) — it becomes a
|
|
4
|
+
no-op when there are zero stored subscriptions, at negligible cost.
|
|
5
|
+
|
|
6
|
+
State-entry debounce: each push fires exactly once per transition into a
|
|
7
|
+
pushable state. The in-memory map is cleared when a session leaves the
|
|
8
|
+
pushable set. Losing the map on daemon restart may produce one duplicate push
|
|
9
|
+
per session — acceptable per the approved design."""
|
|
10
|
+
import asyncio
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
|
|
16
|
+
from pywebpush import WebPushException, webpush
|
|
17
|
+
|
|
18
|
+
from daemon.adapter import tmux
|
|
19
|
+
from daemon.agent_aware import classify
|
|
20
|
+
from daemon.api import vapid
|
|
21
|
+
from daemon.registry import push_store, store
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger("tmux_agent_control.push")
|
|
24
|
+
|
|
25
|
+
# States that require a real human decision — subset of APPROVABLE_STATES that
|
|
26
|
+
# is not in auto_approve.AUTO_APPROVE_STATES. Mirror how auto_approve.py and
|
|
27
|
+
# classify.py define their own state-set constants as module-level names.
|
|
28
|
+
PUSHABLE_STATES = {
|
|
29
|
+
classify.WAITING_FOR_QUESTION_ANSWER,
|
|
30
|
+
classify.PLAN_REVIEW,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
POLL_INTERVAL_SECONDS = float(os.environ.get("TAC_PUSH_INTERVAL_SECONDS", "2.0"))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _matches_filter(session_id: str, state: str, filters: dict) -> bool:
|
|
37
|
+
"""Returns True if this subscription should receive a push for this
|
|
38
|
+
session/state combination."""
|
|
39
|
+
if session_id in filters.get("muted_session_ids", []):
|
|
40
|
+
return False
|
|
41
|
+
session_ids = filters.get("session_ids")
|
|
42
|
+
if session_ids is not None and session_id not in session_ids:
|
|
43
|
+
return False
|
|
44
|
+
if state == classify.WAITING_FOR_QUESTION_ANSWER and not filters.get("push_on_question_answer", True):
|
|
45
|
+
return False
|
|
46
|
+
if state == classify.PLAN_REVIEW and not filters.get("push_on_plan_review", True):
|
|
47
|
+
return False
|
|
48
|
+
return True
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _build_payload(entry: dict, state: str) -> str:
|
|
52
|
+
return json.dumps({
|
|
53
|
+
"session_id": entry["session_id"],
|
|
54
|
+
"project_name": entry.get("project_name", ""),
|
|
55
|
+
"tmux_session": entry.get("tmux_session", ""),
|
|
56
|
+
"state": state,
|
|
57
|
+
"runtime": entry.get("runtime", ""),
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
async def _send_push(sub_id: str, record: dict, payload: str, subs_path) -> None:
|
|
62
|
+
"""Wraps pywebpush.webpush() (synchronous/blocking) in asyncio.to_thread."""
|
|
63
|
+
subscription_info = {
|
|
64
|
+
"endpoint": record["endpoint"],
|
|
65
|
+
"keys": record["keys"],
|
|
66
|
+
}
|
|
67
|
+
vapid_kwargs = vapid.get_vapid_claims()
|
|
68
|
+
try:
|
|
69
|
+
await asyncio.to_thread(
|
|
70
|
+
webpush,
|
|
71
|
+
subscription_info=subscription_info,
|
|
72
|
+
data=payload,
|
|
73
|
+
**vapid_kwargs,
|
|
74
|
+
)
|
|
75
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
76
|
+
push_store.update_last_pushed(sub_id, now, path=subs_path)
|
|
77
|
+
logger.info("push sent sub_id=%s", sub_id)
|
|
78
|
+
except WebPushException as exc:
|
|
79
|
+
if exc.response is not None and exc.response.status_code in (404, 410):
|
|
80
|
+
push_store.remove_subscription(sub_id, path=subs_path)
|
|
81
|
+
logger.info("removed expired subscription sub_id=%s status=%s", sub_id, exc.response.status_code)
|
|
82
|
+
else:
|
|
83
|
+
logger.warning("push failed sub_id=%s error=%s", sub_id, exc)
|
|
84
|
+
except Exception as exc:
|
|
85
|
+
logger.warning("push error sub_id=%s error=%s", sub_id, exc)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
async def run_loop(registry_path=None, subs_path=None):
|
|
89
|
+
logger.info("push watcher loop started (states=%s)", PUSHABLE_STATES)
|
|
90
|
+
# In-memory debounce: {session_id: last_pushed_state}
|
|
91
|
+
last_pushed: dict[str, str] = {}
|
|
92
|
+
|
|
93
|
+
while True:
|
|
94
|
+
entries = store.list_entries(path=registry_path)
|
|
95
|
+
live_session_ids = set()
|
|
96
|
+
|
|
97
|
+
for entry in entries:
|
|
98
|
+
session_id = entry["session_id"]
|
|
99
|
+
live_session_ids.add(session_id)
|
|
100
|
+
try:
|
|
101
|
+
pane = tmux.find_pane(entry["tmux_session"])
|
|
102
|
+
if pane is None:
|
|
103
|
+
continue
|
|
104
|
+
state = classify.classify(tmux.capture_screen(pane))
|
|
105
|
+
|
|
106
|
+
if state not in PUSHABLE_STATES:
|
|
107
|
+
last_pushed.pop(session_id, None)
|
|
108
|
+
continue
|
|
109
|
+
|
|
110
|
+
if last_pushed.get(session_id) == state:
|
|
111
|
+
continue
|
|
112
|
+
|
|
113
|
+
last_pushed[session_id] = state
|
|
114
|
+
payload = _build_payload(entry, state)
|
|
115
|
+
|
|
116
|
+
subscriptions = push_store.list_subscriptions(path=subs_path)
|
|
117
|
+
if not subscriptions:
|
|
118
|
+
continue
|
|
119
|
+
|
|
120
|
+
push_tasks = []
|
|
121
|
+
for sub_id, record in subscriptions.items():
|
|
122
|
+
filters = record.get("filters", {})
|
|
123
|
+
if _matches_filter(session_id, state, filters):
|
|
124
|
+
push_tasks.append(_send_push(sub_id, record, payload, subs_path))
|
|
125
|
+
|
|
126
|
+
if push_tasks:
|
|
127
|
+
await asyncio.gather(*push_tasks, return_exceptions=True)
|
|
128
|
+
|
|
129
|
+
except Exception:
|
|
130
|
+
logger.exception(
|
|
131
|
+
"push watcher check failed for session_id=%s, skipping", session_id
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
# Clean up debounce entries for sessions no longer in the registry
|
|
135
|
+
for gone_id in list(last_pushed.keys()):
|
|
136
|
+
if gone_id not in live_session_ids:
|
|
137
|
+
last_pushed.pop(gone_id, None)
|
|
138
|
+
|
|
139
|
+
await asyncio.sleep(POLL_INTERVAL_SECONDS)
|
daemon/api/__init__.py
ADDED
|
File without changes
|
daemon/api/auth.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Bearer-token auth for write routes. Read routes stay open (see
|
|
2
|
+
AGENTS.md — phone/glasses clients poll status freely; only mutation is
|
|
3
|
+
gated). Token is generated once and persisted next to the registry so it
|
|
4
|
+
survives daemon restarts without any config step."""
|
|
5
|
+
import secrets
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from fastapi import Header, HTTPException
|
|
9
|
+
|
|
10
|
+
TOKEN_PATH = Path.home() / ".tmux-agent-control" / "token"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _load_or_create_token(path=None):
|
|
14
|
+
path = path or TOKEN_PATH
|
|
15
|
+
if path.exists():
|
|
16
|
+
return path.read_text().strip()
|
|
17
|
+
|
|
18
|
+
token = secrets.token_urlsafe(32)
|
|
19
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
20
|
+
path.write_text(token)
|
|
21
|
+
path.chmod(0o600)
|
|
22
|
+
return token
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def require_token(authorization: str = Header(default="")):
|
|
26
|
+
expected = _load_or_create_token()
|
|
27
|
+
scheme, _, presented = authorization.partition(" ")
|
|
28
|
+
if scheme.lower() != "bearer" or not secrets.compare_digest(presented, expected):
|
|
29
|
+
raise HTTPException(status_code=401, detail="missing or invalid bearer token")
|
daemon/api/main.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
from contextlib import asynccontextmanager
|
|
4
|
+
|
|
5
|
+
from fastapi import FastAPI
|
|
6
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
7
|
+
|
|
8
|
+
from daemon.agent_aware import auto_approve, push_watcher
|
|
9
|
+
from daemon.api.auth import _load_or_create_token
|
|
10
|
+
from daemon.api.push import read_router as push_read_router
|
|
11
|
+
from daemon.api.push import write_router as push_write_router
|
|
12
|
+
from daemon.api.read import public_router as read_public_router
|
|
13
|
+
from daemon.api.read import router as read_router
|
|
14
|
+
from daemon.api.write import router as write_router
|
|
15
|
+
from daemon.registry import reaper
|
|
16
|
+
|
|
17
|
+
# Without this, every module-level `logging.getLogger(...).info(...)` call
|
|
18
|
+
# (auto_approve's "loop started"/"auto-approved", write.py's audit_log, the
|
|
19
|
+
# reaper) is silently dropped — the root logger's default level is WARNING,
|
|
20
|
+
# so INFO never reaches daemon.log. That made a truly stuck/crashed
|
|
21
|
+
# auto-approve loop indistinguishable from a working-but-just-quiet one when
|
|
22
|
+
# grepping the log for evidence either way.
|
|
23
|
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _log_task_exception(task: asyncio.Task, name: str):
|
|
27
|
+
"""asyncio silently swallows an unhandled exception in a background
|
|
28
|
+
task unless something explicitly retrieves it — a crash in
|
|
29
|
+
auto_approve.run_loop() or reaper.run_loop() would otherwise just stop
|
|
30
|
+
that loop forever with zero trace in daemon.log."""
|
|
31
|
+
if task.cancelled():
|
|
32
|
+
return
|
|
33
|
+
exc = task.exception()
|
|
34
|
+
if exc is not None:
|
|
35
|
+
logging.getLogger("tmux_agent_control.main").error("%s crashed", name, exc_info=exc)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@asynccontextmanager
|
|
39
|
+
async def lifespan(app):
|
|
40
|
+
token = _load_or_create_token()
|
|
41
|
+
print(f"tmux-agent-control write-API token: {token}")
|
|
42
|
+
|
|
43
|
+
reaper_task = asyncio.create_task(reaper.run_loop())
|
|
44
|
+
reaper_task.add_done_callback(lambda t: _log_task_exception(t, "reaper.run_loop"))
|
|
45
|
+
auto_approve_task = asyncio.create_task(auto_approve.run_loop())
|
|
46
|
+
auto_approve_task.add_done_callback(lambda t: _log_task_exception(t, "auto_approve.run_loop"))
|
|
47
|
+
push_watcher_task = asyncio.create_task(push_watcher.run_loop())
|
|
48
|
+
push_watcher_task.add_done_callback(lambda t: _log_task_exception(t, "push_watcher.run_loop"))
|
|
49
|
+
|
|
50
|
+
tasks = [reaper_task, auto_approve_task, push_watcher_task]
|
|
51
|
+
yield
|
|
52
|
+
for task in tasks:
|
|
53
|
+
task.cancel()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
app = FastAPI(title="tmux-agent-control", lifespan=lifespan)
|
|
57
|
+
|
|
58
|
+
# Daemon binds to 127.0.0.1 only (see AGENTS.md) — CORS just lets a page
|
|
59
|
+
# served from a different local origin (e.g. a static file server for the
|
|
60
|
+
# mobile-web client) call it from a browser. Write routes require a bearer
|
|
61
|
+
# token (daemon.api.auth); CORS alone was never meant to gate anything.
|
|
62
|
+
app.add_middleware(
|
|
63
|
+
CORSMiddleware,
|
|
64
|
+
allow_origins=["*"],
|
|
65
|
+
allow_methods=["GET", "POST"],
|
|
66
|
+
allow_headers=["*"],
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
app.include_router(read_public_router)
|
|
70
|
+
app.include_router(read_router)
|
|
71
|
+
app.include_router(write_router)
|
|
72
|
+
app.include_router(push_read_router)
|
|
73
|
+
app.include_router(push_write_router)
|
daemon/api/pairing.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Pairing-window state, shared by the public `GET /pair` (daemon/api/read.py)
|
|
2
|
+
and the token-gated `POST /pair/open` / `POST /pair/close` (daemon/api/
|
|
3
|
+
write.py). Kept in its own module so read.py doesn't need write.py's auth
|
|
4
|
+
dependency wiring just to check the flag, and write.py doesn't need read.py's
|
|
5
|
+
segno/QR rendering just to flip it.
|
|
6
|
+
|
|
7
|
+
The window used to open automatically for 10 minutes after every daemon
|
|
8
|
+
start — meaning any restart (intentional, crash-loop, container restart)
|
|
9
|
+
silently reopened an unauthenticated token-serving endpoint to anyone who
|
|
10
|
+
could reach the port. Opening it is now an explicit action (`tac-daemon
|
|
11
|
+
--pair`, which reads the token from the local filesystem and calls
|
|
12
|
+
`POST /pair/open` itself) — closed by default, independent of process
|
|
13
|
+
lifecycle.
|
|
14
|
+
"""
|
|
15
|
+
import time
|
|
16
|
+
|
|
17
|
+
WINDOW_SECONDS = 10 * 60
|
|
18
|
+
|
|
19
|
+
_open_until = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def open_window(seconds=WINDOW_SECONDS):
|
|
23
|
+
global _open_until
|
|
24
|
+
_open_until = time.monotonic() + seconds
|
|
25
|
+
return _open_until
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def close_window():
|
|
29
|
+
global _open_until
|
|
30
|
+
_open_until = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def is_open():
|
|
34
|
+
return _open_until is not None and time.monotonic() < _open_until
|
daemon/api/panes.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Shared pane-resolution helper for both read and write routers."""
|
|
2
|
+
from fastapi import HTTPException
|
|
3
|
+
|
|
4
|
+
from daemon.adapter import tmux
|
|
5
|
+
from daemon.registry import store
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def resolve_pane(session_id):
|
|
9
|
+
entry = store.get_entry(session_id)
|
|
10
|
+
if entry is None:
|
|
11
|
+
raise HTTPException(status_code=404, detail="unknown session_id")
|
|
12
|
+
|
|
13
|
+
pane = tmux.find_pane(entry["tmux_session"])
|
|
14
|
+
if pane is None:
|
|
15
|
+
raise HTTPException(status_code=404, detail="tmux session not found")
|
|
16
|
+
|
|
17
|
+
return pane
|
daemon/api/push.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Push notification API — VAPID public key, subscribe, unsubscribe.
|
|
2
|
+
|
|
3
|
+
Two separate APIRouters per the project's read/write separation rule, so
|
|
4
|
+
main.py can apply the same router-level auth dependency as read.py/write.py:
|
|
5
|
+
- read_router: GET /push/vapid-public-key
|
|
6
|
+
- write_router: POST /push/subscribe, POST /push/unsubscribe
|
|
7
|
+
"""
|
|
8
|
+
import hashlib
|
|
9
|
+
import logging
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
from fastapi import APIRouter, Depends, HTTPException
|
|
14
|
+
from pydantic import BaseModel
|
|
15
|
+
|
|
16
|
+
from daemon.api.auth import require_token
|
|
17
|
+
from daemon.api import vapid
|
|
18
|
+
from daemon.registry import push_store
|
|
19
|
+
|
|
20
|
+
read_router = APIRouter(dependencies=[Depends(require_token)])
|
|
21
|
+
write_router = APIRouter(dependencies=[Depends(require_token)])
|
|
22
|
+
audit_log = logging.getLogger("tmux_agent_control.audit")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _subscription_id(endpoint: str) -> str:
|
|
26
|
+
return hashlib.sha256(endpoint.encode()).hexdigest()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# --- Pydantic models ---
|
|
30
|
+
|
|
31
|
+
class SubscriptionKeys(BaseModel):
|
|
32
|
+
p256dh: str
|
|
33
|
+
auth: str
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class SubscriptionInfo(BaseModel):
|
|
37
|
+
endpoint: str
|
|
38
|
+
keys: SubscriptionKeys
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class SubscriptionFilters(BaseModel):
|
|
42
|
+
session_ids: Optional[list[str]] = None
|
|
43
|
+
muted_session_ids: list[str] = []
|
|
44
|
+
push_on_question_answer: bool = True
|
|
45
|
+
push_on_plan_review: bool = True
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class SubscribeRequest(BaseModel):
|
|
49
|
+
subscription: SubscriptionInfo
|
|
50
|
+
filters: SubscriptionFilters = SubscriptionFilters()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class UnsubscribeRequest(BaseModel):
|
|
54
|
+
subscription_id: Optional[str] = None
|
|
55
|
+
endpoint: Optional[str] = None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# --- Read routes ---
|
|
59
|
+
|
|
60
|
+
@read_router.get("/push/vapid-public-key")
|
|
61
|
+
def get_vapid_public_key():
|
|
62
|
+
return {"public_key": vapid.get_public_key_b64()}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# --- Write routes ---
|
|
66
|
+
|
|
67
|
+
@write_router.post("/push/subscribe")
|
|
68
|
+
def subscribe(body: SubscribeRequest):
|
|
69
|
+
if not body.subscription.endpoint:
|
|
70
|
+
raise HTTPException(status_code=400, detail="endpoint is required")
|
|
71
|
+
|
|
72
|
+
sub_id = _subscription_id(body.subscription.endpoint)
|
|
73
|
+
record = {
|
|
74
|
+
"endpoint": body.subscription.endpoint,
|
|
75
|
+
"keys": {
|
|
76
|
+
"p256dh": body.subscription.keys.p256dh,
|
|
77
|
+
"auth": body.subscription.keys.auth,
|
|
78
|
+
},
|
|
79
|
+
"filters": {
|
|
80
|
+
"session_ids": body.filters.session_ids,
|
|
81
|
+
"muted_session_ids": body.filters.muted_session_ids,
|
|
82
|
+
"push_on_question_answer": body.filters.push_on_question_answer,
|
|
83
|
+
"push_on_plan_review": body.filters.push_on_plan_review,
|
|
84
|
+
},
|
|
85
|
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
86
|
+
"last_pushed_at": None,
|
|
87
|
+
}
|
|
88
|
+
push_store.upsert_subscription(sub_id, record)
|
|
89
|
+
audit_log.info("push subscribe sub_id=%s endpoint=%s", sub_id, body.subscription.endpoint)
|
|
90
|
+
return {"subscription_id": sub_id}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@write_router.post("/push/unsubscribe")
|
|
94
|
+
def unsubscribe(body: UnsubscribeRequest):
|
|
95
|
+
if body.subscription_id is None and body.endpoint is None:
|
|
96
|
+
raise HTTPException(status_code=400, detail="subscription_id or endpoint is required")
|
|
97
|
+
|
|
98
|
+
sub_id = body.subscription_id
|
|
99
|
+
if sub_id is None:
|
|
100
|
+
sub_id = _subscription_id(body.endpoint)
|
|
101
|
+
|
|
102
|
+
removed = push_store.remove_subscription(sub_id)
|
|
103
|
+
audit_log.info("push unsubscribe sub_id=%s removed=%s", sub_id, removed)
|
|
104
|
+
return {"removed": removed}
|