claude-discover 1.10.0 → 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;
@@ -51,6 +61,7 @@ class DeepLink extends EventEmitter {
51
61
  });
52
62
  this.#register();
53
63
  this.#pending = findTarget(process.argv);
64
+ return this;
54
65
  }
55
66
  open(target) {
56
67
  if (target) this.emit("open", target);
@@ -71,7 +82,7 @@ class DeepLink extends EventEmitter {
71
82
  // @windows Rewritten on every launch — idempotent, and the last launched checkout wins.
72
83
  #register() {
73
84
  if (process.platform !== "win32") return;
74
- if (IS_EPHEMERAL) return;
85
+ if (LAUNCH_MODE === LAUNCH_MODES.NPX_TEMP) return;
75
86
  app.setAsDefaultProtocolClient(SCHEME, process.execPath, [app.getAppPath()]);
76
87
  }
77
88
  }
@@ -638,6 +649,7 @@ class RequestFile {
638
649
  const parser = new RequestParser();
639
650
  const files = /* @__PURE__ */ new Map();
640
651
  for (const rec of await this.read()) {
652
+ if (!rec.request) continue;
641
653
  const record = (f) => ({ timestamp: rec.timestamp, ...f });
642
654
  const sys = parser.systemPrompt(rec);
643
655
  if (sys && !files.has(sys.hash)) files.set(sys.hash, record(sys));
@@ -693,6 +705,8 @@ function freshMeta({ sessionId, parentSessionId, filePath, parentFilePath, fileS
693
705
  mtime,
694
706
  startedAt: null,
695
707
  lastActivityAt: null,
708
+ continuesFrom: null,
709
+ continuesTo: null,
696
710
  entrypoint: null,
697
711
  project: null,
698
712
  worktree: null,
@@ -701,6 +715,7 @@ function freshMeta({ sessionId, parentSessionId, filePath, parentFilePath, fileS
701
715
  version: null,
702
716
  model: null,
703
717
  models: [],
718
+ efforts: [],
704
719
  serviceTier: null,
705
720
  speed: null,
706
721
  fastPricingUnknown: false,
@@ -737,6 +752,8 @@ class SessionParser {
737
752
  this.prevMessage = null;
738
753
  this.tokenTotal = 0;
739
754
  this.lastSeenTs = null;
755
+ this.commandUuids = /* @__PURE__ */ new Set();
756
+ this.seenUuids = /* @__PURE__ */ new Set();
740
757
  this.appliedById = /* @__PURE__ */ new Map();
741
758
  }
742
759
  // Returns true if the item passes `this.range`.
@@ -758,12 +775,23 @@ class SessionParser {
758
775
  const inherited = parsed > 0 ? parsed : this.lastSeenTs;
759
776
  const ts = inherited ?? meta.mtime;
760
777
  if (parsed > 0) this.lastSeenTs = parsed;
761
- 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
+ }
762
783
  obj._ts = ts;
763
784
  if (inherited != null) {
764
785
  if (meta.startedAt == null || inherited < meta.startedAt) meta.startedAt = inherited;
765
786
  if (inherited > meta.lastActivityAt) meta.lastActivityAt = inherited;
766
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
+ }
767
795
  if (t === "attachment") {
768
796
  obj.isMeta = true;
769
797
  if (obj.attachment?.type !== "queued_command") this.prevMessage = obj;
@@ -778,7 +806,7 @@ class SessionParser {
778
806
  meta.activityPeriods.push({ start: ts, end: ts });
779
807
  }
780
808
  if (t === "user") {
781
- if (obj.turnCompanion && !obj.sourceToolUseID) meta.skillCalls += 1;
809
+ if (obj.turnCompanion && !obj.sourceToolUseID && this.commandUuids.has(obj.parentUuid)) meta.skillCalls += 1;
782
810
  this._feedUser(obj);
783
811
  } else if (t === "assistant") {
784
812
  const msg = obj.message;
@@ -787,6 +815,8 @@ class SessionParser {
787
815
  if (!meta.model) meta.model = msg.model;
788
816
  if (!meta.models.includes(msg.model)) meta.models.push(msg.model);
789
817
  }
818
+ const effort = obj.perTurnEffort ?? obj.effort;
819
+ if (effort && !meta.efforts.includes(effort)) meta.efforts.push(effort);
790
820
  if (Array.isArray(msg.content)) {
791
821
  for (const b of msg.content) {
792
822
  if (b?.type !== "tool_use") continue;
@@ -806,6 +836,7 @@ class SessionParser {
806
836
  const text = extractText(obj.message?.content);
807
837
  if (text.startsWith("<command-name>") || text.startsWith("<command-message>")) {
808
838
  if (!meta.firstUserCommand) meta.firstUserCommand = text;
839
+ if (obj.uuid) this.commandUuids.add(obj.uuid);
809
840
  obj.isMeta = true;
810
841
  } else if (text.startsWith("<local-command-stdout>")) {
811
842
  if (meta.firstUserCommand) meta.firstUserCommand += "\n" + text;
@@ -814,7 +845,7 @@ class SessionParser {
814
845
  obj.isMeta = true;
815
846
  } else if (text.startsWith("<scheduled-task")) {
816
847
  meta.hasScheduledTask = true;
817
- } else if (text && !obj.isMeta) {
848
+ } else if (text && !obj.isMeta && !obj.isCompactSummary) {
818
849
  if (!meta.firstUserPrompt) meta.firstUserPrompt = text.slice(0, 300);
819
850
  }
820
851
  }
@@ -1513,7 +1544,7 @@ class WorkHours {
1513
1544
  }
1514
1545
  }
1515
1546
  class AgentRunner {
1516
- async run(text, sender, systemTools = false, cache = false) {
1547
+ async run(text, sender) {
1517
1548
  const { query } = await import("@anthropic-ai/claude-agent-sdk");
1518
1549
  const send = (chunk) => {
1519
1550
  if (!sender.isDestroyed()) sender.send("agent:output", chunk);
@@ -1522,9 +1553,10 @@ class AgentRunner {
1522
1553
  prompt: text,
1523
1554
  options: {
1524
1555
  includePartialMessages: true,
1525
- env: cache ? { ...process.env, FORCE_PROMPT_CACHING_5M: "1" } : { ...process.env, DISABLE_PROMPT_CACHING: "1" },
1526
- tools: systemTools ? void 0 : [],
1527
- settingSources: systemTools ? void 0 : []
1556
+ env: { ...process.env, DISABLE_PROMPT_CACHING: "1" },
1557
+ // @macOS - process.env must be included
1558
+ tools: [],
1559
+ settingSources: []
1528
1560
  }
1529
1561
  });
1530
1562
  for await (const message of response) {
@@ -1900,12 +1932,12 @@ class Switchers {
1900
1932
  }
1901
1933
  async status(name) {
1902
1934
  const s = await this.#get(name).status();
1903
- 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;
1904
1936
  }
1905
1937
  activate(name, ...args) {
1906
1938
  return this.#attempt(name, async (s) => {
1907
1939
  await s.activate(...args);
1908
- this.#keepActive[name] &&= !IS_EPHEMERAL;
1940
+ this.#keepActive[name] &&= LAUNCH_MODE !== LAUNCH_MODES.NPX_TEMP;
1909
1941
  });
1910
1942
  }
1911
1943
  // an npx-cache run undoes what it activated
@@ -1936,6 +1968,30 @@ class Switchers {
1936
1968
  }
1937
1969
  }
1938
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
+ }
1939
1995
  const VIEWABLE_EXT = /* @__PURE__ */ new Set([".md", ".txt", ".json", ".jsonl", ".log", ".csv", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".pdf"]);
1940
1996
  async function openLinkSafely(href, baseFile) {
1941
1997
  if (/^(https?:|mailto:)/i.test(href)) return shell.openExternal(href);
@@ -2079,14 +2135,15 @@ class Application {
2079
2135
  this.agentRunner = new AgentRunner();
2080
2136
  this.workHours = new WorkHours();
2081
2137
  this.sessionsService = new SessionsService();
2138
+ this.autoUpdate = new AutoUpdate();
2082
2139
  this.switchers = new Switchers({ restart: () => this.restart() });
2083
2140
  }
2084
2141
  start() {
2085
- const { deepLink, win, agentRunner, workHours, sessionsService, switchers } = this;
2142
+ const { deepLink, win, agentRunner, workHours, sessionsService, autoUpdate, switchers } = this;
2086
2143
  Menu.setApplicationMenu(this.#buildMenu());
2087
2144
  sessionsService.on("update", (sessions) => win.send("sessions:update", sessions));
2088
2145
  sessionsService.on("progress", (p) => win.send("sessions:scan-progress", p));
2089
- deepLink.on("open", (target) => {
2146
+ deepLink?.on("open", (target) => {
2090
2147
  win.focus();
2091
2148
  win.send("deeplink:open-session", target);
2092
2149
  });
@@ -2099,9 +2156,11 @@ class Application {
2099
2156
  ipcMain.handle("switch:keep-active", (_e, name, value) => switchers.setKeepActive(name, value));
2100
2157
  ipcMain.handle("work-hours:get", () => workHours.read());
2101
2158
  ipcMain.handle("work-hours:set", (_e, data) => workHours.write(data));
2102
- 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));
2103
2162
  ipcMain.handle("shell:open-link", (_e, href, baseFile) => openLinkSafely(href, baseFile));
2104
- ipcMain.handle("deeplink:take-pending", () => deepLink.takePending());
2163
+ ipcMain.handle("deeplink:take-pending", () => deepLink?.takePending() ?? null);
2105
2164
  ipcMain.on("find:query", (_e, text, options) => win.findBar?.query(text, options));
2106
2165
  ipcMain.on("find:stop", () => win.findBar?.stop());
2107
2166
  ipcMain.on("find:close", () => win.findBar?.hide());
@@ -2171,11 +2230,80 @@ class Application {
2171
2230
  ]);
2172
2231
  }
2173
2232
  }
2233
+ const APP_ID = "claude-discover";
2234
+ const GLOBAL = LAUNCH_MODE === LAUNCH_MODES.NPM_GLOBAL;
2235
+ const NAME = GLOBAL ? "Claude Discover" : "Claude Discover (local)";
2236
+ class Shortcuts {
2237
+ #os = process.platform === "win32" ? new WindowsLinks() : process.platform === "darwin" ? new MacBundle() : null;
2238
+ // Call before any window is created, so the window picks up the app id.
2239
+ activate() {
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;
2242
+ const created = config.read().shortcuts ?? {};
2243
+ const install = fs.statSync(app.getAppPath()).birthtimeMs;
2244
+ const newInstall = created[NAME] !== install;
2245
+ for (const path2 of this.#os.paths()) {
2246
+ const exists = fs.existsSync(path2);
2247
+ if (!exists && !newInstall) continue;
2248
+ this.#os.write(path2, exists);
2249
+ }
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
+ `);
2299
+ }
2300
+ }
2174
2301
  const isFirstInstance = app.requestSingleInstanceLock();
2175
2302
  if (isFirstInstance) {
2176
- let application;
2177
- const deepLink = new DeepLink();
2178
- deepLink.activate();
2303
+ let application = null;
2304
+ let deepLink = null;
2305
+ optional("deep links", () => deepLink = new DeepLink().activate());
2306
+ optional("shortcuts", () => new Shortcuts().activate());
2179
2307
  app.whenReady().then(() => {
2180
2308
  application = new Application({ deepLink });
2181
2309
  application.start();
@@ -2185,7 +2313,7 @@ if (isFirstInstance) {
2185
2313
  if (argv.includes("--restart")) {
2186
2314
  application?.restart();
2187
2315
  } else if (target) {
2188
- deepLink.open(target);
2316
+ deepLink?.open(target);
2189
2317
  } else {
2190
2318
  application?.win.focus();
2191
2319
  }
@@ -2193,3 +2321,10 @@ if (isFirstInstance) {
2193
2321
  } else {
2194
2322
  app.exit(0);
2195
2323
  }
2324
+ function optional(label, activate) {
2325
+ try {
2326
+ activate();
2327
+ } catch (err) {
2328
+ console.warn(`[${label}] skipped:`, err.message);
2329
+ }
2330
+ }
@@ -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"),