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