API reference
sessionmux is a terminal application; this reference documents its internal modules
for contributors. The entry point is sessionmux.app:main (the sessionmux command).
Sessions
Discovering, encoding and tracking Claude Code conversations.
sessionmux.sessions
Data layer for the Claude Code session switcher.
Pure stdlib. Models a session transcript and the sidecar metadata (label,
group, tags) we keep apart from Claude's own files, in the XDG data directory
(~/.local/share/sessionmux/), with the trash.
Our own metadata for one session. Stored in the sidecar, never in the JSONL.
Source code in src/sessionmux/sessions.py
| @dataclass(frozen=True)
class Meta:
"""Our own metadata for one session. Stored in the sidecar, never in the JSONL."""
label: str = ""
group: str = ""
tags: tuple[str, ...] = ()
|
Filter
dataclass
Which conversations the list shows: group is one of groups (when any) AND at least
one tag is in tags (when any). Names compare without case.
Source code in src/sessionmux/sessions.py
| @dataclass(frozen=True)
class Filter:
"""Which conversations the list shows: group is one of `groups` (when any) AND at least
one tag is in `tags` (when any). Names compare without case."""
groups: tuple[str, ...] = ()
tags: tuple[str, ...] = ()
@property
def active(self) -> bool:
return bool(self.groups or self.tags)
def matches(self, meta: Meta) -> bool:
if self.groups and meta.group.casefold() not in {g.casefold() for g in self.groups}:
return False
if self.tags:
want = {t.casefold() for t in self.tags}
return any(t.casefold() in want for t in meta.tags)
return True
def text(self) -> str:
"""As typed in the `f` minibuffer: `g:work t:urgent` (quoted when a name has spaces)."""
words = [f"g:{g}" for g in self.groups] + [f"t:{t}" for t in self.tags]
return " ".join(shlex.quote(w) for w in words)
@classmethod
def parse(cls, text: str) -> Filter:
"""The inverse of `text`. Raises ValueError on a word without `g:` / `t:`."""
groups: list[str] = []
tags: list[str] = []
for word in shlex.split(text):
kind, _, name = word.partition(":")
kind, name = kind.lower(), name.strip()
if kind not in ("g", "t") or not name:
raise ValueError(f"not g:<group> or t:<tag>: {word}")
(groups if kind == "g" else tags).append(name)
return cls(groups=tuple(groups), tags=tuple(tags))
|
text
As typed in the f minibuffer: g:work t:urgent (quoted when a name has spaces).
Source code in src/sessionmux/sessions.py
| def text(self) -> str:
"""As typed in the `f` minibuffer: `g:work t:urgent` (quoted when a name has spaces)."""
words = [f"g:{g}" for g in self.groups] + [f"t:{t}" for t in self.tags]
return " ".join(shlex.quote(w) for w in words)
|
parse
classmethod
parse(text: str) -> Filter
The inverse of text. Raises ValueError on a word without g: / t:.
Source code in src/sessionmux/sessions.py
| @classmethod
def parse(cls, text: str) -> Filter:
"""The inverse of `text`. Raises ValueError on a word without `g:` / `t:`."""
groups: list[str] = []
tags: list[str] = []
for word in shlex.split(text):
kind, _, name = word.partition(":")
kind, name = kind.lower(), name.strip()
if kind not in ("g", "t") or not name:
raise ValueError(f"not g:<group> or t:<tag>: {word}")
(groups if kind == "g" else tags).append(name)
return cls(groups=tuple(groups), tags=tuple(tags))
|
Session
dataclass
One transcript on disk, read-only.
Source code in src/sessionmux/sessions.py
| @dataclass(frozen=True)
class Session:
"""One transcript on disk, read-only."""
session_id: str
path: Path
cwd: str # launch dir (first `user` line)
last_cwd: str # latest cwd recorded; follows `cd` during the session
git_branch: str
first_prompt: str
ai_title: str
custom_title: str
continued_in: str # `continued-in` line: ← in Claude moved the conversation to this id
has_messages: bool # False for a continuation's stub (title lines only)
mtime: float
size: int
|
Row
dataclass
One conversation — a continued-in chain, origin first — joined with our metadata.
Pressing ← inside Claude continues the conversation under a new session id
(a background session). The chain keeps those ids together as one row.
Source code in src/sessionmux/sessions.py
| @dataclass(frozen=True)
class Row:
"""One conversation — a `continued-in` chain, origin first — joined with our metadata.
Pressing ← inside Claude continues the conversation under a new session id
(a background session). The chain keeps those ids together as one row.
"""
chain: tuple[Session, ...]
meta: Meta
meta_key: str # sidecar key: the chain id that already has metadata, else the origin
@property
def head(self) -> Session:
"""The newest session of the conversation: the one to open."""
return self.chain[-1]
@property
def ids(self) -> tuple[str, ...]:
return tuple(s.session_id for s in self.chain)
def _newest(self, attr: str) -> str:
return next((getattr(s, attr) for s in reversed(self.chain) if getattr(s, attr)), "")
@property
def title(self) -> str:
"""Best human-readable name Claude itself knows."""
return (
self._newest("custom_title")
or self._newest("ai_title")
or self.chain[0].first_prompt
or self.head.session_id[:8]
)
@property
def display_title(self) -> str:
return self.meta.label or self.title
@property
def cwd(self) -> str:
"""Where the conversation is working now."""
return self._newest("last_cwd") or self._newest("cwd")
@property
def git_branch(self) -> str:
return self._newest("git_branch")
@property
def mtime(self) -> float:
return max(s.mtime for s in self.chain)
@property
def resumable(self) -> Session:
"""Newest session with real messages: what `claude --resume` can load."""
return next((s for s in reversed(self.chain) if s.has_messages), self.chain[0])
@property
def age(self) -> str:
secs = max(0.0, time.time() - self.mtime)
for unit, size in (("d", 86400), ("h", 3600), ("m", 60)):
if secs >= size:
return f"{int(secs // size)}{unit}"
return f"{int(secs)}s"
|
head
property
The newest session of the conversation: the one to open.
title
property
Best human-readable name Claude itself knows.
cwd
property
Where the conversation is working now.
resumable
property
Newest session with real messages: what claude --resume can load.
Read
dataclass
One transcript as far as it was read. Claude only appends to a transcript, so the
next read starts at end and adds the new lines to session.
Source code in src/sessionmux/sessions.py
| @dataclass(frozen=True)
class Read:
"""One transcript as far as it was read. Claude only appends to a transcript, so the
next read starts at `end` and adds the new lines to `session`."""
stamp: tuple[int, int] # (mtime_ns, size) of the file when it was read
end: int # bytes read: up to the end of the last complete line
session: Session
|
Cancelled
Bases: Exception
The scan was cancelled (the app is closing).
Source code in src/sessionmux/sessions.py
| class Cancelled(Exception):
"""The scan was cancelled (the app is closing)."""
|
config_dir
Root of Claude's config (honours CLAUDE_CONFIG_DIR, like the CLI does).
Source code in src/sessionmux/sessions.py
| def config_dir() -> Path:
"""Root of Claude's config (honours CLAUDE_CONFIG_DIR, like the CLI does)."""
env = os.environ.get("CLAUDE_CONFIG_DIR")
return Path(env).expanduser() if env else Path.home() / ".claude"
|
encode_cwd
encode_cwd(cwd: str) -> str
A working dir maps to a project folder: every non-alphanumeric -> '-'.
/home/jcardona/stuff/projects -> -home-jcardona-stuff-projects
Source code in src/sessionmux/sessions.py
| def encode_cwd(cwd: str) -> str:
"""A working dir maps to a project folder: every non-alphanumeric -> '-'.
/home/jcardona/stuff/projects -> -home-jcardona-stuff-projects
"""
return re.sub(r"[^a-zA-Z0-9]", "-", cwd)
|
project_dirs
project_dirs(cwd: str) -> list[Path]
The folders of one working directory: Claude names the folder after the real path
(symlinks resolved); older transcripts may sit under the path as given.
Source code in src/sessionmux/sessions.py
| def project_dirs(cwd: str) -> list[Path]:
"""The folders of one working directory: Claude names the folder after the real path
(symlinks resolved); older transcripts may sit under the path as given."""
return list(dict.fromkeys([project_dir(cwd), project_dir(os.path.realpath(cwd))]))
|
folder_dirs
folder_dirs(folder: str, limit: int = 500) -> list[str]
Existing directories whose path encodes to this folder name, found by walking the
filesystem from / (the encoding is lossy: '-' was '/', '.', '_' or '-'). No symlinks
on the way, so each one's real path is itself.
Source code in src/sessionmux/sessions.py
| def folder_dirs(folder: str, limit: int = 500) -> list[str]:
"""Existing directories whose path encodes to this folder name, found by walking the
filesystem from / (the encoding is lossy: '-' was '/', '.', '_' or '-'). No symlinks
on the way, so each one's real path is itself."""
found: list[str] = []
todo = [("/", folder[1:])] if folder.startswith("-") else []
while todo and limit > 0:
base, rest = todo.pop()
limit -= 1
try:
entries = list(os.scandir(base))
except OSError:
continue
for e in entries:
if not e.is_dir(follow_symlinks=False):
continue
name = encode_cwd(e.name)
if rest == name:
found.append(e.path)
elif rest.startswith(name + "-"):
todo.append((e.path, rest[len(name) + 1 :]))
return sorted(found)
|
launch_dir
launch_dir(s: Session) -> str | None
A directory to start Claude in for this transcript; None when none exists any more.
claude --resume from any other directory still loads the conversation, but Claude
then works in that other directory. Claude keeps a transcript in the folder named
after the real path of the directory it runs in, so this is a directory whose real
path gives the transcript's folder name: the recorded cwd if it does (it need not,
for older transcripts), else one found from the folder name.
Source code in src/sessionmux/sessions.py
| def launch_dir(s: Session) -> str | None:
"""A directory to start Claude in for this transcript; None when none exists any more.
`claude --resume` from any other directory still loads the conversation, but Claude
then works in that other directory. Claude keeps a transcript in the folder named
after the real path of the directory it runs in, so this is a directory whose real
path gives the transcript's folder name: the recorded cwd if it does (it need not,
for older transcripts), else one found from the folder name.
"""
folder = s.path.parent.name
for d in (s.cwd, s.last_cwd):
if d and os.path.isdir(d) and encode_cwd(os.path.realpath(d)) == folder:
return d
found = folder_dirs(folder)
return found[0] if found else None
|
data_dir
Our own files: $XDG_DATA_HOME/sessionmux (default ~/.local/share).
Source code in src/sessionmux/sessions.py
| def data_dir() -> Path:
"""Our own files: $XDG_DATA_HOME/sessionmux (default ~/.local/share)."""
return xdgdirs.data_home() / xdgdirs.APP
|
sidecar_path
Labels, groups and tags, keyed by session id.
Source code in src/sessionmux/sessions.py
| def sidecar_path() -> Path:
"""Labels, groups and tags, keyed by session id."""
return data_dir() / "meta.json"
|
trash_dir
Where deleted conversations go (recoverable: see RESTORE.txt in each folder).
Source code in src/sessionmux/sessions.py
| def trash_dir() -> Path:
"""Where deleted conversations go (recoverable: see RESTORE.txt in each folder)."""
return data_dir() / "trash"
|
read_transcript
read_transcript(
path: Path, prev: Read | None = None
) -> Read | None
Read a transcript; with prev, only the lines appended since (all of it if it shrank).
Source code in src/sessionmux/sessions.py
| def read_transcript(path: Path, prev: Read | None = None) -> Read | None:
"""Read a transcript; with `prev`, only the lines appended since (all of it if it shrank)."""
try:
st = path.stat()
except OSError:
return None
if prev is not None and st.st_size < prev.end:
prev = None # rewritten: read it again from the start
old = prev.session if prev else None
session_id = old.session_id if old else ""
cwd = old.cwd if old else ""
git_branch = old.git_branch if old else ""
first_prompt = old.first_prompt if old else ""
ai_title = old.ai_title if old else ""
custom_title = old.custom_title if old else ""
continued_in = old.continued_in if old else ""
has_messages = old.has_messages if old else False
end = prev.end if prev else 0
try:
with path.open("rb") as fh:
fh.seek(end)
for line in fh:
# a last line without its newline is still being written: it is read
# now and again next time (every field below takes it twice the same way)
if line.endswith(b"\n"):
end += len(line)
# Transcripts reach hundreds of MB: look at the start of the raw line and
# parse JSON only for the few lines that carry what the list shows. In user
# and title lines Claude writes the top-level "type" before any nested
# object, within the first 200 bytes (checked on every local transcript:
# 2.1.302). An assistant line mostly starts with its nested "message", so
# its first "type" is "message": a conversation counts as having messages
# by its user lines (and the few assistant lines written type-first).
if not session_id and (m := _SESSION_ID.search(line)):
session_id = m.group(1).decode()
m = _TYPE.search(line, 0, 200)
kind = m.group(1) if m else b""
if kind in (b"user", b"assistant"):
has_messages = True
wanted = (
kind == b"user" and not (cwd and git_branch and first_prompt)
) or kind in _PARSED_TYPES
if not wanted:
continue
try:
d = json.loads(line)
except json.JSONDecodeError:
continue
t = d.get("type")
if t == "ai-title" and d.get("aiTitle"):
ai_title = d["aiTitle"]
elif t == "custom-title" and d.get("customTitle"):
custom_title = d["customTitle"]
elif t == "continued-in" and d.get("continuedInSessionId"):
continued_in = d["continuedInSessionId"]
elif t == "user":
if not cwd and d.get("cwd"):
cwd = d["cwd"]
if not git_branch and d.get("gitBranch"):
git_branch = d["gitBranch"]
if not first_prompt:
msg = d.get("message") or {}
txt = _text_of(msg.get("content")) if isinstance(msg, dict) else ""
# skip tool-result / slash-command noise for the preview
if txt and not txt.startswith(("<", "/")):
first_prompt = txt.strip()
last_cwd = _last_cwd(path)
except OSError:
return None
session = Session(
session_id=session_id or path.stem,
path=path,
cwd=cwd,
last_cwd=last_cwd,
git_branch=git_branch,
first_prompt=first_prompt,
ai_title=ai_title,
custom_title=custom_title,
continued_in=continued_in,
has_messages=has_messages,
mtime=st.st_mtime,
size=st.st_size,
)
return Read(stamp=(st.st_mtime_ns, st.st_size), end=end, session=session)
|
scan
scan(
cwd: str | None, cancelled: Callable[[], bool] = _never
) -> list[Session]
All transcripts of one working directory (None: of every directory), newest first.
A transcript whose mtime and size did not change is not read again; one that grew
is read from where the last read stopped. cancelled is checked before each file
(raises Cancelled), so a first read of every directory does not hold up a quit.
Source code in src/sessionmux/sessions.py
| def scan(cwd: str | None, cancelled: Callable[[], bool] = _never) -> list[Session]:
"""All transcripts of one working directory (None: of every directory), newest first.
A transcript whose mtime and size did not change is not read again; one that grew
is read from where the last read stopped. `cancelled` is checked before each file
(raises Cancelled), so a first read of every directory does not hold up a quit.
"""
with _scan_lock:
return _scan(cwd, cancelled)
|
load_meta() -> dict[str, Meta]
The sidecar; entries of the wrong shape are skipped (it is a file people can edit).
Source code in src/sessionmux/sessions.py
| def load_meta() -> dict[str, Meta]:
"""The sidecar; entries of the wrong shape are skipped (it is a file people can edit)."""
return read_meta(sidecar_path())
|
save_meta(meta: dict[str, Meta]) -> None
Write the sidecar atomically: a crash leaves the old file or the new one, never half.
Source code in src/sessionmux/sessions.py
| def save_meta(meta: dict[str, Meta]) -> None:
"""Write the sidecar atomically: a crash leaves the old file or the new one, never half."""
path = sidecar_path()
xdgdirs.make_dirs(path.parent, xdgdirs.data_home())
payload = {
sid: {"label": m.label, "group": m.group, "tags": list(m.tags)}
for sid, m in meta.items()
if m.label or m.group or m.tags
}
tmp = path.with_name(f"{path.name}.{os.getpid()}.tmp")
tmp.write_text(json.dumps(payload, indent=2), encoding="utf-8")
os.replace(tmp, path)
|
meta_locked() -> Generator[None]
Hold the sidecar for a read-modify-write: excludes other threads and other app
instances (flock on a lock file next to it).
Source code in src/sessionmux/sessions.py
| @contextmanager
def meta_locked() -> Generator[None]:
"""Hold the sidecar for a read-modify-write: excludes other threads and other app
instances (flock on a lock file next to it)."""
lock = sidecar_path().with_name(sidecar_path().name + ".lock")
xdgdirs.make_dirs(lock.parent, xdgdirs.data_home())
with lock.open("a") as fh:
fcntl.flock(fh, fcntl.LOCK_EX)
yield # closing the file releases the lock
|
update_meta(
key: str, change: Callable[[Meta], Meta]
) -> None
Change one session's metadata.
Source code in src/sessionmux/sessions.py
| def update_meta(key: str, change: Callable[[Meta], Meta]) -> None:
"""Change one session's metadata."""
with meta_locked():
meta = load_meta()
meta[key] = change(meta.get(key, Meta()))
save_meta(meta)
|
chains
chains(
sessions: list[Session],
) -> list[tuple[Session, ...]]
Group transcripts into conversations by following continued-in links.
Source code in src/sessionmux/sessions.py
| def chains(sessions: list[Session]) -> list[tuple[Session, ...]]:
"""Group transcripts into conversations by following `continued-in` links."""
by_id = {s.session_id: s for s in sessions}
targets = {s.continued_in for s in sessions if s.continued_in in by_id}
origins = [s for s in sessions if s.session_id not in targets]
placed: set[str] = set()
out: list[tuple[Session, ...]] = []
# origins first; then whatever is left (only a broken, cyclic chain) so nothing disappears
for start in origins + [s for s in sessions if s.session_id in targets]:
if start.session_id in placed:
continue
chain, cur = [start], start
placed.add(start.session_id)
while cur.continued_in in by_id and cur.continued_in not in placed:
cur = by_id[cur.continued_in]
chain.append(cur)
placed.add(cur.session_id)
out.append(tuple(chain))
return out
|
build_rows
build_rows(
cwd: str | None,
cancelled: Callable[[], bool] = _never,
only: Filter = NO_FILTER,
) -> list[Row]
The conversations of one working directory (None: of every directory) that only
matches.
Source code in src/sessionmux/sessions.py
| def build_rows(
cwd: str | None, cancelled: Callable[[], bool] = _never, only: Filter = NO_FILTER
) -> list[Row]:
"""The conversations of one working directory (None: of every directory) that `only`
matches."""
meta = load_meta()
rows: list[Row] = []
for chain in chains(scan(cwd, cancelled)):
key = next((s.session_id for s in chain if s.session_id in meta), chain[0].session_id)
row_meta = meta.get(key, Meta())
if only.matches(row_meta):
rows.append(Row(chain=chain, meta=row_meta, meta_key=key))
# group together, ungrouped last, then newest-first inside each group
rows.sort(key=lambda r: (r.meta.group == "", r.meta.group, -r.mtime))
return rows
|
trash
Move the conversation's transcripts to the trash and drop its metadata.
After this, neither the list nor Claude's /resume shows it. Returns the
trash folder; its RESTORE.txt says where each file came from.
Source code in src/sessionmux/sessions.py
| def trash(row: Row) -> Path:
"""Move the conversation's transcripts to the trash and drop its metadata.
After this, neither the list nor Claude's `/resume` shows it. Returns the
trash folder; its RESTORE.txt says where each file came from.
"""
dest = trash_dir() / f"{time.strftime('%Y%m%d-%H%M%S')}-{row.chain[0].session_id[:8]}"
xdgdirs.make_dirs(dest, xdgdirs.data_home())
moved = []
for s in row.chain:
if s.path.exists():
target = dest / s.path.name
shutil.move(s.path, target)
moved.append(f"{target} -> {s.path}")
(dest / "RESTORE.txt").write_text(
f"{row.display_title}\nMove each file back to restore the conversation:\n"
+ "\n".join(moved)
+ "\n",
encoding="utf-8",
)
drop_meta(row.ids)
return dest
|
State
On-disk state: labels, the trash, and what stays open across runs.
sessionmux.state
What the app had open, kept so the next start (or the start after a crash) has it again.
Pure stdlib. One small JSON file per list directory, under the XDG state directory
($XDG_STATE_HOME, default ~/.local/state: "current state of the application that can
be reused on a restart"). Written on every change, atomically, so it is current even
when the computer dies.
OpenState
dataclass
The conversations open in the app for one list directory.
Source code in src/sessionmux/state.py
| @dataclass(frozen=True)
class OpenState:
"""The conversations open in the app for one list directory."""
cwd: str # the list directory (the app's own)
all_dirs: bool = False # the list showed every directory (`a`)
filter: Filter = NO_FILTER # the list showed only these groups / tags (`f`, -G, -t)
open: tuple[str, ...] = () # session ids of the open conversations, in opening order
shown: str | None = None # the one in the main pane
|
load
load(cwd: str) -> OpenState
The saved state of this list directory; an empty one when there is none or it
cannot be read (it is only a convenience).
Source code in src/sessionmux/state.py
| def load(cwd: str) -> OpenState:
"""The saved state of this list directory; an empty one when there is none or it
cannot be read (it is only a convenience)."""
empty = OpenState(cwd=cwd)
try:
raw = json.loads(state_path(cwd).read_text(encoding="utf-8"))
except (OSError, ValueError):
return empty
if not isinstance(raw, dict) or raw.get("cwd") != cwd: # another dir, same encoded name
return empty
ids = raw.get("open")
shown = raw.get("shown")
return OpenState(
cwd=cwd,
all_dirs=raw.get("all_dirs") is True,
filter=_read_filter(raw.get("filter")),
open=tuple(i for i in ids if isinstance(i, str) and i) if isinstance(ids, list) else (),
shown=shown if isinstance(shown, str) and shown else None,
)
|
save
save(state: OpenState) -> None
Write atomically (temp file + rename); missing directories are made 0700.
Raises OSError.
Source code in src/sessionmux/state.py
| def save(state: OpenState) -> None:
"""Write atomically (temp file + rename); missing directories are made 0700.
Raises OSError."""
path = state_path(state.cwd)
make_dirs(path.parent, state_home())
payload = {
"cwd": state.cwd,
"all_dirs": state.all_dirs,
"filter": {"groups": list(state.filter.groups), "tags": list(state.filter.tags)},
"open": list(state.open),
"shown": state.shown,
}
tmp = path.with_name(f"{path.name}.{os.getpid()}.tmp")
tmp.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
os.replace(tmp, path)
|
Usage
CPU and memory accounting for the running Claude processes.
sessionmux.usage
CPU, memory and wakeups of process trees, from Linux /proc (no root needed).
A Claude session is a tree of processes: claude, its MCP servers and its tool runs.
Per tree, between two samples:
- CPU: % of one core. Each process's utime + stime, plus what processes that ended
in between used: a parent's cutime + cstime grows by the whole life of a child it
waited for, minus what the last sample had already counted for that child. So a
short tool run the walk never saw still counts.
- Memory: anonymous resident memory (statm: resident - shared). It leaves out file
pages shared with other processes (libraries), so a sum over a tree is not inflated
the way RSS is. On this machine it was within 6% of PSS (smaps_rollup) for 232
Claude processes, at 10 ms instead of 435 ms.
- Wakeups per second: how often its threads were put on a CPU (third field of
/proc//task//schedstat); waking a CPU from idle is what costs power, the
kind of number powertop shows. It reads every thread (~2,400 files for 23 Claudes),
so only when asked (the top view).
Finding the tree: a child belongs to the thread that made it. Reading every thread's
children file finds all (71 ms for 23 Claudes); the main thread's alone misses a few
(8 of 232) at 6 ms. So a round walks every thread only every FULL_WALK_ROUNDS rounds,
and in between keeps the processes found before.
Pure stdlib. Nothing here is available off Linux: available() says so.
Proc
dataclass
One process at one moment.
Source code in src/sessionmux/usage.py
| @dataclass(frozen=True)
class Proc:
"""One process at one moment."""
pid: int
ppid: int
comm: str
start: int # starttime, ticks since boot: tells a reused pid apart
ticks: int # utime + stime, all its threads
child_ticks: int # cutime + cstime: its ended children it waited for
anon_kb: int # anonymous resident memory
slices: int | None # times its threads were put on a CPU; None: not read
@property
def key(self) -> tuple[int, int]:
return self.pid, self.start
|
Sample
dataclass
A process tree at one moment.
Source code in src/sessionmux/usage.py
| @dataclass(frozen=True)
class Sample:
"""A process tree at one moment."""
root: int
at: float # time.monotonic()
procs: tuple[Proc, ...] # the root first
|
Use
dataclass
One tree's use between two samples (or several trees together).
Source code in src/sessionmux/usage.py
| @dataclass(frozen=True)
class Use:
"""One tree's use between two samples (or several trees together)."""
cpu: float # % of one core, ended processes included
mem_mb: float # now
wakeups: float | None # per second; None: not measured
procs: tuple[ProcUse, ...] # busiest first
ended_cpu: float # the part of `cpu` used by processes that ended in between
@staticmethod
def total(uses: list[Use]) -> Use:
known = [u.wakeups for u in uses if u.wakeups is not None]
return Use(
cpu=sum(u.cpu for u in uses),
mem_mb=sum(u.mem_mb for u in uses),
wakeups=sum(known) if known else None,
procs=tuple(p for u in uses for p in u.procs),
ended_cpu=sum(u.ended_cpu for u in uses),
)
|
Round
dataclass
One sampling round over every tree: what the next round starts from.
Source code in src/sessionmux/usage.py
| @dataclass(frozen=True)
class Round:
"""One sampling round over every tree: what the next round starts from."""
samples: dict[int, Sample] # root pid -> its tree now
uses: dict[int, Use] # root pid -> its use since the last round
number: int = 0 # rounds so far (every FULL_WALK_ROUNDS-th walks fully)
|
children
children(pid: int, every_thread: bool = True) -> list[int]
Its child processes: through every thread's children file, or the main
thread's alone (cheaper; misses children another thread made).
Source code in src/sessionmux/usage.py
| def children(pid: int, every_thread: bool = True) -> list[int]:
"""Its child processes: through every thread's `children` file, or the main
thread's alone (cheaper; misses children another thread made)."""
tids = _threads(pid) if every_thread else [str(pid)]
out: list[int] = []
for tid in tids:
text = _read(f"/proc/{pid}/task/{tid}/children")
if text:
out += [int(c) for c in text.split()]
return out
|
sample
sample(
root: int,
with_children: bool = True,
*,
wakeups: bool = False,
full: bool = True,
known: Mapping[int, int] | None = None,
) -> Sample | None
The tree under root now; None when root is gone.
full: find children through every thread; else through the main thread, then add
the known processes (pid -> start, from the last sample) the walk missed that are
still there, under a process of the tree.
Source code in src/sessionmux/usage.py
| def sample(
root: int,
with_children: bool = True,
*,
wakeups: bool = False,
full: bool = True,
known: Mapping[int, int] | None = None,
) -> Sample | None:
"""The tree under `root` now; None when `root` is gone.
full: find children through every thread; else through the main thread, then add
the `known` processes (pid -> start, from the last sample) the walk missed that are
still there, under a process of the tree.
"""
at = time.monotonic()
first = read_proc(root, wakeups)
if first is None:
return None
procs = [first]
seen = {root}
todo = children(root, every_thread=full) if with_children else []
def walk() -> None:
while todo:
pid = todo.pop(0)
if pid in seen:
continue
seen.add(pid)
proc = read_proc(pid, wakeups)
if proc is not None:
procs.append(proc)
todo.extend(children(pid, every_thread=full))
walk()
if with_children and known and not full:
missed = sorted(pid for pid in known if pid not in seen) # parents first, mostly
for pid in missed:
proc = read_proc(pid, wakeups)
if proc is not None and proc.start == known[pid] and proc.ppid in seen:
seen.add(pid)
procs.append(proc)
todo.extend(children(pid, every_thread=False))
walk()
return Sample(root=root, at=at, procs=tuple(procs))
|
use
use(prev: Sample, now: Sample) -> Use
What the tree used between prev and now.
Source code in src/sessionmux/usage.py
| def use(prev: Sample, now: Sample) -> Use:
"""What the tree used between `prev` and `now`."""
dt = max(now.at - prev.at, 1e-6)
before = {p.key: p for p in prev.procs}
keys = {p.key for p in now.procs}
procs: list[ProcUse] = []
reaped = 0
for p in now.procs:
q = before.get(p.key)
ticks = p.ticks - q.ticks if q else p.ticks # new: started in between
wake = None
if p.slices is not None and (q is None or q.slices is not None):
wake = max(p.slices - (q.slices if q and q.slices is not None else 0), 0) / dt
if q:
reaped += p.child_ticks - q.child_ticks
procs.append(
ProcUse(
pid=p.pid,
comm=p.comm,
cpu=max(ticks, 0) * 100 / TICKS / dt,
mem_mb=p.anon_kb / 1024,
wakeups=wake,
)
)
counted = sum(q.ticks for k, q in before.items() if k not in keys)
ended = max(reaped - counted, 0) * 100 / TICKS / dt
procs.sort(key=lambda u: u.cpu, reverse=True)
known = [u.wakeups for u in procs if u.wakeups is not None]
return Use(
cpu=sum(u.cpu for u in procs) + ended,
mem_mb=sum(u.mem_mb for u in procs),
wakeups=sum(known) if known else None,
procs=tuple(procs),
ended_cpu=ended,
)
|
sample_all
sample_all(
roots: Mapping[int, bool],
last: Round,
*,
wakeups: bool = False,
) -> Round
Sample every tree (root pid -> with its children). A use needs the same root
process in the last round (not a reused pid).
Source code in src/sessionmux/usage.py
| def sample_all(roots: Mapping[int, bool], last: Round, *, wakeups: bool = False) -> Round:
"""Sample every tree (root pid -> with its children). A use needs the same root
process in the last round (not a reused pid)."""
full = last.number % FULL_WALK_ROUNDS == 0
samples: dict[int, Sample] = {}
uses: dict[int, Use] = {}
for root, with_children in roots.items():
q = last.samples.get(root)
known = {p.pid: p.start for p in q.procs} if q else None
s = sample(root, with_children, wakeups=wakeups, full=full, known=known)
if s is None:
continue
samples[root] = s
if q is not None and q.procs[0].key == s.procs[0].key:
uses[root] = use(q, s)
return Round(samples=samples, uses=uses, number=last.number + 1)
|
Agents
Resolving the agent/process tree behind each session.
sessionmux.agents
Live session state from Claude Code's own claude agents --json.
Read-only. Used to mark rows (running / waiting / working), to open background
sessions with claude attach, and to tell which session the main pane shows
after ← in Claude (the pane title is the shown agent's name).
live_agents
live_agents() -> dict[str, AgentState] | None
session_id -> AgentState; None when claude agents failed (then nothing is known,
which is not the same as nothing running). Takes ~0.35 s — call it off the UI thread.
Source code in src/sessionmux/agents.py
| def live_agents() -> dict[str, AgentState] | None:
"""session_id -> AgentState; None when `claude agents` failed (then nothing is known,
which is not the same as nothing running). Takes ~0.35 s — call it off the UI thread."""
try:
r = _run("agents", "--json", "--all", timeout=6)
data = json.loads(r.stdout)
except (OSError, ValueError, subprocess.SubprocessError):
return None
if r.returncode != 0 or not isinstance(data, list):
return None
out: dict[str, AgentState] = {}
for e in data:
if not isinstance(e, dict):
continue
sid = _text(e, "sessionId")
if sid:
pid = e.get("pid")
out[sid] = AgentState(
session_id=sid,
agent_id=_text(e, "id") or sid[:8],
kind=_text(e, "kind"),
name=_text(e, "name"),
cwd=_text(e, "cwd"),
status=_text(e, "status"),
state=_text(e, "state"),
pid=pid if isinstance(pid, int) and not isinstance(pid, bool) else None,
)
return out
|
remove
remove(agent_id: str) -> str | None
Stop and delete a background session (its agent-view row, and a worktree
Claude made for it when that is safe). None on success, else Claude's reason
for refusing, e.g. a worktree with unpushed commits.
Source code in src/sessionmux/agents.py
| def remove(agent_id: str) -> str | None:
"""Stop and delete a background session (its agent-view row, and a worktree
Claude made for it when that is safe). None on success, else Claude's reason
for refusing, e.g. a worktree with unpushed commits."""
try:
_run("stop", agent_id, timeout=20)
r = _run("rm", agent_id, timeout=20)
except (OSError, subprocess.SubprocessError) as exc:
return str(exc)
if r.returncode == 0:
return None
lines = (r.stderr or r.stdout).strip().splitlines()
return lines[0] if lines else f"`claude rm {agent_id}` failed"
|
wait_exit
wait_exit(pids: list[int], timeout: float = 5.0) -> bool
Wait until these processes are gone (so none writes its transcript again).
Source code in src/sessionmux/agents.py
| def wait_exit(pids: list[int], timeout: float = 5.0) -> bool:
"""Wait until these processes are gone (so none writes its transcript again)."""
end = time.monotonic() + timeout
while True:
alive = [p for p in pids if _alive(p)]
if not alive:
return True
if time.monotonic() > end:
return False
time.sleep(0.05)
|
title_name
title_name(title: str) -> str
The name in a terminal title, without Claude's spinner glyph.
Source code in src/sessionmux/agents.py
| def title_name(title: str) -> str:
"""The name in a terminal title, without Claude's spinner glyph."""
return _SPINNER.sub("", title).strip()
|
session_for_title
session_for_title(
title: str,
agents: dict[str, AgentState],
current: str | None,
) -> str | None
The session a terminal title names, or None when it names none or several.
Claude sets the title to the shown session's name (with a spinner glyph
while working), and to "... · claude agents" in agent view. This is
observed behaviour on 2.1.302, not a documented interface.
Source code in src/sessionmux/agents.py
| def session_for_title(title: str, agents: dict[str, AgentState], current: str | None) -> str | None:
"""The session a terminal title names, or None when it names none or several.
Claude sets the title to the shown session's name (with a spinner glyph
while working), and to "... · claude agents" in agent view. This is
observed behaviour on 2.1.302, not a documented interface.
"""
name = title_name(title)
if not name:
return None
if name.endswith("…"):
stem = name[:-1].rstrip()
hits = [a.session_id for a in agents.values() if a.name and a.name.startswith(stem)]
else:
hits = [a.session_id for a in agents.values() if a.name == name]
if current in hits:
return current
return hits[0] if len(hits) == 1 else None
|
XDG directories
Where sessionmux keeps its state and config.
sessionmux.xdgdirs
The XDG base directories this app writes to (specifications.freedesktop.org/basedir).
- data ($XDG_DATA_HOME, default ~/.local/share): labels, groups, tags; the trash.
- state ($XDG_STATE_HOME, default ~/.local/state): the open conversations, for the next start.
A value that is not an absolute path is invalid and ignored (the spec). Missing
directories are made with mode 0700; existing ones keep their mode.
make_dirs
make_dirs(path: Path, base: Path) -> None
Make path and its missing parents up to and including base, each with mode 0700.
Source code in src/sessionmux/xdgdirs.py
| def make_dirs(path: Path, base: Path) -> None:
"""Make `path` and its missing parents up to and including `base`, each with mode 0700."""
todo = []
d = path
while not d.is_dir():
todo.append(d)
if d == base or d.parent == d:
break
d = d.parent
if base in todo and not base.parent.is_dir():
base.parent.mkdir(parents=True, exist_ok=True) # above the base: not ours
for d in reversed(todo):
d.mkdir(mode=0o700, exist_ok=True)
|