muxtower 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.
Files changed (203) hide show
  1. muxtower-0.1.0/PKG-INFO +32 -0
  2. muxtower-0.1.0/README.md +8 -0
  3. muxtower-0.1.0/pyproject.toml +62 -0
  4. muxtower-0.1.0/src/muxtower/__init__.py +6 -0
  5. muxtower-0.1.0/src/muxtower/__main__.py +3 -0
  6. muxtower-0.1.0/src/muxtower/agent_text.py +31 -0
  7. muxtower-0.1.0/src/muxtower/agents/__init__.py +8 -0
  8. muxtower-0.1.0/src/muxtower/agents/accounts.py +91 -0
  9. muxtower-0.1.0/src/muxtower/agents/codex/__init__.py +0 -0
  10. muxtower-0.1.0/src/muxtower/agents/codex/processes.py +66 -0
  11. muxtower-0.1.0/src/muxtower/agents/codex/rollout.py +155 -0
  12. muxtower-0.1.0/src/muxtower/agents/processes.py +22 -0
  13. muxtower-0.1.0/src/muxtower/app.py +242 -0
  14. muxtower-0.1.0/src/muxtower/approval/__init__.py +0 -0
  15. muxtower-0.1.0/src/muxtower/approval/auto.py +53 -0
  16. muxtower-0.1.0/src/muxtower/approval/models.py +13 -0
  17. muxtower-0.1.0/src/muxtower/approval/service.py +78 -0
  18. muxtower-0.1.0/src/muxtower/asr/__init__.py +0 -0
  19. muxtower-0.1.0/src/muxtower/asr/models.py +5 -0
  20. muxtower-0.1.0/src/muxtower/asr/openai_compatible.py +42 -0
  21. muxtower-0.1.0/src/muxtower/asr/ports.py +7 -0
  22. muxtower-0.1.0/src/muxtower/asr/routes.py +47 -0
  23. muxtower-0.1.0/src/muxtower/asr/service.py +51 -0
  24. muxtower-0.1.0/src/muxtower/auth/__init__.py +0 -0
  25. muxtower-0.1.0/src/muxtower/auth/deps.py +30 -0
  26. muxtower-0.1.0/src/muxtower/auth/models.py +26 -0
  27. muxtower-0.1.0/src/muxtower/auth/pairing.py +77 -0
  28. muxtower-0.1.0/src/muxtower/auth/ports.py +19 -0
  29. muxtower-0.1.0/src/muxtower/auth/repository.py +39 -0
  30. muxtower-0.1.0/src/muxtower/auth/routes.py +57 -0
  31. muxtower-0.1.0/src/muxtower/auth/service.py +61 -0
  32. muxtower-0.1.0/src/muxtower/auth/stream_cookie.py +41 -0
  33. muxtower-0.1.0/src/muxtower/builders.py +85 -0
  34. muxtower-0.1.0/src/muxtower/claude_conversation.py +229 -0
  35. muxtower-0.1.0/src/muxtower/cli/__init__.py +0 -0
  36. muxtower-0.1.0/src/muxtower/cli/client.py +70 -0
  37. muxtower-0.1.0/src/muxtower/cli/main.py +130 -0
  38. muxtower-0.1.0/src/muxtower/cli/pair.py +100 -0
  39. muxtower-0.1.0/src/muxtower/cli/serve.py +82 -0
  40. muxtower-0.1.0/src/muxtower/concierge/__init__.py +0 -0
  41. muxtower-0.1.0/src/muxtower/concierge/ports.py +17 -0
  42. muxtower-0.1.0/src/muxtower/concierge/prompt.py +21 -0
  43. muxtower-0.1.0/src/muxtower/concierge/service.py +168 -0
  44. muxtower-0.1.0/src/muxtower/concierge/store.py +33 -0
  45. muxtower-0.1.0/src/muxtower/config.py +202 -0
  46. muxtower-0.1.0/src/muxtower/errors.py +30 -0
  47. muxtower-0.1.0/src/muxtower/hooks/__init__.py +0 -0
  48. muxtower-0.1.0/src/muxtower/hooks/claude_code.py +55 -0
  49. muxtower-0.1.0/src/muxtower/hooks/codex.py +99 -0
  50. muxtower-0.1.0/src/muxtower/hooks/events.py +79 -0
  51. muxtower-0.1.0/src/muxtower/hooks/harnesses.py +12 -0
  52. muxtower-0.1.0/src/muxtower/hooks/ports.py +34 -0
  53. muxtower-0.1.0/src/muxtower/hooks/relay.py +40 -0
  54. muxtower-0.1.0/src/muxtower/hooks/routes.py +28 -0
  55. muxtower-0.1.0/src/muxtower/infra/__init__.py +0 -0
  56. muxtower-0.1.0/src/muxtower/infra/audit.py +21 -0
  57. muxtower-0.1.0/src/muxtower/infra/bus.py +28 -0
  58. muxtower-0.1.0/src/muxtower/infra/db.py +60 -0
  59. muxtower-0.1.0/src/muxtower/infra/migrations/001_tokens_audit.sql +16 -0
  60. muxtower-0.1.0/src/muxtower/infra/migrations/002_sessions_pairing_push.sql +31 -0
  61. muxtower-0.1.0/src/muxtower/infra/migrations/003_hook_state.sql +2 -0
  62. muxtower-0.1.0/src/muxtower/infra/migrations/004_summaries.sql +6 -0
  63. muxtower-0.1.0/src/muxtower/infra/migrations/005_push_done.sql +2 -0
  64. muxtower-0.1.0/src/muxtower/infra/migrations/006_session_reads.sql +8 -0
  65. muxtower-0.1.0/src/muxtower/infra/migrations/007_session_marks.sql +3 -0
  66. muxtower-0.1.0/src/muxtower/infra/migrations/008_push_device.sql +5 -0
  67. muxtower-0.1.0/src/muxtower/infra/migrations/009_archive.sql +11 -0
  68. muxtower-0.1.0/src/muxtower/infra/migrations/010_session_place.sql +4 -0
  69. muxtower-0.1.0/src/muxtower/infra/migrations/011_session_named.sql +3 -0
  70. muxtower-0.1.0/src/muxtower/infra/migrations/012_session_notify.sql +3 -0
  71. muxtower-0.1.0/src/muxtower/infra/migrations/013_nodes.sql +11 -0
  72. muxtower-0.1.0/src/muxtower/infra/migrations/014_node_processes.sql +4 -0
  73. muxtower-0.1.0/src/muxtower/infra/migrations/__init__.py +0 -0
  74. muxtower-0.1.0/src/muxtower/infra/procfs.py +83 -0
  75. muxtower-0.1.0/src/muxtower/infra/tmux.py +120 -0
  76. muxtower-0.1.0/src/muxtower/mcp/__init__.py +0 -0
  77. muxtower-0.1.0/src/muxtower/mcp/routes.py +101 -0
  78. muxtower-0.1.0/src/muxtower/mcp/service.py +161 -0
  79. muxtower-0.1.0/src/muxtower/net.py +29 -0
  80. muxtower-0.1.0/src/muxtower/nodes/__init__.py +0 -0
  81. muxtower-0.1.0/src/muxtower/nodes/api.py +84 -0
  82. muxtower-0.1.0/src/muxtower/nodes/client.py +175 -0
  83. muxtower-0.1.0/src/muxtower/nodes/directory.py +92 -0
  84. muxtower-0.1.0/src/muxtower/nodes/hub.py +250 -0
  85. muxtower-0.1.0/src/muxtower/nodes/link.py +80 -0
  86. muxtower-0.1.0/src/muxtower/nodes/models.py +49 -0
  87. muxtower-0.1.0/src/muxtower/nodes/processes.py +73 -0
  88. muxtower-0.1.0/src/muxtower/nodes/protocol.py +75 -0
  89. muxtower-0.1.0/src/muxtower/nodes/repository.py +77 -0
  90. muxtower-0.1.0/src/muxtower/nodes/routes.py +117 -0
  91. muxtower-0.1.0/src/muxtower/nodes/session.py +138 -0
  92. muxtower-0.1.0/src/muxtower/nodes/source.py +195 -0
  93. muxtower-0.1.0/src/muxtower/nodes/store.py +33 -0
  94. muxtower-0.1.0/src/muxtower/nodes/update.py +125 -0
  95. muxtower-0.1.0/src/muxtower/notify/__init__.py +0 -0
  96. muxtower-0.1.0/src/muxtower/notify/models.py +46 -0
  97. muxtower-0.1.0/src/muxtower/notify/ports.py +22 -0
  98. muxtower-0.1.0/src/muxtower/notify/repository.py +49 -0
  99. muxtower-0.1.0/src/muxtower/notify/routes.py +34 -0
  100. muxtower-0.1.0/src/muxtower/notify/service.py +165 -0
  101. muxtower-0.1.0/src/muxtower/notify/text.py +60 -0
  102. muxtower-0.1.0/src/muxtower/notify/webpush_sender.py +57 -0
  103. muxtower-0.1.0/src/muxtower/origins.py +24 -0
  104. muxtower-0.1.0/src/muxtower/runtimes/__init__.py +0 -0
  105. muxtower-0.1.0/src/muxtower/runtimes/defaults/__init__.py +0 -0
  106. muxtower-0.1.0/src/muxtower/runtimes/defaults/claude-code.toml +77 -0
  107. muxtower-0.1.0/src/muxtower/runtimes/defaults/codex.toml +60 -0
  108. muxtower-0.1.0/src/muxtower/runtimes/defaults/opencode.toml +4 -0
  109. muxtower-0.1.0/src/muxtower/runtimes/models.py +41 -0
  110. muxtower-0.1.0/src/muxtower/runtimes/spec.py +206 -0
  111. muxtower-0.1.0/src/muxtower/sessions/__init__.py +0 -0
  112. muxtower-0.1.0/src/muxtower/sessions/archive.py +103 -0
  113. muxtower-0.1.0/src/muxtower/sessions/archive_repository.py +44 -0
  114. muxtower-0.1.0/src/muxtower/sessions/archiving.py +29 -0
  115. muxtower-0.1.0/src/muxtower/sessions/claude_processes.py +105 -0
  116. muxtower-0.1.0/src/muxtower/sessions/decorate.py +47 -0
  117. muxtower-0.1.0/src/muxtower/sessions/directory.py +233 -0
  118. muxtower-0.1.0/src/muxtower/sessions/launcher.py +56 -0
  119. muxtower-0.1.0/src/muxtower/sessions/live.py +103 -0
  120. muxtower-0.1.0/src/muxtower/sessions/marks.py +136 -0
  121. muxtower-0.1.0/src/muxtower/sessions/merged.py +29 -0
  122. muxtower-0.1.0/src/muxtower/sessions/models.py +206 -0
  123. muxtower-0.1.0/src/muxtower/sessions/monitor.py +182 -0
  124. muxtower-0.1.0/src/muxtower/sessions/placement.py +30 -0
  125. muxtower-0.1.0/src/muxtower/sessions/ports.py +108 -0
  126. muxtower-0.1.0/src/muxtower/sessions/repository.py +64 -0
  127. muxtower-0.1.0/src/muxtower/sessions/routes.py +207 -0
  128. muxtower-0.1.0/src/muxtower/sessions/service.py +234 -0
  129. muxtower-0.1.0/src/muxtower/sessions/transcripts.py +164 -0
  130. muxtower-0.1.0/src/muxtower/sessions/turns.py +82 -0
  131. muxtower-0.1.0/src/muxtower/sources/__init__.py +0 -0
  132. muxtower-0.1.0/src/muxtower/sources/codex/__init__.py +0 -0
  133. muxtower-0.1.0/src/muxtower/sources/codex/adapter.py +178 -0
  134. muxtower-0.1.0/src/muxtower/sources/codex/items.py +122 -0
  135. muxtower-0.1.0/src/muxtower/sources/codex/ports.py +31 -0
  136. muxtower-0.1.0/src/muxtower/sources/codex/requests.py +85 -0
  137. muxtower-0.1.0/src/muxtower/sources/codex/service.py +220 -0
  138. muxtower-0.1.0/src/muxtower/sources/codex/store.py +20 -0
  139. muxtower-0.1.0/src/muxtower/sources/codex/thread.py +142 -0
  140. muxtower-0.1.0/src/muxtower/sources/remote_control/__init__.py +0 -0
  141. muxtower-0.1.0/src/muxtower/sources/remote_control/adapter.py +124 -0
  142. muxtower-0.1.0/src/muxtower/sources/remote_control/folder.py +50 -0
  143. muxtower-0.1.0/src/muxtower/sources/remote_control/ports.py +37 -0
  144. muxtower-0.1.0/src/muxtower/sources/remote_control/service.py +267 -0
  145. muxtower-0.1.0/src/muxtower/sources/remote_control/view.py +141 -0
  146. muxtower-0.1.0/src/muxtower/summaries/__init__.py +0 -0
  147. muxtower-0.1.0/src/muxtower/summaries/cli.py +64 -0
  148. muxtower-0.1.0/src/muxtower/summaries/models.py +14 -0
  149. muxtower-0.1.0/src/muxtower/summaries/ports.py +12 -0
  150. muxtower-0.1.0/src/muxtower/summaries/repository.py +29 -0
  151. muxtower-0.1.0/src/muxtower/summaries/routes.py +34 -0
  152. muxtower-0.1.0/src/muxtower/summaries/service.py +118 -0
  153. muxtower-0.1.0/src/muxtower/system/__init__.py +0 -0
  154. muxtower-0.1.0/src/muxtower/system/routes.py +36 -0
  155. muxtower-0.1.0/src/muxtower/terminal/__init__.py +0 -0
  156. muxtower-0.1.0/src/muxtower/terminal/models.py +36 -0
  157. muxtower-0.1.0/src/muxtower/terminal/ports.py +38 -0
  158. muxtower-0.1.0/src/muxtower/terminal/service.py +44 -0
  159. muxtower-0.1.0/src/muxtower/terminal/stream.py +213 -0
  160. muxtower-0.1.0/src/muxtower/vendor/__init__.py +0 -0
  161. muxtower-0.1.0/src/muxtower/vendor/claude_rc/LICENSE +21 -0
  162. muxtower-0.1.0/src/muxtower/vendor/claude_rc/VENDORED.md +14 -0
  163. muxtower-0.1.0/src/muxtower/vendor/claude_rc/__init__.py +11 -0
  164. muxtower-0.1.0/src/muxtower/vendor/claude_rc/_version.py +1 -0
  165. muxtower-0.1.0/src/muxtower/vendor/claude_rc/client.py +870 -0
  166. muxtower-0.1.0/src/muxtower/vendor/claude_rc/credentials.py +320 -0
  167. muxtower-0.1.0/src/muxtower/vendor/claude_rc/events.py +371 -0
  168. muxtower-0.1.0/src/muxtower/vendor/claude_rc/sse.py +68 -0
  169. muxtower-0.1.0/src/muxtower/web/apple-touch-icon.png +0 -0
  170. muxtower-0.1.0/src/muxtower/web/assets/_sessions-BPnsIMWg.js +1 -0
  171. muxtower-0.1.0/src/muxtower/web/assets/_sessions-F6Hfi8Dj.js +1 -0
  172. muxtower-0.1.0/src/muxtower/web/assets/card-BXVZ-VmL.js +1 -0
  173. muxtower-0.1.0/src/muxtower/web/assets/circle-x-CWQghSvw.js +1 -0
  174. muxtower-0.1.0/src/muxtower/web/assets/createLucideIcon-BXLm2Wke.js +1 -0
  175. muxtower-0.1.0/src/muxtower/web/assets/d._daemonId.s._sessionId-CyoPJIeV.js +44 -0
  176. muxtower-0.1.0/src/muxtower/web/assets/daemons-kRSvABqz.js +8 -0
  177. muxtower-0.1.0/src/muxtower/web/assets/empty-state-Cli24rK7.js +1 -0
  178. muxtower-0.1.0/src/muxtower/web/assets/index-6VCCVynB.css +2 -0
  179. muxtower-0.1.0/src/muxtower/web/assets/index-CEU_Oypb.js +11 -0
  180. muxtower-0.1.0/src/muxtower/web/assets/input-LSp6RxG5.js +1 -0
  181. muxtower-0.1.0/src/muxtower/web/assets/pair-BZfb2lgz.js +1 -0
  182. muxtower-0.1.0/src/muxtower/web/assets/pair-claim-Bv8z2eK-.js +1 -0
  183. muxtower-0.1.0/src/muxtower/web/assets/pane-1oqVJ-cZ.js +1 -0
  184. muxtower-0.1.0/src/muxtower/web/assets/preload-helper-BM7m-G0T.js +1 -0
  185. muxtower-0.1.0/src/muxtower/web/assets/query-state-BmXDJtvN.js +1 -0
  186. muxtower-0.1.0/src/muxtower/web/assets/streams-CtDcAcEr.js +41 -0
  187. muxtower-0.1.0/src/muxtower/web/assets/use-media-query-gZIa5xwu.js +1 -0
  188. muxtower-0.1.0/src/muxtower/web/assets/useNavigate-ByR6iLuJ.js +1 -0
  189. muxtower-0.1.0/src/muxtower/web/badge-96.png +0 -0
  190. muxtower-0.1.0/src/muxtower/web/config.json +1 -0
  191. muxtower-0.1.0/src/muxtower/web/favicon.svg +7 -0
  192. muxtower-0.1.0/src/muxtower/web/g2/assets/index-Be_dtrCV.js +2 -0
  193. muxtower-0.1.0/src/muxtower/web/g2/assets/index-JqCupXI_.css +1 -0
  194. muxtower-0.1.0/src/muxtower/web/g2/index.html +13 -0
  195. muxtower-0.1.0/src/muxtower/web/icon-192.png +0 -0
  196. muxtower-0.1.0/src/muxtower/web/icon-512.png +0 -0
  197. muxtower-0.1.0/src/muxtower/web/icon-maskable-512.png +0 -0
  198. muxtower-0.1.0/src/muxtower/web/icon.svg +7 -0
  199. muxtower-0.1.0/src/muxtower/web/index.html +24 -0
  200. muxtower-0.1.0/src/muxtower/web/manifest.webmanifest +1 -0
  201. muxtower-0.1.0/src/muxtower/web/registerSW.js +1 -0
  202. muxtower-0.1.0/src/muxtower/web/sw.js +1 -0
  203. muxtower-0.1.0/src/muxtower/web.py +47 -0
