claude-discover 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +74 -0
- package/bin/claude/hooks.mjs +102 -0
- package/bin/claude/statusline.mjs +130 -0
- package/bin/claude-discover.mjs +16 -0
- package/bin/proxy.config.js +19 -0
- package/bin/proxy.mjs +268 -0
- package/out/main/main.js +1542 -0
- package/out/preload/findPreload.cjs +16 -0
- package/out/preload/preload.cjs +28 -0
- package/out/renderer/assets/claude-icon-BqihzN4C.svg +3 -0
- package/out/renderer/assets/find-CxcUmpSm.css +88 -0
- package/out/renderer/assets/find-DSEw-KLs.js +59 -0
- package/out/renderer/assets/index-0bBLuYQv.css +2331 -0
- package/out/renderer/assets/index-BLwpO6Wp.js +2629 -0
- package/out/renderer/assets/inter-latin-400-normal-C38fXH4l.woff2 +0 -0
- package/out/renderer/assets/inter-latin-400-normal-CyCys3Eg.woff +0 -0
- package/out/renderer/assets/inter-latin-700-normal-BLAVimhd.woff +0 -0
- package/out/renderer/assets/inter-latin-700-normal-Yt3aPRUw.woff2 +0 -0
- package/out/renderer/assets/jetbrains-mono-latin-200-normal-CHn02WOn.woff +0 -0
- package/out/renderer/assets/jetbrains-mono-latin-200-normal-DvzYDkvL.woff2 +0 -0
- package/out/renderer/assets/jetbrains-mono-latin-400-normal-6-qcROiO.woff +0 -0
- package/out/renderer/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
- package/out/renderer/assets/jetbrains-mono-latin-700-normal-BYuf6tUa.woff2 +0 -0
- package/out/renderer/assets/jetbrains-mono-latin-700-normal-D3wTyLJW.woff +0 -0
- package/out/renderer/assets/vendors-B4XMnGdr.css +153 -0
- package/out/renderer/assets/vendors-D6iOvAG3.js +49248 -0
- package/out/renderer/find/find.html +26 -0
- package/out/renderer/index.html +17 -0
- package/package.json +75 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Georgi Dichev
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
> Session browser + Token costs + API traffic
|
|
2
|
+
# <img src="src/renderer/assets/claude-icon.svg" width="20"> Claude Discover
|
|
3
|
+
|
|
4
|
+
Ever wondered what Claude Code is **actually** doing all day?
|
|
5
|
+
|
|
6
|
+
<img width="1660" height="1256" alt="claude-discover-dimmed-screenshot" src="https://github.com/user-attachments/assets/708d05c7-8769-47d8-b5bd-14337d1d6037" />
|
|
7
|
+
<br><br>
|
|
8
|
+
|
|
9
|
+
**Claude Discover** is a desktop tool that shows you what **Claude Code** is actually doing. It collects all your sessions (from the CLI, Desktop, or SDK) and puts them on one timeline, where you can read every conversation, peek at the system prompts, memories, and tools, inspect the raw API traffic behind it all, and see exactly where your tokens - and your money - went. (Spoiler: mostly on re-sending tool definitions.)
|
|
10
|
+
|
|
11
|
+
No configuration, no API keys, and **everything stays on your machine**.
|
|
12
|
+
|
|
13
|
+
## Getting started
|
|
14
|
+
|
|
15
|
+
The tool works on Windows, macOS, and Linux. If you have Node.js 22+, just try it:
|
|
16
|
+
```
|
|
17
|
+
npx claude-discover@latest
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Like it and want to keep it around? Install it:
|
|
21
|
+
```
|
|
22
|
+
npm install -g claude-discover
|
|
23
|
+
claude-discover
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Features
|
|
27
|
+
|
|
28
|
+
- **Timeline**\
|
|
29
|
+
Your Claude sessions laid out on a daily, weekly, or monthly timeline. One glance tells you where your tokens went; the second finds the session that quietly spawned 100+ agents.
|
|
30
|
+
|
|
31
|
+
- **Sessions list**\
|
|
32
|
+
Filter and sort every session in the selected period. Warning badges flag the expensive habits: oversized context, extended 1h cache, fast mode, marathon sessions - and more to come.
|
|
33
|
+
|
|
34
|
+
- **Session preview**
|
|
35
|
+
- **Conversation view**\
|
|
36
|
+
The full transcript rendered like a chat: your prompts, Claude's replies, and every tool call expanded. Everything the terminal scrolled past, now readable at human pace.
|
|
37
|
+
|
|
38
|
+
- **Raw logs**\
|
|
39
|
+
The same session exactly as Claude Code stores it on disk, one JSON line at a time. For when you don't want the story, you want the evidence.
|
|
40
|
+
|
|
41
|
+
- **API request inspector**\
|
|
42
|
+
A Postman-like view of every API request and response, captured by an optional local proxy (one click on, one click off). This is where the secrets live: the system prompt, your CLAUDE.md and memory files, tool definitions, injected reminders - all the stuff session logs politely never record.
|
|
43
|
+
|
|
44
|
+
- **Session summary**\
|
|
45
|
+
Token usage and cost for every session, and totals for the whole period. Computed from your local transcripts with current model rates - accurate to the cent.
|
|
46
|
+
|
|
47
|
+
- **AI session analysis**\
|
|
48
|
+
Ask Claude to review a session and tell you where the tokens leaked - with concrete hints on how to spend fewer next time. Yes, that's Claude critiquing Claude's own spending habits, and yes it runs on your own subscription and local credentials.
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
## Bonus - a status line
|
|
52
|
+
Install the bundled Claude Code status line to see context usage, token counts, and rate limits right in your terminal - so the surprise comes *before* the limit, not after:
|
|
53
|
+
```text
|
|
54
|
+
[Opus 4.8] Context: ▓▓▓░░░░░░░ 32% used (45.2k, 87% cached) | Tokens: 1.2M total (+45.2k, 3 turns) ...
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Contributing
|
|
58
|
+
|
|
59
|
+
PRs and feature requests are welcome.
|
|
60
|
+
```
|
|
61
|
+
git clone https://github.com/dichev/claude-discover.git && cd claude-discover
|
|
62
|
+
npm install
|
|
63
|
+
npm run dev # start the tool with vite's hot reload
|
|
64
|
+
npm test # run the test suite
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
**Verifying token/cost calculations** - trust, but verify. You can print the tool's per-period token usage and cost as a terminal table, and then compare it against **ccusage** - the numbers should match to the cent:
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
node test/scripts/usage.mjs monthly --timezone UTC
|
|
71
|
+
npx ccusage claude monthly -z UTC
|
|
72
|
+
```
|
|
73
|
+
---
|
|
74
|
+
**[MIT license](LICENSE)** - Not affiliated with Anthropic.
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/*
|
|
3
|
+
Single entry point for every Claude Code hook this app installs — reads the hook event JSON from stdin
|
|
4
|
+
and dispatches on hook_event_name; add new hooks as new branches in main(). Standalone (no imports from
|
|
5
|
+
src/), wired into <CLAUDE_DIR>/settings.json by the app's ProxySwitch (StatusBar Activate/Deactivate button).
|
|
6
|
+
|
|
7
|
+
Currently handled:
|
|
8
|
+
- SessionStart → ensureProxy(): revive the request-capture proxy (bin/proxy.mjs) if it's
|
|
9
|
+
down. settings.json's env.ANTHROPIC_BASE_URL survives a PC restart or proxy crash, and without this every
|
|
10
|
+
Claude Code request would hit a dead port. A SessionStart hook's stdout is injected into the session
|
|
11
|
+
context, so only stderr may speak — exit 1 shows the warning without blocking the session.
|
|
12
|
+
|
|
13
|
+
Copy-paste into <CLAUDE_DIR>/settings.json (replace the path):
|
|
14
|
+
{
|
|
15
|
+
"hooks": {
|
|
16
|
+
"SessionStart": [
|
|
17
|
+
{ "hooks": [{ "type": "command", "command": "node /ABSOLUTE/PATH/TO/claude-discover/bin/claude/hooks.mjs" }] }
|
|
18
|
+
]
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
import fs from 'node:fs'
|
|
25
|
+
import path from 'node:path'
|
|
26
|
+
import { spawn } from 'node:child_process'
|
|
27
|
+
import { text } from 'node:stream/consumers'
|
|
28
|
+
import { PROXY_URL, PING_ROUTE, PING_RESPONSE, DATA_DIR, ERROR_LOG_PATH } from '../proxy.config.js'
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
const DEBUG_LOG = false // Log every incoming hook event to DEBUG_LOG_FILE
|
|
32
|
+
const DEBUG_LOG_FILE = path.join(DATA_DIR, 'hooks.debug.log')
|
|
33
|
+
const ERROR_LOG = true // Log hook errors to ERROR_LOG_FILE
|
|
34
|
+
const ERROR_LOG_FILE = path.join(DATA_DIR, 'hooks.error.log')
|
|
35
|
+
|
|
36
|
+
const CLAUDE_HOOKS = {
|
|
37
|
+
SESSION_START: 'SessionStart',
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const PROXY_PATH = path.join(import.meta.dirname, '..', 'proxy.mjs')
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
function log(file, data) {
|
|
44
|
+
try {
|
|
45
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
46
|
+
fs.appendFileSync(file, `${new Date().toISOString()} ${data}\n`)
|
|
47
|
+
} catch {}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
async function ping() {
|
|
52
|
+
try {
|
|
53
|
+
const res = await fetch(`${PROXY_URL}${PING_ROUTE}`, { signal: AbortSignal.timeout(1000) })
|
|
54
|
+
return res.ok && (await res.text()) === PING_RESPONSE
|
|
55
|
+
} catch { return false }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
async function ensureProxy() {
|
|
60
|
+
if (await ping()) return // the common case — one loopback round-trip per session start
|
|
61
|
+
|
|
62
|
+
const child = spawn(process.execPath, [PROXY_PATH], { detached: true, stdio: 'ignore', windowsHide: true })
|
|
63
|
+
const exited = new Promise(resolve => child.once('exit', resolve)) // idempotent: exits 0 when one is already listening
|
|
64
|
+
child.unref()
|
|
65
|
+
|
|
66
|
+
// Poll until it answers (startup includes a ≤5s upstream check), racing the child's exit.
|
|
67
|
+
const settle = (async () => {
|
|
68
|
+
for (let i = 0; i < 40; i++) {
|
|
69
|
+
if (await ping()) return 'up'
|
|
70
|
+
await new Promise(r => setTimeout(r, 200))
|
|
71
|
+
}
|
|
72
|
+
return 'unresponsive'
|
|
73
|
+
})()
|
|
74
|
+
|
|
75
|
+
const outcome = await Promise.race([exited, settle])
|
|
76
|
+
if (outcome === 'up' || await ping()) return // up, or a proxy of ours won the race and is now answering
|
|
77
|
+
|
|
78
|
+
// Exit 0 ("already running") + failed ping = a foreign process holds the port Claude Code sends API traffic to
|
|
79
|
+
let msg = `capture proxy failed to start (see ${ERROR_LOG_PATH}) — Claude Code cannot reach the API until it runs; disable capture via the app's Stop button to unblock`
|
|
80
|
+
if (outcome === 0) msg = `${PROXY_URL} is held by another process that is not the capture proxy — Claude Code is configured to send API traffic there`
|
|
81
|
+
if (outcome === 2) msg = 'cannot reach api.anthropic.com — capture proxy not started'
|
|
82
|
+
console.error('claude-discover: ' + msg)
|
|
83
|
+
process.exitCode = 1
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
async function main() {
|
|
88
|
+
const raw = await text(process.stdin)
|
|
89
|
+
if (DEBUG_LOG) log(DEBUG_LOG_FILE, raw.trim())
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
const event = JSON.parse(raw || '{}') // inside the try so a malformed payload is logged, not silently swallowed
|
|
93
|
+
if (event.hook_event_name === CLAUDE_HOOKS.SESSION_START) {
|
|
94
|
+
await ensureProxy()
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
if (ERROR_LOG) log(ERROR_LOG_FILE, err?.stack || err)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
main().catch(() => {}).finally(() => process.exit(process.exitCode ?? 0))
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
//
|
|
3
|
+
// Claude Code status line. Reads the hook JSON from stdin and prints one colored line
|
|
4
|
+
// summarizing the model, context-window usage, token totals, and rate-limit windows.
|
|
5
|
+
// Installed as the `statusLine` command from the app: StatusBar → Status line → Activate
|
|
6
|
+
// (see src/main/services/switchers/StatuslineSwitch.js).
|
|
7
|
+
//
|
|
8
|
+
// Example output:
|
|
9
|
+
// [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) | Weekly limit: 18% used (resets in 3d 4h)
|
|
10
|
+
//
|
|
11
|
+
|
|
12
|
+
import { readFileSync } from 'node:fs'
|
|
13
|
+
import { styleText } from 'node:util'
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
// Formatting
|
|
17
|
+
const THRESHOLDS = { // (white, yellow, red)
|
|
18
|
+
context: [0, 100_000, 160_000],
|
|
19
|
+
limit: [0, 60, 90],
|
|
20
|
+
cache: [90, 60, 0],
|
|
21
|
+
tokens: [0, 250_000, 1_000_000],
|
|
22
|
+
session: [0, 5_000_000, 20_000_000],
|
|
23
|
+
turns: [0, 10, 25],
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const NO_COLOR = process.env.NO_COLOR !== undefined // standard opt-out; styleText can't honor it for piped output, so gate here
|
|
27
|
+
|
|
28
|
+
function colorize(kind, text, value = 0) {
|
|
29
|
+
const bounds = THRESHOLDS[kind]
|
|
30
|
+
const n = bounds.filter(b => value >= b).length
|
|
31
|
+
const TIERS = [null, 'yellow', 'red'] // indexed by severity tier
|
|
32
|
+
const idx = bounds[0] < bounds.at(-1) ? n - 1 : bounds.length - n
|
|
33
|
+
const color = TIERS[idx]
|
|
34
|
+
// validateStream:false forces codes through the pipe (statusline output isn't a TTY)
|
|
35
|
+
return color && !NO_COLOR ? styleText(color, text, { validateStream: false }) : text
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
// Collect stdin data
|
|
40
|
+
const data = JSON.parse(readFileSync(0, 'utf-8')) // fd 0 = stdin
|
|
41
|
+
const modelName = data.model?.display_name?.replace('Claude ', '') || '?'
|
|
42
|
+
const parts = []
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
// Context window
|
|
47
|
+
const ctx = data.context_window || {}
|
|
48
|
+
const total = ctx.context_window_size || 0
|
|
49
|
+
if (total) {
|
|
50
|
+
const usage = ctx.current_usage || {}
|
|
51
|
+
const hit = usage.cache_read_input_tokens || 0
|
|
52
|
+
const used = hit + (usage.cache_creation_input_tokens || 0) + (usage.input_tokens || 0)
|
|
53
|
+
const pct = Math.round((used / total) * 100)
|
|
54
|
+
const cpct = used ? Math.floor((hit * 100) / used) : 0
|
|
55
|
+
const filled = Math.floor(pct / 10)
|
|
56
|
+
const bar = '▓'.repeat(filled) + '░'.repeat(10 - filled)
|
|
57
|
+
const head = colorize('context', `${bar} ${pct}% used`, used)
|
|
58
|
+
const tokens = colorize('context', `${(used / 1000).toFixed(1)}k`, used)
|
|
59
|
+
const cached = used ? colorize('cache', `${cpct}% cached`, cpct) : `${cpct}% cached`
|
|
60
|
+
parts.push(`Context: ${head} (${tokens}, ${cached})`)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
// Total tokens (per session and per reply loop)
|
|
66
|
+
function agentLoopUsage(transcriptPath) { // Read the transcript JSONL and sum API usage for the current agent loop and the whole session.
|
|
67
|
+
const lines = readFileSync(transcriptPath, 'utf-8').split(/\r?\n/).filter(Boolean)
|
|
68
|
+
let loop = { total: 0, turns: 0 } // last prompt-reply breakdown
|
|
69
|
+
const session = { total: 0, turns: 0 } // whole-session breakdown
|
|
70
|
+
let seen = new Set() // One API response spans multiple lines (one per content block), each repeating the usage block, so dedupe by message.id.
|
|
71
|
+
|
|
72
|
+
for (const line of lines) {
|
|
73
|
+
let e
|
|
74
|
+
try { e = JSON.parse(line) } catch { continue } // the transcript may be mid-append — skip a half-written line
|
|
75
|
+
const msg = e.message || {}
|
|
76
|
+
|
|
77
|
+
if (e.type === 'user' && !e.isMeta) {
|
|
78
|
+
const isToolReply = Array.isArray(msg.content) && msg.content.some(b => b.type === 'tool_result')
|
|
79
|
+
if (!isToolReply) { // real user prompt = new loop
|
|
80
|
+
loop = { total: 0, turns: 0 }
|
|
81
|
+
seen = new Set()
|
|
82
|
+
}
|
|
83
|
+
} else if (e.type === 'assistant' && !seen.has(msg.id)) {
|
|
84
|
+
seen.add(msg.id)
|
|
85
|
+
const u = msg.usage || {}
|
|
86
|
+
const tokens = (u.input_tokens || 0) + (u.output_tokens || 0) + (u.cache_creation_input_tokens || 0) + (u.cache_read_input_tokens || 0)
|
|
87
|
+
for (const d of [loop, session]) {
|
|
88
|
+
d.total += tokens
|
|
89
|
+
d.turns += 1
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return [loop, session]
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (data.transcript_path) {
|
|
97
|
+
const [loop, session] = agentLoopUsage(data.transcript_path)
|
|
98
|
+
const fmt = n => n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : `${(n / 1000).toFixed(1)}k`
|
|
99
|
+
const tokensS = colorize('tokens', fmt(loop.total), loop.total)
|
|
100
|
+
const sessS = colorize('session', fmt(session.total), session.total)
|
|
101
|
+
const turnsS = colorize('turns', `${loop.turns} turn${loop.turns !== 1 ? 's' : ''}`, loop.turns)
|
|
102
|
+
parts.push(`Tokens: ${sessS} total (+${tokensS}, ${turnsS})`)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
// Rate limits (5-hour and weekly windows)
|
|
108
|
+
function fmtReset(resetsAt) { // Format remaining time until a unix-epoch reset, e.g. "2h 30m" (null if no timestamp).
|
|
109
|
+
if (!resetsAt) return null
|
|
110
|
+
const secs = Math.max(0, Math.floor(Number(resetsAt)) - Math.floor(Date.now() / 1000))
|
|
111
|
+
const min = Math.floor(secs / 60) % 60
|
|
112
|
+
const hrs = Math.floor(secs / 3600) % 24
|
|
113
|
+
const days = Math.floor(secs / 86400)
|
|
114
|
+
if (days) return `${days}d ${hrs}h`
|
|
115
|
+
if (hrs) return `${hrs}h ${min}m`
|
|
116
|
+
return `${min}m`
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const rl = data.rate_limits || {}
|
|
120
|
+
for (const [label, window] of [['Usage limit', rl.five_hour], ['Weekly limit', rl.seven_day]]) {
|
|
121
|
+
if (!window) continue
|
|
122
|
+
const pct = Math.floor(window.used_percentage || 0)
|
|
123
|
+
const reset = fmtReset(window.resets_at)
|
|
124
|
+
const resetStr = reset ? ` (resets in ${reset})` : ''
|
|
125
|
+
parts.push(`${label}: ${colorize('limit', `${pct}% used${resetStr}`, pct)}`)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
// Status line
|
|
130
|
+
process.stdout.write(`[${modelName}] ` + parts.join(' | ') + '\n')
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Launches the app from an npm install (`npx claude-discover`): points the local
|
|
3
|
+
// Electron binary at the package root, whose package.json main is out/main/main.js.
|
|
4
|
+
import { spawn } from 'node:child_process'
|
|
5
|
+
import { existsSync } from 'node:fs'
|
|
6
|
+
import { fileURLToPath } from 'node:url'
|
|
7
|
+
import electron from 'electron' // under plain node the electron package exports its binary path
|
|
8
|
+
|
|
9
|
+
const appDir = fileURLToPath(new URL('..', import.meta.url))
|
|
10
|
+
if (!existsSync(new URL('../out/main/main.js', import.meta.url))) {
|
|
11
|
+
console.error('claude-discover: missing build output — run `npm run build` first')
|
|
12
|
+
process.exit(1)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
spawn(electron, [appDir, ...process.argv.slice(2)], { stdio: 'inherit' })
|
|
16
|
+
.on('close', code => process.exit(code ?? 0))
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Shared config for the request-capture proxy (bin/proxy.mjs) — the single place its port,
|
|
2
|
+
// routes, ping body and paths are defined, imported by bin/ and src/main/. Sole exception:
|
|
3
|
+
// the renderer doesn't import from bin/, so StatusBar's tooltip hardcodes PROXY_URL.
|
|
4
|
+
|
|
5
|
+
import os from 'node:os'
|
|
6
|
+
import path from 'node:path'
|
|
7
|
+
|
|
8
|
+
export const HOST = '127.0.0.1' // loopback only, on purpose — the proxy sees auth headers and must never listen on external interfaces
|
|
9
|
+
export const PORT = process.env.CLAUDE_DISCOVER_PORT || 41414 // arbitrary uncommon port (unregistered, unlikely to collide); env override used only by tests
|
|
10
|
+
export const PROXY_URL = `http://${HOST}:${PORT}`
|
|
11
|
+
export const UPSTREAM = process.env.CLAUDE_DISCOVER_UPSTREAM || 'https://api.anthropic.com' // env override used only by tests
|
|
12
|
+
|
|
13
|
+
export const EXIT_ROUTE = '/claude-discover/exit' // POST here makes the proxy exit — how --restart replaces a running instance (the port is loopback-only and assumed ours)
|
|
14
|
+
export const PING_ROUTE = '/claude-discover/ping' // answered directly (never forwarded) — polled by the app's ProxySwitch to show/settle the running state
|
|
15
|
+
export const PING_RESPONSE = 'claude-discover-proxy' // the ping body — proves it's this proxy on the port, not some other process
|
|
16
|
+
|
|
17
|
+
export const DATA_DIR = process.env.CLAUDE_DISCOVER_DIR || path.join(os.homedir(), '.claude-discover') // global on purpose — session ids are unique, so one flat dir serves every Claude dir (env override used only by tests)
|
|
18
|
+
export const REQUESTS_DIR = path.join(DATA_DIR, 'requests') // logs are kept forever — nothing deletes them
|
|
19
|
+
export const ERROR_LOG_PATH = path.join(DATA_DIR, 'proxy.error.log') // capture errors — capture must never fail a request
|
package/bin/proxy.mjs
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
// Local logging proxy for Claude Code's API traffic — captures what transcripts never record
|
|
2
|
+
// (system prompt, tool definitions, injected reminders, request params) plus the raw responses.
|
|
3
|
+
// Claude Code is pointed at it via env.ANTHROPIC_BASE_URL (installed by the app's StatusBar
|
|
4
|
+
// Activate button); started manually with `npm run proxy`. Capture must never fail or delay a request — errors go
|
|
5
|
+
// to ~/.claude-discover/proxy.error.log instead of the client.
|
|
6
|
+
//
|
|
7
|
+
// Usage: node bin/proxy.mjs [--restart] — port/upstream/dir come from proxy.config.js ($CLAUDE_DISCOVER_* env overrides used only by tests)
|
|
8
|
+
|
|
9
|
+
import fs from 'node:fs'
|
|
10
|
+
import path from 'node:path'
|
|
11
|
+
import http from 'node:http'
|
|
12
|
+
import https from 'node:https'
|
|
13
|
+
import zlib from 'node:zlib'
|
|
14
|
+
import crypto from 'node:crypto'
|
|
15
|
+
import { pathToFileURL } from 'node:url'
|
|
16
|
+
import { parseArgs } from 'node:util'
|
|
17
|
+
|
|
18
|
+
import { HOST, PORT, PROXY_URL, UPSTREAM, EXIT_ROUTE, PING_ROUTE, PING_RESPONSE, REQUESTS_DIR, ERROR_LOG_PATH } from './proxy.config.js'
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
// ── 1. Generic tee proxy — knows nothing about Claude ────────────────────────
|
|
22
|
+
// Forwards verbatim to `upstream` and pipes the response back while buffering both bodies.
|
|
23
|
+
// `onExchange(exchange)` fires exactly once per request, after piping (its response fields are
|
|
24
|
+
// undefined when upstream never responded); `errorBody(err)` shapes the 502 when it's unreachable.
|
|
25
|
+
|
|
26
|
+
function createProxy({ upstream, onExchange, onError, errorBody }) {
|
|
27
|
+
const mod = upstream.protocol === 'http:' ? http : https
|
|
28
|
+
|
|
29
|
+
return http.createServer(async (req, res) => {
|
|
30
|
+
if (req.method === 'POST' && req.url === EXIT_ROUTE) {
|
|
31
|
+
// 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.
|
|
32
|
+
if (req.headers.origin || req.headers.referer || req.headers['sec-fetch-site']) return res.writeHead(403).end('forbidden')
|
|
33
|
+
return res.end('bye', () => process.exit(0))
|
|
34
|
+
}
|
|
35
|
+
if (req.url === PING_ROUTE) return res.end(PING_RESPONSE)
|
|
36
|
+
const started = Date.now()
|
|
37
|
+
let requestBody
|
|
38
|
+
try { requestBody = Buffer.concat(await req.toArray()) } catch { return res.destroy() }
|
|
39
|
+
|
|
40
|
+
let done = false // response 'close' and upstream 'error' can race — fire onExchange exactly once
|
|
41
|
+
const finish = (status, responseHeaders, responseBody) => {
|
|
42
|
+
if (done) return
|
|
43
|
+
done = true
|
|
44
|
+
const exchange = { req, requestBody, status, responseHeaders, responseBody, timestamp: new Date(started).toISOString(), durationMs: Date.now() - started }
|
|
45
|
+
try { onExchange(exchange) } catch (err) { onError(err, `logging ${req.method} ${req.url}`) }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const headers = { ...req.headers, host: upstream.host }
|
|
49
|
+
delete headers.connection
|
|
50
|
+
|
|
51
|
+
// Claude code offers "gzip, deflate, br, zstd", but we accept gzip only to keep it simple
|
|
52
|
+
if (/\bgzip\b/.test(headers['accept-encoding'] ?? '')) {
|
|
53
|
+
headers['accept-encoding'] = 'gzip'
|
|
54
|
+
} else {
|
|
55
|
+
delete headers['accept-encoding']
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const up = mod.request(new URL(req.url, upstream), { method: req.method, headers }, upRes => {
|
|
59
|
+
const resHeaders = { ...upRes.headers }
|
|
60
|
+
delete resHeaders['transfer-encoding'] // node re-frames the piped body itself
|
|
61
|
+
delete resHeaders.connection
|
|
62
|
+
res.writeHead(upRes.statusCode, resHeaders)
|
|
63
|
+
const chunks = []
|
|
64
|
+
upRes.on('data', c => chunks.push(c))
|
|
65
|
+
upRes.pipe(res)
|
|
66
|
+
// 'close' fires after 'end' and also on an aborted stream — a truncated body is still captured
|
|
67
|
+
upRes.on('close', () => {
|
|
68
|
+
finish(upRes.statusCode, upRes.headers, decode(upRes.headers['content-encoding'], Buffer.concat(chunks)))
|
|
69
|
+
if (!upRes.complete) res.destroy() // upstream died mid-body — surface the reset so the client can retry
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
// Client gone mid-exchange — tear down upstream too; headersSent means upstream responded and its 'close' captures the partial body, otherwise log the attempt here (up.destroy() emits no 'error')
|
|
73
|
+
res.on('close', () => {
|
|
74
|
+
if (done) return
|
|
75
|
+
up.destroy()
|
|
76
|
+
if (!res.headersSent) finish()
|
|
77
|
+
})
|
|
78
|
+
up.on('error', err => {
|
|
79
|
+
onError(err, `${req.method} ${req.url} → ${upstream.host}`)
|
|
80
|
+
finish()
|
|
81
|
+
if (res.headersSent) return res.destroy() // mid-stream failure — never append an error body to a partially-piped response
|
|
82
|
+
res.writeHead(502, { 'content-type': 'application/json' })
|
|
83
|
+
res.end(errorBody(err))
|
|
84
|
+
})
|
|
85
|
+
up.end(requestBody)
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// gzip is the only encoding ever advertised upstream, so it's the only decoder needed.
|
|
90
|
+
// Z_SYNC_FLUSH tolerates a truncated stream (aborted response) and yields what did decompress.
|
|
91
|
+
function decode(encoding, buf) {
|
|
92
|
+
if (encoding !== 'gzip') return buf.toString('utf8')
|
|
93
|
+
try { return zlib.gunzipSync(buf, { finishFlush: zlib.constants.Z_SYNC_FLUSH }).toString('utf8') } catch { return buf.toString('utf8') }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ── 2. Claude Code capture ───────────────────────────────────────────────────
|
|
97
|
+
// One JSON record per exchange in ~/.claude-discover/requests/<sessionId>.requests.jsonl
|
|
98
|
+
// (session id from the X-Claude-Code-Session-Id header).
|
|
99
|
+
|
|
100
|
+
// Keep the header name (useful signal) but never the credential.
|
|
101
|
+
function redactHeaders(headers, keys) {
|
|
102
|
+
for (const k of keys) if (headers?.[k]) headers[k] = '<redacted>'
|
|
103
|
+
return headers
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function logRequest({ req, requestBody, status, responseHeaders, responseBody, timestamp, durationMs }) {
|
|
107
|
+
const sessionId = req.headers['x-claude-code-session-id']
|
|
108
|
+
if (!sessionId || !/^[\w-]+$/.test(sessionId)) return // header value becomes a filename — accept only safe ids
|
|
109
|
+
const logPath = path.join(REQUESTS_DIR, `${sessionId}.requests.jsonl`)
|
|
110
|
+
const record = { type: 'api-request', timestamp, url: `${req.method} ${req.url}`, status, durationMs }
|
|
111
|
+
record.requestHeaders = redactHeaders(req.headers, ['authorization', 'x-api-key', 'proxy-authorization', 'cookie'])
|
|
112
|
+
record.responseHeaders = redactHeaders(responseHeaders, ['set-cookie']) // upstream sits behind Cloudflare
|
|
113
|
+
let body
|
|
114
|
+
try { body = JSON.parse(requestBody.toString('utf8')) } catch {}
|
|
115
|
+
// Every endpoint is captured in full — count_tokens probes re-send the conversation, so the
|
|
116
|
+
// shared per-session seen-set collapses their payloads to $refs. An unparsable body: size only.
|
|
117
|
+
if (body) record.request = dedupeRequest(body, seenFor(sessionId, logPath))
|
|
118
|
+
else if (requestBody.length) record.size = requestBody.length
|
|
119
|
+
record.response = parseResponse(responseHeaders?.['content-type'], responseBody)
|
|
120
|
+
fs.mkdirSync(REQUESTS_DIR, { recursive: true })
|
|
121
|
+
fs.appendFileSync(logPath, JSON.stringify(record) + '\n')
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function parseResponse(contentType, text) {
|
|
125
|
+
if (text == null) return undefined // upstream never responded — the attempt is logged without a response
|
|
126
|
+
if (contentType?.includes('text/event-stream')) return assembleSSE(text)
|
|
127
|
+
try { return JSON.parse(text) } catch { return text.slice(0, 20_000) }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Folds a raw SSE stream back into the final API message — what a non-streaming request would have
|
|
131
|
+
// returned. Malformed/truncated streams degrade to { $sseParseError, raw } instead of throwing.
|
|
132
|
+
export function assembleSSE(text) {
|
|
133
|
+
try {
|
|
134
|
+
let message
|
|
135
|
+
const partialJson = {} // content index → accumulated input_json_delta string
|
|
136
|
+
for (const chunk of text.split(/\r?\n\r?\n/)) {
|
|
137
|
+
const data = chunk.split(/\r?\n/).find(l => l.startsWith('data:'))
|
|
138
|
+
if (!data) continue
|
|
139
|
+
const ev = JSON.parse(data.slice(5))
|
|
140
|
+
const block = message?.content[ev.index]
|
|
141
|
+
switch (ev.type) {
|
|
142
|
+
case 'message_start': message = ev.message; break
|
|
143
|
+
case 'content_block_start': message.content[ev.index] = ev.content_block; break
|
|
144
|
+
case 'content_block_delta': {
|
|
145
|
+
const d = ev.delta
|
|
146
|
+
if (d.type === 'text_delta') block.text += d.text
|
|
147
|
+
else if (d.type === 'thinking_delta') block.thinking += d.thinking
|
|
148
|
+
else if (d.type === 'signature_delta') block.signature = (block.signature ?? '') + d.signature
|
|
149
|
+
else if (d.type === 'input_json_delta') partialJson[ev.index] = (partialJson[ev.index] ?? '') + d.partial_json
|
|
150
|
+
break
|
|
151
|
+
}
|
|
152
|
+
case 'content_block_stop': if (ev.index in partialJson) block.input = JSON.parse(partialJson[ev.index] || '{}'); break
|
|
153
|
+
case 'message_delta': Object.assign(message, ev.delta); Object.assign(message.usage ??= {}, ev.usage); break
|
|
154
|
+
case 'error': return ev
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (!message) throw new Error('no message_start event')
|
|
158
|
+
return message
|
|
159
|
+
} catch (err) {
|
|
160
|
+
return { $sseParseError: String(err?.message || err), raw: text.slice(0, 20_000) }
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ── 3. Dedup hashing ─────────────────────────────────────────────────────────
|
|
165
|
+
// The API re-sends the whole conversation each turn, so each bulk value is logged in full
|
|
166
|
+
// ({ $hash, value }) only the first time, then as { $ref }; readers rebuild the map top-down.
|
|
167
|
+
|
|
168
|
+
// The logged request body: params verbatim, bulk fields (system, tools, each message) deduped.
|
|
169
|
+
export function dedupeRequest(body, seen = new Set()) {
|
|
170
|
+
const dedupe = value => {
|
|
171
|
+
// hash with cache_control stripped — breakpoints move between requests, unchanged content must still match
|
|
172
|
+
const json = JSON.stringify(value, (k, v) => k === 'cache_control' ? undefined : v)
|
|
173
|
+
// 16 base64url chars = 96 bits — a collision (silent: a new value logged as someone else's $ref) is negligible
|
|
174
|
+
const hash = crypto.createHash('sha256').update(json).digest('base64url').slice(0, 16)
|
|
175
|
+
if (seen.has(hash)) return { $ref: hash }
|
|
176
|
+
seen.add(hash)
|
|
177
|
+
return { $hash: hash, value: deep(value) }
|
|
178
|
+
}
|
|
179
|
+
// Inside a newly-seen value, array elements (content blocks, tool schemas, …) are deduped
|
|
180
|
+
// individually too, at any depth — one injected/updated element (e.g. a system-reminder)
|
|
181
|
+
// must not re-log its siblings. Primitive elements stay inline: a $ref would outweigh them.
|
|
182
|
+
const deep = v =>
|
|
183
|
+
Array.isArray(v) ? v.map(el => el && typeof el === 'object' ? dedupe(el) : el)
|
|
184
|
+
: v && typeof v === 'object' ? Object.fromEntries(Object.entries(v).map(([k, x]) => [k, deep(x)]))
|
|
185
|
+
: v
|
|
186
|
+
const out = { ...body }
|
|
187
|
+
if (out.system != null) out.system = dedupe(out.system)
|
|
188
|
+
if (out.tools != null) out.tools = dedupe(out.tools)
|
|
189
|
+
if (out.messages) out.messages = out.messages.map(dedupe)
|
|
190
|
+
return out
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Warm the seen-set from the request log on first touch, so a proxy restart doesn't re-log full values.
|
|
194
|
+
const seenHashes = new Map() // sessionId → Set of hashes already logged in full
|
|
195
|
+
function seenFor(sessionId, logPath) {
|
|
196
|
+
let seen = seenHashes.get(sessionId)
|
|
197
|
+
if (!seen) seenHashes.set(sessionId, seen = warmFromLog(logPath))
|
|
198
|
+
return seen
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function warmFromLog(logPath) {
|
|
202
|
+
const seen = new Set()
|
|
203
|
+
const walk = v => { // collect every $hash at any depth
|
|
204
|
+
if (!v || typeof v !== 'object') return
|
|
205
|
+
if (v.$hash) seen.add(v.$hash)
|
|
206
|
+
for (const x of Object.values(v)) walk(x)
|
|
207
|
+
}
|
|
208
|
+
let lines = []
|
|
209
|
+
try { lines = fs.readFileSync(logPath, 'utf8').split('\n') } catch {}
|
|
210
|
+
for (const line of lines) {
|
|
211
|
+
try { walk(JSON.parse(line).request) } catch {}
|
|
212
|
+
}
|
|
213
|
+
return seen
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ── Entry point ──────────────────────────────────────────────────────────────
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
// Capture/upstream errors land here instead of ever reaching the client.
|
|
220
|
+
function logError(err, context) {
|
|
221
|
+
try { // AggregateError (e.g. all of a host's addresses failed to connect) hides the detail in err.errors
|
|
222
|
+
const causes = (err?.errors ?? []).map(e => `\n cause: ${e?.message || e}`).join('')
|
|
223
|
+
const message = `${new Date().toISOString()} ${context ? `[${context}] ` : ''}${err?.stack || err}${causes}\n`
|
|
224
|
+
fs.mkdirSync(path.dirname(ERROR_LOG_PATH), { recursive: true }) // the error may fire before any request write creates the dir
|
|
225
|
+
fs.appendFileSync(ERROR_LOG_PATH, message)
|
|
226
|
+
} catch {}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href
|
|
230
|
+
if (isMain) {
|
|
231
|
+
const args = parseArgs({
|
|
232
|
+
options: {
|
|
233
|
+
restart: { type: 'boolean' }, // replace an already-running instance instead of exiting
|
|
234
|
+
}
|
|
235
|
+
})
|
|
236
|
+
const upstream = new URL(UPSTREAM)
|
|
237
|
+
// Fail fast when the upstream is unreachable — a clear exit beats a proxy that 502s every request.
|
|
238
|
+
// Runs before --restart so a working instance is never replaced by a broken one. Exit code 2 is
|
|
239
|
+
// recognized by the app's ProxySwitch ("cannot reach upstream").
|
|
240
|
+
try {
|
|
241
|
+
await fetch(new URL('/v1/models', upstream), { signal: AbortSignal.timeout(5000) })
|
|
242
|
+
} catch (err) {
|
|
243
|
+
console.error(`Cannot reach ${upstream.origin} (${err?.cause?.code || err?.cause?.message || err?.name || err})`)
|
|
244
|
+
logError(err, `startup check → ${upstream.origin}`)
|
|
245
|
+
process.exit(2)
|
|
246
|
+
}
|
|
247
|
+
const server = createProxy({
|
|
248
|
+
upstream: upstream,
|
|
249
|
+
onExchange: logRequest,
|
|
250
|
+
onError: logError,
|
|
251
|
+
errorBody: err => JSON.stringify({ type: 'error', error: { type: 'api_error', message: `claude-discover proxy: upstream unreachable (${err.code || err.message})` } }),
|
|
252
|
+
})
|
|
253
|
+
if (args.values.restart) {
|
|
254
|
+
// Ask a previous instance (the port is loopback-only and assumed ours) to exit before listening.
|
|
255
|
+
const replaced = await new Promise(resolve => http.request(`${PROXY_URL}${EXIT_ROUTE}`, { method: 'POST' }, () => resolve(true)).on('error', () => resolve(false)).end())
|
|
256
|
+
if (replaced) {
|
|
257
|
+
console.log(`Replacing running proxy on ${PROXY_URL}...`)
|
|
258
|
+
await new Promise(resolve => setTimeout(resolve, 500)) // give it a moment to release the port
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
server.on('error', err => {
|
|
262
|
+
if (err.code === 'EADDRINUSE' && args.values.restart) { console.error(`Failed to restart: ${HOST}:${PORT} is still busy`); process.exit(1) }
|
|
263
|
+
if (err.code === 'EADDRINUSE') { console.log(`Capture proxy already running on ${HOST}:${PORT}`); process.exit(0) }
|
|
264
|
+
logError(err)
|
|
265
|
+
process.exit(1)
|
|
266
|
+
})
|
|
267
|
+
server.listen(PORT, HOST, () => console.log(`Capture proxy listening on ${PROXY_URL} → ${upstream.origin}\nLogging requests to ${REQUESTS_DIR}`))
|
|
268
|
+
}
|