master
John Lauer Publish 1.1.120 13ac4bc 2d ago
#!/usr/bin/env python3
"""Claude Remote Control in the title bar: how many sessions your phone can reach right now, and
on hover, what it should show you. Reads the Claude Code sign-in in ~/.claude/.credentials.json and
asks the same session list the Claude app and claude.ai/code read (GET /v1/code/sessions), plus the
machine each session runs on (GET /v1/environments). For THIS PC it also asks Hydrogen which Claude
threads are open and whether each one has Remote Control on, because a conversation only appears on
the phone once RC is on for it (John 2026-10-20: "i would expect to see like 20 active ai threads on
my phone ... but i only see 2"). Asks at most once a minute, pauses while the window is hidden."""
import json, os, socket, sys, time, urllib.parse, urllib.request
sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)))
from importlib.machinery import SourceFileLoader
marks = SourceFileLoader('hwmarks', os.path.join(os.path.dirname(os.path.realpath(__file__)), 'hydrogen-widget-marks.py')).load_module()
lib = SourceFileLoader('hwlib', os.path.join(os.path.dirname(os.path.realpath(__file__)), 'hydrogen-widget-lib.py')).load_module()

API = 'https://api.anthropic.com'
# A session the cloud still lists as active whose machine has not been heard from in this long is
# a leftover (a host that died without signing off), not something to look for on the phone.
STALE_S = 6 * 3600

def epoch(iso):
    if not iso: return None
    try:
        from datetime import datetime
        return round(datetime.fromisoformat(str(iso).replace('Z', '+00:00')).timestamp())
    except Exception: return None

def token():
    if not os.path.exists(os.path.expanduser('~/.claude/.credentials.json')):
        return None, 'sign in to Claude Code in this workspace'
    c = lib.read_json_if_changed('~/.claude/.credentials.json')
    oa = c.get('claudeAiOauth') or c
    tok = oa.get('accessToken')
    if not tok: return None, 'not signed in to Claude Code'
    exp = oa.get('expiresAt') or 0
    if exp and exp < time.time() * 1000:
        e = Exception('token expired'); e.code = 401; raise e
    return tok, None

def get(path, tok, beta=None):
    h = {'Authorization': 'Bearer ' + tok, 'anthropic-version': '2023-06-01', 'User-Agent': 'hydrogen-widget-claude-rc'}
    if beta: h['anthropic-beta'] = beta
    return lib.http_json(API + path, h)

def hd_threads():
    """This PC's Claude threads from Hydrogen's AI Threads census: (rows, note). Each row says
    whether Remote Control is on for it (None when its tab has not loaded since Hydrogen started)."""
    ctl = (os.environ.get('ADOM_HD_CONTROL_URL') or '').strip()
    if not ctl:
        try: ctl = open(os.path.expanduser('~/.adom/hydrogen-control-url')).read().strip()
        except Exception: ctl = ''
    if not ctl: return None, 'Hydrogen is not reachable from this workspace'
    try:
        with urllib.request.urlopen(ctl.rstrip('/') + '/ai-threads', timeout=25) as r:
            d = json.load(r)
    except Exception as e:
        return None, 'Hydrogen did not answer (%s)' % (str(e)[:60])
    if not d.get('ok', True) and d.get('error'): return None, str(d['error'])[:90]
    rows = []
    for g in d.get('groups') or []:
        for t in g.get('threads') or []:
            if t.get('type') != 'claude' or t.get('tui'): continue
            rows.append({'name': t.get('name') or t.get('ai_title') or t.get('label') or 'Claude Code',
                         'rc': t.get('remote_control'), 'tid': t.get('tid'), 'sid': t.get('sid'),
                         'working': bool((t.get('working') or {}).get('state') in ('working', 'running', 'answering'))})
    return rows, None

# ── The other machines, over the Adom Bridge mesh (John 2026-10-20: "combine adom bridge mesh to
# understand what account i'm logged into on other boxes like arav-rog or crr cuz i sometimes log
# into those on other accounts ... so i know when i'm on my phone which account to log into"). Each
# machine's own Hydrogen answers which Claude account its workspace uses (/claude/auth-status) and
# its Claude threads with their Remote Control state (/ai-threads). Asked in parallel, at most every
# 5 minutes: these are real calls into every PC, the cloud list above is the live part.
MESH_TTL = 300
_mesh = {'t': 0, 'v': None}

