claude-discover 1.10.3 → 1.10.5

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,125 +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 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')
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')
@@ -3,8 +3,8 @@
3
3
  // Electron binary at the package root, whose package.json main is out/main/main.js.
4
4
  import { spawn } from 'node:child_process'
5
5
  import { existsSync } from 'node:fs'
6
+ import { access, constants } from 'node:fs/promises'
6
7
  import { fileURLToPath } from 'node:url'
7
- import electron from 'electron' // under plain node the electron package exports its binary path
8
8
 
9
9
  const appDir = fileURLToPath(new URL('..', import.meta.url))
10
10
  if (!existsSync(new URL('../out/main/main.js', import.meta.url))) {
@@ -12,5 +12,36 @@ if (!existsSync(new URL('../out/main/main.js', import.meta.url))) {
12
12
  process.exit(1)
13
13
  }
14
14
 
15
+
16
+ if (process.platform === 'linux') { // @linux
17
+ // Importing electron downloads its binary on first run, which can't unzip into a root-owned `sudo npm i -g` install,
18
+ // so it's imported dynamically, only after this check (a static import is hoisted and would run first)
19
+
20
+ const electronDir = new URL('.', import.meta.resolve('electron'))
21
+ const isWritable = await access(electronDir, constants.W_OK).then(() => true, () => false)
22
+ if (!existsSync(new URL('path.txt', electronDir)) && !isWritable) {
23
+ console.error(`
24
+ Electron can't finish installing — its folder isn't writable by your user.
25
+
26
+ Global npm packages shouldn't need sudo. The recommended setup is Node from a version
27
+ manager like nvm, which keeps global npm packages in your home folder:
28
+
29
+ sudo npm uninstall -g claude-discover
30
+ wget -qO- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.8/install.sh | bash
31
+
32
+ Then open a new terminal and run:
33
+
34
+ nvm install --lts
35
+ npm install -g claude-discover
36
+ claude-discover
37
+
38
+ See https://docs.npmjs.com/resolving-eacces-permissions-errors-when-installing-packages-globally`)
39
+ process.exit(1)
40
+ }
41
+ }
42
+
43
+
44
+ const { default: electron } = await import('electron') // under plain node the electron package exports its binary path
45
+
15
46
  spawn(electron, [appDir, ...process.argv.slice(2)], { stdio: 'inherit' })
16
47
  .on('close', code => process.exit(code ?? 0))
package/bin/proxy.mjs CHANGED
@@ -16,20 +16,25 @@ import { parseArgs } from 'node:util'
16
16
 
17
17
  import { HOST, PORT, PROXY_URL, UPSTREAM, EXIT_ROUTE, PING_ROUTE, PING_RESPONSE, REQUESTS_DIR, ERROR_LOG_PATH } from './proxy.config.js'
18
18
 
19
+ // How long an exiting proxy keeps serving in-flight responses — long replies stream for minutes
20
+ const DRAIN_TIMEOUT_MS = 10 * 60_000
21
+
19
22
 
20
23
  // ── 1. Generic tee proxy — knows nothing about Claude ────────────────────────
21
24
  // Forwards verbatim to `upstream` and pipes the response back while buffering both bodies.
22
25
  // `onExchange(exchange)` fires exactly once per request, after piping (its response fields are
23
- // undefined when upstream never responded); `errorBody(err)` shapes the 502 when it's unreachable.
26
+ // undefined when upstream never responded); `errorBody(err)` shapes the 502 when it's unreachable;
27
+ // `onExit()` runs once the exit route has answered.
24
28
 
25
- function createProxy({ upstream, onExchange, onError, errorBody }) {
29
+ function createProxy({ upstream, onExchange, onError, errorBody, onExit }) {
26
30
  const mod = upstream.protocol === 'http:' ? http : https
27
31
 
28
32
  return http.createServer(async (req, res) => {
29
33
  if (req.method === 'POST' && req.url === EXIT_ROUTE) {
30
34
  // Loopback-only control route. The restart client is a bare Node request; reject anything carrying browser fetch metadata so a web page can't CSRF the proxy into exiting.
31
35
  if (req.headers.origin || req.headers.referer || req.headers['sec-fetch-site']) return res.writeHead(403).end('forbidden')
32
- return res.end('bye', () => process.exit(0))
36
+ res.setHeader('connection', 'close')
37
+ return res.end('bye', onExit)
33
38
  }
34
39
  if (req.url === PING_ROUTE) return res.end(PING_RESPONSE)
35
40
  const started = Date.now()
@@ -246,7 +251,17 @@ if (isMain) {
246
251
  onExchange: logRequest,
247
252
  onError: logError,
248
253
  errorBody: err => JSON.stringify({ type: 'error', error: { type: 'api_error', message: `claude-discover proxy: upstream unreachable (${err.code || err.message})` } }),
254
+ onExit: () => drain(),
249
255
  })
256
+ // Stop listening at once (frees the port for a successor) but let in-flight responses finish — exiting outright would cut a reply mid-stream
257
+ let draining = false
258
+ const drain = () => {
259
+ if (draining) return
260
+ draining = true
261
+ server.close(() => process.exit(0))
262
+ setTimeout(() => process.exit(0), DRAIN_TIMEOUT_MS)
263
+ }
264
+ process.on('SIGTERM', drain) // @macOS @linux launchd/systemd stop the service this way, right after ProxySwitch's exit request
250
265
  if (args.values.restart) {
251
266
  // Ask a previous instance (the port is loopback-only and assumed ours) to exit before listening.
252
267
  const replaced = await new Promise(resolve => http.request(`${PROXY_URL}${EXIT_ROUTE}`, { method: 'POST' }, () => resolve(true)).on('error', () => resolve(false)).end())
package/bin/update.mjs CHANGED
@@ -1,42 +1,42 @@
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
- import { spawn, spawnSync } from 'node:child_process'
5
- import path from 'node:path'
6
- import { setTimeout as sleep } from 'node:timers/promises'
7
- import { parseArgs } from 'node:util'
8
-
9
-
10
- // ── 1. CLI, then wait for the app to exit ────────────────────────────────────
11
-
12
- const { values, positionals: [pid, version] } = parseArgs({
13
- allowPositionals: true,
14
- options: {
15
- 'no-reopen': { type: 'boolean' },
16
- 'help': { type: 'boolean', short: 'h' },
17
- },
18
- })
19
-
20
- if (values.help || !version) {
21
- console.error('Usage: node bin/update.mjs <pid> <version> [--no-reopen]')
22
- process.exit(1)
23
- }
24
-
25
- while (true) {
26
- try {
27
- process.kill(Number(pid), 0)
28
- } catch (err) {
29
- if (err.code !== 'EPERM') break // EPERM: alive, just not ours to signal
30
- }
31
- await sleep(200)
32
- }
33
-
34
-
35
- // ── 2. Update and reopen ─────────────────────────────────────────────────────
36
-
37
- const { status } = spawnSync(`npm i -g claude-discover@${version} --loglevel=http`, { shell: true, stdio: 'inherit' })
38
- if (status !== 0) await sleep(5_000) // leaves npm's error readable before the window closes
39
-
40
- // Reopen even after a failed update; dev passes --no-reopen, as its Vite renderer died with the app
41
- const globalBin = path.join(spawnSync('npm root -g', { shell: true, encoding: 'utf8' }).stdout.trim(), 'claude-discover/bin/claude-discover.mjs')
42
- if (!values['no-reopen']) spawn(process.execPath, [globalBin], { detached: true, stdio: 'ignore' }).unref()
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
+ import { spawn, spawnSync } from 'node:child_process'
5
+ import path from 'node:path'
6
+ import { setTimeout as sleep } from 'node:timers/promises'
7
+ import { parseArgs } from 'node:util'
8
+
9
+
10
+ // ── 1. CLI, then wait for the app to exit ────────────────────────────────────
11
+
12
+ const { values, positionals: [pid, version] } = parseArgs({
13
+ allowPositionals: true,
14
+ options: {
15
+ 'no-reopen': { type: 'boolean' },
16
+ 'help': { type: 'boolean', short: 'h' },
17
+ },
18
+ })
19
+
20
+ if (values.help || !version) {
21
+ console.error('Usage: node bin/update.mjs <pid> <version> [--no-reopen]')
22
+ process.exit(1)
23
+ }
24
+
25
+ while (true) {
26
+ try {
27
+ process.kill(Number(pid), 0)
28
+ } catch (err) {
29
+ if (err.code !== 'EPERM') break // EPERM: alive, just not ours to signal
30
+ }
31
+ await sleep(200)
32
+ }
33
+
34
+
35
+ // ── 2. Update and reopen ─────────────────────────────────────────────────────
36
+
37
+ const { status } = spawnSync(`npm i -g claude-discover@${version} --loglevel=http`, { shell: true, stdio: 'inherit' })
38
+ if (status !== 0) await sleep(5_000) // leaves npm's error readable before the window closes
39
+
40
+ // Reopen even after a failed update; dev passes --no-reopen, as its Vite renderer died with the app
41
+ const globalBin = path.join(spawnSync('npm root -g', { shell: true, encoding: 'utf8' }).stdout.trim(), 'claude-discover/bin/claude-discover.mjs')
42
+ if (!values['no-reopen']) spawn(process.execPath, [globalBin], { detached: true, stdio: 'ignore' }).unref()
package/out/main/main.js CHANGED
@@ -1,4 +1,4 @@
1
- import { app, dialog, BrowserWindow, shell, WebContentsView, Menu, ipcMain } from "electron";
1
+ import { app, dialog, BrowserWindow, shell, 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";
@@ -14,6 +14,7 @@ import semver from "semver";
14
14
  import { pathToFileURL, fileURLToPath } from "node:url";
15
15
  import windowStateKeeper from "electron-window-state";
16
16
  import contextMenu from "electron-context-menu";
17
+ import { FindOverlay } from "electron-find-overlay";
17
18
  class ConfigFile {
18
19
  dataDir = join(homedir(), ".claude-discover");
19
20
  configFile = join(this.dataDir, "config.json");
@@ -435,7 +436,7 @@ const MEMORY_LABELS = {
435
436
  "user's private project instructions, not checked in": "Local",
436
437
  "user's auto-memory, persists across conversations": "Auto"
437
438
  };
438
- const MEMORY_FILES = { User: "CLAUDE.md", Project: "CLAUDE.md", Local: "CLAUDE.local.md", Auto: "MEMORY.md" };
439
+ const MEMORY_FILES = { User: ["CLAUDE.md"], Project: ["CLAUDE.md", "AGENTS.md"], Local: ["CLAUDE.local.md"], Auto: ["MEMORY.md"] };
439
440
  function splitClaudeMd(text) {
440
441
  const marker = text.match(/\n# claudeMd\r?\n|Codebase and user instructions are shown below\./);
441
442
  if (!marker) return { files: [], rest: text };
@@ -449,7 +450,7 @@ function splitClaudeMd(text) {
449
450
  const base = h[1].split(/[\\/]/).pop();
450
451
  return {
451
452
  file_path: h[1],
452
- name: base === MEMORY_FILES[memory_type] ? base : h[1],
453
+ name: MEMORY_FILES[memory_type]?.includes(base) ? base : h[1],
453
454
  memory_type,
454
455
  content: region.slice(h.index + h[0].length, heads[i + 1]?.index ?? region.length).trim()
455
456
  };
@@ -759,6 +760,7 @@ class SessionParser {
759
760
  this.prevMessage = null;
760
761
  this.tokenTotal = 0;
761
762
  this.lastSeenTs = null;
763
+ this.modelId = null;
762
764
  this.commandUuids = /* @__PURE__ */ new Set();
763
765
  this.seenUuids = /* @__PURE__ */ new Set();
764
766
  this.appliedById = /* @__PURE__ */ new Map();
@@ -774,6 +776,7 @@ class SessionParser {
774
776
  if (t === "tag") meta.tag = obj.tag || null;
775
777
  if (t === "queue-operation" && obj.content?.includes("<scheduled-task")) meta.hasScheduledTask = true;
776
778
  if (t === "started" && obj.key && obj.agentId) meta.workflowAgents += 1;
779
+ if (t === "attachment" && obj.attachment?.type === "model") this.modelId = obj.attachment.identity?.modelId;
777
780
  if (obj.cwd && !meta.project) meta.project = obj.cwd;
778
781
  if (obj.gitBranch && !meta.gitBranch) meta.gitBranch = obj.gitBranch;
779
782
  if (obj.entrypoint && !meta.entrypoint) meta.entrypoint = obj.entrypoint;
@@ -818,9 +821,10 @@ class SessionParser {
818
821
  } else if (t === "assistant") {
819
822
  const msg = obj.message;
820
823
  if (!msg || msg.model === "<synthetic>") return true;
821
- if (msg.model) {
822
- if (!meta.model) meta.model = msg.model;
823
- if (!meta.models.includes(msg.model)) meta.models.push(msg.model);
824
+ const model = this.modelId?.replace(/\[.*\]$/, "") === msg.model ? this.modelId : msg.model;
825
+ if (model) {
826
+ if (!meta.model) meta.model = model;
827
+ if (!meta.models.includes(model)) meta.models.push(model);
824
828
  }
825
829
  const effort = obj.perTurnEffort ?? obj.effort;
826
830
  if (effort && !meta.efforts.includes(effort)) meta.efforts.push(effort);
@@ -1608,21 +1612,6 @@ class ClaudeSettings {
1608
1612
  }
1609
1613
  }
1610
1614
  }
1611
- // Removes every hook (any event) whose command matches `pattern`, dropping groups and events
1612
- // left empty. Returns the removed commands.
1613
- removeHooks(pattern) {
1614
- const matches = (h) => pattern.test(h.command ?? "");
1615
- const removed = [];
1616
- for (const [event, groups] of Object.entries(this.cfg.hooks ?? {})) {
1617
- const hit = groups.flatMap((g) => (g.hooks ?? []).filter(matches).map((h) => h.command));
1618
- if (!hit.length) continue;
1619
- removed.push(...hit);
1620
- for (const g of groups) g.hooks = (g.hooks ?? []).filter((h) => !matches(h));
1621
- this.cfg.hooks[event] = groups.filter((g) => g.hooks.length);
1622
- if (!this.cfg.hooks[event].length) delete this.cfg.hooks[event];
1623
- }
1624
- return removed;
1625
- }
1626
1615
  // Days Claude Code keeps transcripts before auto-deleting them; undefined when unset (its default is 30).
1627
1616
  get cleanupPeriodDays() {
1628
1617
  return this.cfg.cleanupPeriodDays;
@@ -1790,15 +1779,9 @@ WantedBy=default.target
1790
1779
  }
1791
1780
  }
1792
1781
  const NODE = await which("node", { nothrow: true }) ?? "node";
1782
+ const SETTINGS_RELOAD_GRACE_MS = 3e3;
1793
1783
  class ProxySwitch {
1794
1784
  #service = new LoginService({ name: "claude-discover-proxy", command: [NODE, PROXY_PATH] });
1795
- constructor() {
1796
- try {
1797
- const settings = new ClaudeSettings();
1798
- if (settings.removeHooks(/claude-discover/).length) settings.save();
1799
- } catch {
1800
- }
1801
- }
1802
1785
  async status() {
1803
1786
  return {
1804
1787
  running: await this.#running(),
@@ -1828,16 +1811,17 @@ class ProxySwitch {
1828
1811
  settings.setEnv("ENABLE_TOOL_SEARCH", "true");
1829
1812
  settings.save();
1830
1813
  }
1831
- // Stop the proxy, remove the service and our env keys — a foreign base URL is left alone
1814
+ // Remove our env keys, then stop the proxy and remove the service — a foreign base URL is left alone
1832
1815
  async deactivate() {
1833
- await this.#exit();
1834
- await this.#service.uninstall();
1835
1816
  const settings = new ClaudeSettings();
1836
1817
  if (settings.env?.ANTHROPIC_BASE_URL === PROXY_URL) {
1837
- settings.deleteEnv("ANTHROPIC_BASE_URL");
1818
+ settings.setEnv("ANTHROPIC_BASE_URL", "");
1838
1819
  settings.deleteEnv("ENABLE_TOOL_SEARCH");
1839
1820
  settings.save();
1821
+ await new Promise((r) => setTimeout(r, SETTINGS_RELOAD_GRACE_MS));
1840
1822
  }
1823
+ await this.#exit();
1824
+ await this.#service.uninstall();
1841
1825
  }
1842
1826
  // Ask a running instance to exit via its control route and wait for the port to free up
1843
1827
  async #exit() {
@@ -2052,61 +2036,6 @@ function lockNavigation(contents) {
2052
2036
  contents.on("will-navigate", block);
2053
2037
  contents.on("will-redirect", block);
2054
2038
  }
2055
- const __dirname$2 = path.dirname(fileURLToPath(import.meta.url));
2056
- const DEV_URL$1 = process.env.ELECTRON_RENDERER_URL;
2057
- const WIDTH = 320, HEIGHT = 56, MARGIN = 6;
2058
- class FindBar {
2059
- constructor(win) {
2060
- this.win = win;
2061
- this.view = new WebContentsView({
2062
- webPreferences: {
2063
- preload: path.join(__dirname$2, "../preload/findPreload.cjs"),
2064
- contextIsolation: true,
2065
- nodeIntegration: false,
2066
- sandbox: true
2067
- }
2068
- });
2069
- this.view.setBackgroundColor("#00000000");
2070
- lockNavigation(this.view.webContents);
2071
- win.contentView.addChildView(this.view);
2072
- this.view.setVisible(false);
2073
- if (DEV_URL$1) this.view.webContents.loadURL(`${DEV_URL$1}/find/find.html`);
2074
- else this.view.webContents.loadFile(path.join(__dirname$2, "../renderer/find/find.html"));
2075
- win.webContents.on("found-in-page", (_e, r) => {
2076
- return this.view.webContents.send("find:result", { active: r.activeMatchOrdinal, total: r.matches, id: r.requestId });
2077
- });
2078
- win.on("resize", () => {
2079
- if (this.visible) this.layout();
2080
- });
2081
- }
2082
- get visible() {
2083
- return this.view.getVisible();
2084
- }
2085
- layout() {
2086
- const [w] = this.win.getContentSize();
2087
- this.view.setBounds({ x: Math.max(0, w - WIDTH - MARGIN), y: MARGIN, width: WIDTH, height: HEIGHT });
2088
- }
2089
- query(text, options) {
2090
- this.win.webContents.findInPage(text, options);
2091
- }
2092
- stop() {
2093
- this.win.webContents.stopFindInPage("clearSelection");
2094
- }
2095
- show() {
2096
- this.layout();
2097
- this.view.setVisible(true);
2098
- this.view.webContents.focus();
2099
- this.view.webContents.send("find:open");
2100
- this.win.webContents.send("find:active", true);
2101
- }
2102
- hide() {
2103
- if (!this.visible) return;
2104
- this.view.setVisible(false);
2105
- this.stop();
2106
- this.win.webContents.focus();
2107
- this.win.webContents.send("find:active", false);
2108
- }
2109
- }
2110
2039
  const __dirname$1 = path.dirname(fileURLToPath(import.meta.url));
2111
2040
  const DEV_URL = process.env.ELECTRON_RENDERER_URL;
2112
2041
  class MainWindow {
@@ -2114,6 +2043,7 @@ class MainWindow {
2114
2043
  this.win = null;
2115
2044
  }
2116
2045
  create() {
2046
+ nativeTheme.themeSource = "dark";
2117
2047
  const state = windowStateKeeper({ defaultWidth: 1500, defaultHeight: 900 });
2118
2048
  this.win = new BrowserWindow({
2119
2049
  x: state.x,
@@ -2139,7 +2069,9 @@ class MainWindow {
2139
2069
  } else {
2140
2070
  this.win.loadFile(path.join(__dirname$1, "../renderer/index.html"));
2141
2071
  }
2142
- this.findBar = new FindBar(this.win);
2072
+ this.findBar = new FindOverlay(this.win);
2073
+ this.findBar.on("show", () => this.send("find:active", true));
2074
+ this.findBar.on("hide", () => this.send("find:active", false));
2143
2075
  return this.win;
2144
2076
  }
2145
2077
  send(channel, payload) {
@@ -2185,8 +2117,6 @@ class Application {
2185
2117
  ipcMain.handle("agent:run", (e, text) => agentRunner.run(text, e.sender));
2186
2118
  ipcMain.handle("shell:open-link", (_e, href, baseFile) => openLinkSafely(href, baseFile));
2187
2119
  ipcMain.handle("deeplink:take-pending", () => deepLink?.takePending() ?? null);
2188
- ipcMain.on("find:query", (_e, text, options) => win.findBar?.query(text, options));
2189
- ipcMain.on("find:stop", () => win.findBar?.stop());
2190
2120
  ipcMain.on("find:close", () => win.findBar?.hide());
2191
2121
  ipcMain.on("claude-settings:get", (e) => e.returnValue = { claudeDir: CLAUDE_DIR });
2192
2122
  app.on("activate", () => {
@@ -1085,12 +1085,19 @@ button.msg-summary:hover { color: var(--text); background: var(--bg-elev-2); bor
1085
1085
  /* Instruction files (system prompt / CLAUDE.md / memory) extracted from the captured API requests,
1086
1086
  and the label over each group — one unbroken strip down the whole run. */
1087
1087
  .instruction-file,
1088
- .instruction-source {
1088
+ .instruction-source,
1089
+ .instruction-missing {
1089
1090
  border-left: 2px solid #a78bfa;
1090
1091
  padding-left: 8px;
1091
1092
  margin-left: -10px;
1092
1093
  }
1093
1094
 
1095
+ /* Stands in for the strips when nothing was captured (see InstructionsMissing). */
1096
+ .instruction-missing {
1097
+ color: var(--text-faint);
1098
+ font-size: calc(11px * var(--font-scale, 1));
1099
+ }
1100
+
1094
1101
  /* Group label over the strips read from one part of the request (system prompt / tools / user message). */
1095
1102
  .instruction-source {
1096
1103
  font-family: var(--font-mono), monospace;
@@ -1280,6 +1287,20 @@ button.msg-summary:hover { color: var(--text); background: var(--bg-elev-2); bor
1280
1287
  padding-top: 6px;
1281
1288
  }
1282
1289
 
1290
+ .requests-list li.requests-missed {
1291
+ margin: 18px 0;
1292
+ padding: 6px 10px;
1293
+ background: var(--bg-elev);
1294
+ border-radius: 4px;
1295
+ }
1296
+
1297
+ /* the wavy divider already separates the turns */
1298
+ .requests-list li.requests-missed + li.new-turn {
1299
+ border-top: 0;
1300
+ margin-top: 0;
1301
+ padding-top: 0;
1302
+ }
1303
+
1283
1304
  .requests-list button {
1284
1305
  display: flex;
1285
1306
  align-items: center;
@@ -1471,6 +1492,25 @@ button.msg-summary:hover { color: var(--text); background: var(--bg-elev-2); bor
1471
1492
  .requests-empty {
1472
1493
  padding: 10px 20px;
1473
1494
  }
1495
+
1496
+ /* Empty state: sells the Capture proxy instead of just saying nothing was recorded. */
1497
+ .requests-pitch {
1498
+ margin: 60px auto auto;
1499
+ max-width: 480px;
1500
+ text-align: center;
1501
+ padding: 24px 28px;
1502
+ border: 1px solid rgba(167, 139, 250, 0.35);
1503
+ border-radius: 8px;
1504
+ background: rgba(167, 139, 250, 0.06);
1505
+ color: var(--text-dim);
1506
+ line-height: 1.6;
1507
+
1508
+ & h3 { display: flex; align-items: center; justify-content: center; gap: 8px; margin: 0 0 8px; color: var(--text); font-size: 17px; }
1509
+ & p { margin: 0 0 6px; }
1510
+ & b { color: #a78bfa; }
1511
+ }
1512
+ .requests-pitch-icon { width: 22px; height: 22px; color: #a78bfa; }
1513
+ .requests-pitch-hint { color: var(--text); }
1474
1514
  .agent-prompt-typing {
1475
1515
  display: inline-flex;
1476
1516
  align-items: center;
@@ -34,13 +34,13 @@ function useLocalStorage(key, initial) {
34
34
  }
35
35
  function clearOutdatedLocalStorage() {
36
36
  const version = localStorage.getItem(VERSION_KEY);
37
- if (version !== "1.10.3") {
38
- const [MAJOR, MINOR, PATCH] = "1.10.3".split(".");
37
+ if (version !== "1.10.5") {
38
+ const [MAJOR, MINOR, PATCH] = "1.10.5".split(".");
39
39
  const [major, minor, patch] = (version ?? "").split(".");
40
40
  if (MAJOR !== major || MINOR !== minor) {
41
41
  localStorage.clear();
42
42
  }
43
- localStorage.setItem(VERSION_KEY, "1.10.3");
43
+ localStorage.setItem(VERSION_KEY, "1.10.5");
44
44
  }
45
45
  }
46
46
  function useMouseFontScale(storageKey) {
@@ -892,7 +892,7 @@ function instructionTitle(it, model) {
892
892
  return parts ? `${it.name ?? it.file_path} (${parts})` : it.name ?? it.file_path;
893
893
  }
894
894
  function compactTitle({ trigger, preTokens }) {
895
- const detail = [trigger, preTokens && `${fmtCompact(preTokens)} tokens before`].filter(Boolean).join(" · ");
895
+ const detail = [trigger, preTokens && `${fmtCompact(preTokens)} context tokens before`].filter(Boolean).join(" · ");
896
896
  return detail ? `Conversation compacted (${detail})` : "Conversation compacted";
897
897
  }
898
898
  const TIPS = {
@@ -1122,12 +1122,18 @@ function flatten(items, instructions = []) {
1122
1122
  const companions = {};
1123
1123
  const compactions = {};
1124
1124
  const state = { mode: null, cwd: null, artifacts: /* @__PURE__ */ new Set() };
1125
+ const byUuid = new Map(items.map((it) => [it.uuid, it]));
1126
+ const boundaryOf = (it) => {
1127
+ let p = it.parentUuid;
1128
+ while (byUuid.get(p)?.type === "attachment") p = byUuid.get(p).parentUuid;
1129
+ return p ?? it.uuid;
1130
+ };
1125
1131
  for (let it of items) {
1126
1132
  if (it._relogged) continue;
1127
1133
  const change = stateNote(it, state);
1128
1134
  if (change) turns.push(noteTurn(it, change, `state-${turns.length}`));
1129
1135
  if (it.subtype === "compact_boundary" || it.isCompactSummary) {
1130
- const key = it.isCompactSummary ? it.parentUuid ?? it.uuid : it.uuid;
1136
+ const key = it.isCompactSummary ? boundaryOf(it) : it.uuid;
1131
1137
  let turn = compactions[key];
1132
1138
  if (!turn) turns.push(turn = compactions[key] = metaTurn(it, "compact", [{ type: "compact" }]));
1133
1139
  if (it.isCompactSummary) {
@@ -1530,7 +1536,7 @@ function useCollapsed(defaultOpen) {
1530
1536
  }
1531
1537
  const isCommandTurn = (t) => t.blocks.some((b) => b.type === "text" && parseCommand(b.text)?.name);
1532
1538
  const isClearTurn = (t) => t.blocks.some((b) => b.type === "text" && parseCommand(b.text)?.name === "/clear");
1533
- function ConversationView({ items, instructions = [], filePath = null, expandAll = null, continuesFrom = null, continuesTo = null, onShowPeriodOf }) {
1539
+ function ConversationView({ items, instructions = [], desktop = false, filePath = null, expandAll = null, continuesFrom = null, continuesTo = null, onShowPeriodOf }) {
1534
1540
  const turns = reactExports.useMemo(() => flatten(items, instructions), [items, instructions]);
1535
1541
  const groups = reactExports.useMemo(() => groupTurns(turns), [turns]);
1536
1542
  const points = reactExports.useMemo(() => tokenPoints(groups), [groups]);
@@ -1550,6 +1556,7 @@ function ConversationView({ items, instructions = [], filePath = null, expandAll
1550
1556
  "Continues from ",
1551
1557
  format(continuesFrom, "MMM d, yyyy HH:mm")
1552
1558
  ] }),
1559
+ instructions.length === 0 && /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "conv-row conv-row-instruction", children: /* @__PURE__ */ jsxRuntimeExports.jsx(InstructionsMissing, { desktop }) }),
1553
1560
  groups.map((g, i) => /* @__PURE__ */ jsxRuntimeExports.jsxs(React.Fragment, { children: [
1554
1561
  i === branch?.index && branchDivider,
1555
1562
  /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: `conv-row conv-row-${g.kind}`, children: [
@@ -1857,13 +1864,21 @@ function safeJson(x) {
1857
1864
  function JsonBlock({ value }) {
1858
1865
  return /* @__PURE__ */ jsxRuntimeExports.jsx(Markdown, { className: "block-text", text: "```json\n" + safeJson(value) + "\n```" });
1859
1866
  }
1867
+ const ProxyHeader = ({ children }) => /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "msg-header msg-proxy", children: [
1868
+ /* @__PURE__ */ jsxRuntimeExports.jsx(Globe, { className: "msg-icon" }),
1869
+ /* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: "msg-author", children: "Proxy" }),
1870
+ children
1871
+ ] });
1872
+ function InstructionsMissing({ desktop }) {
1873
+ return /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "instruction-missing", children: [
1874
+ /* @__PURE__ */ jsxRuntimeExports.jsx(ProxyHeader, { children: /* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: "msg-summary", children: "not captured" }) }),
1875
+ desktop ? "Not yet supported for Claude desktop app sessions." : "Turn on the Capture proxy to see the system prompt, tools and memory files."
1876
+ ] });
1877
+ }
1860
1878
  function InstructionRun({ turns, model }) {
1861
1879
  const strips = turns.flatMap((t) => t.blocks).map(({ it }) => ({ ...it, tokens: countTokens(it.content, it.model ?? model) }));
1862
1880
  return /* @__PURE__ */ jsxRuntimeExports.jsxs(jsxRuntimeExports.Fragment, { children: [
1863
- /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "msg-header msg-proxy", children: [
1864
- /* @__PURE__ */ jsxRuntimeExports.jsx(Globe, { className: "msg-icon" }),
1865
- /* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: "msg-author", children: "Proxy" })
1866
- ] }),
1881
+ /* @__PURE__ */ jsxRuntimeExports.jsx(ProxyHeader, {}),
1867
1882
  groupInstructions(strips).map(([label, list]) => /* @__PURE__ */ jsxRuntimeExports.jsxs(React.Fragment, { children: [
1868
1883
  /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "aux instruction-source", children: label }),
1869
1884
  list.map((it) => /* @__PURE__ */ jsxRuntimeExports.jsx(InstructionFile, { it, model, tokens: it.tokens }, `${it.hash ?? it.file_path}-${it.timestamp}`))
@@ -2112,7 +2127,7 @@ const Pane = React.memo(function Pane2({ value, headers, seen, expandAll }) {
2112
2127
  ] })
2113
2128
  ] });
2114
2129
  });
2115
- function RequestsView({ filePath, date, granularity = "day", fileSize = 0, expandAll = null }) {
2130
+ function RequestsView({ filePath, items, date, granularity = "day", fileSize = 0, desktop = false, expandAll = null }) {
2116
2131
  const [records, setRecords] = reactExports.useState(null);
2117
2132
  const [selected, setSelected] = reactExports.useState(0);
2118
2133
  const [tab, setTab] = reactExports.useState("request");
@@ -2143,21 +2158,49 @@ function RequestsView({ filePath, date, granularity = "day", fileSize = 0, expan
2143
2158
  return /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "requests-view", children: error ? /* @__PURE__ */ jsxRuntimeExports.jsx(ErrorMessage, { title: "Couldn't load the captured requests", error }) : /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "requests-empty", children: "Loading…" }) });
2144
2159
  }
2145
2160
  if (!records.length) {
2146
- return /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "requests-view", children: /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "requests-empty", children: "No captured requests for this session. Requests are recorded only while the capture proxy is running." }) });
2161
+ return /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "requests-view", children: /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "requests-pitch", children: [
2162
+ /* @__PURE__ */ jsxRuntimeExports.jsxs("h3", { children: [
2163
+ /* @__PURE__ */ jsxRuntimeExports.jsx(Globe, { className: "requests-pitch-icon" }),
2164
+ "No API requests captured"
2165
+ ] }),
2166
+ /* @__PURE__ */ jsxRuntimeExports.jsx("p", { children: "See the system prompt, tools and hidden calls behind every reply." }),
2167
+ /* @__PURE__ */ jsxRuntimeExports.jsx("p", { className: "requests-pitch-hint", children: desktop ? "Not yet supported for Claude desktop app sessions." : /* @__PURE__ */ jsxRuntimeExports.jsxs(jsxRuntimeExports.Fragment, { children: [
2168
+ "Turn on ",
2169
+ /* @__PURE__ */ jsxRuntimeExports.jsx("b", { children: "Capture proxy" }),
2170
+ " in the status bar."
2171
+ ] }) })
2172
+ ] }) });
2147
2173
  }
2174
+ const captured = new Set(records.map((r) => r.responseHeaders?.["request-id"]));
2175
+ const missed = items?.filter((o) => o.requestId && !captured.has(o.requestId)) ?? [];
2176
+ const gapRow = (from = "", to) => {
2177
+ const n = new Set(missed.filter((o) => o.timestamp >= from && (!to || o.timestamp < to)).map((o) => o.requestId)).size;
2178
+ return n > 0 && /* @__PURE__ */ jsxRuntimeExports.jsx("li", { className: "requests-missed", children: /* @__PURE__ */ jsxRuntimeExports.jsxs(Divider, { children: [
2179
+ n,
2180
+ " ",
2181
+ n === 1 ? "reply" : "replies",
2182
+ " not captured (proxy was off)"
2183
+ ] }) });
2184
+ };
2148
2185
  return /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "requests-view", children: [
2149
- /* @__PURE__ */ jsxRuntimeExports.jsx("ul", { className: "requests-list", children: records.map((r, i) => {
2150
- const [method, path] = splitUrl(r.url);
2151
- return /* @__PURE__ */ jsxRuntimeExports.jsx("li", { className: i > 0 && r.kind?.[0] === "main" ? "new-turn" : "", children: /* @__PURE__ */ jsxRuntimeExports.jsxs("button", { type: "button", className: i === selected ? "active" : "", onClick: () => setSelected(i), children: [
2152
- /* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: `requests-method ${method.toLowerCase()}`, children: method }),
2153
- /* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: "requests-url", children: path }),
2154
- /* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: `requests-status ${!r.status || r.status >= 400 ? "error" : "ok"}`, children: r.status ?? "—" }),
2155
- /* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: "requests-meta", children: time(r.timestamp) }),
2156
- /* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: "requests-meta requests-duration", children: duration(r.durationMs) }),
2157
- /* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: `requests-kind ${r.kind ? `kind-${r.kind[0]}` : ""}`, children: r.kind?.[1] }),
2158
- /* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: "requests-meta requests-tokens", title: "total tokens (input + output + cache)", children: tokens(totalTokens(r)) })
2159
- ] }) }, i);
2160
- }) }),
2186
+ /* @__PURE__ */ jsxRuntimeExports.jsxs("ul", { className: "requests-list", children: [
2187
+ records.map((r, i) => {
2188
+ const [method, path] = splitUrl(r.url);
2189
+ return /* @__PURE__ */ jsxRuntimeExports.jsxs(React.Fragment, { children: [
2190
+ gapRow(records[i - 1]?.timestamp, r.timestamp),
2191
+ /* @__PURE__ */ jsxRuntimeExports.jsx("li", { className: i > 0 && r.kind?.[0] === "main" ? "new-turn" : "", children: /* @__PURE__ */ jsxRuntimeExports.jsxs("button", { type: "button", className: i === selected ? "active" : "", onClick: () => setSelected(i), children: [
2192
+ /* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: `requests-method ${method.toLowerCase()}`, children: method }),
2193
+ /* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: "requests-url", children: path }),
2194
+ /* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: `requests-status ${!r.status || r.status >= 400 ? "error" : "ok"}`, children: r.status ?? "—" }),
2195
+ /* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: "requests-meta", children: time(r.timestamp) }),
2196
+ /* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: "requests-meta requests-duration", children: duration(r.durationMs) }),
2197
+ /* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: `requests-kind ${r.kind ? `kind-${r.kind[0]}` : ""}`, children: r.kind?.[1] }),
2198
+ /* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: "requests-meta requests-tokens", title: "total tokens (input + output + cache)", children: tokens(totalTokens(r)) })
2199
+ ] }) })
2200
+ ] }, i);
2201
+ }),
2202
+ gapRow(records.at(-1).timestamp)
2203
+ ] }),
2161
2204
  /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "requests-detail", children: [
2162
2205
  /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "requests-tabs", children: ["request", "response"].map((t) => /* @__PURE__ */ jsxRuntimeExports.jsx("button", { type: "button", className: t === tab ? "active" : "", onClick: () => setTab(t), children: t }, t)) }),
2163
2206
  /* @__PURE__ */ jsxRuntimeExports.jsx(Pane, { value: body, headers, seen, expandAll })
@@ -2544,6 +2587,7 @@ function Session({ meta, missing, date, granularity = "day", onShowPeriodOf }) {
2544
2587
  const [expandAll, setExpandAll] = reactExports.useState(null);
2545
2588
  const filePath = meta?.filePath;
2546
2589
  const fileSize = meta?.fileSize;
2590
+ const desktop = meta?.entrypoint === "claude-desktop";
2547
2591
  const agent = useAgent(`${filePath}|${date}`);
2548
2592
  reactExports.useEffect(() => {
2549
2593
  setItems(null);
@@ -2594,7 +2638,7 @@ function Session({ meta, missing, date, granularity = "day", onShowPeriodOf }) {
2594
2638
  }
2595
2639
  )
2596
2640
  ] }),
2597
- mode === "conversation" ? /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "view-tab-pane-content", children: items ? /* @__PURE__ */ jsxRuntimeExports.jsx(ConversationView, { items, instructions, filePath: meta.parentFilePath ?? meta.filePath, expandAll, continuesFrom: meta.continuesFrom, continuesTo: meta.continuesTo, onShowPeriodOf }) : loadError || /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "empty", children: "Loading conversation…" }) }) : mode === "jsonl" ? !items && loadError || /* @__PURE__ */ jsxRuntimeExports.jsx(JsonlView, { items, expandAll }) : /* @__PURE__ */ jsxRuntimeExports.jsx(RequestsView, { filePath, date, granularity, fileSize, expandAll })
2641
+ mode === "conversation" ? /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "view-tab-pane-content", children: items ? /* @__PURE__ */ jsxRuntimeExports.jsx(ConversationView, { items, instructions, desktop, filePath: meta.parentFilePath ?? meta.filePath, expandAll, continuesFrom: meta.continuesFrom, continuesTo: meta.continuesTo, onShowPeriodOf }) : loadError || /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "empty", children: "Loading conversation…" }) }) : mode === "jsonl" ? !items && loadError || /* @__PURE__ */ jsxRuntimeExports.jsx(JsonlView, { items, expandAll }) : /* @__PURE__ */ jsxRuntimeExports.jsx(RequestsView, { filePath, items, date, granularity, fileSize, desktop, expandAll })
2598
2642
  ] }),
2599
2643
  /* @__PURE__ */ jsxRuntimeExports.jsx(SessionSummary, { meta, items, instructions, agent, granularity })
2600
2644
  ] }) }) }) }) });
@@ -2663,7 +2707,7 @@ function StatusSwitch({ service, on, warn, button, className = "", tooltip, chan
2663
2707
  }
2664
2708
  const proxyTooltip = /* @__PURE__ */ jsxRuntimeExports.jsxs(jsxRuntimeExports.Fragment, { children: [
2665
2709
  /* @__PURE__ */ jsxRuntimeExports.jsx("p", { children: "Captures Claude Code's raw API traffic - system prompt, tool definitions, injected reminders, responses." }),
2666
- /* @__PURE__ */ jsxRuntimeExports.jsx("p", { children: "Applies only to newly started Claude Code sessions." })
2710
+ /* @__PURE__ */ jsxRuntimeExports.jsx("p", { children: "Applies to running Claude Code sessions too." })
2667
2711
  ] });
2668
2712
  const proxyChanges = /* @__PURE__ */ jsxRuntimeExports.jsxs(jsxRuntimeExports.Fragment, { children: [
2669
2713
  /* @__PURE__ */ jsxRuntimeExports.jsx("pre", { children: `"env": {
@@ -6,10 +6,10 @@
6
6
  <meta charset="UTF-8" />
7
7
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
8
8
  <title>Discover</title>
9
- <script type="module" crossorigin src="./assets/index-By2RDJFe.js"></script>
9
+ <script type="module" crossorigin src="./assets/index-CHnuzPLr.js"></script>
10
10
  <link rel="modulepreload" crossorigin href="./assets/vendors-CpMM-Zb0.js">
11
11
  <link rel="stylesheet" crossorigin href="./assets/vendors-B4XMnGdr.css">
12
- <link rel="stylesheet" crossorigin href="./assets/index-CTIYCpXd.css">
12
+ <link rel="stylesheet" crossorigin href="./assets/index-BMh5255F.css">
13
13
  </head>
14
14
  <body>
15
15
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-discover",
3
- "version": "1.10.3",
3
+ "version": "1.10.5",
4
4
  "type": "module",
5
5
  "description": "Claude Discover — a desktop tool to browse your local Claude Code sessions: conversations, token costs, and captured API traffic",
6
6
  "license": "MIT",
@@ -18,6 +18,7 @@
18
18
  "bin"
19
19
  ],
20
20
  "scripts": {
21
+ "predev": "kill-port 5555 9333",
21
22
  "dev": "electron-vite dev",
22
23
  "build": "electron-vite build",
23
24
  "start": "electron .",
@@ -27,17 +28,18 @@
27
28
  "smoke:local": "npm pack && npx -y ./claude-discover-${npm_package_version}.tgz",
28
29
  "smoke:global": "npm pack && npm i -g ./claude-discover-${npm_package_version}.tgz && claude-discover",
29
30
  "prepack": "npm run build",
30
- "prepublishOnly": "npm whoami && npm test"
31
+ "publish:patch": "npm version patch && git push --follow-tags"
31
32
  },
32
33
  "dependencies": {
33
34
  "@anthropic-ai/claude-agent-sdk": "^0.3.284",
34
35
  "electron": "44.4.5",
35
- "electron-context-menu": "^5.1.0",
36
+ "electron-context-menu": "^4.1.2",
37
+ "electron-find-overlay": "^1.0.0",
36
38
  "electron-window-state": "^5.0.3",
37
39
  "latest-version": "^9.0.0",
38
40
  "semver": "^7.8.5",
39
- "which": "^7.0.0",
40
- "write-file-atomic": "^8.0.0"
41
+ "which": "^6.0.1",
42
+ "write-file-atomic": "^7.0.1"
41
43
  },
42
44
  "devDependencies": {
43
45
  "@date-fns/tz": "^1.5.0",
@@ -50,6 +52,7 @@
50
52
  "electron-vite": "^5.0.0",
51
53
  "gpt-tokenizer": "^4.0.0",
52
54
  "highlight.js": "^11.12.0",
55
+ "kill-port": "^2.0.1",
53
56
  "lucide-react": "^1.43.0",
54
57
  "react": "^19.3.0",
55
58
  "react-dom": "^19.3.0",
@@ -62,6 +65,9 @@
62
65
  "vite": "^7.3.6",
63
66
  "vitest": "^5.0.2"
64
67
  },
68
+ "allowScripts": {
69
+ "esbuild": true
70
+ },
65
71
  "keywords": [
66
72
  "claude",
67
73
  "claude-code",
@@ -1,16 +0,0 @@
1
- "use strict";
2
- const electron = require("electron");
3
- const subscribe = (channel) => (cb) => {
4
- const listener = (_e, payload) => cb(payload);
5
- electron.ipcRenderer.on(channel, listener);
6
- return () => {
7
- electron.ipcRenderer.removeListener(channel, listener);
8
- };
9
- };
10
- electron.contextBridge.exposeInMainWorld("api", {
11
- findInPage: (text, options) => electron.ipcRenderer.send("find:query", text, options),
12
- stopFind: () => electron.ipcRenderer.send("find:stop"),
13
- findClose: () => electron.ipcRenderer.send("find:close"),
14
- onFindOpen: subscribe("find:open"),
15
- onFindResult: subscribe("find:result")
16
- });
@@ -1,88 +0,0 @@
1
- :root {
2
- --bg-elev: #292a2d;
3
- --border-strong: #3c4043;
4
- --text: #e8eaed;
5
- --text-dim: #9aa0a6;
6
- }
7
- * {
8
- box-sizing: border-box;
9
- }
10
- html, body {
11
- margin: 0;
12
- height: 100%;
13
- background: transparent;
14
- overflow: hidden;
15
- }
16
- body {
17
- font-family: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
18
- font-size: 13px;
19
- color: var(--text);
20
- display: flex;
21
- align-items: flex-start;
22
- justify-content: flex-end;
23
- padding: 4px;
24
- }
25
- .find-bar {
26
- display: flex;
27
- align-items: center;
28
- width: 100%;
29
- height: 40px;
30
- padding: 0 4px 0 12px;
31
- background: var(--bg-elev);
32
- border: 1px solid var(--border-strong);
33
- border-radius: 6px;
34
- box-shadow: 0 2px 10px rgba(0, 0, 0, 0.5);
35
- }
36
- input {
37
- flex: 1;
38
- min-width: 0;
39
- background: transparent;
40
- color: var(--text);
41
- border: none;
42
- padding: 0;
43
- font-size: 13px;
44
- outline: none;
45
-
46
- &::placeholder {
47
- color: var(--text-dim);
48
- }
49
- }
50
- .count {
51
- flex-shrink: 0;
52
- padding: 0 10px;
53
- font-size: 12px;
54
- color: var(--text-dim);
55
- white-space: nowrap;
56
- font-variant-numeric: tabular-nums;
57
- }
58
- .divider {
59
- width: 1px;
60
- height: 20px;
61
- margin: 0 2px;
62
- background: var(--border-strong);
63
- }
64
- button {
65
- display: flex;
66
- align-items: center;
67
- justify-content: center;
68
- flex-shrink: 0;
69
- width: 28px;
70
- height: 28px;
71
- margin: 0 1px;
72
- background: transparent;
73
- color: var(--text-dim);
74
- border: none;
75
- border-radius: 50%;
76
- font-size: 15px;
77
- line-height: 1;
78
- cursor: pointer;
79
-
80
- &:hover:not(:disabled) {
81
- background: rgba(255, 255, 255, 0.1);
82
- color: var(--text);
83
- }
84
- &:disabled {
85
- opacity: 0.35;
86
- cursor: default;
87
- }
88
- }
@@ -1,59 +0,0 @@
1
- class FindBar {
2
- constructor() {
3
- this.dom = {
4
- input: document.getElementById("q"),
5
- count: document.getElementById("count"),
6
- prev: document.getElementById("prev"),
7
- next: document.getElementById("next"),
8
- close: document.getElementById("close")
9
- };
10
- this.lastId = 0;
11
- this.timer = null;
12
- window.api.onFindResult((r) => this.onResult(r));
13
- window.api.onFindOpen(() => this.onOpen());
14
- this.dom.input.addEventListener("input", () => this.onInput());
15
- this.dom.input.addEventListener("keydown", (e) => this.onKeydown(e));
16
- this.dom.prev.addEventListener("click", () => this.find(false));
17
- this.dom.next.addEventListener("click", () => this.find(true));
18
- this.dom.close.addEventListener("click", () => window.api.findClose());
19
- }
20
- render(active, total) {
21
- this.dom.count.textContent = this.dom.input.value ? `${active} / ${total}` : "";
22
- this.dom.prev.disabled = this.dom.next.disabled = !total;
23
- }
24
- onResult(r) {
25
- if (r.id < this.lastId) return;
26
- this.lastId = r.id;
27
- this.render(r.active, r.total);
28
- }
29
- onOpen() {
30
- this.lastId = 0;
31
- this.dom.input.focus();
32
- this.dom.input.select();
33
- }
34
- // Debounced so fast typing fires one search.
35
- onInput() {
36
- clearTimeout(this.timer);
37
- if (!this.dom.input.value) {
38
- window.api.stopFind();
39
- this.render(0, 0);
40
- return;
41
- }
42
- this.timer = setTimeout(() => this.find(true), 150);
43
- }
44
- onKeydown(e) {
45
- if (e.key === "Enter") {
46
- e.preventDefault();
47
- this.find(!e.shiftKey);
48
- } else if (e.key === "Escape") {
49
- e.preventDefault();
50
- window.api.findClose();
51
- }
52
- }
53
- // findNext:true reports the first match of a new query (findNext:false searches silently) and
54
- // continues the session on repeats — so it serves both the initial search and next/prev nav.
55
- find(forward) {
56
- if (this.dom.input.value) window.api.findInPage(this.dom.input.value, { forward, findNext: true });
57
- }
58
- }
59
- new FindBar();
@@ -1,26 +0,0 @@
1
- <!doctype html>
2
- <html>
3
- <head>
4
- <meta http-equiv="Content-Security-Policy" content="default-src &#39;self&#39;; script-src &#39;self&#39;; style-src &#39;self&#39; &#39;unsafe-inline&#39;; img-src &#39;self&#39; data:; font-src &#39;self&#39; data:; connect-src &#39;self&#39;; object-src &#39;none&#39;; base-uri &#39;none&#39;; form-action &#39;none&#39;;">
5
-
6
- <meta charset="utf-8" />
7
- <script type="module" crossorigin src="../assets/find-DSEw-KLs.js"></script>
8
- <link rel="stylesheet" crossorigin href="../assets/find-CxcUmpSm.css">
9
- </head>
10
- <body>
11
- <div class="find-bar">
12
- <input id="q" placeholder="Find" autofocus />
13
- <span class="count" id="count"></span>
14
- <span class="divider"></span>
15
- <button id="prev" title="Previous (Shift+Enter)">
16
- <svg viewBox="0 0 16 16" width="14" height="14"><path d="M3.5 10L8 5.5l4.5 4.5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
17
- </button>
18
- <button id="next" title="Next (Enter)">
19
- <svg viewBox="0 0 16 16" width="14" height="14"><path d="M3.5 6L8 10.5 12.5 6" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
20
- </button>
21
- <button id="close" title="Close (Esc)">
22
- <svg viewBox="0 0 16 16" width="13" height="13"><path d="M4 4l8 8M12 4l-8 8" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
23
- </button>
24
- </div>
25
- </body>
26
- </html>