muxtower 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.
Files changed (204) hide show
  1. muxtower/__init__.py +6 -0
  2. muxtower/__main__.py +3 -0
  3. muxtower/agent_text.py +31 -0
  4. muxtower/agents/__init__.py +8 -0
  5. muxtower/agents/accounts.py +91 -0
  6. muxtower/agents/codex/__init__.py +0 -0
  7. muxtower/agents/codex/processes.py +66 -0
  8. muxtower/agents/codex/rollout.py +155 -0
  9. muxtower/agents/processes.py +22 -0
  10. muxtower/app.py +242 -0
  11. muxtower/approval/__init__.py +0 -0
  12. muxtower/approval/auto.py +53 -0
  13. muxtower/approval/models.py +13 -0
  14. muxtower/approval/service.py +78 -0
  15. muxtower/asr/__init__.py +0 -0
  16. muxtower/asr/models.py +5 -0
  17. muxtower/asr/openai_compatible.py +42 -0
  18. muxtower/asr/ports.py +7 -0
  19. muxtower/asr/routes.py +47 -0
  20. muxtower/asr/service.py +51 -0
  21. muxtower/auth/__init__.py +0 -0
  22. muxtower/auth/deps.py +30 -0
  23. muxtower/auth/models.py +26 -0
  24. muxtower/auth/pairing.py +77 -0
  25. muxtower/auth/ports.py +19 -0
  26. muxtower/auth/repository.py +39 -0
  27. muxtower/auth/routes.py +57 -0
  28. muxtower/auth/service.py +61 -0
  29. muxtower/auth/stream_cookie.py +41 -0
  30. muxtower/builders.py +85 -0
  31. muxtower/claude_conversation.py +229 -0
  32. muxtower/cli/__init__.py +0 -0
  33. muxtower/cli/client.py +70 -0
  34. muxtower/cli/main.py +130 -0
  35. muxtower/cli/pair.py +100 -0
  36. muxtower/cli/serve.py +82 -0
  37. muxtower/concierge/__init__.py +0 -0
  38. muxtower/concierge/ports.py +17 -0
  39. muxtower/concierge/prompt.py +21 -0
  40. muxtower/concierge/service.py +168 -0
  41. muxtower/concierge/store.py +33 -0
  42. muxtower/config.py +202 -0
  43. muxtower/errors.py +30 -0
  44. muxtower/hooks/__init__.py +0 -0
  45. muxtower/hooks/claude_code.py +55 -0
  46. muxtower/hooks/codex.py +99 -0
  47. muxtower/hooks/events.py +79 -0
  48. muxtower/hooks/harnesses.py +12 -0
  49. muxtower/hooks/ports.py +34 -0
  50. muxtower/hooks/relay.py +40 -0
  51. muxtower/hooks/routes.py +28 -0
  52. muxtower/infra/__init__.py +0 -0
  53. muxtower/infra/audit.py +21 -0
  54. muxtower/infra/bus.py +28 -0
  55. muxtower/infra/db.py +60 -0
  56. muxtower/infra/migrations/001_tokens_audit.sql +16 -0
  57. muxtower/infra/migrations/002_sessions_pairing_push.sql +31 -0
  58. muxtower/infra/migrations/003_hook_state.sql +2 -0
  59. muxtower/infra/migrations/004_summaries.sql +6 -0
  60. muxtower/infra/migrations/005_push_done.sql +2 -0
  61. muxtower/infra/migrations/006_session_reads.sql +8 -0
  62. muxtower/infra/migrations/007_session_marks.sql +3 -0
  63. muxtower/infra/migrations/008_push_device.sql +5 -0
  64. muxtower/infra/migrations/009_archive.sql +11 -0
  65. muxtower/infra/migrations/010_session_place.sql +4 -0
  66. muxtower/infra/migrations/011_session_named.sql +3 -0
  67. muxtower/infra/migrations/012_session_notify.sql +3 -0
  68. muxtower/infra/migrations/013_nodes.sql +11 -0
  69. muxtower/infra/migrations/014_node_processes.sql +4 -0
  70. muxtower/infra/migrations/__init__.py +0 -0
  71. muxtower/infra/procfs.py +83 -0
  72. muxtower/infra/tmux.py +120 -0
  73. muxtower/mcp/__init__.py +0 -0
  74. muxtower/mcp/routes.py +101 -0
  75. muxtower/mcp/service.py +161 -0
  76. muxtower/net.py +29 -0
  77. muxtower/nodes/__init__.py +0 -0
  78. muxtower/nodes/api.py +84 -0
  79. muxtower/nodes/client.py +175 -0
  80. muxtower/nodes/directory.py +92 -0
  81. muxtower/nodes/hub.py +250 -0
  82. muxtower/nodes/link.py +80 -0
  83. muxtower/nodes/models.py +49 -0
  84. muxtower/nodes/processes.py +73 -0
  85. muxtower/nodes/protocol.py +75 -0
  86. muxtower/nodes/repository.py +77 -0
  87. muxtower/nodes/routes.py +117 -0
  88. muxtower/nodes/session.py +138 -0
  89. muxtower/nodes/source.py +195 -0
  90. muxtower/nodes/store.py +33 -0
  91. muxtower/nodes/update.py +125 -0
  92. muxtower/notify/__init__.py +0 -0
  93. muxtower/notify/models.py +46 -0
  94. muxtower/notify/ports.py +22 -0
  95. muxtower/notify/repository.py +49 -0
  96. muxtower/notify/routes.py +34 -0
  97. muxtower/notify/service.py +165 -0
  98. muxtower/notify/text.py +60 -0
  99. muxtower/notify/webpush_sender.py +57 -0
  100. muxtower/origins.py +24 -0
  101. muxtower/runtimes/__init__.py +0 -0
  102. muxtower/runtimes/defaults/__init__.py +0 -0
  103. muxtower/runtimes/defaults/claude-code.toml +77 -0
  104. muxtower/runtimes/defaults/codex.toml +60 -0
  105. muxtower/runtimes/defaults/opencode.toml +4 -0
  106. muxtower/runtimes/models.py +41 -0
  107. muxtower/runtimes/spec.py +206 -0
  108. muxtower/sessions/__init__.py +0 -0
  109. muxtower/sessions/archive.py +103 -0
  110. muxtower/sessions/archive_repository.py +44 -0
  111. muxtower/sessions/archiving.py +29 -0
  112. muxtower/sessions/claude_processes.py +105 -0
  113. muxtower/sessions/decorate.py +47 -0
  114. muxtower/sessions/directory.py +233 -0
  115. muxtower/sessions/launcher.py +56 -0
  116. muxtower/sessions/live.py +103 -0
  117. muxtower/sessions/marks.py +136 -0
  118. muxtower/sessions/merged.py +29 -0
  119. muxtower/sessions/models.py +206 -0
  120. muxtower/sessions/monitor.py +182 -0
  121. muxtower/sessions/placement.py +30 -0
  122. muxtower/sessions/ports.py +108 -0
  123. muxtower/sessions/repository.py +64 -0
  124. muxtower/sessions/routes.py +207 -0
  125. muxtower/sessions/service.py +234 -0
  126. muxtower/sessions/transcripts.py +164 -0
  127. muxtower/sessions/turns.py +82 -0
  128. muxtower/sources/__init__.py +0 -0
  129. muxtower/sources/codex/__init__.py +0 -0
  130. muxtower/sources/codex/adapter.py +178 -0
  131. muxtower/sources/codex/items.py +122 -0
  132. muxtower/sources/codex/ports.py +31 -0
  133. muxtower/sources/codex/requests.py +85 -0
  134. muxtower/sources/codex/service.py +220 -0
  135. muxtower/sources/codex/store.py +20 -0
  136. muxtower/sources/codex/thread.py +142 -0
  137. muxtower/sources/remote_control/__init__.py +0 -0
  138. muxtower/sources/remote_control/adapter.py +124 -0
  139. muxtower/sources/remote_control/folder.py +50 -0
  140. muxtower/sources/remote_control/ports.py +37 -0
  141. muxtower/sources/remote_control/service.py +267 -0
  142. muxtower/sources/remote_control/view.py +141 -0
  143. muxtower/summaries/__init__.py +0 -0
  144. muxtower/summaries/cli.py +64 -0
  145. muxtower/summaries/models.py +14 -0
  146. muxtower/summaries/ports.py +12 -0
  147. muxtower/summaries/repository.py +29 -0
  148. muxtower/summaries/routes.py +34 -0
  149. muxtower/summaries/service.py +118 -0
  150. muxtower/system/__init__.py +0 -0
  151. muxtower/system/routes.py +36 -0
  152. muxtower/terminal/__init__.py +0 -0
  153. muxtower/terminal/models.py +36 -0
  154. muxtower/terminal/ports.py +38 -0
  155. muxtower/terminal/service.py +44 -0
  156. muxtower/terminal/stream.py +213 -0
  157. muxtower/vendor/__init__.py +0 -0
  158. muxtower/vendor/claude_rc/LICENSE +21 -0
  159. muxtower/vendor/claude_rc/VENDORED.md +14 -0
  160. muxtower/vendor/claude_rc/__init__.py +11 -0
  161. muxtower/vendor/claude_rc/_version.py +1 -0
  162. muxtower/vendor/claude_rc/client.py +870 -0
  163. muxtower/vendor/claude_rc/credentials.py +320 -0
  164. muxtower/vendor/claude_rc/events.py +371 -0
  165. muxtower/vendor/claude_rc/sse.py +68 -0
  166. muxtower/web/apple-touch-icon.png +0 -0
  167. muxtower/web/assets/_sessions-BPnsIMWg.js +1 -0
  168. muxtower/web/assets/_sessions-F6Hfi8Dj.js +1 -0
  169. muxtower/web/assets/card-BXVZ-VmL.js +1 -0
  170. muxtower/web/assets/circle-x-CWQghSvw.js +1 -0
  171. muxtower/web/assets/createLucideIcon-BXLm2Wke.js +1 -0
  172. muxtower/web/assets/d._daemonId.s._sessionId-CyoPJIeV.js +44 -0
  173. muxtower/web/assets/daemons-kRSvABqz.js +8 -0
  174. muxtower/web/assets/empty-state-Cli24rK7.js +1 -0
  175. muxtower/web/assets/index-6VCCVynB.css +2 -0
  176. muxtower/web/assets/index-CEU_Oypb.js +11 -0
  177. muxtower/web/assets/input-LSp6RxG5.js +1 -0
  178. muxtower/web/assets/pair-BZfb2lgz.js +1 -0
  179. muxtower/web/assets/pair-claim-Bv8z2eK-.js +1 -0
  180. muxtower/web/assets/pane-1oqVJ-cZ.js +1 -0
  181. muxtower/web/assets/preload-helper-BM7m-G0T.js +1 -0
  182. muxtower/web/assets/query-state-BmXDJtvN.js +1 -0
  183. muxtower/web/assets/streams-CtDcAcEr.js +41 -0
  184. muxtower/web/assets/use-media-query-gZIa5xwu.js +1 -0
  185. muxtower/web/assets/useNavigate-ByR6iLuJ.js +1 -0
  186. muxtower/web/badge-96.png +0 -0
  187. muxtower/web/config.json +1 -0
  188. muxtower/web/favicon.svg +7 -0
  189. muxtower/web/g2/assets/index-Be_dtrCV.js +2 -0
  190. muxtower/web/g2/assets/index-JqCupXI_.css +1 -0
  191. muxtower/web/g2/index.html +13 -0
  192. muxtower/web/icon-192.png +0 -0
  193. muxtower/web/icon-512.png +0 -0
  194. muxtower/web/icon-maskable-512.png +0 -0
  195. muxtower/web/icon.svg +7 -0
  196. muxtower/web/index.html +24 -0
  197. muxtower/web/manifest.webmanifest +1 -0
  198. muxtower/web/registerSW.js +1 -0
  199. muxtower/web/sw.js +1 -0
  200. muxtower/web.py +47 -0
  201. muxtower-0.1.0.dist-info/METADATA +32 -0
  202. muxtower-0.1.0.dist-info/RECORD +204 -0
  203. muxtower-0.1.0.dist-info/WHEEL +4 -0
  204. muxtower-0.1.0.dist-info/entry_points.txt +3 -0