def _ab(args, timeout):
    import subprocess, shutil
    exe = shutil.which('adom-bridge') or shutil.which('adom-bridge-cli')
    if not exe: return None
    try:
        r = subprocess.run([exe, '--ai-thread', 'Claude RC widget'] + args, capture_output=True, text=True, timeout=timeout)
        raw = r.stdout
        return json.loads(raw[raw.index('{'):]) if '{' in raw else None
    except Exception: return None

def _hd(target, path, timeout=40):
    d = _ab(['--target', target, 'hd_api', json.dumps({'method': 'GET', 'path': path, 'timeoutSeconds': timeout})], timeout + 15)
    if not d: return None
    b = d.get('body') if isinstance(d, dict) else None
    if isinstance(b, str):
        try: b = json.loads(b)
        except Exception: b = None
    return b if isinstance(b, dict) else None

def _one(name):
    out = {'name': name}
    a = _hd(name, '/claude/auth-status', 30)
    if a is None: out['unreachable'] = True; return out
    out['account'] = a.get('email'); out['logged_in'] = a.get('logged_in'); out['plan'] = a.get('subscription_type')
    t = _hd(name, '/ai-threads', 45)
    if t and t.get('activated') is False:
        # AI Threads is parked there (deliberately free until its panel opens). The extension's
        # own tab mirror still lists every Claude tab at no cost; RC state then comes from the
        # cloud list (a conversation with RC on is a connected session under its title).
        tb = _hd(name, '/ai-threads/tabs', 30) or {}
        tabs = [x for x in ((tb.get('mirror') or {}).get('tabs') or []) if x.get('kind') == 'claude']
        out['threads'] = len(tabs)
        out['thread_rows'] = [{'name': x.get('label') or 'Claude Code', 'rc': None} for x in tabs][:40]
        out['threads_from'] = 'tab list'
        return out
    if t and t.get('groups') is not None:
        rows = [th for g in t.get('groups') or [] for th in g.get('threads') or [] if th.get('type') == 'claude' and not th.get('tui')]
        out['threads'] = len(rows)
        out['threads_rc'] = sum(1 for th in rows if th.get('remote_control') is True)
        out['threads_unknown'] = sum(1 for th in rows if th.get('remote_control') is None)
        out['thread_rows'] = [{'name': th.get('name') or th.get('ai_title') or th.get('label') or 'Claude Code', 'rc': th.get('remote_control')} for th in rows][:30]
    elif t and t.get('error'): out['threads_note'] = str(t['error'])[:90]
    return out

def mesh():
    if _mesh['v'] is not None and time.time() - _mesh['t'] < MESH_TTL: return _mesh['v']
    tg = _ab(['targets'], 30)
    if not tg: return _mesh['v']   # no bridge CLI here, or it did not answer: keep the last answer
    names = [t.get('name') for t in tg.get('targets') or [] if t.get('name') and t.get('os', 'Windows') in ('Windows', 'macOS', 'Darwin')]
    from concurrent.futures import ThreadPoolExecutor
    with ThreadPoolExecutor(max_workers=8) as ex:
        v = list(ex.map(_one, names))
    _mesh.update(t=time.time(), v=v)
    return v

