xa.revive¶
Detect — and reconnect — Claude Code panes whose Remote Control dropped.
Remote Control gives up on its own: after a few minutes of failed reconnects, or roughly thirty minutes of failed presence heartbeats, it stops trying and leaves the local session running but unreachable from claude.ai. The usual cause is a network change (moving between networks, toggling a VPN) that produces a transient HTTP 403 — the same change that clears it, usually within a minute or two.
Two facts shape everything below, and both were verified rather than assumed:
**Two observables, and both were checked against a live session rather than assumed.** The footer carries a
/rcpill that is present while Remote Control is connected (/rc, or/rc activewhen verbose) and disappears when it is not — so the pill’s absence, not its presence, is what marks a dropped session. The ephemeral session file (~/.claude/sessions/<pid>.json) agrees from the other side: itsbridgeSessionIdis a real id while connected andnullonce it is not. The transcript JSONL never mentions Remote Control at all, so there is no third source.``tmux send-keys`` is the only way to act.
/remote-controlis a built-in slash command; the CLI exposes no subcommand and no control request that reconnects a running session (checked againstclaude --helpandclaude remote-control --help, 2.1.251 — the only session-shaped verbs there areagents/attach/logs/stop/rm/respawn, all of which are about background agents).
The detection phrases in MARKERS are transcribed from the strings
embedded in the claude 2.1.251 binary, not guessed from screenshots.
They are data, and DEFAULT_RULES is injectable, so a wording change
upstream is a one-line edit rather than a rewrite.
Four things this module refuses to do, each deliberate:
It never revives a session held elsewhere. When a session was taken over or ended from another device, the TUI says so (WebSocket close code 4090) and omits its “run /remote-control” advice. Sending the command
anyway would steal the session back from the user’s phone mid-sentence.
HELD_ELSEWHEREis therefore tested beforeRECONNECTABLEand acting on it stays opt-in.
It never resends a prompt. An API-stalled pane is reported and left alone: the fix there is a real model call that can duplicate work.
It never types into a pane waiting on a human. A permission dialog or a
/loginprompt consumes keystrokes as answers — sending/remote-controlinto an open “Do you want to proceed?” selector would answer it. Those panes classify asNEEDS_HUMANand are skipped.It does nothing at all unless asked.
apply=Trueis explicit; every entry point is a dry run by default.
Typical use:
from xa.revive import SessionPanes, revive
for action in revive(panes=SessionPanes()): # dry run
print(action.target, action.verdict, action.skipped)
Discovery does not enumerate tmux and guess which pane holds a claude.
It reads the live ephemeral session files, each of which records its own
pane as "tmux": "session:@window.%pane", so the pane refs are exact and
pane_current_command never enters the decision. See local_panes().
Module Attributes
No claude process behind the pane — a shell prompt, or a session that died. |
|
Taken over or ended from another device. |
|
Remote Control is up. |
|
Remote Control is retrying right now. |
|
Blocked on a human — an open permission dialog, or anything else that is eating keystrokes. |
|
claude on this host is logged out. |
|
the workspace-trust prompt is open. |
|
The API call is wedged. |
|
A |
|
Dropped and recoverable — the one verdict |
|
The pane says nothing either way. |
|
Every verdict |
|
Verdicts that mean "a human must act on the host". |
|
Verdicts |
|
Substring markers per verdict. |
|
The footer pill. |
|
Ordered |
|
the prompt glyph, then U+00A0, then whatever the user has typed. ``> `` is the plain-ASCII rendering some terminals get. |
|
Statuses in which a keystroke is a command. |
|
Long enough that a cron tick can never turn into a keystroke storm, short enough that a real network change is followed within one settling period. |
|
The keystrokes that reconnect a REPL session. |
|
Verdicts worth a human's eye, in the order a report should present them. |
Functions
|
Return the verdict for |
|
The marker that earned |
|
One line per pane, most actionable first. |
|
Default relevance test: the recorded pid is a claude that still exists. |
|
Human fix-it line for a verdict, or |
|
Yield one |
|
True when the pane's input line is empty and safe to type into. |
|
Why typing into |
|
Restart a died |
|
Reconnect every dropped pane. |
|
Keystrokes that restart a Remote Control server in |
Classes
|
Identity of one candidate pane — everything known without reading it. |
|
A pane, as read and judged. |
|
The complete, pure input to a classification rule. |
|
Refuses to touch the same pane twice inside |
|
What was (or would be) done to one pane, and why. |
|
Live view of the panes worth judging: |
- xa.revive.ACTIONABLE = frozenset({'reconnectable'})¶
Verdicts
revive()will send/remote-controlto.HELD_ELSEWHEREis deliberately absent — it is reachable only viainclude_held_elsewhere.
- xa.revive.API_STALLED = 'api_stalled'¶
The API call is wedged. Reported, never acted on (the fix is a new prompt).
- xa.revive.BLOCKED_ON_HUMAN = frozenset({'needs_human', 'needs_login', 'needs_trust'})¶
Verdicts that mean “a human must act on the host”. All are blocked on a person, none are actionable by
revive(), and each has a fix-it line inhint_for().
- xa.revive.CONNECTED = 'connected'¶
Remote Control is up. Nothing to do.
- xa.revive.DEFAULT_RULES: tuple[tuple[str, Callable[[Probe], bool]], ...] = (('no_claude', <function _no_claude>), ('held_elsewhere', <function _any_of.<locals>._pred>), ('reconnecting', <function _any_of.<locals>._pred>), ('connected', <function _pill_present>), ('connected', <function _any_of.<locals>._pred>), ('needs_login', <function _any_of.<locals>._pred>), ('needs_trust', <function _any_of.<locals>._pred>), ('needs_human', <function _any_of.<locals>._pred>), ('api_stalled', <function _any_of.<locals>._pred>), ('server_mode', <function _any_of.<locals>._pred>), ('reconnectable', <function _any_of.<locals>._pred>), ('reconnectable', <function _bridge_down>), ('unknown', <function _always>))¶
Ordered
(verdict, predicate)pairs; first match wins.The order is the policy, not a style choice, and two things decide it.
Safety:
NO_CLAUDEleads, so a dead session’s scrollback can never be read as live state — the pane this was written against still showed a permission dialog and a disconnect notice above a shell prompt, all true once and none true now.HELD_ELSEWHEREprecedes every reconnect verdict, per the rule that a session picked up on another device is never taken back without asking.Freshness: the pill rules are live state and the phrase rules are history. A session that dropped and recovered still carries “Remote Control disconnected” in its scrollback for as long as it stays on screen, so a text rule placed above the pill would report a healthy session as broken until the line scrolled away.
- xa.revive.DFLT_MIN_INTERVAL_SEC = 600.0¶
Long enough that a cron tick can never turn into a keystroke storm, short enough that a real network change is followed within one settling period.
- xa.revive.HELD_ELSEWHERE = 'held_elsewhere'¶
Taken over or ended from another device. Never reconnect without opt-in.
- xa.revive.MARKERS: dict[str, tuple[str, ...]] = {'api_stalled': ('api error', 'retrying in ', 'overloaded'), 'connected': ('remote control active',), 'held_elsewhere': ('ended or archived from another device', 'no longer the active worker for the session', 'already has remote control for this conversation', 'run /remote-control to move it to this terminal'), 'needs_human': ('do you want to proceed?', 'esc to cancel'), 'needs_login': ('run /login', 'please log in', 'select login method', 'oauth token has expired', 'invalid api key', 'unknown command: /remote-control'), 'needs_trust': ('trust this folder', 'do you trust the files'), 'reconnectable': ('remote control disconnected', 'remote control not started here', 'run /remote-control to retry', '/remote-control is no longer active', '/rc failed'), 'reconnecting': ('/rc reconnecting',), 'server_mode': ('re-run `claude remote-control` to try again',)}¶
Substring markers per verdict. Lowercase; matched against a lowered tail.
- xa.revive.NEEDS_HUMAN = 'needs_human'¶
Blocked on a human — an open permission dialog, or anything else that is eating keystrokes. See
NEEDS_LOGIN/NEEDS_TRUSTfor the two causes specific enough to tell the user how to fix.
- xa.revive.NEEDS_LOGIN = 'needs_login'¶
claude on this host is logged out. Login state is per-machine, so one
/loginfixes every session on it.- Type:
Blocked on a human, specifically
- xa.revive.NEEDS_TRUST = 'needs_trust'¶
the workspace-trust prompt is open.
- Type:
Blocked on a human, specifically
- xa.revive.NO_CLAUDE = 'no_claude'¶
No claude process behind the pane — a shell prompt, or a session that died.
- xa.revive.PROMPT_LINE = re.compile('(?m)^(?:❯|>)[\xa0 ](.*)$', re.MULTILINE)¶
the prompt glyph, then U+00A0, then whatever the user has typed. ``> `` is the plain-ASCII rendering some terminals get.
- Type:
The TUI’s input line
- class xa.revive.PaneRef(target, host='local', claude_pid=None, cwd=None, session_id=None, bridge_session_id=None, name=None, status=None)[source]¶
Bases:
objectIdentity of one candidate pane — everything known without reading it.
targetis a tmux target: a full pane ref (session:@window.%pane, which is what the ephemeral session file records) or a bare session name.
- class xa.revive.PaneState(ref, tail, verdict, evidence=None)[source]¶
Bases:
objectA pane, as read and judged. The value type of
SessionPanes.
- class xa.revive.Probe(text, ref=<factory>)[source]¶
Bases:
objectThe complete, pure input to a classification rule.
textis the pane tail, lowercased once so every rule can use a plain substring test.
- xa.revive.RC_PILL = re.compile('(?m)^.*(?<![\\w/])/rc(?: active)?\\s*$', re.MULTILINE)¶
The footer pill. Present while Remote Control is connected —
/rcon its own right-aligned line, or/rc activein verbose mode — and gone the moment it is not. Anchored to a line end because a bare"/rc" in textwould also match a file path ending insrc/rc.The direction matters and is easy to get backwards: the pill is evidence of health. Verified by disconnecting a live session and watching the line vanish (claude 2.1.251).
- xa.revive.RECONNECTABLE = 'reconnectable'¶
Dropped and recoverable — the one verdict
revive()acts on.
- xa.revive.RECONNECTING = 'reconnecting'¶
Remote Control is retrying right now. Leave it alone; it may well succeed.
- xa.revive.RECONNECT_KEYS = ('/remote-control', 'Enter')¶
The keystrokes that reconnect a REPL session.
Entersubmits.
- xa.revive.REPORT_ORDER = ('reconnectable', 'server_mode', 'held_elsewhere', 'needs_human', 'api_stalled', 'reconnecting', 'no_claude', 'unknown', 'connected')¶
Verdicts worth a human’s eye, in the order a report should present them.
- class xa.revive.RateGuard(*, store=None, min_interval_sec=600.0, clock=<built-in function time>)[source]¶
Bases:
objectRefuses to touch the same pane twice inside
min_interval_sec.Without this a cron tick, a hook and an impatient human can each send
/remote-controlinto the same pane seconds apart. Backed by any{key: bytes}mapping — the default is xa’s on-diskFileStore, so the interval survives process exit, which is the entire point.>>> clock = iter([100.0, 200.0, 800.0]).__next__ >>> guard = RateGuard(store={}, min_interval_sec=600.0, clock=clock) >>> guard.allow('work:@1.%1') # never attempted True >>> guard.record('work:@1.%1') # stamped at t=100 >>> guard.allow('work:@1.%1') # t=200 — 100s later, still held off False >>> guard.allow('work:@1.%1') # t=800 — 700s later, released True
- class xa.revive.ReviveAction(target, verdict, keys=(), sent=False, skipped=None, evidence=None, cwd=None)[source]¶
Bases:
objectWhat was (or would be) done to one pane, and why.
- xa.revive.SERVER_MODE = 'server_mode'¶
A
claude remote-controlserver died. Seerestart_server_mode().
- class xa.revive.SessionPanes(*, panes=<function local_panes>, is_relevant=<function has_live_claude>, tail=<function _capture>, rules=(('no_claude', <function _no_claude>), ('held_elsewhere', <function _any_of.<locals>._pred>), ('reconnecting', <function _any_of.<locals>._pred>), ('connected', <function _pill_present>), ('connected', <function _any_of.<locals>._pred>), ('needs_login', <function _any_of.<locals>._pred>), ('needs_trust', <function _any_of.<locals>._pred>), ('needs_human', <function _any_of.<locals>._pred>), ('api_stalled', <function _any_of.<locals>._pred>), ('server_mode', <function _any_of.<locals>._pred>), ('reconnectable', <function _any_of.<locals>._pred>), ('reconnectable', <function _bridge_down>), ('unknown', <function _always>)))[source]¶
Bases:
MappingLive view of the panes worth judging:
{tmux target: PaneState}.Every source of truth is injected, which is what lets the whole classification path be tested without a tmux server:
panes— callable returningPaneRefobjects (default:local_panes());is_relevant— per-ref filter (default:has_live_claude());tail— callable reading a ref’s pane text (default: tmux capture);rules— the classification table (default:DEFAULT_RULES).
Pane text is read lazily and cached, so building the mapping costs nothing and iterating it costs one capture per pane.
>>> refs = [PaneRef(target='w:@1.%1', claude_pid=7, bridge_session_id='s_1')] >>> panes = SessionPanes( ... panes=lambda: refs, ... is_relevant=lambda ref: True, ... tail=lambda ref: 'Remote Control disconnected', ... ) >>> list(panes) ['w:@1.%1'] >>> panes['w:@1.%1'].verdict 'reconnectable' >>> panes.by_verdict()['reconnectable'] ['w:@1.%1']
- xa.revive.TYPEABLE_STATUS = frozenset({'idle', None})¶
Statuses in which a keystroke is a command. Anything else —
busy,waiting— means the session is mid-turn or holding a dialog, and typed text would be queued into it.Noneis a claude too old to say.
- xa.revive.UNKNOWN = 'unknown'¶
The pane says nothing either way. Never acted on.
- xa.revive.VERDICTS = frozenset({'api_stalled', 'connected', 'held_elsewhere', 'needs_human', 'needs_login', 'needs_trust', 'no_claude', 'reconnectable', 'reconnecting', 'server_mode', 'unknown'})¶
Every verdict
DEFAULT_RULEScan produce. Custom rules may add more.
- xa.revive.classify(probe, *, rules=(('no_claude', <function _no_claude>), ('held_elsewhere', <function _any_of.<locals>._pred>), ('reconnecting', <function _any_of.<locals>._pred>), ('connected', <function _pill_present>), ('connected', <function _any_of.<locals>._pred>), ('needs_login', <function _any_of.<locals>._pred>), ('needs_trust', <function _any_of.<locals>._pred>), ('needs_human', <function _any_of.<locals>._pred>), ('api_stalled', <function _any_of.<locals>._pred>), ('server_mode', <function _any_of.<locals>._pred>), ('reconnectable', <function _any_of.<locals>._pred>), ('reconnectable', <function _bridge_down>), ('unknown', <function _always>)))[source]¶
Return the verdict for
probe. Pure — no I/O, no tmux, no clock.A session whose footer pill is gone and whose session file records no bridge id has dropped:
- Return type:
>>> dropped = PaneRef(target='w:@1.%1', claude_pid=42) >>> classify(Probe(text='conversation text\n❯ ', ref=dropped)) 'reconnectable'
One still carrying the pill has not, whatever its scrollback remembers:
>>> live = PaneRef(target='w:@1.%1', claude_pid=42, bridge_session_id='s') >>> classify(Probe(text='remote control disconnected\n /rc', ref=live)) 'connected'
A session someone picked up on their phone is never reconnectable, even though the pane also says it disconnected:
>>> taken = 'remote control disconnected\nended or archived from another device' >>> classify(Probe(text=taken, ref=dropped)) 'held_elsewhere'
Neither is one sitting on a permission dialog:
>>> classify(Probe(text='/rc failed\ndo you want to proceed?', ref=live)) 'needs_human'
And a healthy, quiet, connected pane is simply left alone:
>>> classify(Probe(text='just some conversation\n /rc', ref=live)) 'connected'
- xa.revive.evidence_for(probe, verdict, *, rules=(('no_claude', <function _no_claude>), ('held_elsewhere', <function _any_of.<locals>._pred>), ('reconnecting', <function _any_of.<locals>._pred>), ('connected', <function _pill_present>), ('connected', <function _any_of.<locals>._pred>), ('needs_login', <function _any_of.<locals>._pred>), ('needs_trust', <function _any_of.<locals>._pred>), ('needs_human', <function _any_of.<locals>._pred>), ('api_stalled', <function _any_of.<locals>._pred>), ('server_mode', <function _any_of.<locals>._pred>), ('reconnectable', <function _any_of.<locals>._pred>), ('reconnectable', <function _bridge_down>), ('unknown', <function _always>)))[source]¶
The marker that earned
verdict, for a report a human has to trust.>>> ref = PaneRef(target='w:@1.%1', claude_pid=1) >>> evidence_for(Probe(text='/rc failed', ref=ref), 'reconnectable') '/rc failed'
- xa.revive.format_actions(actions)[source]¶
One line per pane, most actionable first.
- Return type:
>>> print(format_actions([ ... ReviveAction('a:@1.%1', RECONNECTABLE, keys=RECONNECT_KEYS, ... skipped='dry-run', evidence='/rc failed'), ... ReviveAction('b:@1.%1', CONNECTED), ... ])) reconnectable a:@1.%1 would send /remote-control [/rc failed] connected b:@1.%1
- xa.revive.has_live_claude(ref)[source]¶
Default relevance test: the recorded pid is a claude that still exists.
Identity, not just liveness — a stale ephemeral file whose pid got recycled must never direct keystrokes at an unrelated process.
- Return type:
- xa.revive.hint_for(verdict, *, tmux_name=None)[source]¶
Human fix-it line for a verdict, or
Nonewhen there is nothing to say.SSOT for the wording shown by the CLI, the web UI and the HTTP API, so the three can’t drift. Only the verdicts a person can actually clear get a line;
connected/unknown/reconnectablereturnNone(nothing to do, nothing known, or xa’s own job respectively).>>> hint_for(CONNECTED) is None True >>> hint_for(NEEDS_TRUST, tmux_name='sess').startswith('Claude is waiting') True
- xa.revive.local_panes(*, claude_home=PosixPath('/home/runner/.claude'))[source]¶
Yield one
PaneRefper live claude that records a tmux pane.Derived from the ephemeral session files rather than from
tmux list-panes: each file names its own pane, so no heuristic has to decide whether a pane holds a claude. A session not under tmux (every session on a machine that runs claude in terminal tabs) simply has notmuxkey and is skipped — there is no pane to send keys to.
- xa.revive.prompt_is_clear(tail)[source]¶
True when the pane’s input line is empty and safe to type into.
This is the difference between reconnecting a session and destroying the instruction its owner left half-typed:
send-keysappends to whatever is already in the buffer, so/remote-controlsent at a pane whose prompt readsyes, port it into the reposubmitsyes, port it into the repo/remote-control.Conservative by construction — a pane whose prompt line cannot be found is reported as not clear, because “I could not see the buffer” and “the buffer is empty” must never collapse into the same answer.
- Return type:
>>> prompt_is_clear('---\n❯ \n---') True >>> prompt_is_clear('❯ yes, port it into the repo') False >>> prompt_is_clear('no prompt line here at all') False
- xa.revive.refusal_to_type(state)[source]¶
Why typing into
statewould be unsafe, orNoneif it is fine.Two independent gates, because each catches what the other misses: the reported status catches a session mid-turn whose buffer happens to be empty, and the buffer check catches an idle session holding text the status knows nothing about.
>>> ref = PaneRef(target='t', claude_pid=1, status='idle') >>> refusal_to_type(PaneState(ref=ref, tail='❯ ', verdict=RECONNECTABLE)) >>> busy = replace(ref, status='busy') >>> refusal_to_type(PaneState(ref=busy, tail='❯ ', verdict=RECONNECTABLE)) 'session is busy'
- xa.revive.restart_server_mode(state, *, cwd=None, apply=False, rate_guard=None, send=None, resolve_cwd=None)[source]¶
Restart a died
claude remote-controlserver in its own directory.The directory is not recorded by this package. It is read back from what already knows it — the pane’s own working directory via tmux, or the ephemeral session file when a claude is still alive — and the session identity is left to
claude remote-control -c, which keeps its own per-directory record. Adding a fourth place to write it down would only create something to drift.Refuses whenever a live claude still owns the pane: this sends a shell command, and a running TUI would take it as prompt text.
- Return type:
>>> ref = PaneRef(target='srv:@1.%1', cwd='/srv/app') >>> state = PaneState(ref=ref, tail='', verdict=SERVER_MODE) >>> keyed = [] >>> action = restart_server_mode( ... state, ... apply=True, ... rate_guard=RateGuard(store={}, clock=lambda: 0.0), ... send=lambda target, *keys: keyed.append((target, keys)), ... ) >>> action.sent, keyed (True, [('srv:@1.%1', ('cd /srv/app && claude remote-control -c', 'Enter'))])
>>> live = PaneState(ref=replace(ref, claude_pid=9), tail='', verdict=SERVER_MODE) >>> restart_server_mode(live, apply=True).skipped 'a claude still owns this pane — keystrokes would land in its prompt'
- xa.revive.revive(*, panes=None, apply=False, include_held_elsewhere=False, rate_guard=None, send=None, actionable=frozenset({'reconnectable'}))[source]¶
Reconnect every dropped pane. **Dry run unless
apply=True.**Returns one
ReviveActionper pane examined — including the ones left alone, each carrying the reason, because “nothing happened” is only useful next to what was looked at.include_held_elsewhere=Trueopts into reconnecting sessions that were taken over from another device. That steals the session back from whatever device holds it; it is off by default and should stay a deliberate keystroke.- Return type:
>>> refs = [ ... PaneRef(target='a:@1.%1', claude_pid=1), ... PaneRef(target='b:@1.%1', claude_pid=2, bridge_session_id='s'), ... ] >>> tails = {'a:@1.%1': '/rc failed\n❯ ', 'b:@1.%1': 'all quiet\n❯ \n /rc'} >>> panes = SessionPanes( ... panes=lambda: refs, ... is_relevant=lambda ref: True, ... tail=lambda ref: tails[ref.target], ... ) >>> sent = [] >>> actions = revive( ... panes=panes, ... apply=True, ... rate_guard=RateGuard(store={}, clock=lambda: 0.0), ... send=lambda target, *keys: sent.append((target, keys)), ... ) >>> [(a.target, a.verdict, a.sent) for a in actions] [('a:@1.%1', 'reconnectable', True), ('b:@1.%1', 'connected', False)] >>> sent [('a:@1.%1', ('/remote-control', 'Enter'))]
- xa.revive.server_mode_command(cwd)[source]¶
Keystrokes that restart a Remote Control server in
cwd.claude remote-control -creattaches the session the command last recorded for that directory (within ~4h), which is why the directory — and nothing else — is what a restart needs to know.>>> server_mode_command('/root/py/proj') ('cd /root/py/proj && claude remote-control -c', 'Enter') >>> server_mode_command(None) ('claude remote-control -c', 'Enter')