@@ -0,0 +1,32 @@
1
+ Metadata-Version: 2.4
2
+ Name: muxtower
3
+ Version: 0.1.0
4
+ Summary: Approve and drive coding agents running in tmux on a remote machine, from your phone. Headless daemon + API, one Bearer token, works behind any reverse proxy.
5
+ License-Expression: Apache-2.0
6
+ Keywords: tmux,claude-code,codex,coding-agent,remote,approval,pwa
7
+ Requires-Python: >=3.11,<4.0
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.11
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Classifier: Programming Language :: Python :: 3.13
12
+ Classifier: Programming Language :: Python :: 3.14
13
+ Classifier: Programming Language :: Python :: 3.15
14
+ Requires-Dist: fastapi (>=0.115)
15
+ Requires-Dist: httpx (>=0.27)
16
+ Requires-Dist: libtmux (>=0.61)
17
+ Requires-Dist: pydantic (>=2.7)
18
+ Requires-Dist: pywebpush (>=2.0)
19
+ Requires-Dist: segno (>=1.6)
20
+ Requires-Dist: uvicorn[standard] (>=0.30)
21
+ Requires-Dist: websockets (>=14)
22
+ Description-Content-Type: text/markdown
23
+
24
+ # muxtower (Python package)
25
+
26
+ `pipx install muxtower` installs one command, `muxtower`:
27
+
28
+ - `muxtower serve` — run the daemon: the API under `/v1` and the web app at `/`.
29
+ - `muxtower token add|list|revoke` — manage device tokens.
30
+
31
+ See the repository README and `docs/design.md`.
32
+
@@ -0,0 +1,8 @@
1
+ # muxtower (Python package)
2
+
3
+ `pipx install muxtower` installs one command, `muxtower`:
4
+
5
+ - `muxtower serve` — run the daemon: the API under `/v1` and the web app at `/`.
6
+ - `muxtower token add|list|revoke` — manage device tokens.
7
+
8
+ See the repository README and `docs/design.md`.
@@ -0,0 +1,62 @@
1
+ [project]
2
+ name = "muxtower"
3
+ version = "0.1.0"
4
+ description = "Approve and drive coding agents running in tmux on a remote machine, from your phone. Headless daemon + API, one Bearer token, works behind any reverse proxy."
5
+ readme = "README.md"
6
+ license = "Apache-2.0"
7
+ requires-python = ">=3.11,<4.0"
8
+ keywords = ["tmux", "claude-code", "codex", "coding-agent", "remote", "approval", "pwa"]
9
+ dependencies = [
10
+ "fastapi>=0.115",
11
+ "uvicorn[standard]>=0.30",
12
+ "pydantic>=2.7",
13
+ "libtmux>=0.61",
14
+ "pywebpush>=2.0",
15
+ "segno>=1.6",
16
+ "httpx>=0.27", # vendored claude_rc (Remote Control), joining a hub
17
+ "websockets>=14", # Codex's app-server, a node's link to its hub
18
+ ]
19
+
20
+ [project.scripts]
21
+ muxtower = "muxtower.cli.main:main"
22
+
23
+ [tool.poetry]
24
+ packages = [{ include = "muxtower", from = "src" }]
25
+ # The built UI is copied into src/muxtower/web/ by `make wheel`; it is git-ignored, so include it explicitly.
26
+ include = [{ path = "src/muxtower/web/**/*", format = ["sdist", "wheel"] }]
27
+
28
+ [tool.poetry.group.dev.dependencies]
29
+ pytest = "^8.3"
30
+ pytest-asyncio = "^0.24"
31
+ httpx = "^0.27"
32
+ ruff = "^0.7"
33
+ mypy = "^1.13"
34
+
35
+ [build-system]
36
+ requires = ["poetry-core>=2.0"]
37
+ build-backend = "poetry.core.masonry.api"
38
+
39
+ [tool.ruff]
40
+ line-length = 120
41
+ extend-exclude = ["src/muxtower/vendor"]
42
+ target-version = "py311"
43
+
44
+ [tool.ruff.lint]
45
+ select = ["E", "F", "I", "B", "UP", "S", "BLE"]
46
+ ignore = ["S101"]
47
+
48
+ [tool.ruff.lint.per-file-ignores]
49
+ "tests/**" = ["S105", "S106"]
50
+
51
+ [tool.pytest.ini_options]
52
+ asyncio_mode = "auto"
53
+ testpaths = ["tests"]
54
+
55
+ [tool.mypy]
56
+ strict = true
57
+ ignore_missing_imports = true
58
+ exclude = ["src/muxtower/vendor/"]
59
+
60
+ [[tool.mypy.overrides]]
61
+ module = "muxtower.vendor.*"
62
+ follow_imports = "skip"
@@ -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"
@@ -0,0 +1,3 @@
1
+ from muxtower.cli.main import main
2
+
3
+ main()
@@ -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()}