sessionmux 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.
- sessionmux/__init__.py +3 -0
- sessionmux/agents.py +161 -0
- sessionmux/app.py +1232 -0
- sessionmux/hosting.py +156 -0
- sessionmux/legacy.py +128 -0
- sessionmux/py.typed +0 -0
- sessionmux/sessions.py +535 -0
- sessionmux/state.py +71 -0
- sessionmux/usage.py +279 -0
- sessionmux/xdgdirs.py +43 -0
- sessionmux-0.1.0.dist-info/METADATA +254 -0
- sessionmux-0.1.0.dist-info/RECORD +15 -0
- sessionmux-0.1.0.dist-info/WHEEL +4 -0
- sessionmux-0.1.0.dist-info/entry_points.txt +2 -0
- sessionmux-0.1.0.dist-info/licenses/LICENSE +21 -0
sessionmux/__init__.py
ADDED
sessionmux/agents.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Live session state from Claude Code's own `claude agents --json`.
|
|
2
|
+
|
|
3
|
+
Read-only. Used to mark rows (running / waiting / working), to open background
|
|
4
|
+
sessions with `claude attach`, and to tell which session the main pane shows
|
|
5
|
+
after ← in Claude (the pane title is the shown agent's name).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import subprocess
|
|
14
|
+
import time
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class AgentState:
|
|
20
|
+
session_id: str
|
|
21
|
+
agent_id: str # short id: what `claude attach` takes
|
|
22
|
+
kind: str # interactive | background
|
|
23
|
+
name: str # what Claude puts in the terminal title
|
|
24
|
+
cwd: str
|
|
25
|
+
status: str # busy | waiting | idle (live interactive)
|
|
26
|
+
state: str # working | blocked | done | ... (background)
|
|
27
|
+
pid: int | None # set while the process is alive
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def needs_input(self) -> bool:
|
|
31
|
+
return self.status == "waiting" or self.state == "blocked"
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def working(self) -> bool:
|
|
35
|
+
return self.status == "busy" or self.state == "working"
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def alive(self) -> bool:
|
|
39
|
+
return self.pid is not None or self.state in ("working", "blocked")
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def attachable(self) -> bool:
|
|
43
|
+
return self.kind == "background"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _run(*args: str, timeout: float) -> subprocess.CompletedProcess[str]:
|
|
47
|
+
# stdin from /dev/null: the app's terminal belongs to the app
|
|
48
|
+
return subprocess.run(
|
|
49
|
+
["claude", *args],
|
|
50
|
+
capture_output=True,
|
|
51
|
+
text=True,
|
|
52
|
+
timeout=timeout,
|
|
53
|
+
stdin=subprocess.DEVNULL,
|
|
54
|
+
check=False,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _text(entry: dict, key: str) -> str:
|
|
59
|
+
value = entry.get(key)
|
|
60
|
+
return value if isinstance(value, str) else ""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def live_agents() -> dict[str, AgentState] | None:
|
|
64
|
+
"""session_id -> AgentState; None when `claude agents` failed (then nothing is known,
|
|
65
|
+
which is not the same as nothing running). Takes ~0.35 s — call it off the UI thread."""
|
|
66
|
+
try:
|
|
67
|
+
r = _run("agents", "--json", "--all", timeout=6)
|
|
68
|
+
data = json.loads(r.stdout)
|
|
69
|
+
except (OSError, ValueError, subprocess.SubprocessError):
|
|
70
|
+
return None
|
|
71
|
+
if r.returncode != 0 or not isinstance(data, list):
|
|
72
|
+
return None
|
|
73
|
+
out: dict[str, AgentState] = {}
|
|
74
|
+
for e in data:
|
|
75
|
+
if not isinstance(e, dict):
|
|
76
|
+
continue
|
|
77
|
+
sid = _text(e, "sessionId")
|
|
78
|
+
if sid:
|
|
79
|
+
pid = e.get("pid")
|
|
80
|
+
out[sid] = AgentState(
|
|
81
|
+
session_id=sid,
|
|
82
|
+
agent_id=_text(e, "id") or sid[:8],
|
|
83
|
+
kind=_text(e, "kind"),
|
|
84
|
+
name=_text(e, "name"),
|
|
85
|
+
cwd=_text(e, "cwd"),
|
|
86
|
+
status=_text(e, "status"),
|
|
87
|
+
state=_text(e, "state"),
|
|
88
|
+
pid=pid if isinstance(pid, int) and not isinstance(pid, bool) else None,
|
|
89
|
+
)
|
|
90
|
+
return out
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def remove(agent_id: str) -> str | None:
|
|
94
|
+
"""Stop and delete a background session (its agent-view row, and a worktree
|
|
95
|
+
Claude made for it when that is safe). None on success, else Claude's reason
|
|
96
|
+
for refusing, e.g. a worktree with unpushed commits."""
|
|
97
|
+
try:
|
|
98
|
+
_run("stop", agent_id, timeout=20)
|
|
99
|
+
r = _run("rm", agent_id, timeout=20)
|
|
100
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
101
|
+
return str(exc)
|
|
102
|
+
if r.returncode == 0:
|
|
103
|
+
return None
|
|
104
|
+
lines = (r.stderr or r.stdout).strip().splitlines()
|
|
105
|
+
return lines[0] if lines else f"`claude rm {agent_id}` failed"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def wait_exit(pids: list[int], timeout: float = 5.0) -> bool:
|
|
109
|
+
"""Wait until these processes are gone (so none writes its transcript again)."""
|
|
110
|
+
end = time.monotonic() + timeout
|
|
111
|
+
while True:
|
|
112
|
+
alive = [p for p in pids if _alive(p)]
|
|
113
|
+
if not alive:
|
|
114
|
+
return True
|
|
115
|
+
if time.monotonic() > end:
|
|
116
|
+
return False
|
|
117
|
+
time.sleep(0.05)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _alive(pid: int) -> bool:
|
|
121
|
+
"""Running, i.e. it exists and is not a zombie (exited, not yet reaped by its parent)."""
|
|
122
|
+
try:
|
|
123
|
+
os.kill(pid, 0)
|
|
124
|
+
except ProcessLookupError:
|
|
125
|
+
return False
|
|
126
|
+
except PermissionError:
|
|
127
|
+
return True
|
|
128
|
+
try:
|
|
129
|
+
with open(f"/proc/{pid}/stat", encoding="ascii") as fh:
|
|
130
|
+
state = fh.read().rsplit(")", 1)[1].split()[0] # field 3; the name may hold spaces
|
|
131
|
+
except (OSError, IndexError):
|
|
132
|
+
return True
|
|
133
|
+
return state != "Z"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
_SPINNER = re.compile(r"^[^\w(\[]+") # leading status glyphs: ✳ ✻ ⠂ ...
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def title_name(title: str) -> str:
|
|
140
|
+
"""The name in a terminal title, without Claude's spinner glyph."""
|
|
141
|
+
return _SPINNER.sub("", title).strip()
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def session_for_title(title: str, agents: dict[str, AgentState], current: str | None) -> str | None:
|
|
145
|
+
"""The session a terminal title names, or None when it names none or several.
|
|
146
|
+
|
|
147
|
+
Claude sets the title to the shown session's name (with a spinner glyph
|
|
148
|
+
while working), and to "... · claude agents" in agent view. This is
|
|
149
|
+
observed behaviour on 2.1.302, not a documented interface.
|
|
150
|
+
"""
|
|
151
|
+
name = title_name(title)
|
|
152
|
+
if not name:
|
|
153
|
+
return None
|
|
154
|
+
if name.endswith("…"):
|
|
155
|
+
stem = name[:-1].rstrip()
|
|
156
|
+
hits = [a.session_id for a in agents.values() if a.name and a.name.startswith(stem)]
|
|
157
|
+
else:
|
|
158
|
+
hits = [a.session_id for a in agents.values() if a.name == name]
|
|
159
|
+
if current in hits:
|
|
160
|
+
return current
|
|
161
|
+
return hits[0] if len(hits) == 1 else None
|