claude-discover 1.10.1 → 1.10.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.
@@ -1,124 +1,125 @@
1
- #!/usr/bin/env node
2
- // Claude Code status line, installed by StatuslineSwitch. Reads the hook JSON from stdin and
3
- // prints one colored line — model, context usage, token totals, rate-limit windows:
4
- // [Opus 4.8] Context: ▓▓▓░░░░░░░ 32% used (45.2k, 87% cached) | Tokens: 1.2M total (+45.2k, 3 turns) | Usage limit: 42% used (resets in 2h 30m)
5
-
6
- import { readFileSync } from 'node:fs'
7
- import { styleText } from 'node:util'
8
-
9
-
10
- // Formatting
11
- const THRESHOLDS = { // (white, yellow, red)
12
- context: [0, 100_000, 160_000],
13
- limit: [0, 60, 90],
14
- cache: [90, 60, 0],
15
- tokens: [0, 250_000, 1_000_000],
16
- session: [0, 5_000_000, 20_000_000],
17
- turns: [0, 10, 25],
18
- }
19
-
20
- const NO_COLOR = process.env.NO_COLOR !== undefined // standard opt-out; styleText can't honor it for piped output, so gate here
21
-
22
- function colorize(kind, text, value = 0) {
23
- const bounds = THRESHOLDS[kind]
24
- const n = bounds.filter(b => value >= b).length
25
- const TIERS = [null, 'yellow', 'red'] // indexed by severity tier
26
- const idx = bounds[0] < bounds.at(-1) ? n - 1 : bounds.length - n
27
- const color = TIERS[idx]
28
- // validateStream:false forces codes through the pipe (statusline output isn't a TTY)
29
- return color && !NO_COLOR ? styleText(color, text, { validateStream: false }) : text
30
- }
31
-
32
-
33
- // Collect stdin data
34
- const data = JSON.parse(readFileSync(0, 'utf-8')) // fd 0 = stdin
35
- const modelName = data.model?.display_name?.replace('Claude ', '') || '?'
36
- const parts = []
37
-
38
-
39
-
40
- // Context window
41
- const ctx = data.context_window || {}
42
- const total = ctx.context_window_size || 0
43
- if (total) {
44
- const usage = ctx.current_usage || {}
45
- const hit = usage.cache_read_input_tokens || 0
46
- const used = hit + (usage.cache_creation_input_tokens || 0) + (usage.input_tokens || 0)
47
- const pct = Math.round((used / total) * 100)
48
- const cpct = used ? Math.floor((hit * 100) / used) : 0
49
- const filled = Math.floor(pct / 10)
50
- const bar = '▓'.repeat(filled) + '░'.repeat(10 - filled)
51
- const head = colorize('context', `${bar} ${pct}% used`, used)
52
- const tokens = colorize('context', `${(used / 1000).toFixed(1)}k`, used)
53
- const cached = used ? colorize('cache', `${cpct}% cached`, cpct) : `${cpct}% cached`
54
- parts.push(`Context: ${head} (${tokens}, ${cached})`)
55
- }
56
-
57
-
58
-
59
- // Total tokens (per session and per reply loop)
60
- function agentLoopUsage(transcriptPath) { // Read the transcript JSONL and sum API usage for the current agent loop and the whole session.
61
- const lines = readFileSync(transcriptPath, 'utf-8').split(/\r?\n/).filter(Boolean)
62
- let loop = { total: 0, turns: 0 } // last prompt-reply breakdown
63
- const session = { total: 0, turns: 0 } // whole-session breakdown
64
- let seen = new Set() // One API response spans multiple lines (one per content block), each repeating the usage block, so dedupe by message.id.
65
-
66
- for (const line of lines) {
67
- let e
68
- try { e = JSON.parse(line) } catch { continue } // the transcript may be mid-append — skip a half-written line
69
- const msg = e.message || {}
70
-
71
- if (e.type === 'user' && !e.isMeta) {
72
- const isToolReply = Array.isArray(msg.content) && msg.content.some(b => b.type === 'tool_result')
73
- if (!isToolReply) { // real user prompt = new loop
74
- loop = { total: 0, turns: 0 }
75
- seen = new Set()
76
- }
77
- } else if (e.type === 'assistant' && !seen.has(msg.id)) {
78
- seen.add(msg.id)
79
- const u = msg.usage || {}
80
- const tokens = (u.input_tokens || 0) + (u.output_tokens || 0) + (u.cache_creation_input_tokens || 0) + (u.cache_read_input_tokens || 0)
81
- for (const d of [loop, session]) {
82
- d.total += tokens
83
- d.turns += 1
84
- }
85
- }
86
- }
87
- return [loop, session]
88
- }
89
-
90
- if (data.transcript_path) {
91
- const [loop, session] = agentLoopUsage(data.transcript_path)
92
- const fmt = n => n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : `${(n / 1000).toFixed(1)}k`
93
- const tokensS = colorize('tokens', fmt(loop.total), loop.total)
94
- const sessS = colorize('session', fmt(session.total), session.total)
95
- const turnsS = colorize('turns', `${loop.turns} turn${loop.turns !== 1 ? 's' : ''}`, loop.turns)
96
- parts.push(`Tokens: ${sessS} total (+${tokensS}, ${turnsS})`)
97
- }
98
-
99
-
100
-
101
- // Rate limits (5-hour and weekly windows)
102
- function fmtReset(resetsAt) { // Format remaining time until a unix-epoch reset, e.g. "2h 30m" (null if no timestamp).
103
- if (!resetsAt) return null
104
- const secs = Math.max(0, Math.floor(Number(resetsAt)) - Math.floor(Date.now() / 1000))
105
- const min = Math.floor(secs / 60) % 60
106
- const hrs = Math.floor(secs / 3600) % 24
107
- const days = Math.floor(secs / 86400)
108
- if (days) return `${days}d ${hrs}h`
109
- if (hrs) return `${hrs}h ${min}m`
110
- return `${min}m`
111
- }
112
-
113
- const rl = data.rate_limits || {}
114
- for (const [label, window] of [['Usage limit', rl.five_hour], ['Weekly limit', rl.seven_day]]) {
115
- if (!window) continue
116
- const pct = Math.floor(window.used_percentage || 0)
117
- const reset = fmtReset(window.resets_at)
118
- const resetStr = reset ? ` (resets in ${reset})` : ''
119
- parts.push(`${label}: ${colorize('limit', `${pct}% used${resetStr}`, pct)}`)
120
- }
121
-
122
-
123
- // Status line
124
- process.stdout.write(`[${modelName}] ` + parts.join(' | ') + '\n')
1
+ #!/usr/bin/env node
2
+ // Claude Code status line, installed by StatuslineSwitch. Reads the hook JSON from stdin and
3
+ // prints one colored line — model and effort, context usage, token totals, rate-limit windows:
4
+ // [Opus 4.8 high] Context: ▓▓▓░░░░░░░ 32% used (45.2k, 87% cached) | Tokens: 1.2M total (+45.2k, 3 turns) | Daily: 42% used (resets in 2h 30m)
5
+
6
+ import { readFileSync } from 'node:fs'
7
+ import { styleText } from 'node:util'
8
+
9
+
10
+ // Formatting
11
+ const THRESHOLDS = { // (white, yellow, red)
12
+ context: [0, 100_000, 250_000],
13
+ limit: [0, 60, 90],
14
+ cache: [90, 60, 0],
15
+ tokens: [0, 250_000, 1_000_000],
16
+ session: [0, 3_000_000, 8_000_000],
17
+ turns: [0, 10, 25],
18
+ }
19
+
20
+ const NO_COLOR = process.env.NO_COLOR !== undefined // standard opt-out; styleText can't honor it for piped output, so gate here
21
+
22
+ function colorize(kind, text, value = 0) {
23
+ const bounds = THRESHOLDS[kind]
24
+ const n = bounds.filter(b => value >= b).length
25
+ const TIERS = [null, 'yellow', 'red'] // indexed by severity tier
26
+ const idx = bounds[0] < bounds.at(-1) ? n - 1 : bounds.length - n
27
+ const color = TIERS[idx]
28
+ // validateStream:false forces codes through the pipe (statusline output isn't a TTY)
29
+ return color && !NO_COLOR ? styleText(color, text, { validateStream: false }) : text
30
+ }
31
+
32
+
33
+ // Collect stdin data
34
+ const data = JSON.parse(readFileSync(0, 'utf-8')) // fd 0 = stdin
35
+ const modelName = data.model?.display_name?.replace('Claude ', '') || '?'
36
+ const effort = data.effort?.level // absent when the model doesn't support effort
37
+ const parts = []
38
+
39
+
40
+
41
+ // Context window
42
+ const ctx = data.context_window || {}
43
+ const total = ctx.context_window_size || 0
44
+ if (total) {
45
+ const usage = ctx.current_usage || {}
46
+ const hit = usage.cache_read_input_tokens || 0
47
+ const used = hit + (usage.cache_creation_input_tokens || 0) + (usage.input_tokens || 0)
48
+ const pct = Math.round((used / total) * 100)
49
+ const cpct = used ? Math.floor((hit * 100) / used) : 0
50
+ const filled = Math.floor(pct / 10)
51
+ const bar = '▓'.repeat(filled) + '░'.repeat(10 - filled)
52
+ const head = colorize('context', `${bar} ${pct}% used`, used)
53
+ const tokens = colorize('context', `${(used / 1000).toFixed(1)}k`, used)
54
+ const cached = used ? colorize('cache', `${cpct}% cached`, cpct) : `${cpct}% cached`
55
+ parts.push(`Context: ${head} (${tokens}, ${cached})`)
56
+ }
57
+
58
+
59
+
60
+ // Total tokens (per session and per reply loop)
61
+ function agentLoopUsage(transcriptPath) { // Read the transcript JSONL and sum API usage for the current agent loop and the whole session.
62
+ const lines = readFileSync(transcriptPath, 'utf-8').split(/\r?\n/).filter(Boolean)
63
+ let loop = { total: 0, turns: 0 } // last prompt-reply breakdown
64
+ const session = { total: 0, turns: 0 } // whole-session breakdown
65
+ let seen = new Set() // One API response spans multiple lines (one per content block), each repeating the usage block, so dedupe by message.id.
66
+
67
+ for (const line of lines) {
68
+ let e
69
+ try { e = JSON.parse(line) } catch { continue } // the transcript may be mid-append — skip a half-written line
70
+ const msg = e.message || {}
71
+
72
+ if (e.type === 'user' && !e.isMeta) {
73
+ const isToolReply = Array.isArray(msg.content) && msg.content.some(b => b.type === 'tool_result')
74
+ if (!isToolReply) { // real user prompt = new loop
75
+ loop = { total: 0, turns: 0 }
76
+ seen = new Set()
77
+ }
78
+ } else if (e.type === 'assistant' && !seen.has(msg.id)) {
79
+ seen.add(msg.id)
80
+ const u = msg.usage || {}
81
+ const tokens = (u.input_tokens || 0) + (u.output_tokens || 0) + (u.cache_creation_input_tokens || 0) + (u.cache_read_input_tokens || 0)
82
+ for (const d of [loop, session]) {
83
+ d.total += tokens
84
+ d.turns += 1
85
+ }
86
+ }
87
+ }
88
+ return [loop, session]
89
+ }
90
+
91
+ if (data.transcript_path) {
92
+ const [loop, session] = agentLoopUsage(data.transcript_path)
93
+ const fmt = n => n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : `${(n / 1000).toFixed(1)}k`
94
+ const tokensS = colorize('tokens', fmt(loop.total), loop.total)
95
+ const sessS = colorize('session', fmt(session.total), session.total)
96
+ const turnsS = colorize('turns', `${loop.turns} turn${loop.turns !== 1 ? 's' : ''}`, loop.turns)
97
+ parts.push(`Tokens: ${sessS} total (+${tokensS}, ${turnsS})`)
98
+ }
99
+
100
+
101
+
102
+ // Rate limits (5-hour and weekly windows)
103
+ function fmtReset(resetsAt) { // Format remaining time until a unix-epoch reset, e.g. "2h 30m" (null if no timestamp).
104
+ if (!resetsAt) return null
105
+ const secs = Math.max(0, Math.floor(Number(resetsAt)) - Math.floor(Date.now() / 1000))
106
+ const min = Math.floor(secs / 60) % 60
107
+ const hrs = Math.floor(secs / 3600) % 24
108
+ const days = Math.floor(secs / 86400)
109
+ if (days) return `${days}d ${hrs}h`
110
+ if (hrs) return `${hrs}h ${min}m`
111
+ return `${min}m`
112
+ }
113
+
114
+ const rl = data.rate_limits || {}
115
+ for (const [label, window] of [['Daily', rl.five_hour], ['Weekly', rl.seven_day]]) {
116
+ if (!window) continue
117
+ const pct = Math.floor(window.used_percentage || 0)
118
+ const reset = fmtReset(window.resets_at)
119
+ const resetStr = reset ? ` (resets in ${reset})` : ''
120
+ parts.push(`${label}: ${colorize('limit', `${pct}% used${resetStr}`, pct)}`)
121
+ }
122
+
123
+
124
+ // Status line
125
+ process.stdout.write(`[${modelName}${effort ? ` ${effort}` : ''}] ` + parts.join(' | ') + '\n')
package/bin/update.mjs ADDED
@@ -0,0 +1,33 @@
1
+ // Updates an `npm i -g` install from outside it. AutoUpdate starts it detached as the app quits:
2
+ // Windows locks the app's running electron.exe, so npm can only replace the package once the app is gone.
3
+ //
4
+ // Usage: node bin/update.mjs <pid> <version> [--no-reopen] (dev passes --no-reopen)
5
+
6
+ import { spawn, spawnSync } from 'node:child_process'
7
+ import path from 'node:path'
8
+ import { setTimeout as sleep } from 'node:timers/promises'
9
+
10
+ const [pid, version, noReopen] = process.argv.slice(2)
11
+
12
+ while (isRunning(pid)) await sleep(200)
13
+
14
+ try {
15
+ // Into the helper's console window (@windows), where --loglevel=http lists each package as npm fetches it
16
+ const { status } = spawnSync(`npm i -g claude-discover@${version} --loglevel=http`, { shell: true, stdio: 'inherit' })
17
+ if (status !== 0) await sleep(5_000) // leaves npm's error readable before the window closes
18
+ } finally {
19
+ // Reopen the global install (the one just updated, or just created from an npx run), even when the update failed
20
+ if (!noReopen) {
21
+ const root = spawnSync('npm root -g', { shell: true, encoding: 'utf8' }).stdout.trim()
22
+ spawn(process.execPath, [path.join(root, 'claude-discover/bin/claude-discover.mjs')], { detached: true, stdio: 'ignore' }).unref()
23
+ }
24
+ }
25
+
26
+ function isRunning(pid) {
27
+ try {
28
+ process.kill(Number(pid), 0)
29
+ return true
30
+ } catch (err) {
31
+ return err.code === 'EPERM' // alive, just not ours to signal
32
+ }
33
+ }
package/out/main/main.js CHANGED
@@ -7,8 +7,10 @@ import { sync } from "write-file-atomic";
7
7
  import fsp from "node:fs/promises";
