app
adom-vscode
Public Made by Adomby adom
Let Claude, Codex and Kimi drive VS Code inside your Adom container
master
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292
#!/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)