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 ADDED
@@ -0,0 +1,3 @@
1
+ """sessionmux — a two-panel, keyboard-only switcher for Claude Code sessions in one terminal."""
2
+
3
+ __version__ = "0.1.0"
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