8
8
  import crypto from "node:crypto";
9
9
  import which from "which";
10
- import { execFile } from "node:child_process";
10
+ import { execFile, spawn } from "node:child_process";
11
11
  import { promisify } from "node:util";
12
+ import latestVersion from "latest-version";
13
+ import semver from "semver";
12
14
  import { pathToFileURL, fileURLToPath } from "node:url";
13
15
  import windowStateKeeper from "electron-window-state";
14
16
  import contextMenu from "electron-context-menu";
@@ -35,9 +37,17 @@ const CLAUDE_DIR = local.claudeDir || process.env.CLAUDE_CONFIG_DIR || join(home
35
37
  const RECENT_CLAUDE_DIRS = local.recents || [CLAUDE_DIR];
36
38
  const CLAUDE_PROJECTS_DIR = join(CLAUDE_DIR, "projects");
37
39
  const CLAUDE_SETTINGS = join(CLAUDE_DIR, "settings.json");
38
- const IS_EPHEMERAL = import.meta.dirname.split(/[\\/]/).includes("_npx");
39
40
  const STATUSLINE_PATH = join(import.meta.dirname, "../../bin/claude/statusline.mjs");
40
41
  const PROXY_PATH = join(import.meta.dirname, "../../bin/proxy.mjs");
42
+ const UPDATE_PATH = join(import.meta.dirname, "../../bin/update.mjs");
43
+ const LAUNCH_MODES = { NPM_DEV: "npm-dev", NPM_START: "npm-start", NPX_TEMP: "npx-temp", NPM_GLOBAL: "npm-global" };
44
+ const LAUNCH_MODE = detectLaunchMode();
45
+ function detectLaunchMode() {
46
+ const dirs = import.meta.dirname.split(/[\\/]/);
47
+ if (dirs.includes("_npx")) return LAUNCH_MODES.NPX_TEMP;
48
+ if (dirs.includes("node_modules")) return LAUNCH_MODES.NPM_GLOBAL;
49
+ return LAUNCH_MODES.NPM_START;
50
+ }
41
51
  const SCHEME = "claude-discover";
42
52
  class DeepLink extends EventEmitter {
43
53
  #pending = null;
@@ -72,7 +82,7 @@ class DeepLink extends EventEmitter {
72
82
  // @windows Rewritten on every launch — idempotent, and the last launched checkout wins.
73
83
  #register() {
74
84
  if (process.platform !== "win32") return;
75
- if (IS_EPHEMERAL) return;
85
+ if (LAUNCH_MODE === LAUNCH_MODES.NPX_TEMP) return;
76
86
  app.setAsDefaultProtocolClient(SCHEME, process.execPath, [app.getAppPath()]);
77
87
  }
78
88
  }
@@ -639,6 +649,7 @@ class RequestFile {
639
649
  const parser = new RequestParser();
640
650
  const files = /* @__PURE__ */ new Map();
641
651
  for (const rec of await this.read()) {
652
+ if (!rec.request) continue;
642
653
  const record = (f) => ({ timestamp: rec.timestamp, ...f });
643
654
  const sys = parser.systemPrompt(rec);
644
655
  if (sys && !files.has(sys.hash)) files.set(sys.hash, record(sys));
@@ -694,6 +705,8 @@ function freshMeta({ sessionId, parentSessionId, filePath, parentFilePath, fileS
694
705
  mtime,
695
706
  startedAt: null,
696
707
  lastActivityAt: null,
708
+ continuesFrom: null,
709
+ continuesTo: null,
697
710
  entrypoint: null,
698
711
  project: null,
699
712
  worktree: null,
@@ -702,6 +715,7 @@ function freshMeta({ sessionId, parentSessionId, filePath, parentFilePath, fileS
702
715
  version: null,
703
716
  model: null,
704
717
  models: [],
718
+ efforts: [],
705
719
  serviceTier: null,
706
720
  speed: null,
707
721
  fastPricingUnknown: false,
@@ -738,6 +752,8 @@ class SessionParser {
738
752
  this.prevMessage = null;
739
753
  this.tokenTotal = 0;
740
754
  this.lastSeenTs = null;
755
+ this.commandUuids = /* @__PURE__ */ new Set();
756
+ this.seenUuids = /* @__PURE__ */ new Set();
741
757
  this.appliedById = /* @__PURE__ */ new Map();
742
758
  }
743
759
  // Returns true if the item passes `this.range`.
@@ -759,12 +775,23 @@ class SessionParser {
759
775
  const inherited = parsed > 0 ? parsed : this.lastSeenTs;
760
776
  const ts = inherited ?? meta.mtime;
761
777
  if (parsed > 0) this.lastSeenTs = parsed;
762
- if (this.range && (ts < this.range.start || ts > this.range.end)) return false;
778
+ if (this.range && (ts < this.range.start || ts > this.range.end)) {
779
+ if (inherited != null && ts < this.range.start && ts > meta.continuesFrom) meta.continuesFrom = ts;
780
+ if (inherited != null && ts > this.range.end && (meta.continuesTo == null || ts < meta.continuesTo)) meta.continuesTo = ts;
781
+ return false;
782
+ }
763
783
  obj._ts = ts;
764
784
  if (inherited != null) {
765
785
  if (meta.startedAt == null || inherited < meta.startedAt) meta.startedAt = inherited;
766
786
  if (inherited > meta.lastActivityAt) meta.lastActivityAt = inherited;
767
787
  }
788
+ if (obj.uuid) {
789
+ if (this.seenUuids.has(obj.uuid)) {
790
+ obj._relogged = true;
791
+ return true;
792
+ }
793
+ this.seenUuids.add(obj.uuid);
794
+ }
768
795
  if (t === "attachment") {
769
796
  obj.isMeta = true;
770
797
  if (obj.attachment?.type !== "queued_command") this.prevMessage = obj;
@@ -779,7 +806,7 @@ class SessionParser {
779
806
  meta.activityPeriods.push({ start: ts, end: ts });
780
807
  }
781
808
  if (t === "user") {
782
- if (obj.turnCompanion && !obj.sourceToolUseID) meta.skillCalls += 1;
809
+ if (obj.turnCompanion && !obj.sourceToolUseID && this.commandUuids.has(obj.parentUuid)) meta.skillCalls += 1;
783
810
  this._feedUser(obj);
784
811
  } else if (t === "assistant") {
785
812
  const msg = obj.message;
@@ -788,6 +815,8 @@ class SessionParser {
788
815
  if (!meta.model) meta.model = msg.model;
789
816
  if (!meta.models.includes(msg.model)) meta.models.push(msg.model);
790
817
  }
818
+ const effort = obj.perTurnEffort ?? obj.effort;
819
+ if (effort && !meta.efforts.includes(effort)) meta.efforts.push(effort);
791
820
  if (Array.isArray(msg.content)) {
792
821
  for (const b of msg.content) {
793
822
  if (b?.type !== "tool_use") continue;
@@ -807,6 +836,7 @@ class SessionParser {
807
836
  const text = extractText(obj.message?.content);
808
837
  if (text.startsWith("<command-name>") || text.startsWith("<command-message>")) {
809
838
  if (!meta.firstUserCommand) meta.firstUserCommand = text;
839
+ if (obj.uuid) this.commandUuids.add(obj.uuid);
810
840
  obj.isMeta = true;
811
841
  } else if (text.startsWith("<local-command-stdout>")) {
812
842
  if (meta.firstUserCommand) meta.firstUserCommand += "\n" + text;
@@ -815,7 +845,7 @@ class SessionParser {
815
845
  obj.isMeta = true;
816
846
  } else if (text.startsWith("<scheduled-task")) {
817
847
  meta.hasScheduledTask = true;
818
- } else if (text && !obj.isMeta) {
848
+ } else if (text && !obj.isMeta && !obj.isCompactSummary) {
819
849
  if (!meta.firstUserPrompt) meta.firstUserPrompt = text.slice(0, 300);
820
850
  }
821
851
  }
@@ -1514,7 +1544,7 @@ class WorkHours {
1514
1544
  }
1515
1545
  }
1516
1546
  class AgentRunner {
1517
- async run(text, sender, systemTools = false, cache = false) {
1547
+ async run(text, sender) {
1518
1548
  const { query } = await import("@anthropic-ai/claude-agent-sdk");
1519
1549
  const send = (chunk) => {
1520
1550
  if (!sender.isDestroyed()) sender.send("agent:output", chunk);
@@ -1523,9 +1553,10 @@ class AgentRunner {
1523
1553
  prompt: text,
1524
1554
  options: {
1525
1555
  includePartialMessages: true,
1526
- env: cache ? { ...process.env, FORCE_PROMPT_CACHING_5M: "1" } : { ...process.env, DISABLE_PROMPT_CACHING: "1" },
1527
- tools: systemTools ? void 0 : [],
1528
- settingSources: systemTools ? void 0 : []
1556
+ env: { ...process.env, DISABLE_PROMPT_CACHING: "1" },
1557
+ // @macOS - process.env must be included
1558
+ tools: [],
1559
+ settingSources: []
1529
1560
  }
1530
1561
  });
1531
1562
  for await (const message of response) {
@@ -1901,12 +1932,12 @@ class Switchers {
1901
1932
  }
1902
1933
  async status(name) {
1903
1934
  const s = await this.#get(name).status();
1904
- return name in this.#keepActive ? { ...s, keepActive: this.#keepActive[name], ephemeral: IS_EPHEMERAL } : s;
1935
+ return name in this.#keepActive ? { ...s, keepActive: this.#keepActive[name], ephemeral: LAUNCH_MODE === LAUNCH_MODES.NPX_TEMP } : s;
1905
1936
  }
1906
1937
  activate(name, ...args) {
1907
1938
  return this.#attempt(name, async (s) => {
1908
1939
  await s.activate(...args);
1909
- this.#keepActive[name] &&= !IS_EPHEMERAL;
1940
+ this.#keepActive[name] &&= LAUNCH_MODE !== LAUNCH_MODES.NPX_TEMP;
1910
1941
  });
1911
1942
  }
1912
1943
  // an npx-cache run undoes what it activated
@@ -1937,6 +1968,30 @@ class Switchers {
1937
1968
  }
1938
1969
  }
1939
1970
  }
1971
+ class AutoUpdate {
1972
+ #result = null;
1973
+ // Once per launch — a renderer reload reuses the answer
1974
+ check() {
1975
+ return this.#result ??= this.#fetchNewer().catch((err) => {
1976
+ console.warn("[auto-update] failed:", err.message);
1977
+ return null;
1978
+ });
1979
+ }
1980
+ async install() {
1981
+ const update = await this.check();
1982
+ if (!update) return;
1983
+ const args = [UPDATE_PATH, process.pid, update.latest, ...LAUNCH_MODE === LAUNCH_MODES.NPM_DEV ? ["--no-reopen"] : []].map(String);
1984
+ const [command, commandArgs] = process.platform === "win32" ? ["cmd", ["/c", "start", "Updating Claude Discover", "node", ...args]] : ["node", args];
1985
+ spawn(command, commandArgs, { detached: true, stdio: ["ignore", "inherit", "inherit"], cwd: homedir() }).once("spawn", () => app.quit()).once("error", (err) => dialog.showErrorBox("Update failed", `Could not start node: ${err.message}`));
1986
+ }
1987
+ async #fetchNewer() {
1988
+ if (LAUNCH_MODE === LAUNCH_MODES.NPM_START) return null;
1989
+ const latest = await latestVersion("claude-discover");
1990
+ const current = app.getVersion();
1991
+ if (LAUNCH_MODE === LAUNCH_MODES.NPX_TEMP) return { current, latest, fromNpx: true };
1992
+ return semver.gt(latest, current) ? { current, latest } : null;
1993
+ }
1994
+ }
1940
1995
  const VIEWABLE_EXT = /* @__PURE__ */ new Set([".md", ".txt", ".json", ".jsonl", ".log", ".csv", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".pdf"]);
1941
1996
  async function openLinkSafely(href, baseFile) {
1942
1997
  if (/^(https?:|mailto:)/i.test(href)) return shell.openExternal(href);
@@ -2080,10 +2135,11 @@ class Application {
2080
2135
  this.agentRunner = new AgentRunner();
2081
2136
  this.workHours = new WorkHours();
2082
2137
  this.sessionsService = new SessionsService();
2138
+ this.autoUpdate = new AutoUpdate();
2083
2139
  this.switchers = new Switchers({ restart: () => this.restart() });
2084
2140
  }
2085
2141
  start() {
2086
- const { deepLink, win, agentRunner, workHours, sessionsService, switchers } = this;
2142
+ const { deepLink, win, agentRunner, workHours, sessionsService, autoUpdate, switchers } = this;
2087
2143
  Menu.setApplicationMenu(this.#buildMenu());
2088
2144
  sessionsService.on("update", (sessions) => win.send("sessions:update", sessions));
2089
2145
  sessionsService.on("progress", (p) => win.send("sessions:scan-progress", p));
@@ -2100,7 +2156,9 @@ class Application {
2100
2156
  ipcMain.handle("switch:keep-active", (_e, name, value) => switchers.setKeepActive(name, value));
2101
2157
  ipcMain.handle("work-hours:get", () => workHours.read());
2102
2158
  ipcMain.handle("work-hours:set", (_e, data) => workHours.write(data));
2103
- ipcMain.handle("agent:run", (e, text, systemTools, cache) => agentRunner.run(text, e.sender, systemTools, cache));
2159
+ ipcMain.handle("auto-update:check", () => autoUpdate.check());
2160
+ ipcMain.handle("auto-update:install", () => autoUpdate.install());
2161
+ ipcMain.handle("agent:run", (e, text) => agentRunner.run(text, e.sender));
2104
2162
  ipcMain.handle("shell:open-link", (_e, href, baseFile) => openLinkSafely(href, baseFile));
2105
2163
  ipcMain.handle("deeplink:take-pending", () => deepLink?.takePending() ?? null);
2106
2164
  ipcMain.on("find:query", (_e, text, options) => win.findBar?.query(text, options));
@@ -2173,32 +2231,71 @@ class Application {
2173
2231
  }
2174
2232
  }
2175
2233
  const APP_ID = "claude-discover";
2234
+ const GLOBAL = LAUNCH_MODE === LAUNCH_MODES.NPM_GLOBAL;
2235
+ const NAME = GLOBAL ? "Claude Discover" : "Claude Discover (local)";
2176
2236
  class Shortcuts {
2237
+ #os = process.platform === "win32" ? new WindowsLinks() : process.platform === "darwin" ? new MacBundle() : null;
2177
2238
  // Call before any window is created, so the window picks up the app id.
2178
2239
  activate() {
2179
- if (process.platform !== "win32") return;
2180
- app.setAppUserModelId(APP_ID);
2181
- if (IS_EPHEMERAL || false) return;
2182
- const installed = app.getAppPath().split(/[\\/]/).includes("node_modules");
2183
- const name = installed ? "Claude Discover" : "Claude Discover (local)";
2240
+ if (process.platform === "win32") app.setAppUserModelId(APP_ID);
2241
+ if (!this.#os || LAUNCH_MODE === LAUNCH_MODES.NPX_TEMP || LAUNCH_MODE === LAUNCH_MODES.NPM_DEV) return;
2184
2242
  const created = config.read().shortcuts ?? {};
2185
2243
  const install = fs.statSync(app.getAppPath()).birthtimeMs;
2186
- const newInstall = created[name] !== install;
2187
- for (const dir of [join(app.getPath("appData"), "Microsoft/Windows/Start Menu/Programs"), app.getPath("desktop")]) {
2188
- const path2 = join(dir, `${name}.lnk`);
2244
+ const newInstall = created[NAME] !== install;
2245
+ for (const path2 of this.#os.paths()) {
2189
2246
  const exists = fs.existsSync(path2);
2190
2247
  if (!exists && !newInstall) continue;
2191
- const written = shell.writeShortcutLink(path2, exists ? "replace" : "create", {
2192
- target: process.execPath,
2193
- args: `"${app.getAppPath()}"`,
2194
- description: "Browse your local Claude Code sessions",
2195
- icon: process.execPath,
2196
- iconIndex: 0,
2197
- appUserModelId: APP_ID
2198
- });
2199
- if (!written) throw new Error(`cannot write ${path2}`);
2248
+ this.#os.write(path2, exists);
2200
2249
  }
2201
- if (newInstall) config.save({ shortcuts: { ...created, [name]: install } });
2250
+ if (newInstall) config.save({ shortcuts: { ...created, [NAME]: install } });
2251
+ }
2252
+ }
2253
+ class WindowsLinks {
2254
+ // @windows
2255
+ paths() {
2256
+ return [join(app.getPath("appData"), "Microsoft/Windows/Start Menu/Programs"), app.getPath("desktop")].map((dir) => join(dir, `${NAME}.lnk`));
2257
+ }
2258
+ write(path2, exists) {
2259
+ const written = shell.writeShortcutLink(path2, exists ? "replace" : "create", {
2260
+ target: process.execPath,
2261
+ args: `"${app.getAppPath()}"`,
2262
+ description: "Browse your local Claude Code sessions",
2263
+ icon: process.execPath,
2264
+ iconIndex: 0,
2265
+ appUserModelId: APP_ID
2266
+ });
2267
+ if (!written) throw new Error(`cannot write ${path2}`);
2268
+ }
2269
+ }
2270
+ class MacBundle {
2271
+ // @macOS
2272
+ paths() {
2273
+ return [join(app.getPath("home"), "Applications", `${NAME}.app`)];
2274
+ }
2275
+ write(path2) {
2276
+ const electronApp = join(process.execPath, "../../..");
2277
+ const quote = (s) => `'${s.replaceAll("'", `'\\''`)}'`;
2278
+ fs.mkdirSync(join(path2, "Contents/MacOS"), { recursive: true });
2279
+ fs.mkdirSync(join(path2, "Contents/Resources"), { recursive: true });
2280
+ fs.copyFileSync(join(electronApp, "Contents/Resources/electron.icns"), join(path2, "Contents/Resources/icon.icns"));
2281
+ fs.writeFileSync(join(path2, "Contents/MacOS/launch"), [
2282
+ "#!/bin/sh",
2283
+ `exec open -n ${quote(electronApp)} --env ${quote(`PATH=${process.env.PATH}`)} --args ${quote(app.getAppPath())}`,
2284
+ ""
2285
+ ].join("\n"), { mode: 493 });
2286
+ fs.writeFileSync(join(path2, "Contents/Info.plist"), `<?xml version="1.0" encoding="UTF-8"?>
2287
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
2288
+ <plist version="1.0">
2289
+ <dict>
2290
+ <key>CFBundleName</key><string>${NAME}</string>
2291
+ <key>CFBundleIdentifier</key><string>com.dichev.${APP_ID}${GLOBAL ? "" : ".local"}</string>
2292
+ <key>CFBundlePackageType</key><string>APPL</string>
2293
+ <key>CFBundleExecutable</key><string>launch</string>
2294
+ <key>CFBundleIconFile</key><string>icon</string>
2295
+ <key>LSUIElement</key><true/>
2296
+ </dict>
2297
+ </plist>
2298
+ `);
2202
2299
  }
2203
2300
  }
2204
2301
  const isFirstInstance = app.requestSingleInstanceLock();
@@ -20,8 +20,10 @@ electron.contextBridge.exposeInMainWorld("api", {
20
20
  onScanProgress: subscribe("sessions:scan-progress"),
21
21
  getWorkHours: () => electron.ipcRenderer.invoke("work-hours:get"),
22
22
  setWorkHours: (data) => electron.ipcRenderer.invoke("work-hours:set", data),
23
- runAgentPrompt: (text, systemTools, cache) => electron.ipcRenderer.invoke("agent:run", text, systemTools, cache),
23
+ runAgentPrompt: (text) => electron.ipcRenderer.invoke("agent:run", text),
24
24
  onAgentOutput: subscribe("agent:output"),
25
+ checkUpdate: () => electron.ipcRenderer.invoke("auto-update:check"),
26
+ installUpdate: () => electron.ipcRenderer.invoke("auto-update:install"),
25
27
  openLink: (href, baseFile) => electron.ipcRenderer.invoke("shell:open-link", href, baseFile),
26
28
  onFindActive: subscribe("find:active"),
27
29
  findClose: () => electron.ipcRenderer.send("find:close"),