xa
xa — tools for managing remote Claude Code sessions.
- class xa.ArchiveRecord(id: 'str', name: 'Optional[str]', cwd: 'Optional[str]', created: 'Optional[float]', url: 'Optional[str]', gone: 'Optional[float]', gone_detected: 'Optional[float]', gone_reason: 'Optional[DeathReason]', pane_log_bytes: 'int', claude_session_id: 'Optional[str]', forensics: 'Optional[dict]', label: 'Optional[str]' = None, hidden: 'bool' = False)[source]
- class xa.FileStore(root: Path, *, suffix: str = '')[source]
Directory of files, accessed by key.
Values are
bytes. Keys must match[A-Za-z0-9_.-]+— this rejects path traversal attempts like../etc/passwd.
- class xa.HTTPHost(name: str, *, base_url: str, auth: Literal['basic', 'bearer', None] = None, username: str | None = None, password: str | None = None, token: str | None = None, timeout: float = 30.0)[source]
Client for a remote
xa serveinstance.
- class xa.HistoryEntry(cwd: str | None, project: str | None, display: str | None, pasted_contents: Any | None)[source]
One line from
~/.claude/history.jsonl.
- class xa.JsonLinesStore(path: Path)[source]
Append-only JSONL file with an
Iterablereader.>>> import tempfile, pathlib >>> with tempfile.TemporaryDirectory() as td: ... s = JsonLinesStore(pathlib.Path(td) / 'log.jsonl') ... s.append({'a': 1}) ... s.append({'b': 2}) ... list(s) == [{'a': 1}, {'b': 2}] True
- class xa.LocalHost(name: str = 'local', *, claude_home: Path = PosixPath('/home/runner/.claude'), claude_bin: str = 'claude', tmux_bin: str = 'tmux')[source]
The machine
xais running on.
- class xa.SSHHost(name: str, *, host: str, user: str | None = None, remote_claude_home: str = '~/.claude', cache_dir: Path = PosixPath('/home/runner/.cache/xa/remotes'), stale_threshold_sec: int = 3600, claude_bin: str = 'claude', tmux_bin: str = 'tmux', ssh_bin: str = 'ssh', rsync_bin: str = 'rsync')[source]
Transcripts via rsync; actions via ssh exec.
- Parameters:
name – Logical name used in
Session.hostand elsewhere.host – SSH hostname / alias. Matches cc-sessions: either an
~/.ssh/configalias (devbox) or a raw host (1.2.3.4).user – Optional SSH user for raw hosts.
remote_claude_home – Remote path to
~/.claude/. Defaults to~/.claudewhich resolves relative to the SSH user’s home on the remote side.cache_dir – Where to stage the rsync’d tree locally.
stale_threshold_sec – Re-sync when the cached copy is older than this.
0means always sync.
- spawn(name: str, *, cwd: str, **opts) SpawnResult[source]
Spawn
tmux new-session -d 's <name>' 'cd <cwd> && exec claude'over SSH.URL detection is left to the caller’s next
sync()+ listing — we don’t round-trip waiting for the bridge URL here (that would hold an SSH connection open for up to 2 minutes).
- class xa.Session(id: str, claude_session_id: str | None, bridge_session_id: str | None, host: str, cwd: str | None, project_slug: str, state: Literal['live', 'archived', 'transcript_only'], live_pid: int | None, tmux_name: str | None, name: str | None, summary: str | None, first_user_message: str | None, turn_count: int, forked_from: str | None, created: float | None, modified: float | None, url: str | None, url_source: Literal['session_file', 'pane_capture'] | None, transcript_path: Path | None, pre_first_turn: bool = False)[source]
Canonical session record — valid across hosts and states.
Fields that don’t apply to a given state are
None. Discovery functions return fresh instances; callers should treat it as immutable.
- class xa.Settings(cache_dir: 'Path', stale_threshold_sec: 'int' = 3600, claude_bin: 'str' = 'claude', tmux_bin: 'str' = 'tmux')[source]
- class xa.SpawnResult(name: str, cwd: str, claude_pid: int | None, claude_session_id: str | None, bridge_session_id: str | None, url: str | None, url_source: Literal['session_file', 'pane_capture'] | None, warning: str | None)[source]
What
spawn_session/resume_sessionreturn to the caller.
- class xa.TranscriptForensics(transcript_path: Path | None, line_count: int, last_tool_name: str | None, last_tool_command: str | None, last_tool_exit_code: int | None, last_tool_result_tail: str | None, final_assistant_text: str | None, user_interrupted: bool)[source]
Postmortem facts extracted by walking a transcript from the end.
user_interruptedis the raw marker presence — it is ambiguous (also fires on phone-standby bridge drops), so do not derive user intent from it alone. Callers should corroborate against pane-log tails.
- class xa.TranscriptMeta(path: Path, session_id: str | None, cwd: str | None, project_slug: str, summary: str | None, custom_title: str | None, first_user_message: str | None, turn_count: int, forked_from: str | None, created: float | None, modified: float | None, size_bytes: int)[source]
Summary of a transcript JSONL, cheap enough to compute for listings.
Mark an archived session as hidden (or un-hide it).
- xa.append_label(events: JsonLinesStore, *, id: str, label: str | None) None[source]
Set or clear a user-supplied display label for a session.
idcan be an archive id, a tmux session name, or aclaude_session_id— whatever key the caller will use to look it up later. Empty-string orNonelabel clears any prior label.
- xa.classify_death(pane_kind: Literal['clean_exit', 'abrupt', 'missing', 'unknown'], *, replaced: bool = False, forensics: TranscriptForensics | None = None, oom_markers: tuple[str, ...] = ()) Literal['clean_exit', 'abrupt', 'interrupted', 'tool_crash', 'oom_killed', 'replaced', 'missing'][source]
Pick the most specific death reason from available signals.
oom_markersis the tuple of OOM-shaped strings observed in the pane tail (see_OOM_PANE_MARKERS). When the last tool exited with 137 and at least one marker is present, we promote the verdict tooom_killed— that pair is the strongest single signal we can get without reading kernel logs (which would need root and is not portable).
- xa.default_events_store(state_dir: Path = PosixPath('/home/runner/.xa')) JsonLinesStore[source]
The event log at
<state_dir>/events.jsonl.
- xa.default_hosts() dict[str, Host][source]
The out-of-the-box registry: one local host, nothing else.
- xa.default_pane_store(state_dir: Path = PosixPath('/home/runner/.xa')) FileStore[source]
Per-session pane logs at
<state_dir>/panes/<id>.log.
- xa.encode_project_slug(cwd: str) str[source]
Encode a cwd into the
~/.claude/projects/slug form.>>> encode_project_slug('/root/py/proj/tt/glossa') '-root-py-proj-tt-glossa' >>> encode_project_slug('/') '-'
- xa.get_session(session_id: str, *, hosts: Iterable | None = None, claude_home: Path | None = None) Session | None[source]
Find one session by full ID or unique prefix.
Raises
LookupErrorif the prefix is ambiguous.
- xa.history_iter(*, claude_home: Path = PosixPath('/home/runner/.claude')) Iterator[HistoryEntry][source]
Yield entries from
~/.claude/history.jsonlin file order (oldest first).Useful for cross-project full-text prompt search without loading every transcript.
- xa.iter_ephemeral_sessions(*, claude_home: Path = PosixPath('/home/runner/.claude')) Iterator[dict][source]
Yield all live ephemeral session dicts.
A session file disappears when claude exits, so this reflects momentary state only.
- xa.iter_local_sessions(*, claude_home: Path = PosixPath('/home/runner/.claude'), project_slug: str | None = None, include_live: bool = True, tmux_bin: str | None = None) Iterator[Session][source]
Legacy local-only iterator.
Retained for backward compatibility and because some tests construct sessions directly from a fake
~/.claude/. Wraps a freshLocalHost.
- xa.iter_project_slugs(*, claude_home: Path = PosixPath('/home/runner/.claude')) Iterator[str][source]
Yield slugs (directory names) under
~/.claude/projects/.
- xa.iter_transcript_events(path: Path) Iterator[dict][source]
Yield every event from a transcript JSONL.
Lines that fail to parse are silently skipped — transcripts are append-only on a live process, so the tail can be partial.
- xa.iter_transcript_files(*, claude_home: Path = PosixPath('/home/runner/.claude'), project_slug: str | None = None) Iterator[Path][source]
Yield transcript JSONL paths, optionally restricted to one project.
Only files whose stem is a valid UUID are yielded — this filters out the
memory/subfolder and other non-session artefacts Claude Code stores alongside transcripts.
- xa.kill_session(session: Session, *, hosts: Mapping[str, object] | None = None) None[source]
Kill the backing tmux session of a live
Session.
- xa.list_sessions(*, hosts: Iterable | None = None, project: str | None = None, include_forks: bool = True, include_live: bool = True, state: Literal['live', 'archived', 'transcript_only'] | None = None, limit: int | None = None, claude_home: Path | None = None, tmux_bin: str | None = None) list[Session][source]
Return sessions sorted by recency (live first, then newest-modified).
When
hostsisNonewe default to a singleLocalHost. Ifclaude_homeortmux_binare given, they’re forwarded into that defaultLocalHost— handy for fixture-based tests.Filters:
project— substring match againstcwd(case-insensitive)include_forks=False— drop sessions with aforked_frominclude_live=False— skip live discovery (per host)state— one of"live"/"archived"/"transcript_only"
- xa.load(path: Path | None = None) tuple[Settings, dict][source]
Return
(settings, hosts_registry)from a config file.Missing file →
(defaults, {"local": LocalHost()}).
- xa.overlays(events: JsonLinesStore) dict[str, dict][source]
Fold
labeled/hiddenevents into{id: {label, hidden}}.Later events win. Useful when rendering live sessions too — callers look up by whatever id they know (archive id, claude_session_id, tmux name) and apply the overlay if present.
- xa.parse_project_slug(slug: str) str[source]
Decode a
~/.claude/projects/slug back to a cwd.>>> parse_project_slug('-root-py-proj-tt-glossa') '/root/py/proj/tt/glossa' >>> parse_project_slug('-Users-thorwhalen-tw-server') '/Users/thorwhalen/tw/server'
NB: This transform is lossy — a cwd with literal
-in segment names round-trips to a different slug. Claude Code itself has the same limitation, so we accept it.
- xa.read_ephemeral_session(pid: int, *, claude_home: Path = PosixPath('/home/runner/.claude')) dict | None[source]
Read the per-process session file for
pidif present.
- xa.reconcile(events: JsonLinesStore, panes: FileStore, live_sessions: Iterable[TmuxSession], *, claude_home: Path = PosixPath('/home/runner/.claude')) list[dict][source]
Emit
goneevents for archived sessions missing fromlive_sessions.Returns the list of freshly-emitted events (handy for tests). Idempotent: calling twice with the same live list produces no new events the second time.
- xa.records(events: JsonLinesStore, panes: FileStore) list[ArchiveRecord][source]
Return per-session summaries, newest-first (by creation time).
- xa.resolve_bridge_url(session_name: str, *, claude_home: Path = PosixPath('/home/runner/.claude'), tmux_bin: str = 'tmux', scrape_lines: int = 400) tuple[str | None, Literal['session_file', 'pane_capture'] | None][source]
Return
(url, source)for a live tmux-hosted claude session.Primary path: find the claude descendant pid → read
~/.claude/sessions/<pid>.json→ takebridgeSessionId. Fallback: regex-scrapecapture-paneoutput for the full URL.
- xa.resume(session: Session, *, cwd: str | None = None, name: str | None = None, hosts: Mapping[str, object] | None = None, **opts) SpawnResult[source]
Resume a transcript / archived session on its originating host.
- xa.resume_session(claude_session_id: str, *, cwd: str, name: str | None = None, claude_bin: str = 'claude', claude_home: Path = PosixPath('/home/runner/.claude'), tmux_bin: str = 'tmux', url_timeout_sec: float = 120.0, auto_remote_control: bool = True, pane_log_path: Path | None = None, archive_store=None, pane_store=None, archive_id: str | None = None) SpawnResult[source]
Launch
claude --resume <id>in a new detached tmux session.--resumereuses the cwd’s trusted-folder setting, so we skip the trust-prompt dismiss; the/remote-controlhandshake still runs.
- xa.spawn_session(name: str, *, cwd: str, claude_bin: str = 'claude', claude_home: Path = PosixPath('/home/runner/.claude'), tmux_bin: str = 'tmux', url_timeout_sec: float = 120.0, auto_remote_control: bool = True, pane_log_path: Path | None = None, archive_store=None, pane_store=None, archive_id: str | None = None) SpawnResult[source]
Create a detached tmux session running
claudeincwd.Waits up to
url_timeout_secfor the bridge URL to appear, dismissing the “trust this folder” prompt and issuing/remote-controlas needed.If
archive_store(and optionallypane_store) are given, emitscreated+url_acquiredevents to the archive and pipes the pane output to a file underpane_store.
- xa.transcript_forensics(path: Path) TranscriptForensics[source]
Extract postmortem-relevant facts from the tail of a transcript.
Walks from the end of the file so we find the most recent
tool_use/tool_result/ assistanttextwithout reparsing the whole transcript.
- xa.transcript_metadata(path: Path) TranscriptMeta[source]
Summarise a transcript without holding it all in memory.
Walks forward, counting turns and capturing the first user message / summary / custom title / fork pointer / cwd / sessionId. Stops reading content fields once it has what it needs, but still counts turns to the end. For very large transcripts, callers who only need counts should use
iter_transcript_eventsdirectly.
- xa.transcript_path(cwd: str, session_id: str, *, claude_home: Path = PosixPath('/home/runner/.claude')) Path | None[source]
Return the transcript path for
(cwd, session_id)if it exists.>>> from pathlib import Path >>> import tempfile >>> with tempfile.TemporaryDirectory() as td: ... home = Path(td) ... (home / 'projects' / '-foo-bar').mkdir(parents=True) ... f = home / 'projects' / '-foo-bar' / 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee.jsonl' ... _ = f.write_text('') ... p = transcript_path('/foo/bar', 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', claude_home=home) ... p == f True