def data():
    tok, err = token()
    if err: return {'error': err}
    envs = {}
    try:
        ed = get('/v1/environments?beta=true', tok, 'environments-2025-11-01')
        for e in ed.get('data') or []:
            cfg = e.get('config') or {}
            envs[e.get('id')] = cfg.get('machine_name') or (e.get('name') or '').split(':')[0] or 'unknown machine'
    except Exception: pass   # machine names are a nicety; the session list still answers the question
    sessions, cursor = [], None
    for _ in range(3):
        d = get('/v1/code/sessions?limit=100&statuses=active' + ('&cursor=' + urllib.parse.quote(cursor) if cursor else ''), tok)
        sessions += d.get('data') or []
        cursor = d.get('next_cursor')
        if not cursor: break
    now = time.time()
    me = socket.gethostname()
    machines, old = {}, 0
    for s in sessions:
        if s.get('status') == 'archived': continue
        connected = s.get('connection_status') == 'connected'
        last = epoch(s.get('last_event_at') or s.get('updated_at') or s.get('created_at'))
        title = (s.get('title') or 'Untitled').strip()
        # A machine's Remote Control HOST (`claude remote-control --name <machine>-rc`): what puts
        # the machine itself on the phone. Every machine keeps its newest host row even when it is
        # long disconnected, so a machine that fell off the phone shows as such instead of vanishing.
        host = title.lower().endswith('-rc') and int(s.get('user_message_count') or 0) == 0
        mach = envs.get(s.get('environment_id')) or (title[:-3] if host else None) \
            or ('Cloud' if s.get('environment_kind') != 'bridge' else 'unknown machine')
        if not connected and not host and (not last or now - last > STALE_S):
            old += 1; continue
        meta = s.get('external_metadata') or {}
        pts = meta.get('post_turn_summary') or {}
        key = mach.lower()
        m = machines.setdefault(key, {'name': mach, 'this_pc': key == me.lower(), 'host': None, 'sessions': []})
        row = {'title': title, 'connected': connected, 'last': last, 'worker': s.get('worker_status') or '',
               'detail': pts.get('status_detail') or pts.get('recent_action') or '', 'needs': pts.get('needs_action') or ''}
        if host:
            cur = m['host']
            better = cur is None or (connected and not cur['connected']) or (connected == cur['connected'] and (last or 0) > (cur['last'] or 0))
            if better:
                if cur is not None: old += 1
                m['host'] = row
            else: old += 1
        else: m['sessions'].append(row)
    if me.lower() not in machines and os.environ.get('ADOM_HD_CONTROL_URL') is not None:
        machines[me.lower()] = {'name': me, 'this_pc': True, 'host': None, 'sessions': []}
    live = sum(1 for m in machines.values() for r in ([m['host']] if m['host'] else []) + m['sessions'] if r['connected'])
    out = {'live': live, 'old_listed': old, 'machines': sorted(machines.values(), key=lambda m: (not m['this_pc'], m['name'].lower())), 'me': me}
    rows, note = hd_threads()
    if rows is not None:
        out['threads'] = rows
        out['threads_rc'] = sum(1 for r in rows if r['rc'] is True)
    else: out['threads_note'] = note
    try:
        acc = (lib.read_json_if_changed('~/.claude.json').get('oauthAccount') or {})
        if acc.get('emailAddress'): out['account'] = acc['emailAddress']
    except Exception: pass
    # Every machine on the mesh: its Claude account, and its threads with their RC state. A machine
    # the cloud list does not know (its sessions live under ANOTHER account) is added here, so the
    # hover can say which account to open on the phone to see it.
    me_acc = (out.get('account') or '').lower()
    def same_title(a, b):
        a = (a or '').replace('\u2026', '').strip().lower(); b = (b or '').replace('\u2026', '').strip().lower()
        return bool(a and b) and (a.startswith(b) or b.startswith(a))
    for mm in mesh() or []:
        key = mm['name'].lower()
        m = next((x for x in out['machines'] if x['name'].lower() == key), None)
        if m is None:
            m = {'name': mm['name'], 'this_pc': key == me.lower(), 'host': None, 'sessions': []}
            out['machines'].append(m)
        for k in ('account', 'plan', 'logged_in', 'unreachable', 'threads', 'threads_rc', 'threads_unknown', 'thread_rows', 'threads_note'):
            if k in mm: m[k] = mm[k]
        m['other_account'] = bool(mm.get('account')) and mm['account'].lower() != me_acc
    # A conversation's own RC session names no machine (no environment, origin claude_code_vscode):
    # file it under the machine whose open tabs carry its title, which is also what marks that
    # tab "on phone" below.
    orphan = next((x for x in out['machines'] if x['name'] == 'unknown machine'), None)
    if orphan:
        keep = []
        for sess in orphan['sessions']:
            home = next((x for x in out['machines'] if x is not orphan and not x.get('other_account')
                         and any(same_title(r['name'], sess['title']) for r in x.get('thread_rows') or [])), None)
            (home['sessions'] if home else keep).append(sess)
        orphan['sessions'] = keep
        if not keep and not orphan['host']: out['machines'].remove(orphan)
        else: orphan['name'] = 'Not matched to a machine'
    for m in out['machines']:
        if m.get('other_account') or not m.get('thread_rows'): continue
        live = [x['title'] for x in m['sessions'] if x['connected']]
        for r in m['thread_rows']:
            if r['rc'] is not True and any(same_title(r['name'], t) for t in live): r['rc'] = True
            elif r['rc'] is None: r['rc'] = False
    out['machines'].sort(key=lambda m: (not m['this_pc'], m['name'].lower()))
    # Something to fix: this PC's RC host is not connected, or it has Claude threads RC cannot see.
    mine = next((m for m in out['machines'] if m['this_pc']), None)
    off = [r for r in (rows or []) if r['rc'] is False]
    out['warn'] = bool(off) or not (mine and mine['host'] and mine['host']['connected'])
    return out

