tmux-agent-control 0.1.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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tong
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,19 @@
1
+ Metadata-Version: 2.4
2
+ Name: tmux-agent-control
3
+ Version: 0.1.0
4
+ Summary: Local HTTP control daemon for tmux-backed coding-agent sessions
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Author: Tong
8
+ Author-email: 16934247+jiaton@users.noreply.github.com
9
+ Requires-Python: >=3.11,<4.0
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Programming Language :: Python :: 3.14
15
+ Requires-Dist: fastapi (>=0.139.0,<0.140.0)
16
+ Requires-Dist: libtmux (>=0.61.0,<0.62.0)
17
+ Requires-Dist: pywebpush (>=2.0)
18
+ Requires-Dist: segno (>=1.6.0,<2.0.0)
19
+ Requires-Dist: uvicorn[standard] (>=0.50.2,<0.51.0)
File without changes
File without changes
@@ -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)
@@ -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)
File without changes
@@ -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")
@@ -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)
@@ -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
@@ -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
@@ -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}