#!/usr/bin/env python3 """TALLY Link - sends your Claude and OpenAI usage to TALLY over USB. Runs quietly in the background on your Mac (installed as a login item by "Install TALLY Link.command") or on your Windows PC (a sign-in task set up by "Install TALLY Link.cmd"). Everything is read on this computer: Claude - the Claude desktop app's own local files Mac: ~/Library/Application Support/Claude/ Windows: %APPDATA%\\Claude\\ or, for the Microsoft Store / MSIX install, %LOCALAPPDATA%\\Packages\\Claude_*\\LocalCache\\Roaming\\Claude\\ plan-usage-history.json (5-hour + weekly %) Local Storage/leveldb (5-hour reset time) plus Claude Code's sign-in (Mac Keychain, or ~/.claude/.credentials.json) OpenAI - the plan-limit feed the Codex app itself reads (chatgpt.com/backend-api/wham/usage, signed in with Codex's own token in ~/.codex/auth.json, %USERPROFILE%\\.codex\\auth.json on Windows) falling back to Codex's local session logs in ~/.codex/sessions Nothing is sent anywhere except that one OpenAI request (the same one the Codex app makes). No conversation content is ever read. (c) 2026 Meta Studios Creative Agency, Tokyo """ import glob import json import re import struct import os import signal import ssl import sys import time import urllib.request VERSION = "0.5.0" WIN = sys.platform == "win32" MAC = sys.platform == "darwin" COMPUTER = "Mac" if MAC else "PC" HOME = os.path.expanduser("~") BASE = os.path.join(HOME, ".tally-link") CONFIG_PATH = os.path.join(BASE, "config.json") STATE_PATH = os.path.join(BASE, "state.json") if WIN: APP_DIR = os.path.join(os.environ.get("LOCALAPPDATA") or os.path.join(HOME, "AppData", "Local"), "TALLY Link") LOG_PATH = os.path.join(APP_DIR, "TALLY-Link.log") else: LOG_PATH = os.path.join(HOME, "Library", "Logs", "TALLY-Link.log") def _claude_dir(): """Where the Claude desktop app keeps its files. On Windows that is %APPDATA%\\Claude for the classic installer, or a virtualised copy under %LOCALAPPDATA%\\Packages\\Claude_\\LocalCache\\Roaming\\Claude for the Microsoft Store / MSIX install. Both can exist: take the one the app wrote to last.""" if os.environ.get("TALLY_CLAUDE_DIR"): return os.environ["TALLY_CLAUDE_DIR"] if not WIN: return os.path.join(HOME, "Library", "Application Support", "Claude") roaming = os.environ.get("APPDATA") or os.path.join(HOME, "AppData", "Roaming") local = os.environ.get("LOCALAPPDATA") or os.path.join(HOME, "AppData", "Local") cands = [os.path.join(roaming, "Claude")] cands += glob.glob(os.path.join(local, "Packages", "Claude_*", "LocalCache", "Roaming", "Claude")) cands += glob.glob(os.path.join(local, "Packages", "*Claude*", "LocalCache", "Roaming", "Claude")) def fresh(d): t = 0 for sub in ("plan-usage-history.json", os.path.join("Local Storage", "leveldb"), os.path.join("IndexedDB", "https_claude.ai_0.indexeddb.leveldb")): try: p = os.path.join(d, sub) t = max(t, os.path.getmtime(p)) if os.path.isdir(p): t = max([t] + [os.path.getmtime(f) for f in glob.glob(os.path.join(p, "*"))]) except OSError: pass return t seen, best = set(), None for d in cands: k = os.path.normcase(os.path.abspath(d)) if k in seen or not os.path.isdir(d): continue seen.add(k) if best is None or fresh(d) > best[0]: best = (fresh(d), d) return best[1] if best else cands[0] CLAUDE_DIR = _claude_dir() CODEX_DIR = os.environ.get("TALLY_CODEX_DIR") or os.environ.get("CODEX_HOME") or os.path.join(HOME, ".codex") def _quiet(): """subprocess flags: on Windows, never flash a console window from the background.""" return {"creationflags": 0x08000000} if WIN else {} # CREATE_NO_WINDOW DEFAULT_CONFIG = { "claude": True, "openai": True, "openai_live": True, # use the Codex plan-limit feed (most accurate) "claude_weekly_reset": "", # e.g. "Thu 09:00" from Claude > Settings > Usage (optional, learned otherwise) "openai_poll_seconds": 120, } EPOCH_MIN, EPOCH_MAX = 1577836800, 4102444800 DAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] # ----------------------------------------------------------------------------- logging / files def log(msg): line = time.strftime("%Y-%m-%d %H:%M:%S ") + msg try: os.makedirs(os.path.dirname(LOG_PATH), exist_ok=True) if os.path.exists(LOG_PATH) and os.path.getsize(LOG_PATH) > 2_000_000: os.replace(LOG_PATH, LOG_PATH + ".old") with open(LOG_PATH, "a", encoding="utf-8") as f: f.write(line + "\n") except OSError: pass try: if sys.stdout is not None and sys.stdout.isatty(): # no console at all under pythonw (Windows) print(line) except Exception: pass def load_json(path, default): try: with open(path, encoding="utf-8") as f: v = json.load(f) return v if isinstance(v, type(default)) else default except (OSError, ValueError): return default def save_json(path, obj): try: os.makedirs(os.path.dirname(path), exist_ok=True) tmp = path + ".tmp" with open(tmp, "w", encoding="utf-8") as f: json.dump(obj, f, indent=1) os.replace(tmp, path) except OSError: pass def config(): c = dict(DEFAULT_CONFIG) c.update(load_json(CONFIG_PATH, {})) return c # ----------------------------------------------------------------------------- LevelDB (read-only, minimal) # Chromium keeps Local Storage in LevelDB: a write-ahead .log plus sorted .ldb tables, # whose blocks may be Snappy-compressed. This reads every surviving key/value. def _varint(b, i): r = s = 0 while True: c = b[i] i += 1 r |= (c & 0x7F) << s if c < 0x80: return r, i s += 7 try: import cramjam as _cj # fast native Snappy when the installer could add it except Exception: _cj = None def snappy_decompress(d): if _cj is not None: try: return bytes(_cj.snappy.decompress_raw(bytes(d))) except Exception: pass return _snappy_py(d) def _snappy_py(d): n, i = _varint(d, 0) out = bytearray() L = len(d) while i < L: tag = d[i] i += 1 t = tag & 3 if t == 0: ln = tag >> 2 if ln >= 60: nb = ln - 59 ln = int.from_bytes(d[i:i + nb], "little") i += nb ln += 1 out += d[i:i + ln] i += ln continue if t == 1: ln = ((tag >> 2) & 7) + 4 off = ((tag >> 5) << 8) | d[i] i += 1 elif t == 2: ln = (tag >> 2) + 1 off = int.from_bytes(d[i:i + 2], "little") i += 2 else: ln = (tag >> 2) + 1 off = int.from_bytes(d[i:i + 4], "little") i += 4 if off <= 0 or off > len(out): raise ValueError("bad snappy offset") start = len(out) - off if off >= ln: out += out[start:start + ln] else: for k in range(ln): out.append(out[start + k]) if len(out) != n: raise ValueError("snappy length mismatch") return bytes(out) def _batch_entries(rec): """Entries of one LevelDB WriteBatch: (seq, key, value or None).""" if len(rec) < 12: return seq = int.from_bytes(rec[0:8], "little") count = int.from_bytes(rec[8:12], "little") i = 12 for k in range(count): if i >= len(rec): return kind = rec[i] i += 1 kl, i = _varint(rec, i) key = rec[i:i + kl] i += kl val = None if kind == 1: vl, i = _varint(rec, i) val = rec[i:i + vl] i += vl yield seq + k, bytes(key), (bytes(val) if val is not None else None) def _read_log(path): with open(path, "rb") as f: data = f.read() buf = bytearray() pos = 0 BLOCK = 32768 while pos + 7 <= len(data): left = BLOCK - (pos % BLOCK) if left < 7: pos += left continue length = int.from_bytes(data[pos + 4:pos + 6], "little") rtype = data[pos + 6] frag = data[pos + 7:pos + 7 + length] pos += 7 + length if rtype == 0 and length == 0: continue if rtype == 1: yield from _batch_entries(frag) buf = bytearray() elif rtype == 2: buf = bytearray(frag) elif rtype == 3: buf += frag elif rtype == 4: buf += frag yield from _batch_entries(bytes(buf)) buf = bytearray() def _read_block(data, off, size): raw = data[off:off + size] ctype = data[off + size] if off + size < len(data) else 0 if ctype == 1: raw = snappy_decompress(raw) return raw def _block_entries(block): if len(block) < 4: return nr = int.from_bytes(block[-4:], "little") end = len(block) - 4 - 4 * nr i = 0 prev = b"" while i < end: shared, i = _varint(block, i) nonshared, i = _varint(block, i) vlen, i = _varint(block, i) key = prev[:shared] + block[i:i + nonshared] i += nonshared val = block[i:i + vlen] i += vlen prev = key yield bytes(key), bytes(val) def _read_table(path): with open(path, "rb") as f: data = f.read() if len(data) < 48 or data[-8:] != bytes.fromhex("57fb808b247547db"): return foot = data[-48:] _, j = _varint(foot, 0) _, j = _varint(foot, j) ioff, j = _varint(foot, j) isize, j = _varint(foot, j) index = _read_block(data, ioff, isize) for _, handle in _block_entries(index): boff, k = _varint(handle, 0) bsize, k = _varint(handle, k) try: block = _read_block(data, boff, bsize) except (ValueError, IndexError): continue for ikey, val in _block_entries(block): if len(ikey) < 8: continue tag = int.from_bytes(ikey[-8:], "little") yield tag >> 8, ikey[:-8], (val if (tag & 0xFF) == 1 else None) def leveldb_items(dir_path, want): """Every surviving (seq, key, value) whose key passes want(key).""" out = [] files = glob.glob(os.path.join(dir_path, "*.ldb")) + glob.glob(os.path.join(dir_path, "*.log")) for p in files: try: it = _read_log(p) if p.endswith(".log") else _read_table(p) for seq, key, val in it: if want(key): out.append((seq, key, val)) except (OSError, ValueError, IndexError): continue return out def ls_text(raw): if not raw: return None try: return raw[1:].decode("utf-16-le") if raw[0] == 0 else raw[1:].decode("utf-8") except (UnicodeDecodeError, IndexError): return None # ----------------------------------------------------------------------------- V8 value decoder # Claude Desktop keeps Cowork sessions in IndexedDB as V8-serialised values. # This walks the format tag by tag (never pattern-matching text), so words that # merely appear inside a conversation can never be mistaken for a reading. # Nothing decoded here is ever logged: only the usage numbers leave this function. class _V8Error(Exception): pass class _V8: def __init__(self, b): self.b, self.i, self.objs = b, 0, [] def u8(self): if self.i >= len(self.b): raise _V8Error("eof") c = self.b[self.i] self.i += 1 return c def var(self): r = s = 0 while True: c = self.u8() r |= (c & 0x7F) << s if c < 0x80: return r s += 7 if s > 70: raise _V8Error("varint") def take(self, n): if n < 0 or self.i + n > len(self.b): raise _V8Error("eof") v = self.b[self.i:self.i + n] self.i += n return v def dbl(self): return struct.unpack(" 200: raise _V8Error("deep") while True: t = self.u8() if t == 0x00: continue # padding if t == 0xFF: self.var() # version header continue if t == 0x3F: # '?' verify object count self.var() continue break c = chr(t) if c == "_" or c == "-": return None if c == "0": return None if c == "T": return True if c == "F": return False if c == "I": v = self.var() return (v >> 1) ^ -(v & 1) if c == "U": return self.var() if c == "N": return self.dbl() if c == "Z": bf = self.var() raw = self.take(bf >> 1) v = int.from_bytes(raw, "little") return -v if bf & 1 else v if c in '"cS': return self.str_(t) if c == "^": k = self.var() if k >= len(self.objs): raise _V8Error("ref") return self.objs[k] if c == "o": d = {} self.objs.append(d) self._props(d, "{", depth) return d if c == "A": n = self.var() a = [] self.objs.append(a) for _ in range(n): a.append(self.value(depth + 1)) extra = {} self._props(extra, "$", depth) self.var() return a if c == "a": self.var() d = {} self.objs.append(d) self._props(d, "@", depth) self.var() return d if c == "D": v = self.dbl() self.objs.append(v) return v if c in "yx": self.objs.append(c == "y") return c == "y" if c == "n": v = self.dbl() self.objs.append(v) return v if c == "z": bf = self.var() self.take(bf >> 1) self.objs.append(None) return None if c == "s": v = self.value(depth + 1) self.objs.append(v) return v if c == "R": v = self.value(depth + 1) self.var() self.objs.append(v) return v if c == ";": d = {} self.objs.append(d) while True: if self.i < len(self.b) and self.b[self.i] == 0x3A: self.i += 1 self.var() return d k = self.value(depth + 1) d[k if isinstance(k, (str, int, float)) else repr(k)] = self.value(depth + 1) if c == "'": out = [] self.objs.append(out) while True: if self.i < len(self.b) and self.b[self.i] == 0x2C: self.i += 1 self.var() return out out.append(self.value(depth + 1)) if c == "B": n = self.var() self.take(n) self.objs.append(None) if self.i < len(self.b) and self.b[self.i] == 0x56: # 'V' view on it self.i += 1 self.u8() self.var() self.var() return None raise _V8Error("tag %02x" % t) def _props(self, d, end, depth): endc = ord(end) while True: if self.i < len(self.b) and self.b[self.i] == endc: self.i += 1 self.var() return k = self.value(depth + 1) v = self.value(depth + 1) d[k if isinstance(k, str) else str(k)] = v def v8_decode(b): """Decode one V8 ValueSerializer payload (with or without Blink's envelope).""" r = _V8(b) # Blink envelope: 0xFF [0xFE <8-byte offset> <4-byte size> trailer pointer] while r.i < len(b) and b[r.i] == 0xFF: r.i += 1 r.var() if r.i < len(b) and b[r.i] == 0xFE: r.i += 13 return r.value() def idb_payload(val): """IndexedDB record value -> V8 bytes, or None for a value kept in a blob file. Layout: varint record version, then the serialised value. Chromium marks a value it moved to a blob with FF 11 01 and a Snappy-compressed one with FF 11 02.""" try: _, i = _varint(val, 0) except IndexError: return None body = val[i:] if body[:2] == b"\xff\x11": if body[2:3] == b"\x02": try: return snappy_decompress(body[3:]) except (ValueError, IndexError): return None return None return body # ----------------------------------------------------------------------------- Claude (desktop app files) def claude_history(): """Newest (observed_s, five_hour_pct, weekly_pct) from plan-usage-history.json.""" doc = load_json(os.path.join(CLAUDE_DIR, "plan-usage-history.json"), {}) samples = doc.get("samples") or doc.get("history") or [] best = None for s in samples if isinstance(samples, list) else []: try: t = float(s["t"]) / 1000.0 u = s["u"] except (KeyError, TypeError, ValueError): continue if not (EPOCH_MIN <= t <= EPOCH_MAX) or not isinstance(u, dict): continue fh, sd = u.get("fh"), u.get("sd") ok = lambda v: isinstance(v, (int, float)) and not isinstance(v, bool) and 0 <= v <= 1000 if not ok(fh) and not ok(sd): continue if best is None or t > best[0]: best = (t, float(fh) if ok(fh) else None, float(sd) if ok(sd) else None) return best def claude_local_storage(): """Usage records the app keeps in Local Storage: list of dicts with utilization (fraction), resetsAt (s), observedAt (s), window ('5h'|'7d').""" d = os.path.join(CLAUDE_DIR, "Local Storage", "leveldb") if not os.path.isdir(d): return [] recs = {} for seq, key, val in leveldb_items(d, lambda k: b"claude.ai" in k or b"ochre_heron_tide" in k): if val is None: continue txt = ls_text(val) if not txt or "utilization" not in txt: continue try: obj = json.loads(txt) except ValueError: continue cands = obj if isinstance(obj, list) else [obj] for o in cands: if not isinstance(o, dict): continue util, rs, obs = o.get("utilization"), o.get("resetsAt"), o.get("observedAt") if not all(isinstance(v, (int, float)) and not isinstance(v, bool) for v in (util, rs, obs)): continue if obs > 1e12: obs /= 1000.0 if rs > 1e12: rs /= 1000.0 if not (EPOCH_MIN <= obs <= EPOCH_MAX and EPOCH_MIN <= rs <= EPOCH_MAX): continue pct = util * 100.0 if util <= 10 else float(util) window = "7d" if (rs - obs) > 26 * 3600 else "5h" k = (key, window) if k not in recs or recs[k][0] <= seq: recs[k] = (seq, {"pct": pct, "resets_at": rs, "observed_at": obs, "window": window}) return [v[1] for v in recs.values()] def next_weekly_from_text(txt, now): """'Thu 09:00' -> next epoch of that local weekday/time.""" try: day, hm = txt.strip().lower().split() wd = DAYS.index(day[:3]) h, m = [int(x) for x in hm.split(":")] except (ValueError, AttributeError): return None lt = time.localtime(now) base = time.mktime((lt.tm_year, lt.tm_mon, lt.tm_mday, h, m, 0, 0, 0, -1)) delta = (wd - lt.tm_wday) % 7 t = base + delta * 86400 if t <= now: t += 7 * 86400 return int(t) # Cowork sessions in IndexedDB carry the app's own rate-limit events: both windows, # both reset times, refreshed every turn. Only keys naming a Cowork session are read. _IDB_CACHE = {} # table path -> (size, mtime, entries) _IDB_KEY = (b"cowork", b"c\x00o\x00w\x00o\x00r\x00k") def _idb_dir(): return os.path.join(CLAUDE_DIR, "IndexedDB", "https_claude.ai_0.indexeddb.leveldb") def _iso_epoch(v): if isinstance(v, (int, float)) and not isinstance(v, bool): v = float(v) return v / 1000.0 if v > 1e12 else v if not isinstance(v, str): return None m = re.match(r"(\d{4})-(\d\d)-(\d\d)[T ](\d\d):(\d\d):(\d\d)(\.\d+)?(Z|[+-]\d\d:?\d\d)?$", v.strip()) if not m: return None y, mo, d, h, mi, se = (int(m.group(k)) for k in range(1, 7)) import calendar t = calendar.timegm((y, mo, d, h, mi, se, 0, 0, 0)) + (float(m.group(7)) if m.group(7) else 0) tz = m.group(8) if tz and tz != "Z": sign = 1 if tz[0] == "+" else -1 hh, mm = int(tz[1:3]), int(tz[-2:]) t -= sign * (hh * 3600 + mm * 60) return t def _windows(uw): out = {} for name, slot in (("five_hour", "5"), ("seven_day", "7")): w = uw.get(name) if not isinstance(w, dict): continue u, rs = w.get("utilization"), w.get("resetsAt") if isinstance(u, bool) or not isinstance(u, (int, float)) or not (0 <= u <= 10): continue rs = _iso_epoch(rs) out["s" + slot] = round(u * 100.0, 1) if rs and EPOCH_MIN <= rs <= EPOCH_MAX: out["r" + slot] = int(rs) return out def _walk_events(node, found, created=None, depth=0, budget=[0]): budget[0] += 1 if depth > 40 or budget[0] > 400000: return if isinstance(node, dict): if node.get("type") == "rate_limit_event" or "created_at" in node: c = _iso_epoch(node.get("created_at")) if c: created = c uw = node.get("unifiedWindows") if isinstance(uw, dict): w = _windows(uw) if w: found.append((created, w)) for v in node.values(): if isinstance(v, (dict, list)): _walk_events(v, found, created, depth + 1, budget) elif isinstance(node, list): for v in node: if isinstance(v, (dict, list)): _walk_events(v, found, created, depth + 1, budget) def claude_cowork(time_budget=6.0): """Newest Cowork rate-limit reading: ({s5,r5,s7,r7}, observed_s) plus counters.""" d = _idb_dir() stats = {"store": os.path.isdir(d), "keys": 0, "blob": 0, "bad": 0, "events": 0, "pending": 0} if not stats["store"]: return None, stats want = lambda k: any(m in k for m in _IDB_KEY) t0 = time.time() entries = [] for p in sorted(glob.glob(os.path.join(d, "*.ldb"))): try: st = os.stat(p) except OSError: continue c = _IDB_CACHE.get(p) if c and c[0] == st.st_size and c[1] == st.st_mtime: entries += c[2] continue if time.time() - t0 > time_budget: stats["pending"] += 1 continue got = [] try: for seq, key, val in _read_table(p): if want(key): got.append((seq, key, val)) except (OSError, ValueError, IndexError): pass _IDB_CACHE[p] = (st.st_size, st.st_mtime, got) entries += got for p in glob.glob(os.path.join(d, "*.log")): try: entries += [(sq, k, v) for sq, k, v in _read_log(p) if want(k)] except (OSError, ValueError, IndexError): pass for p in list(_IDB_CACHE): if not os.path.exists(p): del _IDB_CACHE[p] latest = {} for seq, key, val in entries: if key not in latest or latest[key][0] <= seq: latest[key] = (seq, val) best = None for key, (seq, val) in latest.items(): if val is None: continue # deleted: a tombstone wins stats["keys"] += 1 body = idb_payload(val) if body is None: stats["blob"] += 1 continue try: tree = v8_decode(body) except (_V8Error, ValueError, IndexError, struct.error, RecursionError): stats["bad"] += 1 continue found = [] _walk_events(tree, found, None, 0, [0]) stats["events"] += len(found) for created, w in found: obs = created or 0 if best is None or obs > best[1]: best = (w, obs) return best, stats def _ago(t, now): if not t: return "time unknown" m = int((now - t) / 60) return "just now" if m < 1 else ("%d min ago" % m if m < 120 else "%.1f h ago" % (m / 60.0)) def claude_usage(cfg, state, now, cowork=None, live=None): """Pick each figure from the freshest source that has it.""" hist = claude_history() recs = claude_local_storage() ls5 = max((r for r in recs if r["window"] == "5h"), key=lambda r: r["observed_at"], default=None) cw, cw_stats = cowork if cowork is not None else claude_cowork() cands5, cands7 = [], [] # (observed, pct, reset or None, source) if hist: if hist[1] is not None: cands5.append((hist[0], hist[1], None, "app history")) if hist[2] is not None: cands7.append((hist[0], hist[2], None, "app history")) if ls5: cands5.append((ls5["observed_at"], round(ls5["pct"], 1), int(ls5["resets_at"]), "app session record")) if live: lw, lobs = live if "s5" in lw: cands5.append((lobs, lw["s5"], lw.get("r5"), "Anthropic live")) if "s7" in lw: cands7.append((lobs, lw["s7"], lw.get("r7"), "Anthropic live")) if cw: w, obs = cw if "s5" in w: cands5.append((obs, w["s5"], w.get("r5"), "Cowork")) if "s7" in w: cands7.append((obs, w["s7"], w.get("r7"), "Cowork")) out, why = {}, [] # five-hour window: a figure from before the current window started is history, not usage if cands5: with_r5 = [c for c in cands5 if c[2]] known_rs = max(with_r5, key=lambda c: c[0])[2] if with_r5 else None pool = cands5 if known_rs and known_rs > now: pool = [c for c in cands5 if c[0] >= known_rs - 5 * 3600 - 60] or cands5 obs, pct, rs, src = max(pool, key=lambda c: c[0]) if known_rs and known_rs <= now: out["s5"] = 0.0 # that window has ended; the next starts with your next message why.append("5-hour: window ended (%s)" % src) elif not known_rs and now - obs > 5 * 3600: why.append("5-hour: last figure (%s) too old to trust" % _ago(obs, now)) else: out["s5"] = pct if known_rs: out["r5"] = int(known_rs) why.append("5-hour %s%% from %s (%s)" % (pct, src, _ago(obs, now))) if len(cands5) > 1: why.append("5-hour seen: " + ", ".join("%s %s%% (%s)" % (c[3], c[1], _ago(c[0], now)) for c in sorted(cands5, key=lambda c: -c[0]))) # weekly window if cands7: obs, pct, rs, src = max(cands7, key=lambda c: c[0]) out["s7"] = pct why.append("weekly %s%% from %s (%s)" % (pct, src, _ago(obs, now))) if len(cands7) > 1: why.append("weekly seen: " + ", ".join("%s %s%% (%s)" % (c[3], c[1], _ago(c[0], now)) for c in sorted(cands7, key=lambda c: -c[0]))) with_r7 = [c for c in cands7 if c[2]] exact7 = max(with_r7, key=lambda c: c[0])[2] if with_r7 else None # from the freshest source that has one if exact7: state["claude_weekly_anchor"] = int(exact7) # one exact boundary is enough: it repeats weekly if "s7" in out: t = None a = state.get("claude_weekly_anchor") if a: t = a if a > now else a + ((int(now - a) // (7 * 86400)) + 1) * 7 * 86400 why.append("weekly reset from %s" % ("Claude" if exact7 else "the saved boundary")) if t is None: t = next_weekly_from_text(cfg.get("claude_weekly_reset", ""), now) if t: why.append("weekly reset from your setting (%s)" % cfg.get("claude_weekly_reset")) if t is None: prev = state.get("claude_last_s7") if prev is not None and prev - out["s7"] >= 20: state["claude_weekly_anchor"] = int(now) log("learned Claude weekly reset at %s" % time.ctime(now)) state["claude_last_s7"] = out["s7"] if t: out["r7"] = int(t) if live: for k in ("_scoped", "_plan"): if k in live[0]: out[k] = live[0][k] out["_seen"] = max([c[0] for c in cands5 + cands7] or [0]) or None out["_why"] = "; ".join(why) out["_cowork"] = cw_stats return out # ----------------------------------------------------------------------------- OpenAI (Codex plan limits) _ssl_ctx = None def _ssl(): global _ssl_ctx if _ssl_ctx is None: try: import certifi _ssl_ctx = ssl.create_default_context(cafile=certifi.where()) except ImportError: _ssl_ctx = ssl.create_default_context() return _ssl_ctx def _window(w): """(slot, pct, reset_epoch) from one window dict of either the live feed or the logs.""" if not isinstance(w, dict): return None pct = w.get("used_percent") secs = w.get("limit_window_seconds") if secs is None and w.get("window_minutes") is not None: secs = float(w["window_minutes"]) * 60 rs = w.get("reset_at", w.get("resets_at")) if rs is None and w.get("reset_after_seconds") is not None: rs = time.time() + float(w["reset_after_seconds"]) if not isinstance(pct, (int, float)) or isinstance(pct, bool): return None slot = "5" if (secs is not None and float(secs) <= 86400) else "7" rs = int(rs) if isinstance(rs, (int, float)) and EPOCH_MIN <= rs <= EPOCH_MAX else None return slot, float(pct), rs # ----------------------------------------------------------------------------- Claude live (Claude Code sign-in) # The same figures as Claude > Settings > Usage, asked from Anthropic with the sign-in # Claude Code keeps on this Mac (Keychain item "Claude Code-credentials", or # ~/.claude/.credentials.json). The token is only ever sent to api.anthropic.com and is # never written to the log. CC_SERVICE = "Claude Code-credentials" CC_USAGE_URL = "https://api.anthropic.com/api/oauth/usage" CC_TOKEN_URL = "https://api.anthropic.com/v1/oauth/token" CC_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e" # Claude Code's public OAuth client _cc_agent = None def _cc_user_agent(): global _cc_agent if _cc_agent: return _cc_agent ver = "2.1.290" if WIN: import shutil appdata = os.environ.get("APPDATA") or os.path.join(HOME, "AppData", "Roaming") try: on_path = shutil.which("claude") or "" except Exception: on_path = "" exes = (os.path.join(HOME, ".local", "bin", "claude.exe"), os.path.join(appdata, "npm", "claude.cmd"), on_path) else: exes = (os.path.join(HOME, ".local", "bin", "claude"), "/opt/homebrew/bin/claude", "/usr/local/bin/claude") for exe in exes: if exe and os.path.exists(exe): try: import subprocess out = subprocess.run([exe, "--version"], capture_output=True, text=True, timeout=10, **_quiet()).stdout m = re.search(r"(\d+\.\d+\.\d+)", out or "") if m: ver = m.group(1) break except Exception: pass _cc_agent = "claude-code/" + ver return _cc_agent def _cc_file(): return os.path.join(os.environ.get("CLAUDE_CONFIG_DIR") or os.path.join(HOME, ".claude"), ".credentials.json") def _cc_read(): """(credentials dict, where, keychain account) or (None, reason, None).""" if sys.platform == "darwin": import subprocess try: r = subprocess.run(["/usr/bin/security", "find-generic-password", "-s", CC_SERVICE, "-w"], capture_output=True, text=True, timeout=20) if r.returncode == 0 and r.stdout.strip(): acct = None a = subprocess.run(["/usr/bin/security", "find-generic-password", "-s", CC_SERVICE], capture_output=True, text=True, timeout=20) m = re.search(r'"acct"="([^"]*)"', a.stdout or "") if m: acct = m.group(1) return json.loads(r.stdout.strip()), "keychain", acct except Exception: pass try: with open(_cc_file(), encoding="utf-8") as f: return json.load(f), "file", None except (OSError, ValueError): return None, "Claude Code is not signed in on this %s" % COMPUTER, None def _cc_write(creds, where, acct): data = json.dumps(creds, separators=(",", ":")) if where == "keychain": import subprocess args = ["/usr/bin/security", "add-generic-password", "-U", "-s", CC_SERVICE, "-w", data] if acct: args[3:3] = ["-a", acct] return subprocess.run(args, capture_output=True, timeout=20).returncode == 0 tmp = _cc_file() + ".tally" with open(tmp, "w", encoding="utf-8") as f: f.write(data) os.chmod(tmp, 0o600) os.replace(tmp, _cc_file()) return True class _ApiError(Exception): def __init__(self, stage, code, kind): Exception.__init__(self, "%s refused (HTTP %s%s)" % (stage, code, (", " + kind) if kind else "")) self.code, self.kind = code, kind def _https_json(url, headers, body=None, stage="usage request"): data = json.dumps(body).encode() if body is not None else None h = dict(headers) h.setdefault("Accept", "application/json") if data is not None: h["Content-Type"] = "application/json" req = urllib.request.Request(url, data=data, headers=h) try: with urllib.request.urlopen(req, timeout=20, context=_ssl()) as r: return json.loads(r.read().decode("utf-8")) except urllib.error.HTTPError as e: kind = "" try: # only the error's type name is kept: it never carries a credential j = json.loads(e.read(4096).decode("utf-8", "replace")) err = j.get("error") if isinstance(j, dict) else None kind = (err.get("type") if isinstance(err, dict) else err) or (j.get("type") if isinstance(j, dict) else "") or "" kind = re.sub(r"[^A-Za-z0-9_ .-]", "", str(kind))[:40] except Exception: pass raise _ApiError(stage, e.code, kind) def _cc_refresh(creds, where, acct): login = creds.get("claudeAiOauth") or {} rt = login.get("refreshToken") if not rt: return None # Claude Code may have renewed it a moment ago: re-read before spending the refresh token latest, _, _ = _cc_read() if latest and (latest.get("claudeAiOauth") or {}).get("accessToken") != login.get("accessToken"): return latest res = _https_json(CC_TOKEN_URL, {"User-Agent": _cc_user_agent()}, {"grant_type": "refresh_token", "refresh_token": rt, "client_id": CC_CLIENT_ID}, stage="renewing the Claude Code sign-in") login = dict(login) login["accessToken"] = res["access_token"] if res.get("refresh_token"): login["refreshToken"] = res["refresh_token"] login["expiresAt"] = int(time.time() * 1000 + int(res.get("expires_in", 3600)) * 1000) new = dict(creds) new["claudeAiOauth"] = login _cc_write(new, where, acct) # keep Claude Code signed in with the renewed token return new def _cc_parse(doc): out = {} if isinstance(doc.get("limits"), list): # current shape for e in doc["limits"]: if not isinstance(e, dict) or e.get("percent") is None: continue if e.get("kind") == "weekly_scoped": # a weekly limit for one model family sc = e.get("scope") if isinstance(e.get("scope"), dict) else {} m = sc.get("model") if isinstance(sc.get("model"), dict) else {} name = (m.get("display_name") or m.get("id") or "") if m else "" name = re.sub(r"[^A-Za-z0-9 .-]", "", str(name)).strip()[:15] if name: out.setdefault("_scoped", []).append((name, round(float(e["percent"]), 1), int(_iso_epoch(e.get("resets_at")) or 0))) continue slot = {"session": "5", "weekly_all": "7"}.get(e.get("kind")) if not slot: continue out["s" + slot] = round(float(e["percent"]), 1) r = _iso_epoch(e.get("resets_at")) if r: out["r" + slot] = int(r) for key, slot in (("five_hour", "5"), ("seven_day", "7")): # earlier shape w = doc.get(key) if isinstance(w, dict) and w.get("utilization") is not None and ("s" + slot) not in out: out["s" + slot] = round(float(w["utilization"]), 1) # already a percentage r = _iso_epoch(w.get("resets_at")) if r: out["r" + slot] = int(r) return out def claude_live(force_renew=False): """({s5,r5,s7,r7}, 'live') or (None, reason).""" creds, where, acct = _cc_read() if not creds: return None, where login = creds.get("claudeAiOauth") or {} if not login.get("accessToken"): return None, "Claude Code has no claude.ai sign-in" try: if force_renew or time.time() * 1000 > (login.get("expiresAt") or 0) - 30 * 60000: before = login.get("accessToken") creds = _cc_refresh(creds, where, acct) or creds after = (creds.get("claudeAiOauth") or {}) if after.get("accessToken") != before: saved, _, _ = _cc_read() ok = saved and (saved.get("claudeAiOauth") or {}).get("accessToken") == after.get("accessToken") log("Claude sign-in renewed, valid until %s%s" % ( time.strftime("%a %H:%M", time.localtime((after.get("expiresAt") or 0) / 1000)), "" if ok else " (WARNING: could not save it back for Claude Code)")) for attempt in range(2): tok = (creds.get("claudeAiOauth") or {}).get("accessToken") try: doc = _https_json(CC_USAGE_URL, {"Authorization": "Bearer " + tok, "User-Agent": _cc_user_agent(), "anthropic-beta": "oauth-2025-04-20", "Accept": "application/json"}) break except _ApiError as e: if e.code == 401 and attempt == 0: creds = _cc_refresh(creds, where, acct) or creds continue raise w = _cc_parse(doc if isinstance(doc, dict) else {}) tier = (creds.get("claudeAiOauth") or {}).get("rateLimitTier") or (creds.get("claudeAiOauth") or {}).get("subscriptionType") or "" tier = str(tier).replace("default_claude_", "").replace("_", " ").strip().upper() if w and tier: w["_plan"] = tier[:15] return (w or None), ("live" if w else "usage reply had no windows") except _ApiError as e: if "renewing" in str(e) or e.code in (400, 401): return None, str(e) + " - sign in again with Set Up Claude Live Usage" return None, str(e) except Exception as e: return None, "usage request failed (%s)" % type(e).__name__ def openai_live(): try: with open(os.path.join(CODEX_DIR, "auth.json"), encoding="utf-8") as f: auth = json.load(f) tok = auth.get("tokens") or {} access, account = tok.get("access_token"), tok.get("account_id") if not access: return None, "no Codex sign-in found" req = urllib.request.Request("https://chatgpt.com/backend-api/wham/usage", headers={ "Authorization": "Bearer " + access, "Accept": "application/json", "User-Agent": "TALLY-Link/" + VERSION, **({"ChatGPT-Account-Id": account} if account else {}), }) with urllib.request.urlopen(req, timeout=15, context=_ssl()) as r: doc = json.loads(r.read().decode("utf-8")) except urllib.error.HTTPError as e: return None, "live feed HTTP %d" % e.code except Exception as e: # network down, file missing, unexpected shape return None, "live feed unavailable (%s)" % type(e).__name__ rl = doc.get("rate_limit") or {} out = {"_plan": doc.get("plan_type")} for key in ("primary_window", "secondary_window"): w = _window(rl.get(key)) if w: out["s" + w[0]] = round(w[1], 1) if w[2]: out["r" + w[0]] = w[2] return (out if ("s5" in out or "s7" in out) else None), "live" def _tail(path, nbytes=3_000_000): with open(path, "rb") as f: f.seek(0, 2) size = f.tell() f.seek(max(0, size - nbytes)) data = f.read() return data.decode("utf-8", "replace").splitlines() def codex_files(n=6): files = glob.glob(os.path.join(CODEX_DIR, "sessions", "*", "*", "*", "rollout-*.jsonl")) files.sort(key=lambda p: os.path.getmtime(p), reverse=True) return files[:n] def openai_from_logs(): best = None for p in codex_files(): try: lines = _tail(p) except OSError: continue for line in reversed(lines): if '"rate_limits"' not in line: continue try: o = json.loads(line) except ValueError: continue pl = o.get("payload") or {} rl = pl.get("rate_limits") if not isinstance(rl, dict): continue ts = o.get("timestamp", "") if best is None or ts > best[0]: best = (ts, rl) break if not best: return None out = {} for key in ("primary", "secondary"): w = _window(best[1].get(key)) if w: out["s" + w[0]] = round(w[1], 1) if w[2]: out["r" + w[0]] = w[2] return out or None def codex_sessions(now): """One letter per Codex session active in the last 30 min: w working, t your turn, i idle.""" letters = [] for p in codex_files(8): try: age = now - os.path.getmtime(p) except OSError: continue if age > 1800: continue state = "i" try: for line in reversed(_tail(p, 400_000)): if '"event_msg"' not in line: continue try: t = (json.loads(line).get("payload") or {}).get("type") except ValueError: continue if t == "task_started": state = "w" if age < 120 else "i" break if t == "task_complete": state = "t" if age < 600 else "i" break if t in ("turn_aborted", "error"): state = "e" if t == "error" and age < 600 else "i" break except OSError: pass letters.append(state) return "".join(letters[:6]) # ----------------------------------------------------------------------------- gather everything class Reader: def __init__(self): self.state = load_json(STATE_PATH, {}) self.openai_cache = None self.openai_src = "-" self.openai_next = 0 self.cowork = (None, {}) self.cowork_next = 0 self.cowork_budget = 6.0 self.live = None self.live_next = 0 self.live_src = None self.renew_now = False self.claude_why = None def read(self): cfg = config() now = time.time() res = {} if cfg.get("claude", True): try: if now >= self.cowork_next: self.cowork_next = now + 60 self.cowork = claude_cowork(self.cowork_budget) if cfg.get("claude_live", True) and (now >= self.live_next or self.renew_now): data, why = claude_live(force_renew=self.renew_now) self.renew_now = False self.live_next = now + max(60, int(cfg.get("claude_poll_seconds", 120))) if data: self.live = (data, now) elif self.live and now - self.live[1] > 1800: self.live = None # too old to keep showing if why != self.live_src: log("Claude live usage: " + ("connected" if data else why)) self.live_src = why c = claude_usage(cfg, self.state, now, self.cowork, self.live) why = c.get("_why", "") key = re.sub(r"\([^)]*ago\)|\(just now\)", "", why) if key != self.claude_why: st = c.get("_cowork") or {} log("Claude: %s | Cowork store: %s, %d sessions, %d readings%s%s" % ( why or "no figures found", "found" if st.get("store") else "not found", st.get("keys", 0), st.get("events", 0), (", %d unreadable" % st["bad"]) if st.get("bad") else "", (", %d still scanning" % st["pending"]) if st.get("pending") else "")) self.claude_why = key if "s5" in c or "s7" in c: res["claude"] = c except Exception as e: log("claude read failed: %r" % e) if cfg.get("openai", True): if now >= self.openai_next: self.openai_next = now + max(60, int(cfg.get("openai_poll_seconds", 120))) data, why = (openai_live() if cfg.get("openai_live", True) else (None, "live feed off")) if data: src = "live feed" self.openai_cache = data else: logs = openai_from_logs() src = "Codex logs (%s)" % why if logs else "nothing yet (%s)" % why if logs: self.openai_cache = logs if src != self.openai_src: log("OpenAI source: " + src) self.openai_src = src if self.openai_cache: res["openai"] = dict(self.openai_cache) res["sess"] = codex_sessions(now) save_json(STATE_PATH, self.state) return res # ----------------------------------------------------------------------------- serial link def find_port(): if os.environ.get("TALLY_PORT"): return os.environ["TALLY_PORT"] if not WIN: ports = sorted(glob.glob("/dev/cu.usbmodem*")) return ports[0] if ports else None # Windows: TALLY's ESP32-S3 shows up as a COM port with Espressif's USB vendor id 0x303A try: from serial.tools import list_ports ports = list(list_ports.comports()) except Exception: return None esp = [p for p in ports if getattr(p, "vid", None) == 0x303A] if not esp: # older drivers sometimes hide the id; fall back to the description esp = [p for p in ports if re.search(r"espressif|esp32|usb jtag|usb serial device", "%s %s" % (p.description, p.hwid), re.I)] esp.sort(key=lambda p: int(re.sub(r"\D", "", p.device) or 0)) return esp[0].device if esp else None def _human(p, d): parts = [] for slot, name in (("5", "5-hour"), ("7", "weekly")): if d.get("s" + slot) is not None: r = d.get("r" + slot) parts.append("%s %s%%%s" % (name, d["s" + slot], (" (resets %s)" % time.strftime("%a %H:%M", time.localtime(r))) if r else " (reset unknown)")) return "%s: %s" % ("Claude" if p == "claude" else "OpenAI", ", ".join(parts) or "no figures") def usage_line(p, d): m = {"t": "usage", "p": p} for k in ("s5", "r5", "s7", "r7"): if k in d and d[k] is not None: m[k] = d[k] return json.dumps(m, separators=(",", ":")) def reset_tally(port): """Restart TALLY over USB - the same as unplugging it. DTR must be low first, otherwise the ESP32-S3 would start in its flashing mode instead of TALLY.""" import serial try: s = serial.Serial() s.port, s.baudrate = port, 115200 s.dtr, s.rts = False, False s.open() s.dtr = False time.sleep(0.1) s.rts = True time.sleep(0.2) s.rts = False time.sleep(0.1) s.close() return True except Exception as e: log("could not restart TALLY: %s" % e) return False CMD_PATH = os.path.join(BASE, "commands.txt") # ---------------------------------------------------------------- custom provider icons # Drop claude.png / openai.png (or .jpg) into ~/TALLY Icons; TALLY shows them on its menu. ICON_DIR = os.path.join(HOME, "TALLY Icons") ICON_N = 64 def _png_rgba(data): """Minimal PNG decoder (8-bit, non-interlaced; grey, RGB, palette, grey+alpha, RGBA) -> (w, h, rows of RGBA tuples).""" import zlib if data[:8] != b"\x89PNG\r\n\x1a\n": raise ValueError("not a PNG") pos, idat, pal, trns = 8, b"", None, None w = h = depth = ctype = inter = None while pos < len(data): n = struct.unpack(">I", data[pos:pos + 4])[0]; kind = data[pos + 4:pos + 8]; body = data[pos + 8:pos + 8 + n]; pos += 12 + n if kind == b"IHDR": w, h, depth, ctype, _, _, inter = struct.unpack(">IIBBBBB", body) elif kind == b"PLTE": pal = [tuple(body[i:i + 3]) for i in range(0, len(body), 3)] elif kind == b"tRNS": trns = body elif kind == b"IDAT": idat += body elif kind == b"IEND": break if depth != 8 or inter: raise ValueError("PNG must be 8-bit and not interlaced") ch = {0: 1, 2: 3, 3: 1, 4: 2, 6: 4}[ctype] raw = zlib.decompress(idat); stride = w * ch prev = bytearray(stride); out = []; p = 0 for _ in range(h): f = raw[p]; line = bytearray(raw[p + 1:p + 1 + stride]); p += 1 + stride for i in range(stride): a = line[i - ch] if i >= ch else 0; b = prev[i]; c = prev[i - ch] if i >= ch else 0 if f == 1: line[i] = (line[i] + a) & 255 elif f == 2: line[i] = (line[i] + b) & 255 elif f == 3: line[i] = (line[i] + ((a + b) >> 1)) & 255 elif f == 4: pa, pb, pc = abs(b - c), abs(a - c), abs(a + b - 2 * c) line[i] = (line[i] + (a if pa <= pb and pa <= pc else b if pb <= pc else c)) & 255 prev = line row = [] for x in range(w): q = line[x * ch:(x + 1) * ch] if ctype == 0: row.append((q[0], q[0], q[0], 255)) elif ctype == 2: row.append((q[0], q[1], q[2], 255)) elif ctype == 3: r, g, bb = pal[q[0]]; row.append((r, g, bb, trns[q[0]] if trns and q[0] < len(trns) else 255)) elif ctype == 4: row.append((q[0], q[0], q[0], q[1])) else: row.append(tuple(q)) out.append(row) return w, h, out def icon_mask(path): """Any picture -> 64 x 64 coverage mask (bytes). The shape is whatever stands out from its background.""" import subprocess, tempfile src = path tmp = None if sys.platform == "darwin": # let macOS shrink and convert it first (handles JPEG, HEIC, WebP, big PNGs) tmp = os.path.join(tempfile.gettempdir(), "tally_icon_%d.png" % os.getpid()) if path.lower().endswith(".svg"): # SVG: macOS Quick Look renders it to a PNG first qd = tempfile.mkdtemp(prefix="tally_svg_") subprocess.run(["/usr/bin/qlmanage", "-t", "-s", "512", "-o", qd, path], capture_output=True, timeout=30) outs = [os.path.join(qd, n) for n in os.listdir(qd) if n.lower().endswith(".png")] if not outs: raise ValueError("macOS could not render this SVG - save it as a PNG instead") path = outs[0] r = subprocess.run(["/usr/bin/sips", "-s", "format", "png", "-Z", "128", path, "--out", tmp], capture_output=True) if r.returncode == 0 and os.path.exists(tmp): src = tmp else: # Windows: Pillow (added by the installer) shrinks and converts JPEG, WebP, GIF, BMP, big PNGs try: from PIL import Image if path.lower().endswith(".svg"): raise ValueError("save the SVG as a PNG instead") tmp = os.path.join(tempfile.gettempdir(), "tally_icon_%d.png" % os.getpid()) im = Image.open(path).convert("RGBA") im.thumbnail((128, 128)) im.save(tmp, "PNG") src = tmp except ImportError: pass # no Pillow: plain 8-bit PNGs still work below with open(src, "rb") as f: w, h, px = _png_rgba(f.read()) if tmp and os.path.exists(tmp): os.remove(tmp) flat = [p for row in px for p in row] see_through = sum(1 for p in flat if p[3] < 200) > len(flat) * .05 lum = lambda p: .299 * p[0] + .587 * p[1] + .114 * p[2] if see_through: cov = [[p[3] / 255.0 for p in row] for row in px] else: corners = [px[0][0], px[0][w - 1], px[h - 1][0], px[h - 1][w - 1]] bg = sum(lum(p) for p in corners) / 4 cov = [[abs(lum(p) - bg) for p in row] for row in px] top = max(max(r) for r in cov) or 1 cov = [[min(1.0, v / top * 1.15) for v in row] for row in cov] # fit inside 60 x 60, centred in 64 x 64, box-filtered inner = ICON_N - 4; s = max(w, h) / float(inner) ox, oy = (ICON_N - w / s) / 2, (ICON_N - h / s) / 2 out = bytearray(ICON_N * ICON_N) for y in range(ICON_N): for x in range(ICON_N): x0, x1 = (x - ox) * s, (x + 1 - ox) * s; y0, y1 = (y - oy) * s, (y + 1 - oy) * s tot = area = 0.0 for yy in range(max(0, int(y0)), min(h, int(y1) + 1)): fy = min(y1, yy + 1) - max(y0, yy) if fy <= 0: continue for xx in range(max(0, int(x0)), min(w, int(x1) + 1)): fx = min(x1, xx + 1) - max(x0, xx) if fx <= 0: continue tot += cov[yy][xx] * fx * fy; area += fx * fy out[y * ICON_N + x] = int(round(255 * tot / area)) if area else 0 return bytes(out) def icon_files(): """{'claude': path or None, 'openai': ...} from ~/TALLY Icons.""" found = {"claude": None, "openai": None} try: os.makedirs(ICON_DIR, exist_ok=True) readme = os.path.join(ICON_DIR, "README.txt") if not os.path.exists(readme): with open(readme, "w", encoding="utf-8") as f: f.write("Put a picture called claude.png and/or openai.png here (PNG, JPG, WebP or SVG, any size).\n" "TALLY Link sends it to TALLY, which shows it on its menu in place of its own mark.\n" "Delete the file to go back to TALLY's mark.\n") for name in os.listdir(ICON_DIR): base, ext = os.path.splitext(name.lower()) base = {"anthropic": "claude", "chatgpt": "openai", "codex": "openai"}.get(base, base) if base in found and ext in (".png", ".jpg", ".jpeg", ".heic", ".webp", ".svg", ".gif", ".tif", ".tiff", ".bmp"): found[base] = os.path.join(ICON_DIR, name) except OSError: pass return found def icon_lines(p, mask): import base64 out = [] for o in range(0, len(mask), 384): out.append(json.dumps({"t": "icon", "p": p, "o": o, "d": base64.b64encode(mask[o:o + 384]).decode()}, separators=(",", ":"))) return out def _take_commands(): """Lines queued in ~/.tally-link/commands.txt (then emptied): sent to TALLY as typed, except !renew-claude and !status which TALLY Link handles itself.""" try: with open(CMD_PATH, encoding="utf-8") as f: lines = [l.strip() for l in f.read().splitlines() if l.strip() and not l.startswith("#")] if lines: open(CMD_PATH, "w").close() return lines[:40] except OSError: return [] def _self_mtime(): try: return os.stat(os.path.abspath(__file__)).st_mtime except OSError: return 0 # ---------------------------------------------------------------- one copy at a time (Windows) # On the Mac, launchd keeps a single TALLY Link running. On Windows the sign-in task could be started # twice (or by hand), and two copies would fight over the COM port, so a named mutex keeps it to one. _mutex = None def _single(): global _mutex if not WIN: return True import ctypes k32 = ctypes.WinDLL("kernel32", use_last_error=True) k32.CreateMutexW.restype = ctypes.c_void_p for _ in range(20): # a restart into a new version waits for the old copy to go h = k32.CreateMutexW(None, True, "Local\\TALLY-Link") if h and ctypes.get_last_error() != 183: # ERROR_ALREADY_EXISTS _mutex = h return True if h: k32.CloseHandle(ctypes.c_void_p(h)) time.sleep(0.5) return False def _release_single(): global _mutex if WIN and _mutex: import ctypes k32 = ctypes.WinDLL("kernel32") k32.ReleaseMutex(ctypes.c_void_p(_mutex)) k32.CloseHandle(ctypes.c_void_p(_mutex)) _mutex = None def run(): import serial # pyserial if not _single(): log("TALLY Link is already running - this copy is leaving it to that one") return reader = Reader() running = [True] my_mtime = _self_mtime() last_self_check = time.time() silent_strikes = 0 # connections in a row where TALLY never answered icon_sent = {} # icon file signature last sent to TALLY, per provider resets_tried = 0 # restarts since TALLY last answered; after two, stop and wait last_hint = 0 def stop(*_): running[0] = False signal.signal(signal.SIGTERM, stop) signal.signal(signal.SIGINT, stop) log("TALLY Link %s started" % VERSION) while running[0]: port = find_port() if not port: time.sleep(3) continue try: ser = serial.Serial() ser.port, ser.baudrate, ser.timeout, ser.write_timeout = port, 115200, 0.2, 2 # macOS raises DTR and RTS when a port opens. On the ESP32-S3, DTR low while RTS is high # resets the chip, so match the OS state at open, then drop RTS first and DTR second. ser.dtr, ser.rts = True, True ser.open() try: ser.rts = False ser.dtr = False except Exception: pass except Exception as e: log("cannot open %s: %s" % (port, e)) time.sleep(5) continue log("connected to %s" % port) last_send = last_ping = last_time = 0 sent = {} buf = b"" opened = last_rx = last_hello = time.time() hellos, answered, reason = 1, False, None rx_note_t, rx_notes = 0, 0 last_cmd_check, loud_until = 0, 0 tally_icons, icon_check = None, 0 def send(line): ser.write((line + "\n").encode("utf-8")) try: send('{"t":"hello","app":"%s"}' % VERSION) while running[0]: now = time.time() chunk = ser.read(512) if chunk: buf += chunk while b"\n" in buf: line, buf = buf.split(b"\n", 1) t = line.decode("utf-8", "replace").strip() if not t: continue last_rx = now if not t.startswith("{"): # anything that is not a message: boot notes, crash reports if now - rx_note_t > 60: rx_note_t, rx_notes = now, 0 rx_notes += 1 if rx_notes <= (400 if now < loud_until else 25): log("TALLY: " + t[:200]) continue if t.startswith('{"t":"hi"'): log("TALLY says: " + t) m_ic = re.search(r'"icons":"([01])([01])"', t) if m_ic: tally_icons, icon_check = m_ic.group(1) + m_ic.group(2), 0 last_send = last_time = 0 # resend everything after a (re)boot sent = {} if not answered: answered, silent_strikes, resets_tried = True, 0, 0 if len(buf) > 4096: buf = b"" # is TALLY still listening? it answers every ping with a pong if not answered and now - last_hello > 3 and hellos < 4: send('{"t":"hello","app":"%s"}' % VERSION) hellos, last_hello = hellos + 1, now if not answered and now - opened > 14: reason = "TALLY did not answer after connecting" break if answered and now - last_rx > 45: reason = "TALLY stopped answering" break if now - last_time > 60: off = time.localtime(now).tm_gmtoff send('{"t":"time","epoch":%d,"tz":%d}' % (int(now), off)) last_time = now if now - last_send > 15: data = reader.read() for p in ("claude", "openai"): if p in data: ln = usage_line(p, data[p]) if sent.get(p) != ln or now - sent.get(p + "_t", 0) > 120: send(ln) if sent.get(p) != ln: log("sent " + _human(p, data[p])) sent[p], sent[p + "_t"] = ln, now c = data.get("claude") or {} if c.get("_plan"): pl = json.dumps({"t": "plan", "p": "claude", "name": c["_plan"]}, separators=(",", ":")) if sent.get("plan") != pl: send(pl) sent["plan"] = pl scl = json.dumps({"t": "scopes", "p": "claude", "list": ";".join("%s|%s|%d" % x for x in c.get("_scoped", []))}, separators=(",", ":")) if "_scoped" in c and (sent.get("scopes") != scl): send(scl) sent["scopes"] = scl log("sent Claude per-model limits: " + (", ".join("%s %s%%" % (x[0], x[1]) for x in c["_scoped"]) or "none")) sl = '{"t":"sess","list":"%s"}' % data.get("sess", "") if sent.get("sess") != sl: send(sl) sent["sess"] = sl last_send = time.time() if now - last_ping > 10: send('{"t":"ping"}') last_ping = now # custom icons from ~/TALLY Icons (only once TALLY has said which ones it holds) if answered and tally_icons and now - icon_check > 5: icon_check = now files = icon_files() for k, p in enumerate(("claude", "openai")): f = files[p] try: sig = "%s:%d:%d" % (f, os.path.getmtime(f), os.path.getsize(f)) if f else None except OSError: sig = None if sig and (icon_sent.get(p) != sig or tally_icons[k] == "0"): try: for ln in icon_lines(p, icon_mask(f)): send(ln); time.sleep(.03) log("sent %s icon from %s" % (p, f)) except Exception as e: log("could not use %s as the %s icon: %s" % (f, p, e)) icon_sent[p] = sig tally_icons = tally_icons[:k] + "1" + tally_icons[k + 1:] elif not sig and tally_icons[k] == "1": send('{"t":"icon","p":"%s","clear":1}' % p) log("removed the %s icon (no file in %s)" % (p, ICON_DIR)) icon_sent.pop(p, None) tally_icons = tally_icons[:k] + "0" + tally_icons[k + 1:] # commands queued for TALLY (or for TALLY Link) in commands.txt if now - last_cmd_check > 2: last_cmd_check = now for c in _take_commands(): if c == "!renew-claude": log("request: renew the Claude sign-in now") reader.renew_now, last_send = True, 0 elif c == "!icons": icon_sent.clear(); tally_icons = tally_icons and "00"; icon_check = 0 log("request: send the icons again") elif c == "!status": log("status: port %s, TALLY answering %s, last reply %.0f s ago" % (port, answered, now - last_rx)) else: log("> TALLY " + c) send(c) loud_until = now + 60 # a newer TALLY Link was installed over this one: restart into it if now - last_self_check > 10: last_self_check = now if _self_mtime() != my_mtime: reason = "updated" break if reason is None: try: send('{"t":"bye"}') except Exception: pass except Exception as e: reason = reason or "link dropped: %s" % e try: ser.close() except Exception: pass if reason == "updated": log("TALLY Link was updated on disk - restarting into the new version") if WIN: import subprocess _release_single() subprocess.Popen([sys.executable, os.path.abspath(__file__)], close_fds=True, creationflags=0x00000008 | 0x00000200 | 0x08000000) # detached, new group, no window sys.exit(0) os.execv(sys.executable, [sys.executable, os.path.abspath(__file__)]) if reason and running[0]: log(reason) unplugged = any(w in reason for w in ("Device not configured", "No such file", "could not open", "Errno 2", "Errno 6", "ClearCommError", "WriteFile failed", "GetOverlappedResult", "FileNotFoundError", "device does not recognize", "PermissionError")) if not unplugged: silent_strikes += 1 if silent_strikes >= 2 and resets_tried < 2: log("restarting TALLY to clear the connection (same as unplugging it)") reset_tally(port) silent_strikes, resets_tried = 0, resets_tried + 1 time.sleep(4) elif resets_tried >= 2: # a restart did not help: the USB link itself is stuck (seen right after flashing). # Stop restarting TALLY over and over; keep listening quietly until it is replugged. if time.time() - last_hint > 600: log("TALLY is not answering over USB. Unplug TALLY and plug it back in - TALLY Link carries on by itself.") last_hint = time.time() time.sleep(20) time.sleep(2) log("TALLY Link stopped") def status(): print("TALLY Link %s - what it can read on this %s\n" % (VERSION, COMPUTER)) r = Reader() r.openai_next = 0 r.cowork_budget = 90.0 d = r.read() def fmt(p): x = d.get(p) if not x: return " not found" parts = [] for k, label in (("s5", "5-hour"), ("s7", "weekly")): if k in x: rs = x.get("r" + k[1]) parts.append("%s %.0f%% (resets %s)" % (label, x[k], time.strftime("%a %H:%M", time.localtime(rs)) if rs else "unknown")) return " " + " ".join(parts) print("Claude:"); print(fmt("claude")) c = d.get("claude") or {} if c.get("_why"): for part in c["_why"].split("; "): print(" - " + part) st = r.cowork[1] if r.cowork else {} print(" Cowork store: %s, %d sessions, %d readings%s" % ("found" if st.get("store") else "not found", st.get("keys", 0), st.get("events", 0), (", %d still scanning" % st["pending"]) if st.get("pending") else "")) print("OpenAI (%s):" % r.openai_src); print(fmt("openai")) print("Codex sessions: %s" % (d.get("sess") or "none active")) print("TALLY on USB: %s" % (find_port() or "not plugged in")) if WIN: print("Claude app folder: %s%s" % (CLAUDE_DIR, "" if os.path.isdir(CLAUDE_DIR) else " (not found - is the Claude desktop app installed?)")) print("Codex folder: %s%s" % (CODEX_DIR, "" if os.path.isdir(CODEX_DIR) else " (not found)")) print("\nLog: %s" % LOG_PATH) def claude_check(): creds, where, _ = _cc_read() if creds: login = creds.get("claudeAiOauth") or {} exp = (login.get("expiresAt") or 0) / 1000.0 left = exp - time.time() print(" Sign-in found in the %s: %s, %s refresh token, plan %s" % ( "Keychain" if where == "keychain" else "file ~/.claude/.credentials.json", ("valid for %d more min" % (left / 60)) if left > 0 else ("expired %.1f h ago" % (-left / 3600)) if exp else "no expiry recorded", "with" if login.get("refreshToken") else "no", login.get("subscriptionType") or login.get("rateLimitTier") or "unknown")) data, why = claude_live() if not data: print(" Claude live usage: NOT connected - " + why) return 1 parts = [] for slot, name in (("5", "5-hour"), ("7", "weekly")): if "s" + slot in data: r = data.get("r" + slot) parts.append("%s %s%%%s" % (name, data["s" + slot], (", resets " + time.strftime("%a %H:%M", time.localtime(r))) if r else "")) print(" Claude live usage: connected - " + " | ".join(parts)) return 0 if __name__ == "__main__": if "--claude-check" in sys.argv: sys.exit(claude_check()) if "--status" in sys.argv: status() else: run()