harlay 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/app/dist/main.cjs CHANGED
@@ -2,9 +2,9 @@
2
2
 
3
3
  // src/main.ts
4
4
  var import_electron = require("electron");
5
- var import_promises12 = require("node:fs/promises");
5
+ var import_promises16 = require("node:fs/promises");
6
6
  var import_node_os2 = require("node:os");
7
- var import_node_path14 = require("node:path");
7
+ var import_node_path18 = require("node:path");
8
8
 
9
9
  // ../../packages/core/src/types.ts
10
10
  var TOOLS = ["claude-code", "codex", "cursor"];
@@ -142,9 +142,9 @@ function blockText(content) {
142
142
 
143
143
  // ../../packages/core/src/sources/todos.ts
144
144
  var STATUSES = /* @__PURE__ */ new Set(["pending", "in_progress", "completed", "cancelled"]);
145
- function applyTodoWrite(current, input) {
146
- if (!isObj(input) || !Array.isArray(input.todos)) return current ?? /* @__PURE__ */ new Map();
147
- const next = input.merge === true && current ? new Map(current) : /* @__PURE__ */ new Map();
145
+ function applyTodoWrite(current2, input) {
146
+ if (!isObj(input) || !Array.isArray(input.todos)) return current2 ?? /* @__PURE__ */ new Map();
147
+ const next = input.merge === true && current2 ? new Map(current2) : /* @__PURE__ */ new Map();
148
148
  input.todos.forEach((raw, i) => {
149
149
  if (!isObj(raw)) return;
150
150
  const id = raw.id !== void 0 && raw.id !== null ? String(raw.id) : `#${i}`;
@@ -379,25 +379,25 @@ function usageOf(p) {
379
379
  if (!primary || typeof primary.used_percent !== "number") return null;
380
380
  return { usedPercent: primary.used_percent, resetsAt: typeof primary.resets_at === "number" ? new Date(primary.resets_at * 1e3) : null };
381
381
  }
382
- function reachedHalt(p, usage) {
382
+ function reachedHalt(p, usage2) {
383
383
  const rl = isObj(p.rate_limits) ? p.rate_limits : null;
384
384
  const t = rl ? rl.rate_limit_reached_type : null;
385
385
  if (t === null || t === void 0) return null;
386
- return { reason: "usage_limit", message: `Codex usage limit reached (${String(t)})`, resetsAt: usage?.resetsAt ?? null };
386
+ return { reason: "usage_limit", message: `Codex usage limit reached (${String(t)})`, resetsAt: usage2?.resetsAt ?? null };
387
387
  }
388
- function taskHalt(p, usage) {
388
+ function taskHalt(p, usage2) {
389
389
  const e = isObj(p.error) ? p.error : null;
390
390
  const message = e ? str(e.message) : null;
391
391
  if (!e || !message) return null;
392
392
  const limited = e.codex_error_info === "usage_limit_exceeded" || /usage limit|rate limit/i.test(message);
393
- return { reason: limited ? "usage_limit" : "crash", message, resetsAt: usage?.resetsAt ?? null };
393
+ return { reason: limited ? "usage_limit" : "crash", message, resetsAt: usage2?.resetsAt ?? null };
394
394
  }
395
395
  function normalizeCodex(records, ref, invalid = 0) {
396
396
  const timeline = [];
397
397
  const fallbackPrompts = [];
398
398
  let sawItemPrompt = false;
399
399
  let model = null;
400
- let usage = null;
400
+ let usage2 = null;
401
401
  let halt = null;
402
402
  let unknown = invalid;
403
403
  for (const rec of records) {
@@ -440,16 +440,16 @@ function normalizeCodex(records, ref, invalid = 0) {
440
440
  const text = typeof p.message === "string" ? cleanPrompt(p.message) : null;
441
441
  if (text) fallbackPrompts.push(text);
442
442
  } else if (p.type === "token_count") {
443
- usage = usageOf(p) ?? usage;
444
- halt = reachedHalt(p, usage) ?? halt;
443
+ usage2 = usageOf(p) ?? usage2;
444
+ halt = reachedHalt(p, usage2) ?? halt;
445
445
  } else if (p.type === "task_complete") {
446
- halt = taskHalt(p, usage);
446
+ halt = taskHalt(p, usage2);
447
447
  }
448
448
  }
449
449
  if (!sawItemPrompt && fallbackPrompts.length) {
450
450
  timeline.unshift(...fallbackPrompts.map((text) => ({ kind: "user", text })));
451
451
  }
452
- return { ref, model, timeline, todos: null, halt, usage, unknownRecords: unknown };
452
+ return { ref, model, timeline, todos: null, halt, usage: usage2, unknownRecords: unknown };
453
453
  }
454
454
  function parseSessionMeta(line) {
455
455
  let r;
@@ -1146,15 +1146,15 @@ async function allocateHandoffId(handoffsDir, baseId) {
1146
1146
  async function ensureGitExclude(workdir) {
1147
1147
  const p = (await git(workdir, ["rev-parse", "--git-path", "info/exclude"])).trim();
1148
1148
  const file = (0, import_node_path6.isAbsolute)(p) ? p : (0, import_node_path6.join)(workdir, p);
1149
- let current = "";
1149
+ let current2 = "";
1150
1150
  try {
1151
- current = await (0, import_promises5.readFile)(file, "utf8");
1151
+ current2 = await (0, import_promises5.readFile)(file, "utf8");
1152
1152
  } catch {
1153
- current = "";
1153
+ current2 = "";
1154
1154
  }
1155
- if (current.split("\n").some((l) => l.trim() === ".harlay/" || l.trim() === ".harlay")) return;
1155
+ if (current2.split("\n").some((l) => l.trim() === ".harlay/" || l.trim() === ".harlay")) return;
1156
1156
  await (0, import_promises5.mkdir)((0, import_node_path6.dirname)(file), { recursive: true });
1157
- await (0, import_promises5.appendFile)(file, `${current && !current.endsWith("\n") ? "\n" : ""}.harlay/
1157
+ await (0, import_promises5.appendFile)(file, `${current2 && !current2.endsWith("\n") ? "\n" : ""}.harlay/
1158
1158
  `);
1159
1159
  }
1160
1160
  async function readHandoffState(workdir) {
@@ -1591,12 +1591,535 @@ var Watcher = class extends import_node_events.EventEmitter {
1591
1591
  }
1592
1592
  };
1593
1593
 
1594
- // src/menu.ts
1595
- var import_node_path11 = require("node:path");
1594
+ // ../../packages/core/src/usage/types.ts
1595
+ var WINDOW_MINUTES = { five_hour: 300, weekly: 10080 };
1596
+ function windowKind(minutes) {
1597
+ if (minutes === 300) return "five_hour";
1598
+ if (minutes === 10080) return "weekly";
1599
+ return "other";
1600
+ }
1601
+ function windowLabel(kind, minutes) {
1602
+ if (kind === "five_hour") return "5h";
1603
+ if (kind === "weekly") return "Week";
1604
+ if (!minutes) return "Limit";
1605
+ return minutes % 1440 === 0 ? `${minutes / 1440}d` : `${Math.round(minutes / 60)}h`;
1606
+ }
1607
+ function fullestWindow(s) {
1608
+ let best = null;
1609
+ for (const w of s.windows) if (!best || w.usedPercent > best.usedPercent) best = w;
1610
+ return best;
1611
+ }
1596
1612
 
1597
- // src/settings.ts
1613
+ // ../../packages/core/src/usage/codex.ts
1614
+ var import_node_child_process3 = require("node:child_process");
1598
1615
  var import_promises10 = require("node:fs/promises");
1599
1616
  var import_node_path10 = require("node:path");
1617
+ var PACE_MS = 60 * 6e4;
1618
+ var TAIL_BYTES = 1024 * 1024;
1619
+ function num(x) {
1620
+ return typeof x === "number" && Number.isFinite(x) ? x : null;
1621
+ }
1622
+ function codexWindows(rl) {
1623
+ const out = [];
1624
+ for (const key of ["primary", "secondary"]) {
1625
+ const w = rl[key];
1626
+ if (!isObj(w)) continue;
1627
+ const pct = num(w.used_percent) ?? num(w.usedPercent);
1628
+ if (pct === null) continue;
1629
+ const minutes = num(w.window_minutes) ?? num(w.windowDurationMins);
1630
+ const resets = num(w.resets_at) ?? num(w.resetsAt);
1631
+ const kind = windowKind(minutes);
1632
+ out.push({ kind, label: windowLabel(kind, minutes), usedPercent: pct, resetsAt: resets === null ? null : new Date(resets * 1e3), exact: true });
1633
+ }
1634
+ return out;
1635
+ }
1636
+ function planOf(rl) {
1637
+ const p = rl.plan_type ?? rl.planType;
1638
+ return typeof p === "string" ? p : null;
1639
+ }
1640
+ async function readTail(file, bytes) {
1641
+ const fh = await (0, import_promises10.open)(file, "r");
1642
+ try {
1643
+ const { size } = await fh.stat();
1644
+ const start = Math.max(0, size - bytes);
1645
+ const buf = Buffer.alloc(size - start);
1646
+ await fh.read(buf, 0, buf.length, start);
1647
+ const text = buf.toString("utf8");
1648
+ return start > 0 ? text.slice(text.indexOf("\n") + 1) : text;
1649
+ } finally {
1650
+ await fh.close();
1651
+ }
1652
+ }
1653
+ async function readCodexReadings(home2, files = 8) {
1654
+ const logs = (await listSessionFiles((0, import_node_path10.join)(home2, ".codex", "sessions"), (f) => f.endsWith(".jsonl"))).slice(0, files);
1655
+ const out = [];
1656
+ for (const { path } of logs) {
1657
+ let text;
1658
+ try {
1659
+ text = await readTail(path, TAIL_BYTES);
1660
+ } catch {
1661
+ continue;
1662
+ }
1663
+ for (const line of text.split("\n")) {
1664
+ if (!line.includes('"rate_limits":{')) continue;
1665
+ try {
1666
+ const rec = JSON.parse(line);
1667
+ const p = isObj(rec.payload) ? rec.payload : null;
1668
+ const rl = p && isObj(p.rate_limits) ? p.rate_limits : null;
1669
+ const at = typeof rec.timestamp === "string" ? new Date(rec.timestamp) : null;
1670
+ if (!rl || !at || Number.isNaN(at.getTime())) continue;
1671
+ const windows = codexWindows(rl);
1672
+ if (windows.length) out.push({ at, windows, plan: planOf(rl), resetCredits: null });
1673
+ } catch {
1674
+ }
1675
+ }
1676
+ }
1677
+ return out.sort((a, b) => a.at.getTime() - b.at.getTime());
1678
+ }
1679
+ function minutesToFull(points, now) {
1680
+ const recent = points.filter((p) => now.getTime() - p.at.getTime() <= PACE_MS);
1681
+ if (recent.length < 2) return null;
1682
+ const first = recent[0];
1683
+ const last = recent[recent.length - 1];
1684
+ const minutes = (last.at.getTime() - first.at.getTime()) / 6e4;
1685
+ const rise = last.pct - first.pct;
1686
+ if (minutes < 5 || rise <= 0) return null;
1687
+ return Math.max(0, (100 - last.pct) / (rise / minutes));
1688
+ }
1689
+ function current(w, now) {
1690
+ return w.resetsAt && w.resetsAt.getTime() <= now.getTime() ? { ...w, usedPercent: 0, resetsAt: null } : w;
1691
+ }
1692
+ function codexSnapshot(readings, now, query = null) {
1693
+ const all = query ? [...readings, query].sort((a, b) => a.at.getTime() - b.at.getTime()) : readings;
1694
+ const latest = all[all.length - 1];
1695
+ if (!latest) return null;
1696
+ const windows = latest.windows.map((w) => current(w, now));
1697
+ const fullest = windows.reduce((b, w) => !b || w.usedPercent > b.usedPercent ? w : b, null);
1698
+ let minutesToLimit = null;
1699
+ if (fullest && fullest.usedPercent > 0) {
1700
+ const series = all.flatMap((r) => r.windows.filter((w) => w.kind === fullest.kind).map((w) => ({ at: r.at, pct: w.usedPercent })));
1701
+ minutesToLimit = minutesToFull(series, now);
1702
+ if (minutesToLimit !== null && fullest.resetsAt && now.getTime() + minutesToLimit * 6e4 >= fullest.resetsAt.getTime()) minutesToLimit = null;
1703
+ }
1704
+ return {
1705
+ tool: "codex",
1706
+ windows,
1707
+ observedAt: latest.at,
1708
+ minutesToLimit,
1709
+ plan: latest.plan ?? query?.plan ?? null,
1710
+ resetCredits: query?.resetCredits ?? null
1711
+ };
1712
+ }
1713
+ function queryCodexUsage(codexBin, timeoutMs = 15e3) {
1714
+ return new Promise((resolve) => {
1715
+ let done = false;
1716
+ let buf = "";
1717
+ const child = (0, import_node_child_process3.spawn)(codexBin, ["app-server"], { stdio: ["pipe", "pipe", "ignore"] });
1718
+ const finish = (r) => {
1719
+ if (done) return;
1720
+ done = true;
1721
+ clearTimeout(timer);
1722
+ child.kill();
1723
+ resolve(r);
1724
+ };
1725
+ const timer = setTimeout(() => finish(null), timeoutMs);
1726
+ child.on("error", () => finish(null));
1727
+ child.stdin.on("error", () => finish(null));
1728
+ child.on("exit", () => finish(null));
1729
+ child.stdout.setEncoding("utf8");
1730
+ child.stdout.on("data", (chunk) => {
1731
+ buf += chunk;
1732
+ let nl;
1733
+ while ((nl = buf.indexOf("\n")) !== -1) {
1734
+ const line = buf.slice(0, nl);
1735
+ buf = buf.slice(nl + 1);
1736
+ let msg;
1737
+ try {
1738
+ msg = JSON.parse(line);
1739
+ } catch {
1740
+ continue;
1741
+ }
1742
+ if (msg.id !== 2) continue;
1743
+ const res = isObj(msg.result) ? msg.result : null;
1744
+ const rl = res && isObj(res.rateLimits) ? res.rateLimits : null;
1745
+ if (!res || !rl) return finish(null);
1746
+ const credits = isObj(res.rateLimitResetCredits) ? num(res.rateLimitResetCredits.availableCount) : null;
1747
+ finish({ at: /* @__PURE__ */ new Date(), windows: codexWindows(rl), plan: planOf(rl), resetCredits: credits });
1748
+ }
1749
+ });
1750
+ const send = (o) => child.stdin.write(JSON.stringify(o) + "\n");
1751
+ send({ method: "initialize", id: 1, params: { clientInfo: { name: "harlay", version: "1" } } });
1752
+ send({ method: "initialized" });
1753
+ send({ method: "account/rateLimits/read", id: 2 });
1754
+ });
1755
+ }
1756
+
1757
+ // ../../packages/core/src/usage/claude.ts
1758
+ var import_promises11 = require("node:fs/promises");
1759
+ var import_node_path11 = require("node:path");
1760
+ var HOUR = 36e5;
1761
+ var SAMPLE_MAX_AGE_MS = 30 * 24 * HOUR;
1762
+ var DISPROVE_FACTOR = 1.15;
1763
+ var MIN_CALIBRATION_PCT = 5;
1764
+ var PACE_MS2 = 30 * 6e4;
1765
+ function replyUnits(usage2, model) {
1766
+ const n = (k) => typeof usage2[k] === "number" ? usage2[k] : 0;
1767
+ const tokens = n("input_tokens") + 1.25 * n("cache_creation_input_tokens") + 0.1 * n("cache_read_input_tokens") + 5 * n("output_tokens");
1768
+ const tier = /opus/i.test(model) ? 5 : /haiku/i.test(model) ? 1 : 3;
1769
+ return tokens * tier;
1770
+ }
1771
+ function limitKind(t) {
1772
+ if (t === "five_hour") return "five_hour";
1773
+ if (t === "seven_day" || t === "weekly") return "weekly";
1774
+ return null;
1775
+ }
1776
+ var ClaudeUsageLog = class {
1777
+ constructor(home2) {
1778
+ this.home = home2;
1779
+ }
1780
+ home;
1781
+ files = /* @__PURE__ */ new Map();
1782
+ replies = /* @__PURE__ */ new Map();
1783
+ hits = /* @__PURE__ */ new Map();
1784
+ async refresh(now = /* @__PURE__ */ new Date(), maxAgeMs = 8 * 24 * HOUR) {
1785
+ const root = (0, import_node_path11.join)(this.home, ".claude", "projects");
1786
+ let names;
1787
+ try {
1788
+ names = await (0, import_promises11.readdir)(root, { recursive: true });
1789
+ } catch {
1790
+ return;
1791
+ }
1792
+ for (const name of names) {
1793
+ if (!name.endsWith(".jsonl")) continue;
1794
+ const path = (0, import_node_path11.join)(root, name);
1795
+ try {
1796
+ const s = await (0, import_promises11.stat)(path);
1797
+ if (now.getTime() - s.mtimeMs > maxAgeMs) continue;
1798
+ await this.readFrom(path, s.size);
1799
+ } catch {
1800
+ }
1801
+ }
1802
+ const cutoff = now.getTime() - maxAgeMs;
1803
+ for (const [k, e] of this.replies) if (e.at < cutoff) this.replies.delete(k);
1804
+ for (const [k, h] of this.hits) if (h.at < cutoff - SAMPLE_MAX_AGE_MS) this.hits.delete(k);
1805
+ }
1806
+ async readFrom(path, size) {
1807
+ const st = this.files.get(path) ?? { offset: 0, partial: "" };
1808
+ if (size < st.offset) Object.assign(st, { offset: 0, partial: "" });
1809
+ if (size === st.offset) return;
1810
+ const fh = await (0, import_promises11.open)(path, "r");
1811
+ try {
1812
+ const buf = Buffer.alloc(size - st.offset);
1813
+ await fh.read(buf, 0, buf.length, st.offset);
1814
+ st.offset = size;
1815
+ const text = st.partial + buf.toString("utf8");
1816
+ const lines = text.split("\n");
1817
+ st.partial = lines.pop() ?? "";
1818
+ for (const line of lines) this.ingest(line);
1819
+ } finally {
1820
+ await fh.close();
1821
+ this.files.set(path, st);
1822
+ }
1823
+ }
1824
+ ingest(line) {
1825
+ if (!line.includes('"assistant"')) return;
1826
+ let r;
1827
+ try {
1828
+ r = JSON.parse(line);
1829
+ } catch {
1830
+ return;
1831
+ }
1832
+ if (r.type !== "assistant" || typeof r.timestamp !== "string") return;
1833
+ const at = Date.parse(r.timestamp);
1834
+ if (Number.isNaN(at)) return;
1835
+ if (r.isApiErrorMessage === true) {
1836
+ const q = isObj(r.quotaLimits) ? r.quotaLimits : null;
1837
+ const kind = q ? limitKind(q.rateLimitType) : null;
1838
+ if (q && kind && q.status === "rejected" && typeof q.resetsAt === "number") {
1839
+ this.hits.set(`${kind}|${q.resetsAt}`, { at, kind, resetsAt: q.resetsAt * 1e3 });
1840
+ }
1841
+ return;
1842
+ }
1843
+ const m = isObj(r.message) ? r.message : null;
1844
+ const usage2 = m && isObj(m.usage) ? m.usage : null;
1845
+ const model = m && typeof m.model === "string" ? m.model : "";
1846
+ if (!m || !usage2 || model === "<synthetic>") return;
1847
+ const key = `${String(m.id)}|${String(r.requestId)}`;
1848
+ this.replies.set(key, { at, units: replyUnits(usage2, model) });
1849
+ }
1850
+ entries() {
1851
+ return [...this.replies.values()].sort((a, b) => a.at - b.at);
1852
+ }
1853
+ limitHits() {
1854
+ return [...this.hits.values()].sort((a, b) => a.at - b.at);
1855
+ }
1856
+ };
1857
+ function parseClaudeStatusline(json, at) {
1858
+ if (!isObj(json) || !isObj(json.rate_limits)) return null;
1859
+ const rl = json.rate_limits;
1860
+ const windows = [];
1861
+ for (const [key, kind] of [["five_hour", "five_hour"], ["seven_day", "weekly"]]) {
1862
+ const w = rl[key];
1863
+ if (!isObj(w) || typeof w.used_percentage !== "number" || typeof w.resets_at !== "number") continue;
1864
+ windows.push({ kind, usedPercent: w.used_percentage, resetsAt: w.resets_at * 1e3 });
1865
+ }
1866
+ return windows.length ? { at, windows } : null;
1867
+ }
1868
+ var prefixCache = /* @__PURE__ */ new WeakMap();
1869
+ function prefixOf(entries) {
1870
+ let p = prefixCache.get(entries);
1871
+ if (!p) {
1872
+ const at = [];
1873
+ const sum = [0];
1874
+ for (const e of entries) {
1875
+ at.push(e.at);
1876
+ sum.push(sum[sum.length - 1] + e.units);
1877
+ }
1878
+ p = { at, sum };
1879
+ prefixCache.set(entries, p);
1880
+ }
1881
+ return p;
1882
+ }
1883
+ function bound(at, t, after) {
1884
+ let lo = 0;
1885
+ let hi = at.length;
1886
+ while (lo < hi) {
1887
+ const mid = lo + hi >> 1;
1888
+ if (after ? at[mid] <= t : at[mid] < t) lo = mid + 1;
1889
+ else hi = mid;
1890
+ }
1891
+ return lo;
1892
+ }
1893
+ function sumUnits(entries, from, to) {
1894
+ if (to < from) return 0;
1895
+ const p = prefixOf(entries);
1896
+ return p.sum[bound(p.at, to, true)] - p.sum[bound(p.at, from, false)];
1897
+ }
1898
+ function windowMs(kind) {
1899
+ return kind === "weekly" ? WINDOW_MINUTES.weekly * 6e4 : WINDOW_MINUTES.five_hour * 6e4;
1900
+ }
1901
+ function capacitySamples(entries, hits, exact) {
1902
+ const out = [];
1903
+ for (const h of hits) {
1904
+ const units = sumUnits(entries, h.resetsAt - windowMs(h.kind), h.at);
1905
+ if (units > 0) out.push({ kind: h.kind, capacity: units, at: h.at, source: "limit" });
1906
+ }
1907
+ for (const w of exact?.windows ?? []) {
1908
+ if (w.usedPercent < MIN_CALIBRATION_PCT || !exact) continue;
1909
+ const units = sumUnits(entries, w.resetsAt - windowMs(w.kind), exact.at);
1910
+ if (units > 0) out.push({ kind: w.kind, capacity: units / (w.usedPercent / 100), at: exact.at, source: "exact" });
1911
+ }
1912
+ return out;
1913
+ }
1914
+ function mergeSamples(stored, fresh, now) {
1915
+ const byKey = /* @__PURE__ */ new Map();
1916
+ for (const s of [...stored, ...fresh]) byKey.set(`${s.kind}|${s.source}|${Math.round(s.at / 6e4)}`, s);
1917
+ const kept = [...byKey.values()].filter((s) => now.getTime() - s.at <= SAMPLE_MAX_AGE_MS).sort((a, b) => b.at - a.at);
1918
+ const perKind = /* @__PURE__ */ new Map();
1919
+ return kept.filter((s) => {
1920
+ const n = (perKind.get(s.kind) ?? 0) + 1;
1921
+ perKind.set(s.kind, n);
1922
+ return n <= 20;
1923
+ });
1924
+ }
1925
+ function pickCapacity(kind, samples, entries, hits, now) {
1926
+ const len = windowMs(kind);
1927
+ for (const s of samples.filter((x) => x.kind === kind).sort((a, b) => b.at - a.at)) {
1928
+ let disproved = false;
1929
+ for (let start = s.at; start < now.getTime() && !disproved; start += len / 5) {
1930
+ const end = Math.min(start + len, now.getTime());
1931
+ const hitInside = hits.some((h) => h.kind === kind && h.at > start && h.at <= end);
1932
+ if (!hitInside && sumUnits(entries, start, end) > s.capacity * DISPROVE_FACTOR) disproved = true;
1933
+ }
1934
+ if (!disproved) return s.capacity;
1935
+ }
1936
+ return null;
1937
+ }
1938
+ function currentFiveHourStart(entries, now) {
1939
+ const len = windowMs("five_hour");
1940
+ let start = null;
1941
+ for (const e of entries) {
1942
+ if (e.at > now.getTime()) break;
1943
+ if (start === null || e.at >= start + len) start = Math.floor(e.at / HOUR) * HOUR;
1944
+ }
1945
+ return start !== null && now.getTime() < start + len ? start : null;
1946
+ }
1947
+ function claudeSnapshot({ entries, hits, exact, samples, now }) {
1948
+ const t = now.getTime();
1949
+ const windows = [];
1950
+ let minutesToLimit = null;
1951
+ let observedAt = 0;
1952
+ for (const kind of ["five_hour", "weekly"]) {
1953
+ const len = windowMs(kind);
1954
+ const cap = pickCapacity(kind, samples, entries, hits, now);
1955
+ const ex = exact?.windows.find((w) => w.kind === kind && w.resetsAt > t);
1956
+ const hit = [...hits].reverse().find((h) => h.kind === kind && h.resetsAt > t);
1957
+ let start;
1958
+ let resetsAt;
1959
+ let pct = null;
1960
+ let isExact = false;
1961
+ if (ex && exact) {
1962
+ start = ex.resetsAt - len;
1963
+ resetsAt = ex.resetsAt;
1964
+ const since = sumUnits(entries, exact.at + 1, t);
1965
+ if (since === 0) {
1966
+ pct = ex.usedPercent;
1967
+ isExact = true;
1968
+ } else if (cap) {
1969
+ pct = ex.usedPercent + since / cap * 100;
1970
+ } else {
1971
+ pct = ex.usedPercent;
1972
+ }
1973
+ observedAt = Math.max(observedAt, exact.at);
1974
+ } else if (hit) {
1975
+ start = hit.resetsAt - len;
1976
+ resetsAt = hit.resetsAt;
1977
+ pct = 100;
1978
+ isExact = true;
1979
+ observedAt = Math.max(observedAt, hit.at);
1980
+ } else if (kind === "five_hour") {
1981
+ start = currentFiveHourStart(entries, now);
1982
+ resetsAt = start === null ? null : start + len;
1983
+ if (start === null) {
1984
+ if (cap) {
1985
+ windows.push({ kind, label: "5h", usedPercent: 0, resetsAt: null, exact: false });
1986
+ }
1987
+ continue;
1988
+ }
1989
+ if (cap) pct = sumUnits(entries, start, t) / cap * 100;
1990
+ } else {
1991
+ continue;
1992
+ }
1993
+ if (pct === null) continue;
1994
+ pct = Math.min(100, Math.max(0, pct));
1995
+ windows.push({ kind, label: kind === "five_hour" ? "5h" : "Week", usedPercent: pct, resetsAt: resetsAt === null ? null : new Date(resetsAt), exact: isExact });
1996
+ if (cap && pct < 100) {
1997
+ const recent = sumUnits(entries, t - PACE_MS2, t);
1998
+ if (recent > 0) {
1999
+ const perMinute = recent / (PACE_MS2 / 6e4);
2000
+ const minutes = cap * (1 - pct / 100) / perMinute;
2001
+ const fitsBeforeReset = resetsAt === null || t + minutes * 6e4 < resetsAt;
2002
+ if (fitsBeforeReset && (minutesToLimit === null || minutes < minutesToLimit)) minutesToLimit = minutes;
2003
+ }
2004
+ }
2005
+ }
2006
+ const last = entries[entries.length - 1];
2007
+ if (last) observedAt = Math.max(observedAt, last.at);
2008
+ if (!windows.length) return null;
2009
+ return { tool: "claude-code", windows, observedAt: new Date(observedAt || t), minutesToLimit, plan: null, resetCredits: null };
2010
+ }
2011
+
2012
+ // ../../packages/core/src/usage/bridge.ts
2013
+ var import_promises12 = require("node:fs/promises");
2014
+ var import_node_path12 = require("node:path");
2015
+ var BRIDGE_MARKER = "# harlay-usage";
2016
+ function claudeSettingsPath(home2) {
2017
+ return (0, import_node_path12.join)(home2, ".claude", "settings.json");
2018
+ }
2019
+ function claudeStatusFile(dataDir2) {
2020
+ return (0, import_node_path12.join)(dataDir2, "claude-status.json");
2021
+ }
2022
+ var scriptPath = (dataDir2) => (0, import_node_path12.join)(dataDir2, "claude-statusline.sh");
2023
+ var previousJson = (dataDir2) => (0, import_node_path12.join)(dataDir2, "claude-statusline.previous.json");
2024
+ var previousCommand = (dataDir2) => (0, import_node_path12.join)(dataDir2, "claude-statusline.previous");
2025
+ var BRIDGE_SCRIPT = `#!/bin/sh
2026
+ # Harlay's Claude Code status line. Saves Claude Code's status line input (it includes your exact usage)
2027
+ # for Harlay, then shows your previous status line, or a short usage summary if you had none.
2028
+ # Turn it off in Harlay: Settings > Usage > Exact Claude usage.
2029
+ dir=$(dirname "$0")
2030
+ input=$(cat)
2031
+ printf '%s' "$input" > "$dir/claude-status.json.tmp" && mv -f "$dir/claude-status.json.tmp" "$dir/claude-status.json"
2032
+ if [ -s "$dir/claude-statusline.previous" ]; then
2033
+ printf '%s' "$input" | "\${SHELL:-/bin/sh}" -c "$(cat "$dir/claude-statusline.previous")"
2034
+ exit $?
2035
+ fi
2036
+ pct() {
2037
+ printf '%s' "$input" | tr -d '\\n' | sed -n "s/.*\\"$1\\"[^}]*\\"used_percentage\\"[[:space:]]*:[[:space:]]*\\([0-9][0-9]*\\).*/\\1/p"
2038
+ }
2039
+ five=$(pct five_hour)
2040
+ week=$(pct seven_day)
2041
+ out=""
2042
+ [ -n "$five" ] && out="5h $five%"
2043
+ [ -n "$week" ] && out="\${out:+$out \xB7 }wk $week%"
2044
+ printf '%s' "$out"
2045
+ `;
2046
+ function bridgeCommand(dataDir2) {
2047
+ const q = `'${scriptPath(dataDir2).replace(/'/g, `'\\''`)}'`;
2048
+ return `${q} ${BRIDGE_MARKER}`;
2049
+ }
2050
+ function isObj2(x) {
2051
+ return typeof x === "object" && x !== null && !Array.isArray(x);
2052
+ }
2053
+ function isOurs(statusLine) {
2054
+ return isObj2(statusLine) && typeof statusLine.command === "string" && statusLine.command.includes(BRIDGE_MARKER);
2055
+ }
2056
+ async function readSettings(path) {
2057
+ let text;
2058
+ try {
2059
+ text = await (0, import_promises12.readFile)(path, "utf8");
2060
+ } catch (e) {
2061
+ if (e.code === "ENOENT") return {};
2062
+ throw e;
2063
+ }
2064
+ const parsed = JSON.parse(text);
2065
+ if (!isObj2(parsed)) throw new Error(`${path} is not a JSON object`);
2066
+ return parsed;
2067
+ }
2068
+ async function writeAtomic(path, text) {
2069
+ const tmp = `${path}.harlay-tmp`;
2070
+ await (0, import_promises12.writeFile)(tmp, text);
2071
+ await (0, import_promises12.rename)(tmp, path);
2072
+ }
2073
+ async function bridgeState(home2) {
2074
+ try {
2075
+ return isOurs((await readSettings(claudeSettingsPath(home2))).statusLine) ? "on" : "off";
2076
+ } catch {
2077
+ return "unavailable";
2078
+ }
2079
+ }
2080
+ async function installBridge(home2, dataDir2) {
2081
+ const path = claudeSettingsPath(home2);
2082
+ const s = await readSettings(path);
2083
+ await (0, import_promises12.mkdir)(dataDir2, { recursive: true });
2084
+ await (0, import_promises12.writeFile)(scriptPath(dataDir2), BRIDGE_SCRIPT);
2085
+ await (0, import_promises12.chmod)(scriptPath(dataDir2), 493);
2086
+ if (isOurs(s.statusLine)) return;
2087
+ const prev = s.statusLine;
2088
+ if (prev !== void 0) {
2089
+ await writeAtomic(previousJson(dataDir2), JSON.stringify(prev, null, 2) + "\n");
2090
+ const cmd = isObj2(prev) && typeof prev.command === "string" ? prev.command : "";
2091
+ await writeAtomic(previousCommand(dataDir2), cmd);
2092
+ } else {
2093
+ await (0, import_promises12.rm)(previousJson(dataDir2), { force: true });
2094
+ await (0, import_promises12.rm)(previousCommand(dataDir2), { force: true });
2095
+ }
2096
+ s.statusLine = { ...isObj2(prev) ? prev : {}, type: "command", command: bridgeCommand(dataDir2) };
2097
+ await (0, import_promises12.mkdir)((0, import_node_path12.join)(home2, ".claude"), { recursive: true });
2098
+ await writeAtomic(path, JSON.stringify(s, null, 2) + "\n");
2099
+ }
2100
+ async function removeBridge(home2, dataDir2) {
2101
+ const path = claudeSettingsPath(home2);
2102
+ const s = await readSettings(path);
2103
+ if (!isOurs(s.statusLine)) return;
2104
+ let prev;
2105
+ try {
2106
+ prev = JSON.parse(await (0, import_promises12.readFile)(previousJson(dataDir2), "utf8"));
2107
+ } catch {
2108
+ prev = void 0;
2109
+ }
2110
+ if (prev === void 0) delete s.statusLine;
2111
+ else s.statusLine = prev;
2112
+ await writeAtomic(path, JSON.stringify(s, null, 2) + "\n");
2113
+ await (0, import_promises12.rm)(previousJson(dataDir2), { force: true });
2114
+ await (0, import_promises12.rm)(previousCommand(dataDir2), { force: true });
2115
+ }
2116
+
2117
+ // src/menu.ts
2118
+ var import_node_path14 = require("node:path");
2119
+
2120
+ // src/settings.ts
2121
+ var import_promises13 = require("node:fs/promises");
2122
+ var import_node_path13 = require("node:path");
1600
2123
  var THEMES = ["system", "light", "dark"];
1601
2124
  var isTheme = (x) => THEMES.includes(x);
1602
2125
  var DEFAULT_SETTINGS = {
@@ -1606,12 +2129,13 @@ var DEFAULT_SETTINGS = {
1606
2129
  usageThreshold: 90,
1607
2130
  terminalApp: "Terminal",
1608
2131
  openAtLogin: true,
1609
- theme: "system"
2132
+ theme: "system",
2133
+ claudeBridgeAsked: false
1610
2134
  };
1611
2135
  async function loadSettings(file) {
1612
2136
  let raw = {};
1613
2137
  try {
1614
- raw = JSON.parse(await (0, import_promises10.readFile)(file, "utf8"));
2138
+ raw = JSON.parse(await (0, import_promises13.readFile)(file, "utf8"));
1615
2139
  } catch {
1616
2140
  raw = {};
1617
2141
  }
@@ -1621,6 +2145,7 @@ async function loadSettings(file) {
1621
2145
  if (typeof r.autoContinue === "boolean") s.autoContinue = r.autoContinue;
1622
2146
  if (typeof r.openAtLogin === "boolean") s.openAtLogin = r.openAtLogin;
1623
2147
  if (isTheme(r.theme)) s.theme = r.theme;
2148
+ if (typeof r.claudeBridgeAsked === "boolean") s.claudeBridgeAsked = r.claudeBridgeAsked;
1624
2149
  if (typeof r.terminalApp === "string" && r.terminalApp.trim()) s.terminalApp = r.terminalApp.trim();
1625
2150
  if (typeof r.usageThreshold === "number" && r.usageThreshold > 0 && r.usageThreshold <= 100) s.usageThreshold = r.usageThreshold;
1626
2151
  if (typeof r.modes === "object" && r.modes !== null) {
@@ -1632,12 +2157,12 @@ async function loadSettings(file) {
1632
2157
  return s;
1633
2158
  }
1634
2159
  async function saveSettings(file, s) {
1635
- await (0, import_promises10.mkdir)((0, import_node_path10.dirname)(file), { recursive: true });
1636
- await (0, import_promises10.writeFile)(file, JSON.stringify(s, null, 2) + "\n");
2160
+ await (0, import_promises13.mkdir)((0, import_node_path13.dirname)(file), { recursive: true });
2161
+ await (0, import_promises13.writeFile)(file, JSON.stringify(s, null, 2) + "\n");
1637
2162
  }
1638
2163
 
1639
2164
  // src/state.ts
1640
- var import_promises11 = require("node:fs/promises");
2165
+ var import_promises14 = require("node:fs/promises");
1641
2166
  var DEFAULT_LIMIT_MS = 60 * 6e4;
1642
2167
  var sessionKey = (ref) => `${ref.tool}:${ref.id}`;
1643
2168
  function createState() {
@@ -1702,7 +2227,7 @@ function latestSession(state2) {
1702
2227
  async function existingBrief(t) {
1703
2228
  if (!t?.briefPath) return null;
1704
2229
  try {
1705
- await (0, import_promises11.access)(t.briefPath);
2230
+ await (0, import_promises14.access)(t.briefPath);
1706
2231
  return t.briefPath;
1707
2232
  } catch {
1708
2233
  return null;
@@ -1715,7 +2240,7 @@ var DOT = { limit: "\u25CF", warning: "\u25D0", idle: "\u25CB", handed_off: "\u2
1715
2240
  var MODES = ["app", "terminal"];
1716
2241
  var THEME_LABEL = { system: "System", light: "Light", dark: "Dark" };
1717
2242
  function sessionTitle(t) {
1718
- return t.ref.title ?? (t.ref.cwd ? (0, import_node_path11.basename)(t.ref.cwd) : t.ref.id.slice(0, 8));
2243
+ return t.ref.title ?? (t.ref.cwd ? (0, import_node_path14.basename)(t.ref.cwd) : t.ref.id.slice(0, 8));
1719
2244
  }
1720
2245
  function buildMenuModel(sessions, settings2, available2) {
1721
2246
  const nodes = [
@@ -1775,14 +2300,14 @@ function buildMenuModel(sessions, settings2, available2) {
1775
2300
  }
1776
2301
 
1777
2302
  // src/notifications.ts
1778
- var import_node_path12 = require("node:path");
2303
+ var import_node_path15 = require("node:path");
1779
2304
  function haltNotificationBody(t, first) {
1780
- const what = t.ref.cwd ? `${t.detail} \xB7 ${(0, import_node_path12.basename)(t.ref.cwd)}` : t.detail;
2305
+ const what = t.ref.cwd ? `${t.detail} \xB7 ${(0, import_node_path15.basename)(t.ref.cwd)}` : t.detail;
1781
2306
  return first ? `${what}. Click to continue in ${TOOL_LABEL[first]}.` : `${what}. No other agent is available.`;
1782
2307
  }
1783
2308
 
1784
2309
  // src/popover.ts
1785
- var import_node_path13 = require("node:path");
2310
+ var import_node_path16 = require("node:path");
1786
2311
  var POPOVER_WIDTH = 392;
1787
2312
  function timeAgo(d, now) {
1788
2313
  const s = Math.max(0, Math.floor((now.getTime() - d.getTime()) / 1e3));
@@ -1793,7 +2318,44 @@ function timeAgo(d, now) {
1793
2318
  if (h < 24) return `${h}h ago`;
1794
2319
  return `${Math.floor(h / 24)}d ago`;
1795
2320
  }
1796
- function buildPopoverModel(sessions, settings2, available2, busy, now) {
2321
+ function resetsText(d, now) {
2322
+ if (!d) return null;
2323
+ const ms = d.getTime() - now.getTime();
2324
+ if (ms <= 0) return null;
2325
+ if (ms < 36e5) return `resets in ${Math.max(1, Math.round(ms / 6e4))}m`;
2326
+ const time = d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" });
2327
+ return ms < 20 * 36e5 ? `resets ${time}` : `resets ${d.toLocaleDateString("en-US", { weekday: "short" })} ${time}`;
2328
+ }
2329
+ function paceText(minutes) {
2330
+ if (minutes === null) return null;
2331
+ const m = Math.max(1, Math.round(minutes));
2332
+ return m < 90 ? `\u2248 ${m} min to limit at this pace` : `\u2248 ${Math.round(m / 60)}h to limit at this pace`;
2333
+ }
2334
+ function buildUsageRows(snapshots, available2, bridge, now) {
2335
+ const rows = [];
2336
+ for (const tool of TOOLS) {
2337
+ const snap = snapshots[tool];
2338
+ const installed = available2[tool].app || available2[tool].terminal;
2339
+ if (!snap && !installed) continue;
2340
+ const row = { tool, label: TOOL_LABEL[tool], windows: [], pace: null, note: null, usagePage: false };
2341
+ if (snap) {
2342
+ row.windows = snap.windows.map((w) => ({ label: w.label, pct: Math.round(w.usedPercent), exact: w.exact, resets: resetsText(w.resetsAt, now) }));
2343
+ row.pace = paceText(snap.minutesToLimit);
2344
+ if (snap.resetCredits) row.note = `${snap.resetCredits} free limit reset${snap.resetCredits === 1 ? "" : "s"} available`;
2345
+ else if (tool === "claude-code" && snap.windows.some((w) => !w.exact)) row.note = "\u2248 estimated from your session logs";
2346
+ } else if (tool === "claude-code") {
2347
+ row.note = bridge === "on" ? "Updates after your next Claude Code reply in Terminal" : "Not measured yet. Turn on Exact Claude usage in Settings, or Harlay learns it at the next Claude limit.";
2348
+ } else if (tool === "codex") {
2349
+ row.note = "Updates after your next Codex turn";
2350
+ } else {
2351
+ row.note = "Cursor keeps no usage on this Mac";
2352
+ row.usagePage = true;
2353
+ }
2354
+ rows.push(row);
2355
+ }
2356
+ return rows;
2357
+ }
2358
+ function buildPopoverModel(sessions, settings2, available2, busy, now, usage2 = [], claudeBridge2 = "off", askClaudeBridge = false) {
1797
2359
  const order = [settings2.defaultTarget, ...TOOLS.filter((t) => t !== settings2.defaultTarget)];
1798
2360
  return {
1799
2361
  sessions: sessions.map((t) => {
@@ -1805,13 +2367,15 @@ function buildPopoverModel(sessions, settings2, available2, busy, now) {
1805
2367
  status: t.status,
1806
2368
  detail: t.detail,
1807
2369
  cwd: t.ref.cwd ?? null,
1808
- repo: t.ref.cwd ? (0, import_node_path13.basename)(t.ref.cwd) : null,
2370
+ repo: t.ref.cwd ? (0, import_node_path16.basename)(t.ref.cwd) : null,
1809
2371
  ago: timeAgo(t.updatedAt, now),
1810
2372
  busy: busy.has(key),
1811
2373
  targets: order.filter((x) => x !== t.ref.tool).map((x) => ({ tool: x, label: TOOL_LABEL[x], enabled: available2[x][settings2.modes[x]], isDefault: x === settings2.defaultTarget }))
1812
2374
  };
1813
2375
  }),
1814
- settings: { defaultTarget: settings2.defaultTarget, autoContinue: settings2.autoContinue, openAtLogin: settings2.openAtLogin, theme: settings2.theme },
2376
+ usage: usage2,
2377
+ askClaudeBridge,
2378
+ settings: { defaultTarget: settings2.defaultTarget, autoContinue: settings2.autoContinue, openAtLogin: settings2.openAtLogin, theme: settings2.theme, claudeBridge: claudeBridge2 },
1815
2379
  tools: TOOLS.map((x) => ({ tool: x, label: TOOL_LABEL[x], mode: settings2.modes[x], ...available2[x] })),
1816
2380
  hotkey: HOTKEY.label
1817
2381
  };
@@ -1834,6 +2398,10 @@ function isMenuAction(x) {
1834
2398
  return isTool(a.target) && (a.mode === "app" || a.mode === "terminal");
1835
2399
  case "toggleAuto":
1836
2400
  case "toggleLogin":
2401
+ case "answerClaudeBridge":
2402
+ return typeof a.allow === "boolean";
2403
+ case "toggleClaudeBridge":
2404
+ case "openCursorUsage":
1837
2405
  case "quit":
1838
2406
  return true;
1839
2407
  default:
@@ -1847,6 +2415,163 @@ function pickTargets(source, defaultTarget, available2, modes, limitedUntil = {}
1847
2415
  return order.filter((t) => t !== source && available2[t][modes[t]] && !((limitedUntil[t]?.getTime() ?? 0) > now.getTime()));
1848
2416
  }
1849
2417
 
2418
+ // src/usage.ts
2419
+ var import_node_events2 = require("node:events");
2420
+ var import_node_fs3 = require("node:fs");
2421
+ var import_promises15 = require("node:fs/promises");
2422
+ var import_node_path17 = require("node:path");
2423
+ var CODEX_STALE_MS = 30 * 6e4;
2424
+ var CODEX_QUERY_GAP_MS = 30 * 6e4;
2425
+ var DEBOUNCE_MS = 2e3;
2426
+ var ETA_WARN_PCT = 50;
2427
+ var ETA_WARN_MIN = 20;
2428
+ function clock2(d, now) {
2429
+ const time = d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
2430
+ return d.getTime() - now.getTime() < 20 * 36e5 ? time : `${d.toLocaleDateString([], { weekday: "short" })} ${time}`;
2431
+ }
2432
+ var NAME = { "claude-code": "Claude", codex: "Codex", cursor: "Cursor" };
2433
+ var UsageMonitor = class extends import_node_events2.EventEmitter {
2434
+ constructor(opts) {
2435
+ super();
2436
+ this.opts = opts;
2437
+ this.claudeLog = new ClaudeUsageLog(opts.home);
2438
+ }
2439
+ opts;
2440
+ claudeLog;
2441
+ watchers = [];
2442
+ timers = /* @__PURE__ */ new Map();
2443
+ alerted = /* @__PURE__ */ new Set();
2444
+ snapshots = {};
2445
+ samples = [];
2446
+ codexQuery = null;
2447
+ lastCodexQueryAt = 0;
2448
+ stopped = false;
2449
+ get statusFile() {
2450
+ return claudeStatusFile(this.opts.dataDir);
2451
+ }
2452
+ get samplesFile() {
2453
+ return (0, import_node_path17.join)(this.opts.dataDir, "usage-calibration.json");
2454
+ }
2455
+ current() {
2456
+ return this.snapshots;
2457
+ }
2458
+ async start() {
2459
+ await (0, import_promises15.mkdir)(this.opts.dataDir, { recursive: true });
2460
+ try {
2461
+ const raw = JSON.parse(await (0, import_promises15.readFile)(this.samplesFile, "utf8"));
2462
+ if (Array.isArray(raw)) this.samples = raw.filter((s) => typeof s?.capacity === "number" && typeof s?.at === "number" && typeof s?.kind === "string");
2463
+ } catch {
2464
+ this.samples = [];
2465
+ }
2466
+ const watchDir = (dir, tool, recursive) => {
2467
+ try {
2468
+ const w = (0, import_node_fs3.watch)(dir, { recursive }, (_e, name) => {
2469
+ if (!name || String(name).endsWith(".jsonl") || String(name).startsWith("claude-status")) this.schedule(tool);
2470
+ });
2471
+ w.on("error", () => void 0);
2472
+ this.watchers.push(w);
2473
+ } catch {
2474
+ }
2475
+ };
2476
+ watchDir((0, import_node_path17.join)(this.opts.home, ".codex", "sessions"), "codex", true);
2477
+ watchDir((0, import_node_path17.join)(this.opts.home, ".claude", "projects"), "claude-code", true);
2478
+ watchDir(this.opts.dataDir, "claude-code", false);
2479
+ await Promise.all([this.update("codex"), this.update("claude-code")]);
2480
+ void this.topUpCodex();
2481
+ }
2482
+ stop() {
2483
+ this.stopped = true;
2484
+ for (const w of this.watchers) w.close();
2485
+ for (const t of this.timers.values()) clearTimeout(t);
2486
+ }
2487
+ /** Re-derives readings (e.g. reset times passing) from what is already loaded; cheap. */
2488
+ async refresh() {
2489
+ await Promise.all([this.update("codex"), this.update("claude-code")]);
2490
+ }
2491
+ /** Asks Codex directly if its logs have nothing recent; called when the popover opens. */
2492
+ async topUpCodex() {
2493
+ const now = Date.now();
2494
+ const snap = this.snapshots.codex;
2495
+ if (snap && now - snap.observedAt.getTime() < CODEX_STALE_MS) return;
2496
+ if (now - this.lastCodexQueryAt < CODEX_QUERY_GAP_MS) return;
2497
+ const bin = await this.opts.codexBin();
2498
+ if (!bin) return;
2499
+ this.lastCodexQueryAt = now;
2500
+ const reading = await queryCodexUsage(bin);
2501
+ if (reading) {
2502
+ this.codexQuery = reading;
2503
+ await this.update("codex");
2504
+ }
2505
+ }
2506
+ schedule(tool) {
2507
+ if (this.stopped) return;
2508
+ clearTimeout(this.timers.get(tool));
2509
+ this.timers.set(tool, setTimeout(() => void this.update(tool), DEBOUNCE_MS));
2510
+ }
2511
+ async update(tool) {
2512
+ const now = /* @__PURE__ */ new Date();
2513
+ let snap = null;
2514
+ try {
2515
+ if (tool === "codex") {
2516
+ snap = codexSnapshot(await readCodexReadings(this.opts.home), now, this.codexQuery);
2517
+ if (snap && snap.resetCredits === null && this.codexQuery) snap.resetCredits = this.codexQuery.resetCredits;
2518
+ } else if (tool === "claude-code") {
2519
+ snap = await this.claude(now);
2520
+ }
2521
+ } catch (e) {
2522
+ console.error("[harlay] usage update failed", e);
2523
+ return;
2524
+ }
2525
+ if (snap) this.snapshots[tool] = snap;
2526
+ else delete this.snapshots[tool];
2527
+ this.emit("update", this.snapshots);
2528
+ if (snap) this.checkAlerts(snap, now);
2529
+ }
2530
+ async claude(now) {
2531
+ await this.claudeLog.refresh(now);
2532
+ let exact = null;
2533
+ try {
2534
+ const s = await (0, import_promises15.stat)(this.statusFile);
2535
+ exact = parseClaudeStatusline(JSON.parse(await (0, import_promises15.readFile)(this.statusFile, "utf8")), s.mtimeMs);
2536
+ } catch {
2537
+ exact = null;
2538
+ }
2539
+ const entries = this.claudeLog.entries();
2540
+ const hits = this.claudeLog.limitHits();
2541
+ const merged = mergeSamples(this.samples, capacitySamples(entries, hits, exact), now);
2542
+ if (JSON.stringify(merged) !== JSON.stringify(this.samples)) {
2543
+ this.samples = merged;
2544
+ await (0, import_promises15.writeFile)(this.samplesFile, JSON.stringify(merged)).catch(() => void 0);
2545
+ }
2546
+ return claudeSnapshot({ entries, hits, exact, samples: this.samples, now });
2547
+ }
2548
+ checkAlerts(s, now) {
2549
+ const threshold = this.opts.threshold();
2550
+ const fire = (key, alert) => {
2551
+ if (this.alerted.has(key)) return;
2552
+ this.alerted.add(key);
2553
+ this.emit("alert", alert);
2554
+ };
2555
+ const resetKey = (w) => w.resetsAt ? Math.round(w.resetsAt.getTime() / 6e5) : "none";
2556
+ for (const w of s.windows) {
2557
+ if (w.usedPercent >= 100 || w.usedPercent < threshold) continue;
2558
+ fire(`${s.tool}|${w.kind}|${resetKey(w)}|level`, {
2559
+ tool: s.tool,
2560
+ title: `${NAME[s.tool]} is at ${w.exact ? "" : "\u2248"}${Math.round(w.usedPercent)}% of its ${w.label === "5h" ? "5-hour" : "weekly"} limit`,
2561
+ body: w.resetsAt ? `Resets ${clock2(w.resetsAt, now)}. Click to hand off the latest ${NAME[s.tool]} session.` : `Click to hand off the latest ${NAME[s.tool]} session.`
2562
+ });
2563
+ }
2564
+ const full = fullestWindow(s);
2565
+ if (full && s.minutesToLimit !== null && s.minutesToLimit <= ETA_WARN_MIN && full.usedPercent >= ETA_WARN_PCT && full.usedPercent < threshold) {
2566
+ fire(`${s.tool}|${full.kind}|${resetKey(full)}|pace`, {
2567
+ tool: s.tool,
2568
+ title: `${NAME[s.tool]} may hit its limit in about ${Math.max(1, Math.round(s.minutesToLimit))} min`,
2569
+ body: `At the current pace. It's at ${full.exact ? "" : "\u2248"}${Math.round(full.usedPercent)}% of its ${full.label === "5h" ? "5-hour" : "weekly"} limit.`
2570
+ });
2571
+ }
2572
+ }
2573
+ };
2574
+
1850
2575
  // src/main.ts
1851
2576
  var MAX_LIVE_NOTIFICATIONS = 50;
1852
2577
  var AUTO_HANDOFF_COOLDOWN_MS = 2 * 6e4;
@@ -1862,6 +2587,8 @@ var tray = null;
1862
2587
  var popover = null;
1863
2588
  var popoverHiddenAt = 0;
1864
2589
  var watcher = null;
2590
+ var usage = null;
2591
+ var claudeBridge = "off";
1865
2592
  var lastAutoHandoffAt = 0;
1866
2593
  var previewedBriefs = /* @__PURE__ */ new Map();
1867
2594
  var errorMessage = (e) => e instanceof Error ? e.message : String(e);
@@ -1881,7 +2608,11 @@ function toElectron(nodes) {
1881
2608
  }
1882
2609
  function refreshUi() {
1883
2610
  if (!popover || popover.isDestroyed()) return;
1884
- popover.webContents.send("harlay:model", buildPopoverModel(recentSessions(state, 8), settings, available, handoffsInFlight, /* @__PURE__ */ new Date()));
2611
+ const now = /* @__PURE__ */ new Date();
2612
+ const rows = buildUsageRows(usage?.current() ?? {}, available, claudeBridge, now);
2613
+ const claudeInstalled = available["claude-code"].app || available["claude-code"].terminal;
2614
+ const ask = !settings.claudeBridgeAsked && claudeBridge === "off" && claudeInstalled;
2615
+ popover.webContents.send("harlay:model", buildPopoverModel(recentSessions(state, 8), settings, available, handoffsInFlight, now, rows, claudeBridge, ask));
1885
2616
  }
1886
2617
  function nativeMenu() {
1887
2618
  return import_electron.Menu.buildFromTemplate(toElectron(buildMenuModel(recentSessions(state, 8), settings, available)));
@@ -1946,14 +2677,14 @@ async function previewBrief(ref) {
1946
2677
  path = res.written.latestPath;
1947
2678
  }
1948
2679
  previewedBriefs.set(key, path);
1949
- send({ path, text: await (0, import_promises12.readFile)(path, "utf8") });
2680
+ send({ path, text: await (0, import_promises16.readFile)(path, "utf8") });
1950
2681
  } catch (e) {
1951
2682
  send({ error: errorMessage(e) });
1952
2683
  }
1953
2684
  }
1954
2685
  async function copyBrief(key) {
1955
2686
  const path = previewedBriefs.get(key);
1956
- if (path) import_electron.clipboard.writeText(await (0, import_promises12.readFile)(path, "utf8"));
2687
+ if (path) import_electron.clipboard.writeText(await (0, import_promises16.readFile)(path, "utf8"));
1957
2688
  }
1958
2689
  async function persist() {
1959
2690
  await saveSettings(settingsFile, settings);
@@ -2002,6 +2733,24 @@ async function onAction(a) {
2002
2733
  if (import_electron.app.isPackaged) import_electron.app.setLoginItemSettings({ openAtLogin: settings.openAtLogin });
2003
2734
  await persist();
2004
2735
  break;
2736
+ case "toggleClaudeBridge":
2737
+ case "answerClaudeBridge": {
2738
+ const on = a.type === "answerClaudeBridge" ? a.allow : claudeBridge !== "on";
2739
+ settings.claudeBridgeAsked = true;
2740
+ await saveSettings(settingsFile, settings);
2741
+ try {
2742
+ if (on) await installBridge(home, dataDir());
2743
+ else if (a.type === "toggleClaudeBridge") await removeBridge(home, dataDir());
2744
+ } catch (e) {
2745
+ notify("Harlay could not change Claude Code settings", errorMessage(e));
2746
+ }
2747
+ claudeBridge = await bridgeState(home);
2748
+ refreshUi();
2749
+ break;
2750
+ }
2751
+ case "openCursorUsage":
2752
+ await import_electron.shell.openExternal("https://cursor.com/dashboard?tab=usage");
2753
+ break;
2005
2754
  case "quit":
2006
2755
  import_electron.app.quit();
2007
2756
  break;
@@ -2024,33 +2773,36 @@ function onWatchEvent(ev) {
2024
2773
  void handoff(ev.ref, first);
2025
2774
  return;
2026
2775
  }
2027
- const n2 = new import_electron.Notification({
2776
+ const n = new import_electron.Notification({
2028
2777
  title: `${TOOL_LABEL[ev.ref.tool]} stopped: ${name}`,
2029
2778
  body: haltNotificationBody(tracked, first),
2030
2779
  actions: targets.map((t) => ({ type: "button", text: `Continue in ${TOOL_LABEL[t]}` }))
2031
2780
  });
2032
- keep(n2);
2033
- n2.on("action", (_e, index) => {
2781
+ keep(n);
2782
+ n.on("action", (_e, index) => {
2034
2783
  const t = targets[index];
2035
2784
  if (t) void handoff(ev.ref, t);
2036
2785
  });
2037
- n2.on("click", () => {
2786
+ n.on("click", () => {
2038
2787
  if (first) void handoff(ev.ref, first);
2039
2788
  });
2040
- n2.show();
2789
+ n.show();
2041
2790
  return;
2042
2791
  }
2043
- if (ev.event.usage.usedPercent >= 100) return;
2044
- const n = new import_electron.Notification({
2045
- title: `${TOOL_LABEL[ev.ref.tool]} is at ${Math.round(ev.event.usage.usedPercent)}% of its limit`,
2046
- body: first ? `${name}. Click to hand off to ${TOOL_LABEL[first]} now.` : name
2047
- });
2792
+ }
2793
+ function onUsageAlert(a) {
2794
+ const latest = recentSessions(state, 50).find((t) => t.ref.tool === a.tool && t.status !== "handed_off");
2795
+ const [target] = latest ? pickTargets(a.tool, settings.defaultTarget, available, settings.modes, state.limitedUntil, /* @__PURE__ */ new Date()) : [];
2796
+ const n = new import_electron.Notification({ title: a.title, body: latest && target ? a.body : a.body.replace(/ ?Click to hand off.*$/, "") });
2048
2797
  keep(n);
2049
2798
  n.on("click", () => {
2050
- if (first) void handoff(ev.ref, first);
2799
+ if (latest && target) void handoff(latest.ref, target);
2051
2800
  });
2052
2801
  n.show();
2053
2802
  }
2803
+ function dataDir() {
2804
+ return import_electron.app.getPath("userData");
2805
+ }
2054
2806
  function onHotkey() {
2055
2807
  const latest = latestSession(state);
2056
2808
  if (!latest) {
@@ -2088,13 +2840,13 @@ function createPopover() {
2088
2840
  fullscreenable: false,
2089
2841
  skipTaskbar: true,
2090
2842
  alwaysOnTop: true,
2091
- webPreferences: { preload: (0, import_node_path14.join)(__dirname, "preload.cjs"), contextIsolation: true, nodeIntegration: false, sandbox: true }
2843
+ webPreferences: { preload: (0, import_node_path18.join)(__dirname, "preload.cjs"), contextIsolation: true, nodeIntegration: false, sandbox: true }
2092
2844
  });
2093
2845
  win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
2094
2846
  win.on("blur", hidePopover);
2095
2847
  win.webContents.on("will-navigate", (e) => e.preventDefault());
2096
2848
  win.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
2097
- void win.loadFile((0, import_node_path14.join)(__dirname, "..", "renderer", "popover.html"));
2849
+ void win.loadFile((0, import_node_path18.join)(__dirname, "..", "renderer", "popover.html"));
2098
2850
  return win;
2099
2851
  }
2100
2852
  function showPopover() {
@@ -2104,6 +2856,7 @@ function showPopover() {
2104
2856
  const x = Math.min(Math.max(t.x + t.width / 2 - POPOVER_WIDTH / 2, area.x), area.x + area.width - POPOVER_WIDTH);
2105
2857
  popover.setPosition(Math.round(x), Math.round(t.y + t.height));
2106
2858
  refreshUi();
2859
+ void usage?.topUpCodex();
2107
2860
  popover.show();
2108
2861
  popover.focus();
2109
2862
  popover.webContents.send("harlay:shown");
@@ -2142,7 +2895,7 @@ function listenToPopover() {
2142
2895
  });
2143
2896
  import_electron.ipcMain.on("harlay:action", (e, action) => {
2144
2897
  if (!fromPopover(e) || !isMenuAction(action)) return;
2145
- if (action.type === "handoff" || action.type === "openBrief") hidePopover();
2898
+ if (action.type === "handoff" || action.type === "openBrief" || action.type === "openCursorUsage") hidePopover();
2146
2899
  void onAction(action);
2147
2900
  });
2148
2901
  }
@@ -2155,12 +2908,12 @@ if (!import_electron.app.requestSingleInstanceLock()) {
2155
2908
  import_electron.app.on("second-instance", revealPopover);
2156
2909
  import_electron.app.whenReady().then(async () => {
2157
2910
  import_electron.app.dock?.hide();
2158
- settingsFile = (0, import_node_path14.join)(import_electron.app.getPath("userData"), "settings.json");
2911
+ settingsFile = (0, import_node_path18.join)(import_electron.app.getPath("userData"), "settings.json");
2159
2912
  settings = await loadSettings(settingsFile);
2160
2913
  import_electron.nativeTheme.themeSource = settings.theme;
2161
2914
  if (import_electron.app.isPackaged) import_electron.app.setLoginItemSettings({ openAtLogin: settings.openAtLogin });
2162
2915
  available = await availableTargets(defaultResolveOptions());
2163
- const iconPath = (0, import_node_path14.join)(__dirname, "..", "assets", "trayTemplate.png");
2916
+ const iconPath = (0, import_node_path18.join)(__dirname, "..", "assets", "trayTemplate.png");
2164
2917
  const icon = import_electron.nativeImage.createFromPath(iconPath);
2165
2918
  icon.setTemplateImage(true);
2166
2919
  tray = new import_electron.Tray(icon);
@@ -2175,11 +2928,25 @@ if (!import_electron.app.requestSingleInstanceLock()) {
2175
2928
  listenToPopover();
2176
2929
  popover = createPopover();
2177
2930
  await refreshSessions();
2178
- setInterval(() => void refreshSessions(), 6e4);
2931
+ setInterval(() => {
2932
+ void refreshSessions();
2933
+ void usage?.refresh();
2934
+ }, 6e4);
2179
2935
  watcher = new Watcher(readers, { usageThreshold: settings.usageThreshold });
2180
2936
  watcher.on("event", onWatchEvent);
2181
2937
  watcher.on("error", (e) => console.error("[harlay] watcher error", e));
2182
2938
  await watcher.start();
2939
+ usage = new UsageMonitor({
2940
+ home,
2941
+ dataDir: dataDir(),
2942
+ codexBin: () => resolveBinary("codex", defaultResolveOptions()),
2943
+ threshold: () => settings.usageThreshold
2944
+ });
2945
+ usage.on("update", refreshUi);
2946
+ usage.on("alert", onUsageAlert);
2947
+ claudeBridge = await bridgeState(home);
2948
+ if (claudeBridge === "on") await installBridge(home, dataDir()).catch((e) => console.error("[harlay] status line refresh failed", e));
2949
+ await usage.start().catch((e) => console.error("[harlay] usage monitor failed to start", e));
2183
2950
  if (!import_electron.globalShortcut.register(HOTKEY.accelerator, onHotkey)) console.error(`[harlay] could not register ${HOTKEY.accelerator}`);
2184
2951
  if (!import_electron.app.getLoginItemSettings().wasOpenedAtLogin) revealPopover();
2185
2952
  }).catch((e) => {
@@ -2190,5 +2957,6 @@ if (!import_electron.app.requestSingleInstanceLock()) {
2190
2957
  import_electron.app.on("will-quit", () => {
2191
2958
  import_electron.globalShortcut.unregisterAll();
2192
2959
  watcher?.stop();
2960
+ usage?.stop();
2193
2961
  });
2194
2962
  }