muxtower/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ try:
4
+ __version__ = version("muxtower") # the one in pyproject.toml, as installed
5
+ except PackageNotFoundError: # pragma: no cover - run without being installed
6
+ __version__ = "0.0.0"
muxtower/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from muxtower.cli.main import main
2
+
3
+ main()
muxtower/agent_text.py ADDED
@@ -0,0 +1,31 @@
1
+ """Turning agent tool calls and long text into the short lines a phone or glasses can show."""
2
+
3
+ import json
4
+ from typing import Any
5
+
6
+ DETAIL_LIMIT = 4000
7
+
8
+
9
+ def truncate(text: str) -> str:
10
+ return text if len(text) <= DETAIL_LIMIT else text[:DETAIL_LIMIT] + "…"
11
+
12
+
13
+ def tool_description(tool_input: Any) -> str | None:
14
+ """What the agent said a tool call does, when it said (Claude Code's Bash and Task take one)."""
15
+ value = tool_input.get("description") if isinstance(tool_input, dict) else None
16
+ return (value.strip()[:200] or None) if isinstance(value, str) else None
17
+
18
+
19
+ def describe_tool_input(tool_input: Any) -> str | None:
20
+ """The one thing a human needs to judge a tool call: the command, the path, the URL…"""
21
+ if not isinstance(tool_input, dict):
22
+ return None
23
+ for key in ("command", "cmd", "file_path", "path", "url", "pattern", "query", "prompt", "plan"):
24
+ value = tool_input.get(key)
25
+ if isinstance(value, str) and value:
26
+ return truncate(value)
27
+ questions = tool_input.get("questions")
28
+ if isinstance(questions, list):
29
+ texts = [q.get("question", "") for q in questions if isinstance(q, dict)]
30
+ return truncate("\n".join(t for t in texts if t)) or None
31
+ return truncate(json.dumps(tool_input, ensure_ascii=False, indent=1)) if tool_input else None
@@ -0,0 +1,8 @@
1
+ """Coding agents muxtower knows beyond running them in a terminal, one package each.
2
+
3
+ An agent plugin tells, for a tmux pane running it, which session of the agent that is (`ClaudeProcessPort`:
4
+ its Remote Control id, transcript file and name) and reads its transcript into conversation items
5
+ (`sessions.transcripts.TranscriptFormat`). `agents.processes.AgentProcesses` asks each plugin in turn.
6
+ Claude Code's live in `sessions/claude_processes.py` and `claude_conversation.py`; Codex's here, in
7
+ `agents/codex/`.
8
+ """
@@ -0,0 +1,91 @@
1
+ """The account each agent on this machine runs as, so sessions of different accounts can be told apart
2
+ (two Claude accounts, Claude Code with an API key, Codex). Read from the agents' own login files, for
3
+ who is logged in only: no token is kept, logged or sent anywhere. Re-read when a file changes.
4
+
5
+ Claude Code (and its Remote Control sessions): `~/.claude.json` names the claude.ai account it signed in
6
+ with; without one, an API key (`ANTHROPIC_API_KEY`) is "API key". Codex: `~/.codex/auth.json` holds a
7
+ ChatGPT sign-in, whose ID token names its email, or an API key."""
8
+
9
+ import base64
10
+ import json
11
+ import os
12
+ from collections.abc import Callable, Mapping
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from muxtower.sessions.models import Session
17
+ from muxtower.sessions.ports import SessionSource
18
+
19
+ API_KEY = "API key"
20
+
21
+
22
+ def _jwt_email(token: Any) -> str | None:
23
+ """The email claim of an ID token, read for display only (it is the agent's own; not verified)."""
24
+ if not isinstance(token, str) or token.count(".") != 2:
25
+ return None
26
+ payload = token.split(".")[1]
27
+ try:
28
+ claims = json.loads(base64.urlsafe_b64decode(payload + "=" * (-len(payload) % 4)))
29
+ except ValueError:
30
+ return None
31
+ email = claims.get("email") if isinstance(claims, dict) else None
32
+ return email if isinstance(email, str) and email else None
33
+
34
+
35
+ class _Cached:
36
+ """A value read from a file, again only when the file changed."""
37
+
38
+ def __init__(self, path: Path, read: Callable[[dict[str, Any]], str | None]) -> None:
39
+ self.path = path
40
+ self.read = read
41
+ self._stamp: float | None = None
42
+ self._value: str | None = None
43
+
44
+ def get(self) -> str | None:
45
+ try:
46
+ stamp = self.path.stat().st_mtime
47
+ except OSError:
48
+ return None
49
+ if stamp != self._stamp:
50
+ try:
51
+ data = json.loads(self.path.read_text())
52
+ self._value = self.read(data) if isinstance(data, dict) else None
53
+ except (OSError, ValueError):
54
+ self._value = None
55
+ self._stamp = stamp
56
+ return self._value
57
+
58
+
59
+ class Accounts:
60
+ def __init__(self, home: Path | None = None, env: Mapping[str, str] | None = None) -> None:
61
+ home = home or Path.home()
62
+ self.env = os.environ if env is None else env
63
+ self._claude = _Cached(home / ".claude.json", self._claude_account)
64
+ self._codex = _Cached(home / ".codex" / "auth.json", self._codex_account)
65
+
66
+ @staticmethod
67
+ def _claude_account(data: dict[str, Any]) -> str | None:
68
+ account = data.get("oauthAccount")
69
+ email = account.get("emailAddress") if isinstance(account, dict) else None
70
+ return email if isinstance(email, str) and email else None
71
+
72
+ @staticmethod
73
+ def _codex_account(data: dict[str, Any]) -> str | None:
74
+ tokens = data.get("tokens")
75
+ if data.get("auth_mode") != "apikey" and isinstance(tokens, dict):
76
+ if email := _jwt_email(tokens.get("id_token")):
77
+ return email
78
+ return API_KEY if data.get("OPENAI_API_KEY") else None
79
+
80
+ def claude(self) -> str | None:
81
+ return self._claude.get() or (API_KEY if self.env.get("ANTHROPIC_API_KEY") else None)
82
+
83
+ def codex(self) -> str | None:
84
+ return self._codex.get()
85
+
86
+ def __call__(self, session: Session) -> str | None:
87
+ if session.runtime == "codex" or session.source == SessionSource.CODEX:
88
+ return self.codex()
89
+ if session.runtime.startswith("claude") or session.source == SessionSource.REMOTE_CONTROL:
90
+ return self.claude()
91
+ return None
File without changes
@@ -0,0 +1,66 @@
1
+ """Which Codex session a tmux pane runs.
2
+
3
+ Codex keeps no file per process (Claude Code does), but while a session runs its process holds the
4
+ session's rollout file open for appending: the pane's process, or one it started, has an open file
5
+ `<codex home>/sessions/…/rollout-<time>-<thread id>.jsonl`. The rollout is the transcript; the thread's
6
+ name (what `/rename` set, else its title) is in Codex's state database, read without writing. A
7
+ session's rollout is only created with its first message, so a fresh Codex has none yet. Codex has no
8
+ Remote Control, so no remote id.
9
+ """
10
+
11
+ import os
12
+ import re
13
+ import sqlite3
14
+ from pathlib import Path
15
+
16
+ from muxtower.infra.procfs import Children, family
17
+ from muxtower.sessions.claude_processes import ClaudeProcess
18
+
19
+ SEARCH_DEPTH = 3
20
+ THREAD_ID = re.compile(r"rollout-.*-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$")
21
+ STATE_DB = "state_5.sqlite"
22
+
23
+
24
+ class CodexProcesses:
25
+ def __init__(self, codex_home: Path | None = None, proc: Path = Path("/proc")) -> None:
26
+ self.home = codex_home or Path(os.environ.get("CODEX_HOME") or Path.home() / ".codex")
27
+ self.sessions = str(self.home / "sessions") + os.sep
28
+ self.proc = proc
29
+
30
+ def _rollout(self, pane_pid: int) -> str | None:
31
+ """The rollout file the pane's Codex holds open, if it runs one."""
32
+ for pid in family(pane_pid, Children(self.proc), SEARCH_DEPTH):
33
+ try:
34
+ fds = list((self.proc / str(pid) / "fd").iterdir())
35
+ except OSError:
36
+ continue
37
+ for fd in fds:
38
+ try:
39
+ target = os.readlink(fd)
40
+ except OSError:
41
+ continue
42
+ if target.startswith(self.sessions) and THREAD_ID.search(target):
43
+ return target
44
+ return None
45
+
46
+ def remote_id(self, pane_pid: int) -> str | None:
47
+ return None
48
+
49
+ def transcript(self, pane_pid: int) -> str | None:
50
+ return self._rollout(pane_pid)
51
+
52
+ def name(self, pane_pid: int) -> str | None:
53
+ rollout = self._rollout(pane_pid)
54
+ found = THREAD_ID.search(rollout) if rollout else None
55
+ if not found:
56
+ return None
57
+ try:
58
+ with sqlite3.connect(f"file:{self.home / STATE_DB}?mode=ro", uri=True, timeout=1) as db:
59
+ row = db.execute("SELECT name, title FROM threads WHERE id = ?", (found.group(1),)).fetchone()
60
+ except sqlite3.Error:
61
+ return None
62
+ name = next((str(v).strip() for v in row or () if v and str(v).strip()), None)
63
+ return name[:80] if name else None
64
+
65
+ def running(self) -> dict[str, ClaudeProcess]:
66
+ return {}
@@ -0,0 +1,155 @@
1
+ """A Codex session's conversation, read from its rollout file.
2
+
3
+ Codex writes each session to `~/.codex/sessions/YYYY/MM/DD/rollout-<time>-<thread id>.jsonl`: one
4
+ `{timestamp, type, payload}` per line. What the owner and the agent said are `event_msg`s
5
+ (`user_message`, `agent_message`); tool calls are `response_item`s (`function_call`, `custom_tool_call`,
6
+ `local_shell_call`, `web_search_call`) whose output arrives later under the same `call_id`; a turn ends
7
+ with `task_complete` (or `turn_complete`, `turn_aborted`). Approval prompts are never written down, so
8
+ the file cannot tell a waiting turn from a working one: the screen does.
9
+
10
+ In "code mode" a tool call is a script (`custom_tool_call` named `exec`) that calls the real tools, as
11
+ `tools.exec_command({"cmd": …})`: the first one it calls names the call.
12
+ """
13
+
14
+ import json
15
+ import re
16
+ from datetime import datetime
17
+ from typing import Any
18
+
19
+ from muxtower.agent_text import describe_tool_input, truncate
20
+ from muxtower.sessions.models import ConversationItem, ConversationItemKind, ItemStatus
21
+
22
+ MAX_ITEMS = 2000
23
+ TOOL_CALLS = {"function_call", "custom_tool_call", "local_shell_call", "web_search_call"}
24
+ TOOL_OUTPUTS = {"function_call_output", "custom_tool_call_output", "local_shell_call_output"}
25
+ TURN_ENDS = {"task_complete", "turn_complete"}
26
+ ABORTED = {"turn_aborted", "task_aborted"}
27
+ SCRIPT_CALL = re.compile(r"tools\.(\w+)\(\s*(\{.*?\})\s*\)", re.S)
28
+ FAILED = re.compile(r"^(Script failed|Process exited with code [1-9])|Exit code: [1-9]", re.M)
29
+
30
+
31
+ def _at(value: Any) -> datetime | None:
32
+ try:
33
+ return datetime.fromisoformat(str(value).replace("Z", "+00:00")) if value else None
34
+ except ValueError:
35
+ return None
36
+
37
+
38
+ def _json(text: Any) -> Any:
39
+ try:
40
+ return json.loads(text) if isinstance(text, str) else text
41
+ except ValueError:
42
+ return None
43
+
44
+
45
+ def _output_text(output: Any) -> str:
46
+ if isinstance(output, list):
47
+ return "\n".join(str(o.get("text") or "") for o in output if isinstance(o, dict))
48
+ return str(output or "")
49
+
50
+
51
+ def tool_of(payload: dict[str, Any]) -> tuple[str, str | None]:
52
+ """The tool a call runs and the one line that says what it does."""
53
+ kind = payload.get("type")
54
+ if kind == "local_shell_call":
55
+ command = (payload.get("action") or {}).get("command")
56
+ return "exec_command", " ".join(command) if isinstance(command, list) else None
57
+ if kind == "web_search_call":
58
+ query = (payload.get("action") or {}).get("query")
59
+ return "web_search", str(query) if query else None
60
+ name = str(payload.get("name") or "tool")
61
+ if kind == "custom_tool_call" and name == "exec": # code mode: a script calling tools
62
+ script = str(payload.get("input") or "")
63
+ found = SCRIPT_CALL.search(script)
64
+ if found:
65
+ args = _json(found.group(2))
66
+ return found.group(1), describe_tool_input(args) if isinstance(args, dict) else truncate(found.group(2))
67
+ return "exec", truncate(script) or None
68
+ args = _json(payload.get("arguments")) if kind == "function_call" else payload.get("input")
69
+ if isinstance(args, dict):
70
+ return name, describe_tool_input(args)
71
+ return name, truncate(str(args)) if args else None
72
+
73
+
74
+ class RolloutBuilder:
75
+ """Rollout lines → conversation items, in the order written; a tool call's item is sent again once
76
+ its output tells how it ended."""
77
+
78
+ def __init__(self) -> None:
79
+ self.items: dict[int, ConversationItem] = {}
80
+ self._calls: dict[str, int] = {} # call_id → its item's seq
81
+ self._seq = 0
82
+
83
+ @property
84
+ def last_reply(self) -> str | None:
85
+ for seq in sorted(self.items, reverse=True):
86
+ if self.items[seq].kind == ConversationItemKind.REPLY:
87
+ return self.items[seq].text
88
+ return None
89
+
90
+ def after(self, seq: int, limit: int) -> list[ConversationItem]:
91
+ newer = [self.items[s] for s in sorted(self.items) if s > seq]
92
+ return newer[-limit:] if seq == 0 else newer[:limit]
93
+
94
+ def feed_lines(self, lines: list[bytes]) -> list[ConversationItem]:
95
+ touched: dict[int, ConversationItem] = {}
96
+ for line in lines:
97
+ self._seq += 1
98
+ try:
99
+ record = json.loads(line)
100
+ except ValueError:
101
+ continue
102
+ if isinstance(record, dict) and isinstance(record.get("payload"), dict):
103
+ for item in self._apply(str(record.get("type")), record["payload"], _at(record.get("timestamp"))):
104
+ self.items[item.seq] = item
105
+ touched[item.seq] = item
106
+ for seq in sorted(self.items)[:-MAX_ITEMS]:
107
+ del self.items[seq]
108
+ return [touched[s] for s in sorted(touched)]
109
+
110
+ def _item(self, kind: ConversationItemKind, at: datetime | None, **fields: Any) -> ConversationItem:
111
+ return ConversationItem(seq=self._seq, at=at, kind=kind, **fields)
112
+
113
+ def _finish(self, call_id: Any, status: ItemStatus) -> list[ConversationItem]:
114
+ seq = self._calls.get(str(call_id))
115
+ item = self.items.get(seq) if seq is not None else None
116
+ if item is None or item.status == ItemStatus.ERROR:
117
+ return []
118
+ return [item.model_copy(update={"status": status})]
119
+
120
+ def _apply(self, kind: str, payload: dict[str, Any], at: datetime | None) -> list[ConversationItem]:
121
+ sub = payload.get("type")
122
+ if kind == "event_msg":
123
+ if sub == "user_message" and (text := str(payload.get("message") or "").strip()):
124
+ return [self._item(ConversationItemKind.PROMPT, at, text=text)]
125
+ if sub == "agent_message" and (text := str(payload.get("message") or "").strip()):
126
+ return [self._item(ConversationItemKind.REPLY, at, text=text)]
127
+ if sub == "exec_command_end" and payload.get("exit_code") not in (0, None):
128
+ return self._finish(payload.get("call_id"), ItemStatus.ERROR)
129
+ if sub in TURN_ENDS:
130
+ return [self._item(ConversationItemKind.RESULT, at, status=ItemStatus.DONE)]
131
+ if sub in ABORTED:
132
+ reason = str(payload.get("reason") or "interrupted")
133
+ return [self._item(ConversationItemKind.RESULT, at, text=reason, status=ItemStatus.ERROR)]
134
+ if sub == "error" and (text := str(payload.get("message") or "").strip()):
135
+ return [self._item(ConversationItemKind.RESULT, at, text=text, status=ItemStatus.ERROR)]
136
+ return []
137
+ if kind != "response_item":
138
+ return []
139
+ if sub in TOOL_CALLS:
140
+ name, what = tool_of(payload)
141
+ done = sub == "web_search_call" or payload.get("status") == "failed"
142
+ status = (
143
+ ItemStatus.ERROR
144
+ if payload.get("status") == "failed"
145
+ else ItemStatus.DONE
146
+ if done
147
+ else ItemStatus.RUNNING
148
+ )
149
+ if call_id := payload.get("call_id"):
150
+ self._calls[str(call_id)] = self._seq
151
+ return [self._item(ConversationItemKind.TOOL, at, tool_name=name, text=what, status=status)]
152
+ if sub in TOOL_OUTPUTS:
153
+ failed = FAILED.search(_output_text(payload.get("output")))
154
+ return self._finish(payload.get("call_id"), ItemStatus.ERROR if failed else ItemStatus.DONE)
155
+ return []
@@ -0,0 +1,22 @@
1
+ """Every agent plugin's answer for a pane, asked in turn: the first that recognizes the pane's agent
2
+ answers (a pane runs one agent; each plugin finds only its own)."""
3
+
4
+ from muxtower.sessions.claude_processes import ClaudeProcess
5
+ from muxtower.sessions.ports import ClaudeProcessPort
6
+
7
+
8
+ class AgentProcesses:
9
+ def __init__(self, plugins: list[ClaudeProcessPort]) -> None:
10
+ self.plugins = plugins
11
+
12
+ def remote_id(self, pane_pid: int) -> str | None:
13
+ return next((found for p in self.plugins if (found := p.remote_id(pane_pid))), None)
14
+
15
+ def transcript(self, pane_pid: int) -> str | None:
16
+ return next((found for p in self.plugins if (found := p.transcript(pane_pid))), None)
17
+
18
+ def name(self, pane_pid: int) -> str | None:
19
+ return next((found for p in self.plugins if (found := p.name(pane_pid))), None)
20
+
21
+ def running(self) -> dict[str, ClaudeProcess]:
22
+ return {k: v for p in self.plugins for k, v in p.running().items()}
muxtower/app.py ADDED
@@ -0,0 +1,242 @@
1
+ """FastAPI application factory: builds adapters and services, keeps them on app.state, starts loops."""
2
+
3
+ import asyncio
4
+ import logging
5
+ import socket
6
+ import sys
7
+ from collections.abc import AsyncIterator
8
+ from contextlib import asynccontextmanager
9
+
10
+ from fastapi import FastAPI, Request
11
+ from fastapi.middleware.cors import CORSMiddleware
12
+ from fastapi.responses import JSONResponse
13
+
14
+ from muxtower import __version__
15
+ from muxtower.agents.accounts import Accounts
16
+ from muxtower.agents.codex.processes import CodexProcesses
17
+ from muxtower.agents.processes import AgentProcesses
18
+ from muxtower.approval.auto import AutoApprover
19
+ from muxtower.approval.service import ApprovalService
20
+ from muxtower.asr.routes import router as asr_router
21
+ from muxtower.auth.deps import DAEMON_AUTH_HEADER
22
+ from muxtower.auth.pairing import PairingService
23
+ from muxtower.auth.routes import router as auth_router
24
+ from muxtower.auth.stream_cookie import StreamCookieSigner
25
+ from muxtower.builders import (
26
+ build_asr,
27
+ build_codex,
28
+ build_remote_control,
29
+ build_summaries,
30
+ build_token_service,
31
+ )
32
+ from muxtower.concierge.service import ConciergeService
33
+ from muxtower.concierge.store import FileConciergeStore
34
+ from muxtower.config import Settings
35
+ from muxtower.errors import Conflict, Forbidden, Invalid, NotFound, TooManyRequests, Unauthorized, Unavailable
36
+ from muxtower.hooks.routes import router as hooks_router
37
+ from muxtower.infra.audit import AuditLog
38
+ from muxtower.infra.bus import EventBus
39
+ from muxtower.infra.db import Database
40
+ from muxtower.infra.tmux import TmuxCli, TmuxError
41
+ from muxtower.mcp.routes import router as mcp_router
42
+ from muxtower.mcp.service import McpTools
43
+ from muxtower.net import ClientAddress
44
+ from muxtower.nodes import protocol as node_protocol
45
+ from muxtower.nodes.api import NodeApi
46
+ from muxtower.nodes.client import HubClient
47
+ from muxtower.nodes.directory import HubDirectory
48
+ from muxtower.nodes.hub import Nodes
49
+ from muxtower.nodes.processes import NodeProcesses
50
+ from muxtower.nodes.repository import SqliteNodeRepository
51
+ from muxtower.nodes.routes import router as nodes_router
52
+ from muxtower.nodes.source import NodeSource
53
+ from muxtower.nodes.store import HubStore
54
+ from muxtower.nodes.update import Updater
55
+ from muxtower.notify.repository import SqliteSubscriptionRepository
56
+ from muxtower.notify.routes import router as push_router
57
+ from muxtower.notify.service import PushService
58
+ from muxtower.notify.webpush_sender import WebPushSender
59
+ from muxtower.origins import OriginPolicy
60
+ from muxtower.runtimes.spec import load_runtimes
61
+ from muxtower.sessions.archive import SessionArchive
62
+ from muxtower.sessions.archive_repository import SqliteArchiveRepository
63
+ from muxtower.sessions.archiving import Archiver
64
+ from muxtower.sessions.claude_processes import ClaudeProcesses
65
+ from muxtower.sessions.launcher import Launcher
66
+ from muxtower.sessions.marks import SessionMarks, SqliteMarksRepository
67
+ from muxtower.sessions.monitor import Monitor
68
+ from muxtower.sessions.placement import Placements
69
+ from muxtower.sessions.ports import SessionSourcePort
70
+ from muxtower.sessions.repository import SqliteSessionRepository
71
+ from muxtower.sessions.routes import router as sessions_router
72
+ from muxtower.sessions.service import SessionConfig, SessionService
73
+ from muxtower.sessions.transcripts import Transcripts
74
+ from muxtower.sessions.turns import TurnWatcher
75
+ from muxtower.sources.codex.service import CodexSource
76
+ from muxtower.sources.remote_control.service import RemoteControlSource
77
+ from muxtower.summaries.routes import router as summaries_router
78
+ from muxtower.system.routes import router as system_router
79
+ from muxtower.terminal.ports import TmuxPort
80
+ from muxtower.terminal.service import TerminalService
81
+ from muxtower.terminal.stream import router as stream_router
82
+ from muxtower.web import mount_ui
83
+
84
+ logger = logging.getLogger("muxtower")
85
+
86
+
87
+ def _log_crash(task: asyncio.Task[None]) -> None:
88
+ if not task.cancelled() and task.exception() is not None:
89
+ logger.error("background task %s crashed", task.get_name(), exc_info=task.exception())
90
+
91
+
92
+ def create_app(
93
+ settings: Settings,
94
+ tmux: TmuxPort | None = None,
95
+ run_loops: bool = True,
96
+ remote_control: RemoteControlSource | None = None,
97
+ codex: CodexSource | None = None,
98
+ ) -> FastAPI:
99
+ db = Database(settings.db_path)
100
+ tokens = build_token_service(settings, db)
101
+ daemon_name = settings.name or socket.gethostname()
102
+ bus = EventBus()
103
+ sessions = SessionService(
104
+ SqliteSessionRepository(db),
105
+ tmux or TmuxCli(),
106
+ load_runtimes(settings.config_dir / "runtimes"),
107
+ SessionConfig(settings.tmux_socket, settings.adopt_sockets, settings.local_url, settings.state_dir),
108
+ )
109
+ remote_control = remote_control or build_remote_control(settings)
110
+ codex = codex or build_codex(settings)
111
+ sources: list[SessionSourcePort] = [s for s in (remote_control, codex) if s is not None]
112
+ # Links panes to their agent's session: Remote Control, transcript, name (one plugin per agent).
113
+ processes = AgentProcesses([ClaudeProcesses(), CodexProcesses()])
114
+ monitor = Monitor(sessions, processes)
115
+ approvals = ApprovalService(sessions)
116
+ terminal = TerminalService(sessions, monitor)
117
+ summaries = build_summaries(settings, db)
118
+ marks = SessionMarks(SqliteMarksRepository(db))
119
+ transcripts = Transcripts()
120
+ archive = SessionArchive(SqliteArchiveRepository(db), transcripts)
121
+ audit = AuditLog(db)
122
+ # Daemons joined to this one (nodes): their sessions are one more source.
123
+ nodes = Nodes(SqliteNodeRepository(db), bus)
124
+ node_sessions = NodeSource(nodes)
125
+ node_processes = NodeProcesses(nodes) # where the Remote Control sessions listed here run, if on a node
126
+ processes.plugins.append(node_processes)
127
+ directory = HubDirectory(
128
+ sessions, terminal, approvals, sources, summaries, daemon_name, processes,
129
+ marks, transcripts, archive, Accounts(), nodes=node_sessions, node_processes=node_processes,
130
+ ) # fmt: skip
131
+ launcher = Launcher(directory, [codex] if codex else [], node_sessions)
132
+ archiver = Archiver(directory, archive)
133
+ placements = Placements(directory, marks)
134
+ # This daemon as a node of a hub, once it joined one (`muxtower pair --join`).
135
+ hub = HubClient(
136
+ HubStore(settings.state_dir / "hub.json"), settings.hub.headers,
137
+ NodeApi(directory, launcher, archiver, placements, audit, processes), monitor, bus,
138
+ lambda: node_protocol.Hello(
139
+ protocol=node_protocol.PROTOCOL, name=daemon_name, version=__version__, runtimes=launcher.runtimes(),
140
+ platform=sys.platform,
141
+ ),
142
+ Updater(settings.hub.update),
143
+ ) # fmt: skip
144
+ concierge: ConciergeService | None = None
145
+ if settings.concierge.enabled:
146
+ store = FileConciergeStore(settings.state_dir / "concierge.json", settings.state_dir / "concierge")
147
+ mcp_url = f"{settings.local_url}/v1/mcp"
148
+ concierge = ConciergeService(settings.concierge, sessions, directory, tokens, store, mcp_url)
149
+ push = PushService(
150
+ SqliteSubscriptionRepository(db), WebPushSender(settings.state_dir), directory, daemon_name, tokens.exists
151
+ )
152
+
153
+ @asynccontextmanager
154
+ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
155
+ tasks: list[asyncio.Task[None]] = []
156
+ if run_loops:
157
+ loops = {
158
+ "monitor": monitor.run_loop(bus, settings.tick_seconds),
159
+ "push": push.run_loop(bus),
160
+ "turns": TurnWatcher(marks.finished).run_loop(bus, directory.list),
161
+ "transcripts": transcripts.run_loop(bus, directory.transcript_paths),
162
+ "auto-approve": AutoApprover(approvals, audit).run_loop(bus),
163
+ }
164
+ if summaries.available:
165
+
166
+ def reply_of(session_id: str) -> str | None:
167
+ session = directory.find(session_id)
168
+ return session.last_message if session else None
169
+
170
+ loops["summaries"] = summaries.run_loop(bus, reply_of, settings.summaries.eager)
171
+ if concierge is not None:
172
+ loops["concierge"] = concierge.run_loop(bus)
173
+ if remote_control is not None:
174
+ loops["remote-control"] = remote_control.run_loop(bus, settings.remote_control.poll_seconds)
175
+ if codex is not None:
176
+ loops["codex"] = codex.run_loop(bus)
177
+ loops["hub"] = hub.run_loop()
178
+ for name, coro in loops.items():
179
+ task = asyncio.create_task(coro, name=name)
180
+ task.add_done_callback(_log_crash)
181
+ tasks.append(task)
182
+ yield
183
+ for task in tasks:
184
+ task.cancel()
185
+ if codex is not None:
186
+ codex.port.close()
187
+
188
+ app = FastAPI(title="muxtower", version="1", lifespan=lifespan) # the API's (/v1): a release leaves the contract be
189
+ app.state.settings = settings
190
+ app.state.daemon_name = daemon_name
191
+ app.state.tokens = tokens
192
+ app.state.origins = OriginPolicy(settings.cors.allowed_origins)
193
+ app.state.bus = bus
194
+ app.state.sessions = sessions
195
+ app.state.monitor = monitor
196
+ app.state.directory = directory
197
+ app.state.placements = placements
198
+ app.state.archiver = archiver
199
+ app.state.launcher = launcher
200
+ app.state.nodes = nodes
201
+ app.state.hub = hub
202
+ app.state.summaries = summaries
203
+ app.state.concierge = concierge
204
+ app.state.mcp = McpTools(directory, sessions, concierge, daemon_name)
205
+ app.state.asr = build_asr(settings, lambda: [s.name for s in directory.list()])
206
+ app.state.terminal = terminal
207
+ app.state.approvals = approvals
208
+ app.state.audit = audit
209
+ app.state.push = push
210
+ app.state.pairing = PairingService(db, tokens, settings.pairing, daemon_name)
211
+ app.state.stream_cookies = StreamCookieSigner(settings.state_dir)
212
+ app.state.client_address = ClientAddress(settings.trusted_proxies)
213
+
214
+ if settings.cors.allowed_origins:
215
+ app.add_middleware(
216
+ CORSMiddleware,
217
+ allow_origins=settings.cors.allowed_origins,
218
+ allow_credentials=False, # bearer tokens only; the stream cookie is same-origin
219
+ allow_methods=["GET", "POST", "PATCH", "DELETE"],
220
+ allow_headers=["Authorization", "Content-Type", *settings.cors.extra_headers],
221
+ expose_headers=list(DAEMON_AUTH_HEADER),
222
+ max_age=600,
223
+ )
224
+
225
+ errors = ((NotFound, 404), (Invalid, 400), (Conflict, 409), (Forbidden, 403), (Unauthorized, 401),
226
+ (TooManyRequests, 429), (TmuxError, 502), (Unavailable, 502)) # fmt: skip
227
+ for error, status in errors:
228
+
229
+ def handler(_: Request, exc: Exception, status: int = status) -> JSONResponse:
230
+ headers = DAEMON_AUTH_HEADER if status == 401 else None
231
+ return JSONResponse({"detail": str(exc)}, status_code=status, headers=headers)
232
+
233
+ app.add_exception_handler(error, handler)
234
+
235
+ routers = (system_router, auth_router, sessions_router, summaries_router, asr_router, mcp_router, stream_router,
236
+ push_router, hooks_router, nodes_router) # fmt: skip
237
+ for router in routers:
238
+ app.include_router(router)
239
+
240
+ if settings.serve_ui:
241
+ mount_ui(app, settings.public_url, daemon_name)
242
+ return app
File without changes