# The slot: the RC mark, "RC", how many sessions the phone can reach now, an amber dot when this PC
# has something RC cannot see.
RENDER = r"""(d.error?`<span class="err">${d.error}</span>`:`<span class="lbl">RC</span><span class="big">${d.live}</span>${d.warn?'<span class="rcw" title="This PC has Claude threads your phone cannot see"></span>':''}`)"""

X_CSS = r"""
.rcw{width:7px;height:7px;border-radius:50%;background:#f0a060;margin-left:2px;flex:none}
.rq{font-size:12px;color:rgba(230,235,239,.85);padding:2px 0 8px}
.rq b{font-size:15px;color:#19b3a6}
.rm{padding:8px 0;border-top:1px solid rgba(255,255,255,.07)}
.rm h4{margin:0 0 5px;font-size:12px;font-weight:600;display:flex;align-items:center;gap:6px}
.rm h4 .me{font-size:9.5px;font-weight:500;padding:0 6px;border-radius:8px;border:1px solid rgba(25,179,166,.5);color:#19b3a6}
.rr{display:grid;grid-template-columns:10px minmax(0,1fr) auto;gap:6px;align-items:baseline;padding:2px 0;font-size:11.5px}
.dot{width:7px;height:7px;border-radius:50%;background:rgba(255,255,255,.25);align-self:center}
.dot.on{background:#19b3a6}.dot.off{background:#f0a060}.dot.unk{background:transparent;border:1px solid rgba(255,255,255,.35);box-sizing:border-box}
.rr .t{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.rr .t small{display:block;white-space:normal;color:rgba(230,235,239,.55);font-size:10.5px;margin-top:1px}
.rr .a{font-size:10.5px;color:rgba(230,235,239,.5);white-space:nowrap}
.rn{font-size:11px;color:rgba(230,235,239,.6);padding:2px 0 0 16px}
.rn.bad{color:#f0a060}
.ra{font-size:11px;color:rgba(230,235,239,.62);padding:0 0 4px}
.ra.other{color:#f0a060}
.ra b{font-weight:600}
"""

