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.

path_for(key: str) Path[source]

Public path accessor (for callers that need to hand the path to tmux).

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 serve instance.

sync(*, force: bool = False) None[source]

HTTP sessions are fetched fresh on each listing — no separate sync.

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.Host(*args, **kwargs)[source]

Duck-typed interface every transport satisfies.

class xa.JsonLinesStore(path: Path)[source]

Append-only JSONL file with an Iterable reader.

>>> 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 xa is running on.

sync(*, force: bool = False) None[source]

No-op — local has nothing to sync.

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.host and elsewhere.

  • host – SSH hostname / alias. Matches cc-sessions: either an ~/.ssh/config alias (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 ~/.claude which 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. 0 means 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).

sync(*, force: bool = False) None[source]

Pull <remote_claude_home>/projects/ and /sessions/ into the cache.

Non-existent remote directories are tolerated (rsync returns 23).

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_session return 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_interrupted is 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.

xa.append_hidden(events: JsonLinesStore, *, id: str, hidden: bool) None[source]

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.

id can be an archive id, a tmux session name, or a claude_session_id — whatever key the caller will use to look it up later. Empty-string or None label 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_markers is 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 to oom_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 LookupError if the prefix is ambiguous.

xa.history_iter(*, claude_home: Path = PosixPath('/home/runner/.claude')) Iterator[HistoryEntry][source]

Yield entries from ~/.claude/history.jsonl in 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 fresh LocalHost.

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 hosts is None we default to a single LocalHost. If claude_home or tmux_bin are given, they’re forwarded into that default LocalHost — handy for fixture-based tests.

Filters:

  • project — substring match against cwd (case-insensitive)

  • include_forks=False — drop sessions with a forked_from

  • include_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.load_hosts(path: Path | None = None) dict[source]

Shortcut: just the host registry.

xa.overlays(events: JsonLinesStore) dict[str, dict][source]

Fold labeled / hidden events 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 pid if present.

xa.reconcile(events: JsonLinesStore, panes: FileStore, live_sessions: Iterable[TmuxSession], *, claude_home: Path = PosixPath('/home/runner/.claude')) list[dict][source]

Emit gone events for archived sessions missing from live_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 → take bridgeSessionId. Fallback: regex-scrape capture-pane output 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.

--resume reuses the cwd’s trusted-folder setting, so we skip the trust-prompt dismiss; the /remote-control handshake 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 claude in cwd.

Waits up to url_timeout_sec for the bridge URL to appear, dismissing the “trust this folder” prompt and issuing /remote-control as needed.

If archive_store (and optionally pane_store) are given, emits created + url_acquired events to the archive and pipes the pane output to a file under pane_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 / assistant text without 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_events directly.

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