app
adom-vscode
Public Made by Adomby adom
Let Claude, Codex and Kimi drive VS Code inside your Adom container
← Commit history
release 1.1.118
13 files changed
+230−6
VERSION+1−1@@ -1 +1 @@-1.1.117+1.1.118
bin/adom-vscode⋯ 1 unchanged line ⋯
bin/hydrogen-widget-claude-rcadded+182@@ -0,0 +1,182 @@+#!/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++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+ # 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}+"""++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.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');+ if(m.this_pc){+ if(d.threads){const on=d.threads.filter(t=>t.rc===true).length,off=d.threads.filter(t=>t.rc===false).length,unk=d.threads.length-on-off;+ h+=`<div class="rn${off?' bad':''}">${d.threads.length} Claude thread${d.threads.length===1?'':'s'} open here: ${on} on Remote Control${off?', '+off+' not (not on your phone)':''}${unk?', '+unk+' not loaded yet':''}</div>`;+ for(const t of d.threads)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'));}+ else if(d.threads_note)h+=`<div class="rn">${esc(d.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)
bin/hydrogen-widget-claude-usagebin/hydrogen-widget-codex-usagebin/hydrogen-widget-lib.py+8−2@@ -271,6 +271,9 @@ function limRow(it,i){const c=sevOf(it);const ring=!!(it.resets_at&&it.window_s) let xKey='',xData=null,xTimer=null,xKick=0,lastExpand=''; function xShowing(){return window.innerHeight>=100;} function renderX(d0){const x=document.getElementById('x');if(!x)return;+ if(typeof xCustom==='function'){xData=d0;const h=xCustom(d0)||'';if(!h){x.innerHTML='';withdrawExpand();return;}+ x.classList.toggle('stale',!!(d0&&d0.stale));x.innerHTML=h;const b=x.querySelector('.xr');if(b)b.addEventListener('click',e=>{e.stopPropagation();b.textContent='Checking…';refreshNow(false);});+ if(typeof xCustomTick==='function'){xCustomTick();}postExpand();return;} const d=d0&&d0.stale?d0.stale:d0;xData=d0;const items=(d&&Array.isArray(d.items))?d.items:[]; /* Nothing to say (a widget on this helper that draws no items, and no error): no bigger view, the card keeps its plain brief. */@@ -338,7 +341,10 @@ def _save_state(title, st): json.dump(st, open(tmp, 'w')); os.replace(tmp, p) except Exception: pass -def serve(title, data_fn, render_js, refresh_ms=60000, mark=None, align_ms=0, default_view='rows'):+def serve(title, data_fn, render_js, refresh_ms=60000, mark=None, align_ms=0, default_view='rows', x_css='', x_js=''):+ # x_css / x_js: a widget's OWN hover view. x_js may define xCustom(d0) returning the hover HTML+ # ('' = no bigger view); renderX then uses it instead of the usage layout (Claude RC widget,+ # John 2026-10-20). The usage widgets pass neither and are unchanged. # mark = {'svg': '<path .../>', 'color': '#hex', 'viewBox': '0 0 W H'}: viewBox is the grid the # path is drawn on (default 0 0 16 16). A 24-unit path in the 16-unit box is cut off at the right # and bottom (the Antigravity arch, John 2026-10-17 and again 2026-10-18).@@ -453,7 +459,7 @@ def serve(title, data_fn, render_js, refresh_ms=60000, mark=None, align_ms=0, de title, '40px' if expand else '150px 0 40px 40px', w, h, ground, 'true' if fixed else 'false', 'true' if expand else 'false') self._send(200, 'text/html; charset=utf-8', page.encode()) elif self.path.startswith('/health'): self._send(200, 'application/json', b'{"ok":true}')- else: self._send(200, 'text/html; charset=utf-8', (PAGE % {'title': title, 'default_view': default_view, 'refresh_ms': refresh_ms, 'align_ms': int(align_ms), 'render_js': render_js, 'x_css': X_CSS, 'x_js': X_JS, 'mark_html': ('<span class="mark" style="color:%s"><svg viewBox="%s">%s</svg></span>' % (mark.get('color', 'currentColor'), mark.get('viewBox', '0 0 16 16'), mark.get('svg', ''))) if mark.get('svg') else ''}).encode())+ else: self._send(200, 'text/html; charset=utf-8', (PAGE % {'title': title, 'default_view': default_view, 'refresh_ms': refresh_ms, 'align_ms': int(align_ms), 'render_js': render_js, 'x_css': X_CSS + x_css, 'x_js': X_JS + x_js, 'mark_html': ('<span class="mark" style="color:%s"><svg viewBox="%s">%s</svg></span>' % (mark.get('color', 'currentColor'), mark.get('viewBox', '0 0 16 16'), mark.get('svg', ''))) if mark.get('svg') else ''}).encode()) # Bind with retries: a previous instance Hydrogen just stopped may still be releasing the # port for a moment. Ten tries over five seconds, then give up loudly (the log says why). ThreadingHTTPServer.allow_reuse_address = True
bin/hydrogen-widget-marks.pyhydrogen-widget.json+20@@ -40,6 +40,26 @@ "width_tall": 135, "about": "Reads the Codex sign-in in this workspace (~/.codex/auth.json) and asks Codex's usage endpoint, what Codex's /status shows: the plan's rate-limit window as percent used, plus a secondary window when the plan has one. Hovering it opens the same bigger view as Claude usage: a ring that drains until the reset, the reset time, and a pace sentence. No model runs, so no quota is spent.", "refresh": "the provider at most once every 3 minutes; pauses while the window is hidden; backs off (doubling to 15 minutes) on errors; re-reads the sign-in file only when it changes"+ },+ {+ "id": "claude-rc",+ "name": "Claude RC",+ "icon": "vendor:claude",+ "brief": "How many Claude sessions your phone can reach right now through Remote Control. Hover it for every machine (is it on your phone, when was it last seen) and which of this PC's Claude threads have Remote Control on.",+ "width": 90,+ "install": "adom/adom-vscode",+ "launch": {+ "command": "hydrogen-widget-claude-rc",+ "port": 27014+ },+ "health": {+ "url": "/",+ "timeout_seconds": 60+ },+ "docs": "widgets/README.md",+ "width_tall": 90,+ "about": "Reads the Claude Code sign-in in this workspace and asks the session list the Claude app and claude.ai/code read: every Remote Control session on your account, which machine it runs on, and whether it is connected right now (what your phone shows). Each machine's Remote Control host (the <machine>-rc session Hydrogen keeps) is listed even when it dropped off, with when it was last seen. For this PC it also asks Hydrogen which Claude threads are open and whether each has Remote Control on, because a conversation only reaches the phone once Remote Control is on for it. Sessions the cloud still lists but has not heard from in 6 hours are counted, not shown. No model runs.",+ "refresh": "once a minute; pauses while the window is hidden; backs off on errors; re-reads the sign-in file only when it changes" } ] }
install.shpackage.json+1−1@@ -1,6 +1,6 @@ { "slug": "adom-vscode",- "version": "1.1.117",+ "version": "1.1.118", "type": "app", "description": "CLI + VS Code extension that lets AI assistants and the Hydrogen Desktop frontend drive VS Code from inside an Adom container: open files, live get/set of any setting/theme/font (no reload), native streamed exec, a frontend event queue, container identity (hd/hw), api-key status/inject, extension query/update, modes, paste screenshots, and any VS Code command via a local HTTP API on port 8821 (with port-file fallback discovery).", "license": "PROPRIETARY",
page.json+2−2@@ -4,7 +4,7 @@ "slug": "adom-vscode", "title": "adom-vscode", "brief": "Let Claude, Codex and Kimi drive VS Code inside your Adom container",- "version": "1.1.117",+ "version": "1.1.118", "license": "PROPRIETARY", "visibility": "public", "hero": {@@ -141,4 +141,4 @@ "adom-vscode-extensions", "adom-vscode-modes" ]-}+}
widgets/README.md+16@@ -22,6 +22,22 @@ Your plan's rate-limit window as percent used, plus the secondary window when yo Each widget opens on its weekly limit as one big number. Click it to change the look (all meters, one limit, a ring, a pair, all as numbers or rings); your choice is remembered. +## Claude RC++Shows how many Claude sessions your phone can reach right now through Claude Code Remote Control (`RC 2`), with an amber dot when this PC has Claude threads your phone cannot see.++Hover it for the full picture, one block per machine:++- **The machine's Remote Control host** (the `<machine>-rc` session Hydrogen keeps): green when it is connected, so the machine is on your phone; amber with "last seen" when it dropped off.+- **Every connected session** on that machine, with Claude's own one-line status.+- **On this PC, every open Claude thread** and whether Remote Control is on for it. A conversation reaches your phone only once Remote Control is on for it (`/remote-control` in that conversation). Threads whose tab has not loaded since Hydrogen started show as unknown.++Sessions the cloud still lists but has not heard from in 6 hours are counted at the bottom, not shown: they are not on your phone either.++It reads the same session list the Claude app and claude.ai/code read, with the Claude Code sign-in in this workspace. No model runs.++<img src="docs/claude-rc-hover.png" width="380" alt="Claude RC hover: AdomLapper and ConfRoomROG live, arav-rog and winvm not on the phone">+ ## Hover for when your limits reset Rest the pointer on either widget and its hover card opens a bigger, live view of the same numbers (Hydrogen 1.0.429 and later; older builds keep the plain card):
widgets/docs/claude-rc-hover.pngadded⋯ 1 unchanged line ⋯