X_JS = r"""
function rcAgo(t){if(!t)return '';const s=Date.now()/1000-t;if(s<90)return 'now';if(s<3600)return Math.round(s/60)+' min';if(s<86400)return Math.round(s/3600)+' h';return Math.round(s/86400)+' d';}
function rcRow(dot,title,sub,right,tip){return `<div class="rr" title="${esc(tip||'')}"><span class="dot ${dot}"></span><span class="t">${esc(title)}${sub?`<small>${esc(sub)}</small>`:''}</span><span class="a">${esc(right||'')}</span></div>`;}
function xCustom(d0){const d=d0&&d0.stale?d0.stale:d0;
  if(!d||(!d.machines&&!d0.error))return '';
  let h=`<div class="xhd"><span class="ag"></span></div>`;
  if(d.account)h+=`<div class="xac" title="${esc(d.account)}">Signed in as <b>${esc(d.account)}</b></div>`;
  if(d0&&d0.error){const msg=d0.error==='starting'?'Asking Claude for your sessions…':d0.error;h+=`<div class="xe"><span>${esc(msg.charAt(0).toUpperCase()+msg.slice(1))}</span>${d0.error==='starting'?'':'<button class="xr">Check now</button>'}</div>`;}
  if(!d.machines)return h;
  h+=`<div class="rq">Your phone can reach <b>${d.live}</b> session${d.live===1?'':'s'} right now.</div>`;
  for(const m of d.machines){
    h+=`<div class="rm"><h4>${esc(m.name)}${m.this_pc?'<span class="me">this PC</span>':''}</h4>`;
    if(m.account)h+=`<div class="ra${m.other_account?' other':''}">${m.other_account?'Signed in as <b>'+esc(m.account)+'</b>: a different account. Open the Claude app as '+esc(m.account)+' to see this machine.':'Claude: '+esc(m.account)+(m.plan?' · '+esc(m.plan):'')}</div>`;
    else if(m.unreachable)h+=`<div class="ra">Not reachable over Adom Bridge right now</div>`;
    else if(m.logged_in===false)h+=`<div class="ra other">Claude Code is not signed in on this machine</div>`;
    if(m.other_account){ /* its sessions live under the other account: this account's cloud list cannot speak for it */ }
    else if(m.host)h+=rcRow(m.host.connected?'on':'off','Remote Control host',m.host.connected?'On your phone: start a new session on this machine':'Not connected: this machine is not on your phone',m.host.connected?'live':rcAgo(m.host.last)+' ago',m.host.title);
    else h+=`<div class="rn bad">No Remote Control host on this machine</div>`;
    for(const s of m.sessions)h+=rcRow(s.connected?'on':'off',s.title,(s.needs?'Needs you: '+s.needs:s.detail),s.connected?(s.worker==='working'?'working':'live'):rcAgo(s.last)+' ago',s.connected?'On your phone now':'Listed, but its machine is not connected');
    /* Threads: this PC from its own Hydrogen (d.threads), the others over the mesh (m.thread_rows). */
    const rows=m.this_pc&&d.threads?d.threads:(m.thread_rows||null);
    if(rows){const on=rows.filter(t=>t.rc===true).length,off=rows.filter(t=>t.rc===false).length,unk=rows.length-on-off;
      h+=`<div class="rn${off?' bad':''}">${rows.length} Claude thread${rows.length===1?'':'s'} open: ${on} on Remote Control${off?', '+off+' not (not on your phone)':''}${unk?', '+unk+' not loaded yet':''}</div>`;
      const SHOW=6;for(const t of rows.slice(0,SHOW))h+=rcRow(t.rc===true?'on':(t.rc===false?'off':'unk'),t.name,'',t.rc===true?'on phone':(t.rc===false?'not on RC':'unknown'),t.rc===true?'Remote Control is on for this conversation':(t.rc===false?'Remote Control is off: run /remote-control in this conversation to see it on your phone':'Its tab has not loaded since Hydrogen started, so its state is unknown'));
      if(rows.length>SHOW)h+=`<div class="rn">and ${rows.length-SHOW} more</div>`;}
    else if(m.this_pc&&d.threads_note)h+=`<div class="rn">${esc(d.threads_note)}</div>`;
    else if(m.threads_note)h+=`<div class="rn">${esc(m.threads_note)}</div>`;
    h+=`</div>`;}
  const foot=[];
  if(d.old_listed)foot.push(`<span>${d.old_listed} older session${d.old_listed===1?'':'s'} still listed in the cloud but not heard from in 6 h+ (hidden: they are not on your phone).</span>`);
  foot.push('<span>A conversation appears on your phone only while Remote Control is on for it. Green: reachable now. Amber: not.</span>');
  h+=`<div class="xf">${foot.join('')}</div>`;
  return h;}
function xCustomTick(){const x=document.getElementById('x');const ag=x&&x.querySelector('.ag');if(ag&&xData)ag.textContent=ago(xData.at);}
"""

lib.serve('Claude RC', data, RENDER, 60000, mark=marks.MARKS['claude'], x_css=X_CSS, x_js=X_JS)