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.
- muxtower-0.1.0/PKG-INFO +32 -0
- muxtower-0.1.0/README.md +8 -0
- muxtower-0.1.0/pyproject.toml +62 -0
- muxtower-0.1.0/src/muxtower/__init__.py +6 -0
- muxtower-0.1.0/src/muxtower/__main__.py +3 -0
- muxtower-0.1.0/src/muxtower/agent_text.py +31 -0
- muxtower-0.1.0/src/muxtower/agents/__init__.py +8 -0
- muxtower-0.1.0/src/muxtower/agents/accounts.py +91 -0
- muxtower-0.1.0/src/muxtower/agents/codex/__init__.py +0 -0
- muxtower-0.1.0/src/muxtower/agents/codex/processes.py +66 -0
- muxtower-0.1.0/src/muxtower/agents/codex/rollout.py +155 -0
- muxtower-0.1.0/src/muxtower/agents/processes.py +22 -0
- muxtower-0.1.0/src/muxtower/app.py +242 -0
- muxtower-0.1.0/src/muxtower/approval/__init__.py +0 -0
- muxtower-0.1.0/src/muxtower/approval/auto.py +53 -0
- muxtower-0.1.0/src/muxtower/approval/models.py +13 -0
- muxtower-0.1.0/src/muxtower/approval/service.py +78 -0
- muxtower-0.1.0/src/muxtower/asr/__init__.py +0 -0
- muxtower-0.1.0/src/muxtower/asr/models.py +5 -0
- muxtower-0.1.0/src/muxtower/asr/openai_compatible.py +42 -0
- muxtower-0.1.0/src/muxtower/asr/ports.py +7 -0
- muxtower-0.1.0/src/muxtower/asr/routes.py +47 -0
- muxtower-0.1.0/src/muxtower/asr/service.py +51 -0
- muxtower-0.1.0/src/muxtower/auth/__init__.py +0 -0
- muxtower-0.1.0/src/muxtower/auth/deps.py +30 -0
- muxtower-0.1.0/src/muxtower/auth/models.py +26 -0
- muxtower-0.1.0/src/muxtower/auth/pairing.py +77 -0
- muxtower-0.1.0/src/muxtower/auth/ports.py +19 -0
- muxtower-0.1.0/src/muxtower/auth/repository.py +39 -0
- muxtower-0.1.0/src/muxtower/auth/routes.py +57 -0
- muxtower-0.1.0/src/muxtower/auth/service.py +61 -0
- muxtower-0.1.0/src/muxtower/auth/stream_cookie.py +41 -0
- muxtower-0.1.0/src/muxtower/builders.py +85 -0
- muxtower-0.1.0/src/muxtower/claude_conversation.py +229 -0
- muxtower-0.1.0/src/muxtower/cli/__init__.py +0 -0
- muxtower-0.1.0/src/muxtower/cli/client.py +70 -0
- muxtower-0.1.0/src/muxtower/cli/main.py +130 -0
- muxtower-0.1.0/src/muxtower/cli/pair.py +100 -0
- muxtower-0.1.0/src/muxtower/cli/serve.py +82 -0
- muxtower-0.1.0/src/muxtower/concierge/__init__.py +0 -0
- muxtower-0.1.0/src/muxtower/concierge/ports.py +17 -0
- muxtower-0.1.0/src/muxtower/concierge/prompt.py +21 -0
- muxtower-0.1.0/src/muxtower/concierge/service.py +168 -0
- muxtower-0.1.0/src/muxtower/concierge/store.py +33 -0
- muxtower-0.1.0/src/muxtower/config.py +202 -0
- muxtower-0.1.0/src/muxtower/errors.py +30 -0
- muxtower-0.1.0/src/muxtower/hooks/__init__.py +0 -0
- muxtower-0.1.0/src/muxtower/hooks/claude_code.py +55 -0
- muxtower-0.1.0/src/muxtower/hooks/codex.py +99 -0
- muxtower-0.1.0/src/muxtower/hooks/events.py +79 -0
- muxtower-0.1.0/src/muxtower/hooks/harnesses.py +12 -0
- muxtower-0.1.0/src/muxtower/hooks/ports.py +34 -0
- muxtower-0.1.0/src/muxtower/hooks/relay.py +40 -0
- muxtower-0.1.0/src/muxtower/hooks/routes.py +28 -0
- muxtower-0.1.0/src/muxtower/infra/__init__.py +0 -0
- muxtower-0.1.0/src/muxtower/infra/audit.py +21 -0
- muxtower-0.1.0/src/muxtower/infra/bus.py +28 -0
- muxtower-0.1.0/src/muxtower/infra/db.py +60 -0
- muxtower-0.1.0/src/muxtower/infra/migrations/001_tokens_audit.sql +16 -0
- muxtower-0.1.0/src/muxtower/infra/migrations/002_sessions_pairing_push.sql +31 -0
- muxtower-0.1.0/src/muxtower/infra/migrations/003_hook_state.sql +2 -0
- muxtower-0.1.0/src/muxtower/infra/migrations/004_summaries.sql +6 -0
- muxtower-0.1.0/src/muxtower/infra/migrations/005_push_done.sql +2 -0
- muxtower-0.1.0/src/muxtower/infra/migrations/006_session_reads.sql +8 -0
- muxtower-0.1.0/src/muxtower/infra/migrations/007_session_marks.sql +3 -0
- muxtower-0.1.0/src/muxtower/infra/migrations/008_push_device.sql +5 -0
- muxtower-0.1.0/src/muxtower/infra/migrations/009_archive.sql +11 -0
- muxtower-0.1.0/src/muxtower/infra/migrations/010_session_place.sql +4 -0
- muxtower-0.1.0/src/muxtower/infra/migrations/011_session_named.sql +3 -0
- muxtower-0.1.0/src/muxtower/infra/migrations/012_session_notify.sql +3 -0
- muxtower-0.1.0/src/muxtower/infra/migrations/013_nodes.sql +11 -0
- muxtower-0.1.0/src/muxtower/infra/migrations/014_node_processes.sql +4 -0
- muxtower-0.1.0/src/muxtower/infra/migrations/__init__.py +0 -0
- muxtower-0.1.0/src/muxtower/infra/procfs.py +83 -0
- muxtower-0.1.0/src/muxtower/infra/tmux.py +120 -0
- muxtower-0.1.0/src/muxtower/mcp/__init__.py +0 -0
- muxtower-0.1.0/src/muxtower/mcp/routes.py +101 -0
- muxtower-0.1.0/src/muxtower/mcp/service.py +161 -0
- muxtower-0.1.0/src/muxtower/net.py +29 -0
- muxtower-0.1.0/src/muxtower/nodes/__init__.py +0 -0
- muxtower-0.1.0/src/muxtower/nodes/api.py +84 -0
- muxtower-0.1.0/src/muxtower/nodes/client.py +175 -0
- muxtower-0.1.0/src/muxtower/nodes/directory.py +92 -0
- muxtower-0.1.0/src/muxtower/nodes/hub.py +250 -0
- muxtower-0.1.0/src/muxtower/nodes/link.py +80 -0
- muxtower-0.1.0/src/muxtower/nodes/models.py +49 -0
- muxtower-0.1.0/src/muxtower/nodes/processes.py +73 -0
- muxtower-0.1.0/src/muxtower/nodes/protocol.py +75 -0
- muxtower-0.1.0/src/muxtower/nodes/repository.py +77 -0
- muxtower-0.1.0/src/muxtower/nodes/routes.py +117 -0
- muxtower-0.1.0/src/muxtower/nodes/session.py +138 -0
- muxtower-0.1.0/src/muxtower/nodes/source.py +195 -0
- muxtower-0.1.0/src/muxtower/nodes/store.py +33 -0
- muxtower-0.1.0/src/muxtower/nodes/update.py +125 -0
- muxtower-0.1.0/src/muxtower/notify/__init__.py +0 -0
- muxtower-0.1.0/src/muxtower/notify/models.py +46 -0
- muxtower-0.1.0/src/muxtower/notify/ports.py +22 -0
- muxtower-0.1.0/src/muxtower/notify/repository.py +49 -0
- muxtower-0.1.0/src/muxtower/notify/routes.py +34 -0
- muxtower-0.1.0/src/muxtower/notify/service.py +165 -0
- muxtower-0.1.0/src/muxtower/notify/text.py +60 -0
- muxtower-0.1.0/src/muxtower/notify/webpush_sender.py +57 -0
- muxtower-0.1.0/src/muxtower/origins.py +24 -0
- muxtower-0.1.0/src/muxtower/runtimes/__init__.py +0 -0
- muxtower-0.1.0/src/muxtower/runtimes/defaults/__init__.py +0 -0
- muxtower-0.1.0/src/muxtower/runtimes/defaults/claude-code.toml +77 -0
- muxtower-0.1.0/src/muxtower/runtimes/defaults/codex.toml +60 -0
- muxtower-0.1.0/src/muxtower/runtimes/defaults/opencode.toml +4 -0
- muxtower-0.1.0/src/muxtower/runtimes/models.py +41 -0
- muxtower-0.1.0/src/muxtower/runtimes/spec.py +206 -0
- muxtower-0.1.0/src/muxtower/sessions/__init__.py +0 -0
- muxtower-0.1.0/src/muxtower/sessions/archive.py +103 -0
- muxtower-0.1.0/src/muxtower/sessions/archive_repository.py +44 -0
- muxtower-0.1.0/src/muxtower/sessions/archiving.py +29 -0
- muxtower-0.1.0/src/muxtower/sessions/claude_processes.py +105 -0
- muxtower-0.1.0/src/muxtower/sessions/decorate.py +47 -0
- muxtower-0.1.0/src/muxtower/sessions/directory.py +233 -0
- muxtower-0.1.0/src/muxtower/sessions/launcher.py +56 -0
- muxtower-0.1.0/src/muxtower/sessions/live.py +103 -0
- muxtower-0.1.0/src/muxtower/sessions/marks.py +136 -0
- muxtower-0.1.0/src/muxtower/sessions/merged.py +29 -0
- muxtower-0.1.0/src/muxtower/sessions/models.py +206 -0
- muxtower-0.1.0/src/muxtower/sessions/monitor.py +182 -0
- muxtower-0.1.0/src/muxtower/sessions/placement.py +30 -0
- muxtower-0.1.0/src/muxtower/sessions/ports.py +108 -0
- muxtower-0.1.0/src/muxtower/sessions/repository.py +64 -0
- muxtower-0.1.0/src/muxtower/sessions/routes.py +207 -0
- muxtower-0.1.0/src/muxtower/sessions/service.py +234 -0
- muxtower-0.1.0/src/muxtower/sessions/transcripts.py +164 -0
- muxtower-0.1.0/src/muxtower/sessions/turns.py +82 -0
- muxtower-0.1.0/src/muxtower/sources/__init__.py +0 -0
- muxtower-0.1.0/src/muxtower/sources/codex/__init__.py +0 -0
- muxtower-0.1.0/src/muxtower/sources/codex/adapter.py +178 -0
- muxtower-0.1.0/src/muxtower/sources/codex/items.py +122 -0
- muxtower-0.1.0/src/muxtower/sources/codex/ports.py +31 -0
- muxtower-0.1.0/src/muxtower/sources/codex/requests.py +85 -0
- muxtower-0.1.0/src/muxtower/sources/codex/service.py +220 -0
- muxtower-0.1.0/src/muxtower/sources/codex/store.py +20 -0
- muxtower-0.1.0/src/muxtower/sources/codex/thread.py +142 -0
- muxtower-0.1.0/src/muxtower/sources/remote_control/__init__.py +0 -0
- muxtower-0.1.0/src/muxtower/sources/remote_control/adapter.py +124 -0
- muxtower-0.1.0/src/muxtower/sources/remote_control/folder.py +50 -0
- muxtower-0.1.0/src/muxtower/sources/remote_control/ports.py +37 -0
- muxtower-0.1.0/src/muxtower/sources/remote_control/service.py +267 -0
- muxtower-0.1.0/src/muxtower/sources/remote_control/view.py +141 -0
- muxtower-0.1.0/src/muxtower/summaries/__init__.py +0 -0
- muxtower-0.1.0/src/muxtower/summaries/cli.py +64 -0
- muxtower-0.1.0/src/muxtower/summaries/models.py +14 -0
- muxtower-0.1.0/src/muxtower/summaries/ports.py +12 -0
- muxtower-0.1.0/src/muxtower/summaries/repository.py +29 -0
- muxtower-0.1.0/src/muxtower/summaries/routes.py +34 -0
- muxtower-0.1.0/src/muxtower/summaries/service.py +118 -0
- muxtower-0.1.0/src/muxtower/system/__init__.py +0 -0
- muxtower-0.1.0/src/muxtower/system/routes.py +36 -0
- muxtower-0.1.0/src/muxtower/terminal/__init__.py +0 -0
- muxtower-0.1.0/src/muxtower/terminal/models.py +36 -0
- muxtower-0.1.0/src/muxtower/terminal/ports.py +38 -0
- muxtower-0.1.0/src/muxtower/terminal/service.py +44 -0
- muxtower-0.1.0/src/muxtower/terminal/stream.py +213 -0
- muxtower-0.1.0/src/muxtower/vendor/__init__.py +0 -0
- muxtower-0.1.0/src/muxtower/vendor/claude_rc/LICENSE +21 -0
- muxtower-0.1.0/src/muxtower/vendor/claude_rc/VENDORED.md +14 -0
- muxtower-0.1.0/src/muxtower/vendor/claude_rc/__init__.py +11 -0
- muxtower-0.1.0/src/muxtower/vendor/claude_rc/_version.py +1 -0
- muxtower-0.1.0/src/muxtower/vendor/claude_rc/client.py +870 -0
- muxtower-0.1.0/src/muxtower/vendor/claude_rc/credentials.py +320 -0
- muxtower-0.1.0/src/muxtower/vendor/claude_rc/events.py +371 -0
- muxtower-0.1.0/src/muxtower/vendor/claude_rc/sse.py +68 -0
- muxtower-0.1.0/src/muxtower/web/apple-touch-icon.png +0 -0
- muxtower-0.1.0/src/muxtower/web/assets/_sessions-BPnsIMWg.js +1 -0
- muxtower-0.1.0/src/muxtower/web/assets/_sessions-F6Hfi8Dj.js +1 -0
- muxtower-0.1.0/src/muxtower/web/assets/card-BXVZ-VmL.js +1 -0
- muxtower-0.1.0/src/muxtower/web/assets/circle-x-CWQghSvw.js +1 -0
- muxtower-0.1.0/src/muxtower/web/assets/createLucideIcon-BXLm2Wke.js +1 -0
- muxtower-0.1.0/src/muxtower/web/assets/d._daemonId.s._sessionId-CyoPJIeV.js +44 -0
- muxtower-0.1.0/src/muxtower/web/assets/daemons-kRSvABqz.js +8 -0
- muxtower-0.1.0/src/muxtower/web/assets/empty-state-Cli24rK7.js +1 -0
- muxtower-0.1.0/src/muxtower/web/assets/index-6VCCVynB.css +2 -0
- muxtower-0.1.0/src/muxtower/web/assets/index-CEU_Oypb.js +11 -0
- muxtower-0.1.0/src/muxtower/web/assets/input-LSp6RxG5.js +1 -0
- muxtower-0.1.0/src/muxtower/web/assets/pair-BZfb2lgz.js +1 -0
- muxtower-0.1.0/src/muxtower/web/assets/pair-claim-Bv8z2eK-.js +1 -0
- muxtower-0.1.0/src/muxtower/web/assets/pane-1oqVJ-cZ.js +1 -0
- muxtower-0.1.0/src/muxtower/web/assets/preload-helper-BM7m-G0T.js +1 -0
- muxtower-0.1.0/src/muxtower/web/assets/query-state-BmXDJtvN.js +1 -0
- muxtower-0.1.0/src/muxtower/web/assets/streams-CtDcAcEr.js +41 -0
- muxtower-0.1.0/src/muxtower/web/assets/use-media-query-gZIa5xwu.js +1 -0
- muxtower-0.1.0/src/muxtower/web/assets/useNavigate-ByR6iLuJ.js +1 -0
- muxtower-0.1.0/src/muxtower/web/badge-96.png +0 -0
- muxtower-0.1.0/src/muxtower/web/config.json +1 -0
- muxtower-0.1.0/src/muxtower/web/favicon.svg +7 -0
- muxtower-0.1.0/src/muxtower/web/g2/assets/index-Be_dtrCV.js +2 -0
- muxtower-0.1.0/src/muxtower/web/g2/assets/index-JqCupXI_.css +1 -0
- muxtower-0.1.0/src/muxtower/web/g2/index.html +13 -0
- muxtower-0.1.0/src/muxtower/web/icon-192.png +0 -0
- muxtower-0.1.0/src/muxtower/web/icon-512.png +0 -0
- muxtower-0.1.0/src/muxtower/web/icon-maskable-512.png +0 -0
- muxtower-0.1.0/src/muxtower/web/icon.svg +7 -0
- muxtower-0.1.0/src/muxtower/web/index.html +24 -0
- muxtower-0.1.0/src/muxtower/web/manifest.webmanifest +1 -0
- muxtower-0.1.0/src/muxtower/web/registerSW.js +1 -0
- muxtower-0.1.0/src/muxtower/web/sw.js +1 -0
- muxtower-0.1.0/src/muxtower/web.py +47 -0
muxtower-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
+
|
muxtower-0.1.0/README.md
ADDED
|
@@ -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,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()}
|