claude-discover 1.10.5 → 1.10.6

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.
@@ -10,7 +10,7 @@ import { styleText } from 'node:util'
10
10
  // Formatting
11
11
  const THRESHOLDS = { // (white, yellow, red)
12
12
  context: [0, 100_000, 250_000],
13
- limit: [0, 60, 90],
13
+ limit: [0, 60, 90], // keep in sync with src/renderer/utils/thresholds.js
14
14
  cache: [90, 60, 0],
15
15
  tokens: [0, 250_000, 1_000_000],
16
16
  session: [0, 3_000_000, 8_000_000],
package/out/main/main.js CHANGED
@@ -1,4 +1,4 @@
1
- import { app, dialog, BrowserWindow, shell, nativeTheme, Menu, ipcMain } from "electron";
1
+ import { app, shell, dialog, BrowserWindow, nativeTheme, Menu, ipcMain } from "electron";
2
2
  import { EventEmitter } from "node:events";
3
3
  import os, { homedir } from "node:os";
4
4
  import path, { join, dirname, basename } from "node:path";
@@ -6,12 +6,12 @@ import fs from "node:fs";
6
6
  import { sync } from "write-file-atomic";
7
7
  import fsp from "node:fs/promises";
8
8
  import crypto from "node:crypto";
9
+ import { pathToFileURL, fileURLToPath } from "node:url";
9
10
  import which from "which";
10
11
  import { execFile, spawn } from "node:child_process";
11
12
  import { promisify } from "node:util";
12
13
  import latestVersion from "latest-version";
13
14
  import semver from "semver";
14
- import { pathToFileURL, fileURLToPath } from "node:url";
15
15
  import windowStateKeeper from "electron-window-state";
16
16
  import contextMenu from "electron-context-menu";
17
17
  import { FindOverlay } from "electron-find-overlay";
@@ -680,21 +680,16 @@ function extractText(content) {
680
680
  if (!Array.isArray(content)) return "";
681
681
  return content.map((b) => typeof b === "string" ? b : b?.type === "text" ? b.text : "").filter(Boolean).join("\n");
682
682
  }
683
- function shortProject(dir, depth = 2) {
683
+ function shortProject(dir, depth) {
684
684
  if (!dir) return "(no project)";
685
685
  const parts = dir.replace(/\\/g, "/").split("/").filter(Boolean);
686
686
  return parts.slice(-depth).join("/") || dir;
687
687
  }
688
- const TEMP_ROOT = /^(.*?\/AppData\/Local\/Temp|\/(?:private\/)?(?:tmp|var\/folders\/[^/]+\/[^/]+\/T))\/(.+)/i;
689
- function tempProject(dir) {
690
- const m = dir?.replace(/\\/g, "/").match(TEMP_ROOT);
691
- return m ? { project: dir.slice(0, m[1].length), tag: m[2] } : null;
692
- }
693
- function suffixLabels(dirs, depth = 2) {
694
- const labels = new Map([...new Set(dirs)].map((dir) => [dir, shortProject(dir, depth)]));
688
+ function suffixLabels(dirs) {
689
+ const labels = new Map([...new Set(dirs)].map((dir) => [dir, shortProject(dir, 1)]));
695
690
  const counts = /* @__PURE__ */ new Map();
696
691
  for (const label of labels.values()) counts.set(label, (counts.get(label) ?? 0) + 1);
697
- for (const [dir, label] of labels) if (counts.get(label) > 1) labels.set(dir, shortProject(dir, depth + 1));
692
+ for (const [dir, label] of labels) if (counts.get(label) > 1) labels.set(dir, shortProject(dir, 2));
698
693
  return labels;
699
694
  }
700
695
  function classifySource(meta) {
@@ -715,10 +710,6 @@ function freshMeta({ sessionId, parentSessionId, filePath, parentFilePath, fileS
715
710
  continuesTo: null,
716
711
  entrypoint: null,
717
712
  project: null,
718
- worktree: null,
719
- worktreePath: null,
720
- tempPath: null,
721
- tempTag: null,
722
713
  gitBranch: null,
723
714
  version: null,
724
715
  model: null,
@@ -937,19 +928,6 @@ class SessionParser {
937
928
  if (meta.startedAt == null) meta.startedAt = mtimeFallback;
938
929
  if (meta.lastActivityAt == null) meta.lastActivityAt = mtimeFallback;
939
930
  meta.source = classifySource(meta);
940
- const wt = meta.project && meta.project.replace(/\\/g, "/").match(/\/\.claude\/worktrees\/([^/]+)/);
941
- if (wt) {
942
- meta.worktree = wt[1];
943
- meta.worktreePath = meta.project.slice(0, wt.index + wt[0].length);
944
- meta.project = meta.project.slice(0, wt.index);
945
- }
946
- const tmp = tempProject(meta.project);
947
- if (tmp) {
948
- meta.tempPath = meta.project;
949
- meta.tempTag = tmp.tag;
950
- meta.project = tmp.project;
951
- }
952
- meta.projectShort = shortProject(meta.project);
953
931
  meta.activeMs = meta.activityPeriods.reduce((sum, p) => sum + Math.max(0, p.end - p.start), 0);
954
932
  const t = meta.tokens;
955
933
  meta.totalTokens = t.input + t.output + t.cacheRead + t.cacheCreation;
@@ -1235,6 +1213,52 @@ class MetaCache {
1235
1213
  return this.periodMetas.delete(filePath);
1236
1214
  }
1237
1215
  }
1216
+ const WORKTREE = /\/\.claude\/worktrees\/([^/]+)/;
1217
+ const TEMP_ROOT = /^(.*?\/AppData\/Local\/Temp|\/(?:private\/)?(?:tmp|var\/folders\/[^/]+\/[^/]+\/T))\/(.+)/i;
1218
+ const WINDOWS = process.platform === "win32";
1219
+ const LOCAL_DIR = WINDOWS ? /^[a-z]:[\\/]/i : /^\//;
1220
+ class ProjectResolver {
1221
+ #repoRoots = /* @__PURE__ */ new Map();
1222
+ // `subdir` is where in the project the session ran: a repo or worktree subfolder, or a temp run dir
1223
+ async resolve(cwd) {
1224
+ const out = { project: cwd, subdir: null, worktree: null, worktreePath: null, tempPath: null };
1225
+ const wt = cwd?.replace(/\\/g, "/").match(WORKTREE);
1226
+ if (wt) {
1227
+ out.worktree = wt[1];
1228
+ out.worktreePath = cwd.slice(0, wt.index + wt[0].length);
1229
+ out.subdir = cwd.slice(out.worktreePath.length + 1).replace(/\\/g, "/") || null;
1230
+ out.project = cwd.slice(0, wt.index);
1231
+ }
1232
+ const tmp = out.project?.replace(/\\/g, "/").match(TEMP_ROOT);
1233
+ if (tmp) {
1234
+ out.tempPath = out.project;
1235
+ out.subdir = tmp[2];
1236
+ out.project = out.project.slice(0, tmp[1].length);
1237
+ return out;
1238
+ }
1239
+ const root = await this.repoRoot(out.project);
1240
+ if (root && root !== out.project) {
1241
+ out.subdir = [path.relative(root, out.project).replaceAll("\\", "/"), out.subdir].filter(Boolean).join("/");
1242
+ out.project = root;
1243
+ }
1244
+ return out;
1245
+ }
1246
+ // The nearest ancestor (or dir itself) holding a .git — a dir, or a file in worktrees/submodules — else null
1247
+ repoRoot(dir) {
1248
+ if (!LOCAL_DIR.test(dir ?? "")) return Promise.resolve(null);
1249
+ if (!this.#repoRoots.has(dir)) this.#repoRoots.set(dir, this.#findRepoRoot(dir));
1250
+ return this.#repoRoots.get(dir);
1251
+ }
1252
+ async #findRepoRoot(dir) {
1253
+ const home = os.homedir();
1254
+ const isHome = WINDOWS ? (d) => d.toLowerCase() === home.toLowerCase() : (d) => d === home;
1255
+ for (let d = dir; ; d = path.dirname(d)) {
1256
+ if (isHome(d)) return null;
1257
+ if (await fsp.stat(path.join(d, ".git")).catch(() => null)) return d;
1258
+ if (path.dirname(d) === d) return null;
1259
+ }
1260
+ }
1261
+ }
1238
1262
  const seed = {
1239
1263
  "claude-opus-5": { "input": 5, "output": 25, "cacheRead": 0.5, "cacheWrite": 6.25, "cacheWrite1h": 10, "fastModeMplr": 2 },
1240
1264
  "claude-fable-5-1": { "input": 10, "output": 50, "cacheRead": 0.25, "cacheWrite": 12.5, "cacheWrite1h": 20 },
@@ -1381,6 +1405,7 @@ class SessionsService extends EventEmitter {
1381
1405
  this.pricing = new Pricing();
1382
1406
  this.throttleMs = throttleMs;
1383
1407
  this.metaCache = new MetaCache();
1408
+ this.projects = new ProjectResolver();
1384
1409
  this.activeDay = null;
1385
1410
  this.scanAbort = null;
1386
1411
  this.updateTimer = null;
@@ -1453,7 +1478,10 @@ class SessionsService extends EventEmitter {
1453
1478
  start() {
1454
1479
  this.pricing.on("update", () => this._reprice());
1455
1480
  this.scanner.watch({
1456
- onChange: (p, stat) => this._refresh(p, stat),
1481
+ onChange: (p, stat) => {
1482
+ this.emit("activity");
1483
+ this._refresh(p, stat);
1484
+ },
1457
1485
  onUnlink: (p) => this._evict(p)
1458
1486
  });
1459
1487
  return this;
@@ -1484,7 +1512,8 @@ class SessionsService extends EventEmitter {
1484
1512
  pricing: this.pricing
1485
1513
  });
1486
1514
  await reader.stream((obj) => parser.feed(obj));
1487
- return parser.finalize(stat.mtimeMs);
1515
+ const meta = parser.finalize(stat.mtimeMs);
1516
+ return Object.assign(meta, await this.projects.resolve(meta.project));
1488
1517
  }
1489
1518
  // Resume/fork copies prior message.ids verbatim. Walk earliest-first; if a
1490
1519
  // session's ids overlap one already counted, rescan it with the overlap
@@ -1506,7 +1535,7 @@ class SessionsService extends EventEmitter {
1506
1535
  return stripInternal(rescanned ? { ...m, ...rescanned } : m);
1507
1536
  }));
1508
1537
  const labels = suffixLabels(sessions.map((m) => m.project));
1509
- for (const m of sessions) m.projectShort = labels.get(m.project);
1538
+ for (const m of sessions) m.projectShort = m.tempPath ? "Temp" : labels.get(m.project);
1510
1539
  return sessions;
1511
1540
  }
1512
1541
  _dedupedDay(day) {
@@ -1571,6 +1600,53 @@ class WorkHours {
1571
1600
  return data;
1572
1601
  }
1573
1602
  }
1603
+ const VIEWABLE_EXT = /* @__PURE__ */ new Set([".md", ".txt", ".json", ".jsonl", ".log", ".csv", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".pdf"]);
1604
+ async function openLinkSafely(href, baseFile) {
1605
+ if (/^(https?:|mailto:)/i.test(href)) return shell.openExternal(href);
1606
+ if (!baseFile) return;
1607
+ let decoded;
1608
+ try {
1609
+ decoded = decodeURIComponent(href);
1610
+ } catch {
1611
+ return;
1612
+ }
1613
+ const baseDir = path.dirname(path.resolve(baseFile));
1614
+ const abs = path.resolve(baseDir, decoded);
1615
+ const rel = path.relative(baseDir, abs);
1616
+ if (rel.startsWith("..") || path.isAbsolute(rel)) return;
1617
+ if (!VIEWABLE_EXT.has(path.extname(abs).toLowerCase())) return shell.showItemInFolder(abs);
1618
+ const err = await shell.openPath(abs);
1619
+ if (err) shell.showItemInFolder(abs);
1620
+ return err;
1621
+ }
1622
+ const DEV_ORIGIN = process.env.ELECTRON_RENDERER_URL && new URL(process.env.ELECTRON_RENDERER_URL).origin;
1623
+ const APP_PAGES = pathToFileURL(path.join(import.meta.dirname, "../renderer/")).href;
1624
+ function lockNavigation(contents) {
1625
+ const toBrowser = (url) => /^(https?|mailto):/i.test(url) && shell.openExternal(url);
1626
+ const block = (e, url) => {
1627
+ const { href, origin } = new URL(url);
1628
+ if (href.startsWith(APP_PAGES) || origin === DEV_ORIGIN) return;
1629
+ e.preventDefault();
1630
+ toBrowser(url);
1631
+ };
1632
+ contents.setWindowOpenHandler(({ url }) => {
1633
+ toBrowser(url);
1634
+ return { action: "deny" };
1635
+ });
1636
+ contents.on("will-navigate", block);
1637
+ contents.on("will-redirect", block);
1638
+ }
1639
+ async function withTimeout(promise, ms, cleanup) {
1640
+ const { promise: timeout, reject } = Promise.withResolvers();
1641
+ const timer = setTimeout(reject, ms, new Error(`timed out after ${ms / 1e3}s`));
1642
+ try {
1643
+ return await Promise.race([promise, timeout]);
1644
+ } finally {
1645
+ clearTimeout(timer);
1646
+ cleanup?.();
1647
+ }
1648
+ }
1649
+ const USAGE_TIMEOUT = 3e4;
1574
1650
  class AgentRunner {
1575
1651
  async run(text, sender) {
1576
1652
  const { query } = await import("@anthropic-ai/claude-agent-sdk");
@@ -1597,6 +1673,14 @@ class AgentRunner {
1597
1673
  }
1598
1674
  return { code: 0 };
1599
1675
  }
1676
+ // The structured /usage data, from a session that's closed whether it answers, fails or hangs
1677
+ async usage() {
1678
+ const { query } = await import("@anthropic-ai/claude-agent-sdk");
1679
+ const q = query({ prompt: (async function* () {
1680
+ })(), options: { tools: [], settingSources: [] } });
1681
+ const usage = q.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET({ skipBehaviors: true });
1682
+ return withTimeout(usage, USAGE_TIMEOUT, () => q.close());
1683
+ }
1600
1684
  }
1601
1685
  const mtimeOf = (path2) => fs.statSync(path2, { throwIfNoEntry: false })?.mtimeMs;
1602
1686
  class ClaudeSettings {
@@ -2000,41 +2084,65 @@ class AutoUpdate {
2000
2084
  return semver.gt(latest, current) ? { current, latest } : null;
2001
2085
  }
2002
2086
  }
2003
- const VIEWABLE_EXT = /* @__PURE__ */ new Set([".md", ".txt", ".json", ".jsonl", ".log", ".csv", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".pdf"]);
2004
- async function openLinkSafely(href, baseFile) {
2005
- if (/^(https?:|mailto:)/i.test(href)) return shell.openExternal(href);
2006
- if (!baseFile) return;
2007
- let decoded;
2008
- try {
2009
- decoded = decodeURIComponent(href);
2010
- } catch {
2011
- return;
2087
+ const EVERY = 6e4;
2088
+ const toWindow = (w) => w?.utilization == null ? null : {
2089
+ utilization: w.utilization,
2090
+ // 0..100
2091
+ resetsAt: w.resets_at ? Math.round(Date.parse(w.resets_at) / 6e4) * 6e4 : null
2092
+ // the server sends 16:29:59.72 for 16:30
2093
+ };
2094
+ const toExtra = (e) => e && {
2095
+ on: e.is_enabled,
2096
+ reason: !e.is_enabled && !e.user_disabled ? e.disabled_reason : null,
2097
+ // e.g. 'out_of_credits'
2098
+ used: e.used_credits,
2099
+ limit: e.monthly_limit,
2100
+ currency: e.currency,
2101
+ decimals: e.decimal_places ?? 2
2102
+ };
2103
+ class RateLimits {
2104
+ #agentRunner;
2105
+ #limits = null;
2106
+ // the latest fetch's promise, shared by concurrent callers
2107
+ #fetchedAt = 0;
2108
+ #active = false;
2109
+ // a transcript changed since the last fetch
2110
+ constructor(agentRunner) {
2111
+ this.#agentRunner = agentRunner;
2112
+ }
2113
+ // A transcript changed
2114
+ markActive() {
2115
+ this.#active = true;
2116
+ }
2117
+ // Refetched once over a minute old; with `ifActive`, only if Claude Code wrote a transcript since
2118
+ get({ ifActive = false } = {}) {
2119
+ if (Date.now() - this.#fetchedAt >= EVERY && (this.#active || !ifActive)) {
2120
+ this.#fetchedAt = Date.now();
2121
+ this.#active = false;
2122
+ this.#limits = this.#fetch();
2123
+ }
2124
+ return this.#limits;
2125
+ }
2126
+ async #fetch() {
2127
+ try {
2128
+ const { subscription_type: plan, rate_limits: rl } = await this.#agentRunner.usage();
2129
+ return rl && {
2130
+ at: Date.now(),
2131
+ plan,
2132
+ // 'pro' | 'max' | 'team' | 'enterprise'
2133
+ fiveHour: toWindow(rl.five_hour),
2134
+ sevenDay: toWindow(rl.seven_day),
2135
+ models: (rl.model_scoped ?? []).filter((m) => m.utilization != null).map((m) => ({ name: m.display_name, ...toWindow(m) })),
2136
+ // per-model weekly caps
2137
+ sources: (rl.seven_day_breakdown?.rows ?? []).filter((r) => r.percent > 0).map((r) => ({ name: r.display_name, pct: r.percent })),
2138
+ // undocumented: Claude Code / Chats / Cowork shares of the week
2139
+ extra: toExtra(rl.extra_usage)
2140
+ };
2141
+ } catch (err) {
2142
+ console.warn("[rate-limits]", err.message);
2143
+ return null;
2144
+ }
2012
2145
  }
2013
- const baseDir = path.dirname(path.resolve(baseFile));
2014
- const abs = path.resolve(baseDir, decoded);
2015
- const rel = path.relative(baseDir, abs);
2016
- if (rel.startsWith("..") || path.isAbsolute(rel)) return;
2017
- if (!VIEWABLE_EXT.has(path.extname(abs).toLowerCase())) return shell.showItemInFolder(abs);
2018
- const err = await shell.openPath(abs);
2019
- if (err) shell.showItemInFolder(abs);
2020
- return err;
2021
- }
2022
- const DEV_ORIGIN = process.env.ELECTRON_RENDERER_URL && new URL(process.env.ELECTRON_RENDERER_URL).origin;
2023
- const APP_PAGES = pathToFileURL(path.join(import.meta.dirname, "../renderer/")).href;
2024
- function lockNavigation(contents) {
2025
- const toBrowser = (url) => /^(https?|mailto):/i.test(url) && shell.openExternal(url);
2026
- const block = (e, url) => {
2027
- const { href, origin } = new URL(url);
2028
- if (href.startsWith(APP_PAGES) || origin === DEV_ORIGIN) return;
2029
- e.preventDefault();
2030
- toBrowser(url);
2031
- };
2032
- contents.setWindowOpenHandler(({ url }) => {
2033
- toBrowser(url);
2034
- return { action: "deny" };
2035
- });
2036
- contents.on("will-navigate", block);
2037
- contents.on("will-redirect", block);
2038
2146
  }
2039
2147
  const __dirname$1 = path.dirname(fileURLToPath(import.meta.url));
2040
2148
  const DEV_URL = process.env.ELECTRON_RENDERER_URL;
@@ -2072,6 +2180,7 @@ class MainWindow {
2072
2180
  this.findBar = new FindOverlay(this.win);
2073
2181
  this.findBar.on("show", () => this.send("find:active", true));
2074
2182
  this.findBar.on("hide", () => this.send("find:active", false));
2183
+ this.win.on("closed", () => this.findBar = null);
2075
2184
  return this.win;
2076
2185
  }
2077
2186
  send(channel, payload) {
@@ -2092,13 +2201,15 @@ class Application {
2092
2201
  this.workHours = new WorkHours();
2093
2202
  this.sessionsService = new SessionsService();
2094
2203
  this.autoUpdate = new AutoUpdate();
2204
+ this.rateLimits = new RateLimits(this.agentRunner);
2095
2205
  this.switchers = new Switchers({ restart: () => this.restart() });
2096
2206
  }
2097
2207
  start() {
2098
- const { deepLink, win, agentRunner, workHours, sessionsService, autoUpdate, switchers } = this;
2208
+ const { deepLink, win, agentRunner, workHours, sessionsService, autoUpdate, rateLimits, switchers } = this;
2099
2209
  Menu.setApplicationMenu(this.#buildMenu());
2100
2210
  sessionsService.on("update", (update) => win.send("sessions:update", update));
2101
2211
  sessionsService.on("progress", (p) => win.send("sessions:scan-progress", p));
2212
+ sessionsService.on("activity", () => rateLimits.markActive());
2102
2213
  deepLink?.on("open", (target) => {
2103
2214
  win.focus();
2104
2215
  win.send("deeplink:open-session", target);
@@ -2114,6 +2225,7 @@ class Application {
2114
2225
  ipcMain.handle("work-hours:set", (_e, data) => workHours.write(data));
2115
2226
  ipcMain.handle("auto-update:check", () => autoUpdate.check());
2116
2227
  ipcMain.handle("auto-update:install", () => autoUpdate.install());
2228
+ ipcMain.handle("rate-limits:get", (_e, opts) => rateLimits.get(opts));
2117
2229
  ipcMain.handle("agent:run", (e, text) => agentRunner.run(text, e.sender));
2118
2230
  ipcMain.handle("shell:open-link", (_e, href, baseFile) => openLinkSafely(href, baseFile));
2119
2231
  ipcMain.handle("deeplink:take-pending", () => deepLink?.takePending() ?? null);
@@ -2170,6 +2282,8 @@ class Application {
2170
2282
  { role: "paste" },
2171
2283
  { role: "selectAll" },
2172
2284
  { label: "Find…", accelerator: "CmdOrCtrl+F", click: () => win.findBar?.show() },
2285
+ { label: "Find Next", accelerator: "CmdOrCtrl+G", click: () => win.findBar?.next() },
2286
+ { label: "Find Previous", accelerator: "CmdOrCtrl+Shift+G", click: () => win.findBar?.prev() },
2173
2287
  { label: "Deselect", accelerator: "Escape", click: (_i, w) => win.findBar?.visible ? win.findBar.hide() : w?.webContents.unselect() }
2174
2288
  ]
2175
2289
  },
@@ -24,6 +24,7 @@ electron.contextBridge.exposeInMainWorld("api", {
24
24
  onAgentOutput: subscribe("agent:output"),
25
25
  checkUpdate: () => electron.ipcRenderer.invoke("auto-update:check"),
26
26
  installUpdate: () => electron.ipcRenderer.invoke("auto-update:install"),
27
+ getRateLimits: (opts) => electron.ipcRenderer.invoke("rate-limits:get", opts),
27
28
  openLink: (href, baseFile) => electron.ipcRenderer.invoke("shell:open-link", href, baseFile),
28
29
  onFindActive: subscribe("find:active"),
29
30
  findClose: () => electron.ipcRenderer.send("find:close"),