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.
Files changed (30) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +74 -0
  3. package/bin/claude/hooks.mjs +102 -0
  4. package/bin/claude/statusline.mjs +130 -0
  5. package/bin/claude-discover.mjs +16 -0
  6. package/bin/proxy.config.js +19 -0
  7. package/bin/proxy.mjs +268 -0
  8. package/out/main/main.js +1542 -0
  9. package/out/preload/findPreload.cjs +16 -0
  10. package/out/preload/preload.cjs +28 -0
  11. package/out/renderer/assets/claude-icon-BqihzN4C.svg +3 -0
  12. package/out/renderer/assets/find-CxcUmpSm.css +88 -0
  13. package/out/renderer/assets/find-DSEw-KLs.js +59 -0
  14. package/out/renderer/assets/index-0bBLuYQv.css +2331 -0
  15. package/out/renderer/assets/index-BLwpO6Wp.js +2629 -0
  16. package/out/renderer/assets/inter-latin-400-normal-C38fXH4l.woff2 +0 -0
  17. package/out/renderer/assets/inter-latin-400-normal-CyCys3Eg.woff +0 -0
  18. package/out/renderer/assets/inter-latin-700-normal-BLAVimhd.woff +0 -0
  19. package/out/renderer/assets/inter-latin-700-normal-Yt3aPRUw.woff2 +0 -0
  20. package/out/renderer/assets/jetbrains-mono-latin-200-normal-CHn02WOn.woff +0 -0
  21. package/out/renderer/assets/jetbrains-mono-latin-200-normal-DvzYDkvL.woff2 +0 -0
  22. package/out/renderer/assets/jetbrains-mono-latin-400-normal-6-qcROiO.woff +0 -0
  23. package/out/renderer/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
  24. package/out/renderer/assets/jetbrains-mono-latin-700-normal-BYuf6tUa.woff2 +0 -0
  25. package/out/renderer/assets/jetbrains-mono-latin-700-normal-D3wTyLJW.woff +0 -0
  26. package/out/renderer/assets/vendors-B4XMnGdr.css +153 -0
  27. package/out/renderer/assets/vendors-D6iOvAG3.js +49248 -0
  28. package/out/renderer/find/find.html +26 -0
  29. package/out/renderer/index.html +17 -0
  30. package/package.json +75 -0
@@ -0,0 +1,1542 @@
1
+ import { BrowserWindow, dialog, app, Menu, shell, WebContentsView, ipcMain } from "electron";
2
+ import { EventEmitter } from "node:events";
3
+ import { parseISO, endOfWeek, startOfWeek, endOfMonth, startOfMonth, endOfDay, startOfDay } from "date-fns";
4
+ import fs from "node:fs";
5
+ import fsp from "node:fs/promises";
6
+ import path, { join, dirname, basename } from "node:path";
7
+ import os, { homedir } from "node:os";
8
+ import chokidar from "chokidar";
9
+ import { sync } from "write-file-atomic";
10
+ import { query } from "@anthropic-ai/claude-agent-sdk";
11
+ import { pathToFileURL, fileURLToPath } from "node:url";
12
+ import windowStateKeeper from "electron-window-state";
13
+ import contextMenu from "electron-context-menu";
14
+ import { spawn } from "node:child_process";
15
+ import __cjs_mod__ from "node:module";
16
+ const __filename = import.meta.filename;
17
+ const __dirname = import.meta.dirname;
18
+ const require2 = __cjs_mod__.createRequire(import.meta.url);
19
+ class SessionFile {
20
+ constructor(filePath) {
21
+ this.filePath = filePath;
22
+ const parts = path.dirname(filePath).split(path.sep);
23
+ this.parentSessionId = parts[parts.indexOf("subagents") - 1] ?? null;
24
+ const base = path.basename(filePath, ".jsonl");
25
+ this.sessionId = base === "journal" ? `${parts.at(-1)}-journal` : base;
26
+ }
27
+ async stat() {
28
+ try {
29
+ return await fsp.stat(this.filePath);
30
+ } catch {
31
+ return null;
32
+ }
33
+ }
34
+ // Streams every complete line as parsed JSON; a partially-written trailing line
35
+ // (no newline yet) is skipped, so reading a live session never sees a half record.
36
+ async stream(onLine) {
37
+ let leftover = "";
38
+ await new Promise((resolve) => {
39
+ const stream = fs.createReadStream(this.filePath, { encoding: "utf8" });
40
+ stream.on("data", (chunk) => {
41
+ leftover += chunk;
42
+ let idx;
43
+ while ((idx = leftover.indexOf("\n")) !== -1) {
44
+ const raw = leftover.slice(0, idx);
45
+ leftover = leftover.slice(idx + 1);
46
+ const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
47
+ if (!line) continue;
48
+ let obj;
49
+ try {
50
+ obj = JSON.parse(line);
51
+ } catch {
52
+ continue;
53
+ }
54
+ onLine(obj);
55
+ }
56
+ });
57
+ stream.on("close", resolve);
58
+ stream.on("error", resolve);
59
+ });
60
+ }
61
+ }
62
+ const HOST = "127.0.0.1";
63
+ const PORT = process.env.CLAUDE_DISCOVER_PORT || 41414;
64
+ const PROXY_URL = `http://${HOST}:${PORT}`;
65
+ process.env.CLAUDE_DISCOVER_UPSTREAM || "https://api.anthropic.com";
66
+ const EXIT_ROUTE = "/claude-discover/exit";
67
+ const PING_ROUTE = "/claude-discover/ping";
68
+ const PING_RESPONSE = "claude-discover-proxy";
69
+ const DATA_DIR$1 = process.env.CLAUDE_DISCOVER_DIR || path.join(os.homedir(), ".claude-discover");
70
+ const REQUESTS_DIR = path.join(DATA_DIR$1, "requests");
71
+ const ERROR_LOG_PATH = path.join(DATA_DIR$1, "proxy.error.log");
72
+ const jsonBytes = (x) => x == null ? 0 : Buffer.byteLength(JSON.stringify(x));
73
+ const MEMORY_LABELS = {
74
+ "user's private global instructions for all projects": "User",
75
+ "project instructions, checked into the codebase": "Project",
76
+ "user's private project instructions, not checked in": "Local",
77
+ "user's auto-memory, persists across conversations": "Auto"
78
+ };
79
+ const MEMORY_FILES = { User: "CLAUDE.md", Project: "CLAUDE.md", Local: "CLAUDE.local.md", Auto: "MEMORY.md" };
80
+ function parseClaudeMd(text) {
81
+ const marker = "\n# claudeMd\n";
82
+ const start = text.indexOf(marker);
83
+ if (start === -1) return [];
84
+ let region = text.slice(start + marker.length);
85
+ const ends = [/\n# [a-z][a-zA-Z0-9]*\r?\n/, /\n\s*IMPORTANT: this context/, /<\/system-reminder>/].map((re) => region.search(re)).filter((i) => i !== -1);
86
+ if (ends.length) region = region.slice(0, Math.min(...ends));
87
+ const heads = [...region.matchAll(/^Contents of (.+?) \(([^)]*)\):\r?\n/gm)];
88
+ return heads.map((h, i) => {
89
+ const memory_type = MEMORY_LABELS[h[2]] ?? h[2];
90
+ const base = h[1].split(/[\\/]/).pop();
91
+ return {
92
+ file_path: h[1],
93
+ name: base === MEMORY_FILES[memory_type] ? base : h[1],
94
+ memory_type,
95
+ content: region.slice(h.index + h[0].length, heads[i + 1]?.index ?? region.length).trim()
96
+ };
97
+ });
98
+ }
99
+ const classifyRequest = (req, url) => {
100
+ if (url?.includes("/count_tokens")) return ["count", "Token count"];
101
+ if (!req) return null;
102
+ const un = (x) => x?.value ?? x;
103
+ const sys = un(req.system);
104
+ const sysText = (typeof sys === "string" ? sys : Array.isArray(sys) ? sys.map((b) => un(b)?.text ?? "").join("\n") : "").slice(0, 600);
105
+ const last = (req.messages || []).map(un).findLast((m) => m?.role === "user");
106
+ const content = un(last?.content);
107
+ const blocks = Array.isArray(content) ? content.map(un) : null;
108
+ const user = (typeof content === "string" ? content : blocks?.find((b) => b?.type === "text")?.text ?? "").trimStart().slice(0, 300);
109
+ const tail = (blocks?.findLast((b) => b?.type === "text")?.text ?? user).slice(0, 600);
110
+ if (req.max_tokens === 1 || user.trim() === "quota") return ["quota", "Quota probe"];
111
+ if (sysText.includes("You are a security monitor") || user.startsWith("<transcript>")) return ["security", "Security check"];
112
+ if (sysText.includes("Generate a concise, sentence-case title")) return ["title", "Session title"];
113
+ if (sysText.includes("performing a web search")) return ["web", "Web search"];
114
+ if (user.startsWith("[SUGGESTION MODE")) return ["suggest", "Suggestions"];
115
+ if (user.startsWith("Web page content")) return ["web", "Web fetch"];
116
+ if (user.startsWith("Describe your most recent action")) return ["status", "Status blurb"];
117
+ if (tail.includes("detailed summary of the conversation")) return ["compact", "Compact"];
118
+ if (sysText.includes("built on Anthropic's Claude Agent SDK")) return ["agent", "Subagent"];
119
+ if (!sysText.includes("You are Claude Code")) return null;
120
+ const continues = !last || blocks?.some((b) => b?.type === "tool_result");
121
+ return continues ? ["tools", "Tool turn"] : ["main", "User message"];
122
+ };
123
+ const stripKind = (req) => {
124
+ const kind = classifyRequest(req);
125
+ return !kind || kind[0] === "main" || kind[0] === "tools" ? ["main", null] : kind;
126
+ };
127
+ class RequestParser {
128
+ constructor() {
129
+ this.hashes = /* @__PURE__ */ new Map();
130
+ this.seenTools = /* @__PURE__ */ new Set();
131
+ }
132
+ // Resolves $hash/$ref dedup wrappers at any depth — children first, so stored and returned
133
+ // values are always fully resolved. Feed records in file order. When `seen` is given, the
134
+ // dot-path of every resolved (known) $ref is pushed into it — 'system', 'messages.3.content.2', …
135
+ resolve(v, path2 = "", seen = null) {
136
+ if (v?.$hash) {
137
+ const r = this.resolve(v.value, path2, seen);
138
+ this.hashes.set(v.$hash, r);
139
+ return r;
140
+ }
141
+ if (v?.$ref) {
142
+ if (!this.hashes.has(v.$ref)) return v;
143
+ seen?.push(path2);
144
+ return this.hashes.get(v.$ref);
145
+ }
146
+ if (Array.isArray(v)) return v.map((x, i) => this.resolve(x, `${path2}.${i}`, seen));
147
+ if (v && typeof v === "object") return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, this.resolve(x, `${path2}.${k}`, seen)]));
148
+ return v;
149
+ }
150
+ // Resolves the request's $hash/$ref parts back to full values in place, and annotates the
151
+ // record with $seen — which parts repeated earlier content — so the UI can fade them:
152
+ // `messages` flags whole-message repeats, `paths` every repeated node at any depth.
153
+ resolveRefs(rec) {
154
+ const wasSeen = (x) => !!x?.$ref && this.hashes.has(x.$ref);
155
+ const req = rec.request;
156
+ if (req) {
157
+ const paths = [];
158
+ rec.$seen = { messages: (req.messages || []).map(wasSeen), paths };
159
+ if (req.system != null) req.system = this.resolve(req.system, "system", paths);
160
+ if (req.tools != null) req.tools = this.resolve(req.tools, "tools", paths);
161
+ if (req.messages) req.messages = req.messages.map((m, i) => this.resolve(m, `messages.${i}`, paths));
162
+ }
163
+ rec.kind = classifyRequest(req, rec.url);
164
+ rec.reqSize = jsonBytes(req);
165
+ rec.resSize = jsonBytes(rec.response);
166
+ return rec;
167
+ }
168
+ // The request's system prompt, only when this record logs it in full ({ $hash, value } — repeats
169
+ // are $refs). `system` is either a plain string or an array of text blocks.
170
+ systemPrompt(rec) {
171
+ const sys = rec.request?.system;
172
+ if (!sys?.$hash) return null;
173
+ const value = this.resolve(sys.value);
174
+ const content = typeof value === "string" ? value : value.map((b) => b?.text ?? "").join("\n\n");
175
+ const [kind, kindLabel] = stripKind(rec.request);
176
+ return { file_path: "System Prompt", memory_type: kindLabel || "", model: rec.request.model, content, hash: sys.$hash, kind };
177
+ }
178
+ // The request's tool definitions, only when this record logs the array in full (repeats are
179
+ // $refs). The array grows as deferred tools load mid-session, so only tools this parser hasn't
180
+ // seen yet are returned — the first request yields the full set, later arrays just the additions.
181
+ systemTools(rec) {
182
+ const tools = rec.request?.tools;
183
+ if (!tools?.$hash) return null;
184
+ const fresh = this.resolve(tools.value).filter((t) => t?.name && !this.seenTools.has(t.name));
185
+ if (!fresh.length) return null;
186
+ for (const t of fresh) this.seenTools.add(t.name);
187
+ const content = fresh.map((t) => `## ${t.name}
188
+
189
+ ${t.description || ""}`.trim()).join("\n\n");
190
+ const [kind, kindLabel] = stripKind(rec.request);
191
+ const label = [kindLabel, `${fresh.length} tool${fresh.length === 1 ? "" : "s"}`].filter(Boolean).join(", ");
192
+ return { file_path: "System Tools", memory_type: label, model: rec.request.model, content, hash: tools.$hash, kind };
193
+ }
194
+ // Memory files (CLAUDE.md / MEMORY.md / …) carried by the record's messages inside `# claudeMd`
195
+ // system-reminders — one { file_path, memory_type, content } per file listed.
196
+ memoryFiles(rec) {
197
+ const files = [];
198
+ for (const m of rec.request?.messages || []) {
199
+ const content = (m?.value ?? m)?.content;
200
+ for (const part of Array.isArray(content) ? content : [{ text: content }]) {
201
+ const text = (part?.value ?? part)?.text;
202
+ if (typeof text !== "string" || !text.includes("<system-reminder>")) continue;
203
+ files.push(...parseClaudeMd(text));
204
+ }
205
+ }
206
+ return files;
207
+ }
208
+ }
209
+ class RequestFile {
210
+ constructor(sessionId, dir = REQUESTS_DIR) {
211
+ this.filePath = join(dir, `${sessionId}.requests.jsonl`);
212
+ }
213
+ async lines() {
214
+ const raw = await fsp.readFile(this.filePath, "utf8").catch(() => "");
215
+ return raw.split("\n").filter(Boolean);
216
+ }
217
+ // `range` is { start, end } epoch ms. Refs are resolved over the whole file before the
218
+ // range filter — an out-of-range record may hold the $hash an in-range record refs —
219
+ // so callers see every request exactly as it was sent to the API.
220
+ async read(range = null) {
221
+ const parser = new RequestParser();
222
+ const records = [];
223
+ for (const line of await this.lines()) {
224
+ let rec;
225
+ try {
226
+ rec = JSON.parse(line);
227
+ } catch {
228
+ continue;
229
+ }
230
+ records.push(parser.resolveRefs(rec));
231
+ }
232
+ if (!range) return records;
233
+ return records.filter((r) => {
234
+ const t = Date.parse(r.timestamp);
235
+ return t >= range.start && t <= range.end;
236
+ });
237
+ }
238
+ // System prompts (request.system), tool definitions (request.tools) and memory files
239
+ // (CLAUDE.md / MEMORY.md / … inside a user message's `# claudeMd` system-reminder) — none of
240
+ // which Claude Code records in the session transcript, so the request log is the only source.
241
+ // Returns one record per unique system prompt / tool batch / file_path; readSession ships them
242
+ // alongside the transcript items.
243
+ async readInstructions() {
244
+ const parser = new RequestParser();
245
+ const files = /* @__PURE__ */ new Map();
246
+ for (const line of await this.lines()) {
247
+ const hasSystem = line.includes('"system":{"$hash"');
248
+ const hasTools = line.includes('"tools":{"$hash"');
249
+ if (!hasSystem && !hasTools && !line.includes("# claudeMd")) continue;
250
+ let rec;
251
+ try {
252
+ rec = JSON.parse(line);
253
+ } catch {
254
+ continue;
255
+ }
256
+ const record = (f) => ({ timestamp: rec.timestamp, ...f });
257
+ const sys = hasSystem && parser.systemPrompt(rec);
258
+ if (sys && !files.has(sys.hash)) files.set(sys.hash, record(sys));
259
+ const tools = hasTools && parser.systemTools(rec);
260
+ if (tools) files.set(tools.hash, record(tools));
261
+ for (const f of parser.memoryFiles(rec)) {
262
+ if (!files.has(f.file_path)) files.set(f.file_path, record(f));
263
+ }
264
+ }
265
+ return [...files.values()];
266
+ }
267
+ }
268
+ const ACTIVITY_GAP_MS = 5 * 60 * 1e3;
269
+ const TOKEN_FIELDS = ["input", "output", "cacheRead", "cacheCreation", "cacheCreation5m", "cacheCreation1h"];
270
+ function emptyBucket() {
271
+ return Object.fromEntries(TOKEN_FIELDS.map((k) => [k, 0]));
272
+ }
273
+ function addUsage(target, delta) {
274
+ for (const k of TOKEN_FIELDS) target[k] += delta[k];
275
+ }
276
+ function extractText(content) {
277
+ if (typeof content === "string") return content;
278
+ if (!Array.isArray(content)) return "";
279
+ return content.map((b) => typeof b === "string" ? b : b?.type === "text" ? b.text : "").filter(Boolean).join("\n");
280
+ }
281
+ function shortProject(dir, depth = 2) {
282
+ if (!dir) return "(no project)";
283
+ const parts = dir.replace(/\\/g, "/").split("/").filter(Boolean);
284
+ return parts.slice(-depth).join("/") || dir;
285
+ }
286
+ function suffixLabels(dirs, depth = 2) {
287
+ const labels = new Map([...new Set(dirs)].map((dir) => [dir, shortProject(dir, depth)]));
288
+ const counts = /* @__PURE__ */ new Map();
289
+ for (const label of labels.values()) counts.set(label, (counts.get(label) ?? 0) + 1);
290
+ for (const [dir, label] of labels) if (counts.get(label) > 1) labels.set(dir, shortProject(dir, depth + 1));
291
+ return labels;
292
+ }
293
+ function classifySource(meta) {
294
+ if (meta.hasScheduledTask) return "scheduled";
295
+ return (meta.entrypoint || "").toLowerCase() || "other";
296
+ }
297
+ function freshMeta({ sessionId, parentSessionId, filePath, fileSize, mtime }) {
298
+ return {
299
+ sessionId,
300
+ parentSessionId,
301
+ filePath,
302
+ fileSize,
303
+ mtime,
304
+ startedAt: null,
305
+ lastActivityAt: null,
306
+ entrypoint: null,
307
+ project: null,
308
+ worktree: null,
309
+ worktreePath: null,
310
+ gitBranch: null,
311
+ version: null,
312
+ model: null,
313
+ models: [],
314
+ serviceTier: null,
315
+ speed: null,
316
+ fastPricingUnknown: false,
317
+ priceUnknown: false,
318
+ summary: null,
319
+ aiTitle: null,
320
+ customTitle: null,
321
+ agentName: null,
322
+ firstUserPrompt: null,
323
+ firstUserCommand: null,
324
+ forkedFrom: null,
325
+ messageCount: 0,
326
+ workflowAgents: 0,
327
+ toolCalls: 0,
328
+ tokens: emptyBucket(),
329
+ tokensByModel: {},
330
+ tokensByModelFast: {},
331
+ lastContextTokens: 0,
332
+ serverToolUse: { webSearch: 0, webFetch: 0 },
333
+ hasScheduledTask: false,
334
+ activityPeriods: [],
335
+ costSamples: [],
336
+ seenMessageIds: /* @__PURE__ */ new Set()
337
+ };
338
+ }
339
+ class SessionParser {
340
+ constructor({ sessionId, parentSessionId, filePath, fileSize = 0, mtime = 0, pricing = null, range = null, excludeIds = null }) {
341
+ this.range = range;
342
+ this.excludeIds = excludeIds;
343
+ this.pricing = pricing;
344
+ this.meta = freshMeta({ sessionId, parentSessionId, filePath, fileSize, mtime });
345
+ this.prevMessage = null;
346
+ this.tokenTotal = 0;
347
+ this.lastSeenTs = null;
348
+ this.appliedById = /* @__PURE__ */ new Map();
349
+ }
350
+ // Returns true if the item passes `this.range`.
351
+ feed(obj) {
352
+ const meta = this.meta;
353
+ const t = obj.type;
354
+ if (t === "summary" && obj.summary) meta.summary = obj.summary;
355
+ if (t === "ai-title" && obj.aiTitle) meta.aiTitle = obj.aiTitle;
356
+ if (t === "custom-title" && obj.customTitle) meta.customTitle = obj.customTitle;
357
+ if (t === "agent-name" && obj.agentName) meta.agentName = obj.agentName;
358
+ if (t === "queue-operation" && obj.content?.includes("<scheduled-task")) meta.hasScheduledTask = true;
359
+ if (t === "started" && obj.key && obj.agentId) meta.workflowAgents += 1;
360
+ if (obj.cwd && !meta.project) meta.project = obj.cwd;
361
+ if (obj.gitBranch && !meta.gitBranch) meta.gitBranch = obj.gitBranch;
362
+ if (obj.entrypoint && !meta.entrypoint) meta.entrypoint = obj.entrypoint;
363
+ if (obj.version && !meta.version) meta.version = obj.version;
364
+ const parsed = obj.timestamp && Date.parse(obj.timestamp);
365
+ const inherited = parsed > 0 ? parsed : this.lastSeenTs;
366
+ const ts = inherited ?? meta.mtime;
367
+ if (parsed > 0) this.lastSeenTs = parsed;
368
+ if (this.range && (ts < this.range.start || ts > this.range.end)) return false;
369
+ obj._ts = ts;
370
+ if (inherited != null) {
371
+ if (meta.startedAt == null || inherited < meta.startedAt) meta.startedAt = inherited;
372
+ if (inherited > meta.lastActivityAt) meta.lastActivityAt = inherited;
373
+ }
374
+ if (t === "attachment") {
375
+ obj.isMeta = true;
376
+ this.prevMessage = obj;
377
+ return true;
378
+ }
379
+ if (t !== "user" && t !== "assistant") return true;
380
+ meta.messageCount += 1;
381
+ const last = meta.activityPeriods[meta.activityPeriods.length - 1];
382
+ if (last && ts - last.end <= ACTIVITY_GAP_MS) {
383
+ if (ts > last.end) last.end = ts;
384
+ } else {
385
+ meta.activityPeriods.push({ start: ts, end: ts });
386
+ }
387
+ if (t === "user") {
388
+ this._feedUser(obj);
389
+ } else if (t === "assistant") {
390
+ const msg = obj.message;
391
+ if (!msg || msg.model === "<synthetic>") return true;
392
+ if (msg.model) {
393
+ if (!meta.model) meta.model = msg.model;
394
+ if (!meta.models.includes(msg.model)) meta.models.push(msg.model);
395
+ }
396
+ if (Array.isArray(msg.content)) {
397
+ for (const b of msg.content) if (b?.type === "tool_use") meta.toolCalls += 1;
398
+ }
399
+ this._recordUsage(obj);
400
+ }
401
+ this.prevMessage = obj;
402
+ return true;
403
+ }
404
+ // Capture the first user prompt / command for the session list.
405
+ _feedUser(obj) {
406
+ const meta = this.meta;
407
+ if (!meta.forkedFrom && obj.forkedFrom?.sessionId) meta.forkedFrom = obj.forkedFrom;
408
+ const text = extractText(obj.message?.content);
409
+ if (text.startsWith("<command-name>") || text.startsWith("<command-message>")) {
410
+ if (!meta.firstUserCommand) meta.firstUserCommand = text;
411
+ obj.isMeta = true;
412
+ } else if (text.startsWith("<local-command-stdout>")) {
413
+ if (meta.firstUserCommand) meta.firstUserCommand += "\n" + text;
414
+ obj.isMeta = true;
415
+ } else if (text.startsWith("<local-command-caveat>")) {
416
+ obj.isMeta = true;
417
+ } else if (text.startsWith("<scheduled-task")) {
418
+ meta.hasScheduledTask = true;
419
+ } else if (text) {
420
+ if (!meta.firstUserPrompt) meta.firstUserPrompt = text.slice(0, 300);
421
+ }
422
+ }
423
+ // Stamp obj (and the turn that caused it) with per-item running token totals for the UI.
424
+ // Input tokens attribute to the turn that *caused* the call (user prompt or tool_result) —
425
+ // the model is billed for reading the preceding turn.
426
+ _stampRunningTotals(obj, contextSize, output) {
427
+ const inputTarget = this.prevMessage && this.prevMessage._tokenDelta == null ? this.prevMessage : obj;
428
+ this.tokenTotal += contextSize;
429
+ inputTarget._tokenDelta = (inputTarget._tokenDelta || 0) + contextSize;
430
+ inputTarget._tokenTotal = this.tokenTotal;
431
+ this.tokenTotal += output;
432
+ obj._tokenDelta = (obj._tokenDelta || 0) + output;
433
+ obj._tokenTotal = this.tokenTotal;
434
+ }
435
+ // One assistant reply is written as several jsonl lines sharing message.id:
436
+ // input/cache stay fixed while output_tokens grows toward its final value. We
437
+ // add each line's *growth* over the previous one (the full usage on the first
438
+ // line), so the totals land on the final value — what ccusage bills. The
439
+ // per-item running-total stamps and the stream-stable metadata come only from
440
+ // the first line; ids in `excludeIds` (already counted in an earlier
441
+ // resumed-from session) stamp but never aggregate.
442
+ _recordUsage(obj) {
443
+ const meta = this.meta;
444
+ const msg = obj.message;
445
+ const u = msg?.usage;
446
+ const msgId = msg?.id;
447
+ if (!u || !msgId) return;
448
+ const cc = u.cache_creation || {};
449
+ const usage = {
450
+ input: u.input_tokens || 0,
451
+ output: u.output_tokens || 0,
452
+ cacheRead: u.cache_read_input_tokens || 0,
453
+ cacheCreation: u.cache_creation_input_tokens || 0,
454
+ cacheCreation5m: cc.ephemeral_5m_input_tokens || 0,
455
+ cacheCreation1h: cc.ephemeral_1h_input_tokens || 0
456
+ };
457
+ const model = msg.model || "unknown";
458
+ const fast = u.speed === "fast";
459
+ const contextSize = usage.input + usage.cacheRead + usage.cacheCreation;
460
+ if (!meta.seenMessageIds.has(msgId)) {
461
+ meta.seenMessageIds.add(msgId);
462
+ this._stampRunningTotals(obj, contextSize, usage.output);
463
+ }
464
+ if (this.excludeIds?.has(msgId)) return;
465
+ const applied = this.appliedById.get(msgId);
466
+ if (!applied) {
467
+ meta.lastContextTokens = contextSize;
468
+ if (u.service_tier) meta.serviceTier = u.service_tier;
469
+ if (this.pricing && this.pricing.priceFor(model) == null) meta.priceUnknown = true;
470
+ if (fast) {
471
+ meta.speed = "fast";
472
+ if (this.pricing && this.pricing.fastMultiplier(model) == null) meta.fastPricingUnknown = true;
473
+ } else if (u.speed && meta.speed !== "fast") {
474
+ meta.speed = u.speed;
475
+ }
476
+ if (u.server_tool_use) {
477
+ meta.serverToolUse.webSearch += u.server_tool_use.web_search_requests || 0;
478
+ meta.serverToolUse.webFetch += u.server_tool_use.web_fetch_requests || 0;
479
+ }
480
+ }
481
+ const growth = emptyBucket();
482
+ let changed = false;
483
+ for (const k of TOKEN_FIELDS) {
484
+ growth[k] = Math.max(0, usage[k] - (applied?.[k] ?? 0));
485
+ if (growth[k] !== 0) changed = true;
486
+ }
487
+ if (applied) addUsage(applied, growth);
488
+ else this.appliedById.set(msgId, usage);
489
+ if (!changed) return;
490
+ const byModel = fast ? meta.tokensByModelFast : meta.tokensByModel;
491
+ addUsage(meta.tokens, growth);
492
+ addUsage(byModel[model] ??= emptyBucket(), growth);
493
+ const cost = this.pricing && obj._ts != null ? this.pricing.costForDelta(model, growth, { fast }) : null;
494
+ if (cost > 0) {
495
+ const prevTs = this.prevMessage?._ts;
496
+ meta.costSamples.push({ start: prevTs != null && prevTs < obj._ts ? prevTs : obj._ts, end: obj._ts, cost });
497
+ }
498
+ }
499
+ finalize(mtimeFallback) {
500
+ const meta = this.meta;
501
+ if (meta.startedAt == null) meta.startedAt = mtimeFallback;
502
+ if (meta.lastActivityAt == null) meta.lastActivityAt = mtimeFallback;
503
+ meta.source = classifySource(meta);
504
+ const wt = meta.project && meta.project.replace(/\\/g, "/").match(/\/\.claude\/worktrees\/([^/]+)/);
505
+ if (wt) {
506
+ meta.worktree = wt[1];
507
+ meta.worktreePath = meta.project.slice(0, wt.index + wt[0].length);
508
+ meta.project = meta.project.slice(0, wt.index);
509
+ }
510
+ meta.projectShort = shortProject(meta.project);
511
+ meta.activeMs = meta.activityPeriods.reduce((sum, p) => sum + Math.max(0, p.end - p.start), 0);
512
+ const t = meta.tokens;
513
+ meta.totalTokens = t.input + t.output + t.cacheRead + t.cacheCreation;
514
+ const cacheDenom = t.cacheRead + t.cacheCreation;
515
+ meta.cacheHitRatio = cacheDenom > 0 ? t.cacheRead / cacheDenom : null;
516
+ const costOf = (opts) => !this.pricing ? 0 : (this.pricing.costUSD(meta.tokensByModel, opts) || 0) + (this.pricing.costUSD(meta.tokensByModelFast, { ...opts, fast: true }) || 0);
517
+ meta.cost = costOf({});
518
+ meta.savings = { using5mCache: meta.cost - costOf({ ignoreCache1hPremium: true }) };
519
+ return meta;
520
+ }
521
+ }
522
+ class ConfigFile {
523
+ dataDir = join(homedir(), ".claude-discover");
524
+ configFile = join(this.dataDir, "config.json");
525
+ read() {
526
+ try {
527
+ return JSON.parse(fs.readFileSync(this.configFile, "utf8"));
528
+ } catch (err) {
529
+ if (err.code !== "ENOENT") console.warn(`Ignoring unreadable ${this.configFile}: ${err.message}`);
530
+ return {};
531
+ }
532
+ }
533
+ save(data) {
534
+ fs.mkdirSync(this.dataDir, { recursive: true });
535
+ sync(this.configFile, JSON.stringify({ ...this.read(), ...data }, null, 2));
536
+ }
537
+ }
538
+ const config = new ConfigFile();
539
+ const local = config.read();
540
+ const DATA_DIR = config.dataDir;
541
+ const CLAUDE_DIR = local.claudeDir || process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude");
542
+ const RECENT_CLAUDE_DIRS = local.recents || [CLAUDE_DIR];
543
+ const CLAUDE_PROJECTS_DIR = join(CLAUDE_DIR, "projects");
544
+ const CLAUDE_SETTINGS = join(CLAUDE_DIR, "settings.json");
545
+ const IS_EPHEMERAL = import.meta.dirname.split(/[\\/]/).includes("_npx");
546
+ const STATUSLINE_PATH = join(import.meta.dirname, "../../bin/claude/statusline.mjs");
547
+ const PROXY_PATH = join(import.meta.dirname, "../../bin/proxy.mjs");
548
+ const CLAUDE_HOOKS_PATH = join(import.meta.dirname, "../../bin/claude/hooks.mjs");
549
+ class StatCache {
550
+ constructor() {
551
+ this.stats = /* @__PURE__ */ new Map();
552
+ this.complete = false;
553
+ }
554
+ record(filePath, stat) {
555
+ if (stat) this.stats.set(filePath, stat);
556
+ }
557
+ remove(filePath) {
558
+ this.stats.delete(filePath);
559
+ }
560
+ markComplete() {
561
+ this.complete = true;
562
+ }
563
+ clear() {
564
+ this.stats.clear();
565
+ this.complete = false;
566
+ }
567
+ // In-memory disk walk: one batch over the cached stats, same callbacks as SessionsScanner.scan.
568
+ async scan(inPeriod, { onFile, onBatchDone, onProgress, signal } = {}) {
569
+ const tasks = [];
570
+ for (const [filePath, stat] of this.stats) {
571
+ if (signal?.aborted) return;
572
+ if (inPeriod(stat)) tasks.push(onFile?.(filePath, stat));
573
+ }
574
+ await Promise.all(tasks);
575
+ if (signal?.aborted) return;
576
+ await onBatchDone?.();
577
+ onProgress?.({ done: 1, total: 1, scanning: false });
578
+ }
579
+ }
580
+ const isJsonl = (p) => p.endsWith(".jsonl");
581
+ class SessionsScanner {
582
+ constructor({ root = CLAUDE_PROJECTS_DIR } = {}) {
583
+ this.root = root;
584
+ this.watcher = null;
585
+ this.statCache = new StatCache();
586
+ }
587
+ async _listDirs(p) {
588
+ const entries = await fsp.readdir(p, { withFileTypes: true }).catch(() => []);
589
+ return entries.filter((e) => e.isDirectory()).map((e) => path.join(e.parentPath, e.name));
590
+ }
591
+ // Every .jsonl under `dir`, at any depth — subagent transcripts live arbitrarily deep
592
+ // (e.g. <session>/subagents/workflows/<wf>/agent-*.jsonl). The walk can't prune subtrees
593
+ // by directory mtime, because on Windows appending to an existing transcript does NOT
594
+ // bump its parent dir's mtime.
595
+ async _listJsonl(dir) {
596
+ const entries = await fsp.readdir(dir, { recursive: true, withFileTypes: true }).catch(() => []);
597
+ return entries.filter((e) => e.isFile() && isJsonl(e.name)).map((e) => path.join(e.parentPath, e.name));
598
+ }
599
+ // Append-only transcripts span [birthtime, mtime], so files outside the period are skipped
600
+ // Note birthtime is trusted only when well older than mtime, since copies/restores (git checkout, scp, zip) get a fresh birthtime over old content.
601
+ _inPeriod(stat, day) {
602
+ if (stat.mtimeMs < day.start) return false;
603
+ return !(stat.birthtimeMs > day.end && stat.birthtimeMs + 2e3 <= stat.mtimeMs);
604
+ }
605
+ // Walks each project subtree in full. For each .jsonl whose stat passes _inPeriod
606
+ // (file mtime IS updated on append, unlike dir mtime), calls onFile(filePath, stat).
607
+ // After each project's files are processed, calls onBatchDone() if any onFile returned
608
+ // truthy — lets callers flush UI updates incrementally. An aborted `signal` (a superseded
609
+ // scan) stops the walk: no further stats, onFile calls or emits. Once a walk has completed
610
+ // with the watcher live, the StatCache mirrors the disk and serves later scans in memory.
611
+ async scan(day, { onFile, onBatchDone, onProgress, signal } = {}) {
612
+ if (this.statCache.complete && this.watcher) return this.statCache.scan((stat) => this._inPeriod(stat, day), { onFile, onBatchDone, onProgress, signal });
613
+ const projects = await this._listDirs(this.root);
614
+ let done = 0;
615
+ const progress = () => onProgress?.({ done, total: projects.length, scanning: done < projects.length });
616
+ progress();
617
+ await Promise.all(projects.map(async (projDir) => {
618
+ if (signal?.aborted) return;
619
+ const results = await Promise.all((await this._listJsonl(projDir)).map(async (fp) => {
620
+ const stat = signal?.aborted ? null : await fsp.stat(fp).catch(() => null);
621
+ this.statCache.record(fp, stat);
622
+ return stat && this._inPeriod(stat, day) && !signal?.aborted ? onFile?.(fp, stat) : null;
623
+ }));
624
+ if (signal?.aborted) return;
625
+ if (onBatchDone && results.some(Boolean)) await onBatchDone();
626
+ done++;
627
+ progress();
628
+ }));
629
+ if (signal?.aborted) return;
630
+ if (this.watcher) this.statCache.markComplete();
631
+ if (onBatchDone) await onBatchDone();
632
+ progress();
633
+ }
634
+ watch({ onChange, onUnlink } = {}) {
635
+ const isUNC = this.root.startsWith("\\\\") || this.root.startsWith("//");
636
+ this.watcher = chokidar.watch(this.root, {
637
+ ignoreInitial: true,
638
+ alwaysStat: true,
639
+ awaitWriteFinish: { stabilityThreshold: 400, pollInterval: 100 },
640
+ ignored: (p, stats) => !!stats?.isFile() && !isJsonl(p),
641
+ ...isUNC && { usePolling: true, interval: 1e3 }
642
+ });
643
+ const changed = (p, stat) => {
644
+ if (!isJsonl(p)) return;
645
+ this.statCache.record(p, stat);
646
+ onChange?.(p, stat);
647
+ };
648
+ const removed = (p) => {
649
+ if (!isJsonl(p)) return;
650
+ this.statCache.remove(p);
651
+ onUnlink?.(p);
652
+ };
653
+ this.watcher.on("add", changed);
654
+ this.watcher.on("change", changed);
655
+ this.watcher.on("unlink", removed);
656
+ }
657
+ stop() {
658
+ if (this.watcher) {
659
+ this.watcher.close();
660
+ this.watcher = null;
661
+ this.statCache.clear();
662
+ }
663
+ }
664
+ }
665
+ class MetaCache {
666
+ constructor() {
667
+ this.periodKey = null;
668
+ this.files = /* @__PURE__ */ new Map();
669
+ this.periodMetas = /* @__PURE__ */ new Map();
670
+ }
671
+ // New period → drop the period layer; re-entering the same period keeps it.
672
+ setPeriod(key) {
673
+ if (this.periodKey === key) return;
674
+ this.periodKey = key;
675
+ this.periodMetas.clear();
676
+ }
677
+ get(filePath) {
678
+ return this.periodMetas.get(filePath);
679
+ }
680
+ byId(sessionId) {
681
+ return [...this.periodMetas.values()].find((m) => m.sessionId === sessionId);
682
+ }
683
+ values() {
684
+ return this.periodMetas.values();
685
+ }
686
+ // Meta for one file within `day` ({ start, end, key }), or null when it has no activity there.
687
+ // `parse(range)` runs only on cache misses (range = null → whole file) and for files straddling a period edge.
688
+ async resolve(filePath, stat, day, parse) {
689
+ let e = this.files.get(filePath);
690
+ if (!e || e.size !== stat.size || e.mtimeMs !== stat.mtimeMs) {
691
+ e = { size: stat.size, mtimeMs: stat.mtimeMs, meta: await parse(null) };
692
+ this.files.set(filePath, e);
693
+ }
694
+ let m = e.meta;
695
+ if (m && (m.lastActivityAt < day.start || m.startedAt > day.end)) m = null;
696
+ else if (m && !(m.startedAt >= day.start && m.lastActivityAt <= day.end)) m = await parse(day);
697
+ if (day.key !== this.periodKey) return m;
698
+ if (!m) {
699
+ this.periodMetas.delete(filePath);
700
+ return null;
701
+ }
702
+ this.periodMetas.set(filePath, m);
703
+ return m;
704
+ }
705
+ // Drop both layers, e.g. when a pricing change invalidates every meta.
706
+ clear() {
707
+ this.files.clear();
708
+ this.periodMetas.clear();
709
+ }
710
+ // Forget a deleted file. True if the period layer shrank.
711
+ evict(filePath) {
712
+ this.files.delete(filePath);
713
+ return this.periodMetas.delete(filePath);
714
+ }
715
+ }
716
+ const seed = {
717
+ "claude-fable-5": { "input": 10, "output": 50, "cacheRead": 1, "cacheWrite": 12.5, "cacheWrite1h": 20 },
718
+ "claude-sonnet-5": { "input": 2, "output": 10, "cacheRead": 0.2, "cacheWrite": 2.5, "cacheWrite1h": 4 },
719
+ "claude-opus-4-8": { "input": 5, "output": 25, "cacheRead": 0.5, "cacheWrite": 6.25, "cacheWrite1h": 10, "fastModeMplr": 2 },
720
+ "claude-opus-4-7": { "input": 5, "output": 25, "cacheRead": 0.5, "cacheWrite": 6.25, "cacheWrite1h": 10, "fastModeMplr": 6 },
721
+ "claude-opus-4-6": { "input": 5, "output": 25, "cacheRead": 0.5, "cacheWrite": 6.25, "cacheWrite1h": 10, "fastModeMplr": 6 },
722
+ "claude-opus-4-5": { "input": 5, "output": 25, "cacheRead": 0.5, "cacheWrite": 6.25, "cacheWrite1h": 10 },
723
+ "claude-opus-4-1": { "input": 15, "output": 75, "cacheRead": 1.5, "cacheWrite": 18.75, "cacheWrite1h": 30 },
724
+ "claude-opus-4": { "input": 15, "output": 75, "cacheRead": 1.5, "cacheWrite": 18.75, "cacheWrite1h": 30 },
725
+ "claude-4-opus": { "input": 15, "output": 75, "cacheRead": 1.5, "cacheWrite": 18.75 },
726
+ "claude-sonnet-4-6": { "input": 3, "output": 15, "cacheRead": 0.3, "cacheWrite": 3.75, "cacheWrite1h": 6 },
727
+ "claude-sonnet-4-5": { "input": 3, "output": 15, "cacheRead": 0.3, "cacheWrite": 3.75, "cacheWrite1h": 6 },
728
+ "claude-sonnet-4": { "input": 3, "output": 15, "cacheRead": 0.3, "cacheWrite": 3.75, "cacheWrite1h": 6 },
729
+ "claude-4-sonnet": { "input": 3, "output": 15, "cacheRead": 0.3, "cacheWrite": 3.75 },
730
+ "claude-haiku-4-5": { "input": 1, "output": 5, "cacheRead": 0.1, "cacheWrite": 1.25, "cacheWrite1h": 2 },
731
+ "claude-3-7-sonnet": { "input": 3, "output": 15, "cacheRead": 0.3, "cacheWrite": 3.75, "cacheWrite1h": 6 },
732
+ "claude-3-5-sonnet": { "input": 3, "output": 15, "cacheRead": 0.3, "cacheWrite": 3.75 },
733
+ "claude-3-5-haiku": { "input": 0.8, "output": 4, "cacheRead": 0.08, "cacheWrite": 1 },
734
+ "claude-3-opus": { "input": 15, "output": 75, "cacheRead": 1.5, "cacheWrite": 18.75, "cacheWrite1h": 6 },
735
+ "claude-3-sonnet": { "input": 3, "output": 15, "cacheRead": 0.3, "cacheWrite": 3.75 },
736
+ "claude-3-haiku": { "input": 0.25, "output": 1.25, "cacheRead": 0.03, "cacheWrite": 0.3, "cacheWrite1h": 6 }
737
+ };
738
+ const CURRENT_PATH = path.join(DATA_DIR, "prices.current.json");
739
+ const LITELLM_URL = "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json";
740
+ const DAY_MS = 24 * 3600 * 1e3;
741
+ const MILLION = 1e6;
742
+ class Pricing extends EventEmitter {
743
+ #prices = {};
744
+ #keys = [];
745
+ // longest first, so 'claude-opus-4-7' wins over 'claude-opus-4'
746
+ constructor() {
747
+ super();
748
+ this.load();
749
+ this.refreshFromLiteLLM().catch((err) => console.warn("[pricing] refresh failed:", err.message));
750
+ }
751
+ get prices() {
752
+ return this.#prices;
753
+ }
754
+ set prices(rates) {
755
+ this.#prices = {};
756
+ for (const [key, p] of Object.entries(rates))
757
+ if ([p.input, p.output, p.cacheRead, p.cacheWrite].every((r) => r > 0)) this.#prices[key] = p;
758
+ this.#keys = Object.keys(this.#prices).sort((a, b) => b.length - a.length);
759
+ }
760
+ load() {
761
+ this.prices = fs.existsSync(CURRENT_PATH) ? JSON.parse(fs.readFileSync(CURRENT_PATH, "utf8")) : seed;
762
+ }
763
+ priceFor(model) {
764
+ if (!model) return null;
765
+ const m = model.toLowerCase();
766
+ const key = this.#keys.find((k) => m.startsWith(k) && !/^\w/.test(m.slice(k.length)));
767
+ return key ? this.#prices[key] : null;
768
+ }
769
+ // Fast mode (usage.speed === "fast") bills at a flat per-model multiple of
770
+ // standard rates, applied uniformly across every token category. Sourced from
771
+ // LiteLLM's provider_specific_entry.fast. null when unknown for the model.
772
+ fastMultiplier(model) {
773
+ return this.priceFor(model)?.fastModeMplr ?? null;
774
+ }
775
+ costForDelta(model, bucket, { ignoreCache1hPremium = false, fast = false } = {}) {
776
+ const p = this.priceFor(model);
777
+ if (!p || !bucket) return null;
778
+ const tokens5m = ignoreCache1hPremium ? 0 : bucket.cacheCreation5m || 0;
779
+ const tokens1h = ignoreCache1hPremium ? 0 : bucket.cacheCreation1h || 0;
780
+ const tokensUnknown = (bucket.cacheCreation || 0) - tokens5m - tokens1h;
781
+ const base = ((bucket.input || 0) * p.input + (bucket.output || 0) * p.output + (bucket.cacheRead || 0) * p.cacheRead + tokens5m * p.cacheWrite + tokens1h * (p.cacheWrite1h ?? p.cacheWrite) + // 1h premium unknown → bill at the base write rate, not $0
782
+ tokensUnknown * p.cacheWrite) / MILLION;
783
+ return fast ? base * (this.fastMultiplier(model) ?? 1) : base;
784
+ }
785
+ costUSD(tokensByModel, opts = {}) {
786
+ if (!tokensByModel) return null;
787
+ let total = 0;
788
+ let priced = false;
789
+ for (const [model, bucket] of Object.entries(tokensByModel)) {
790
+ const c = this.costForDelta(model, bucket, opts);
791
+ if (c == null) continue;
792
+ total += c;
793
+ priced = true;
794
+ }
795
+ return priced ? total : null;
796
+ }
797
+ async refreshFromLiteLLM(verbose = true) {
798
+ if (fs.existsSync(CURRENT_PATH) && Date.now() - fs.statSync(CURRENT_PATH).mtimeMs < DAY_MS) return;
799
+ if (verbose) console.info("[pricing] refreshing prices from LiteLLM:", LITELLM_URL);
800
+ const res = await fetch(LITELLM_URL, { signal: AbortSignal.timeout(1e4) });
801
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
802
+ const litellm = await res.json();
803
+ const collect = (prefix) => {
804
+ const id = (k) => k.slice(prefix.length).replace(/[-@]\d{8}(-v\d+(:\d+)?)?$|-v\d+(:\d+)?$/, "");
805
+ const out2 = {};
806
+ Object.entries(litellm).filter(([key]) => key.startsWith(prefix)).forEach(([key, v]) => Object.assign(out2[id(key)] ??= {}, v));
807
+ return out2;
808
+ };
809
+ const claude = collect("claude-");
810
+ const anthropic = collect("anthropic.claude-");
811
+ const models = /* @__PURE__ */ new Set([...Object.keys(anthropic), ...Object.keys(claude)]);
812
+ const out = {};
813
+ for (const name of models) {
814
+ const m = Object.assign({}, anthropic[name], claude[name]);
815
+ const per = (c) => Number.isFinite(c) ? Number((c * MILLION).toPrecision(12)) : void 0;
816
+ out["claude-" + name] = {
817
+ input: per(m.input_cost_per_token),
818
+ output: per(m.output_cost_per_token),
819
+ cacheRead: per(m.cache_read_input_token_cost),
820
+ cacheWrite: per(m.cache_creation_input_token_cost),
821
+ cacheWrite1h: per(m.cache_creation_input_token_cost_above_1hr)
822
+ };
823
+ const fast = m.provider_specific_entry?.fast;
824
+ if (typeof fast === "number") out["claude-" + name].fastModeMplr = fast;
825
+ }
826
+ if (Object.keys(out).length) {
827
+ fs.mkdirSync(DATA_DIR, { recursive: true });
828
+ fs.writeFileSync(CURRENT_PATH, JSON.stringify(out, null, 2) + "\n");
829
+ const before = JSON.stringify(this.#prices);
830
+ this.load();
831
+ if (verbose) console.info(`[pricing] updated ${Object.keys(out).length} model prices`);
832
+ if (JSON.stringify(this.#prices) !== before) this.emit("update");
833
+ }
834
+ }
835
+ }
836
+ const WEEK = { weekStartsOn: 1 };
837
+ function periodBounds(date, granularity = "day", tz = void 0) {
838
+ const opts = { in: tz };
839
+ const d = parseISO(date, opts);
840
+ if (granularity === "week") return { start: +startOfWeek(d, { ...WEEK, ...opts }), end: +endOfWeek(d, { ...WEEK, ...opts }) };
841
+ if (granularity === "month") return { start: +startOfMonth(d, opts), end: +endOfMonth(d, opts) };
842
+ return { start: +startOfDay(d, opts), end: +endOfDay(d, opts) };
843
+ }
844
+ function intersectsDay(meta, day) {
845
+ return meta.lastActivityAt >= day.start && meta.startedAt <= day.end;
846
+ }
847
+ function stripInternal({ seenMessageIds, ...meta }) {
848
+ return meta;
849
+ }
850
+ class SessionsService extends EventEmitter {
851
+ constructor({ root, throttleMs = 100, scanner = null } = {}) {
852
+ super();
853
+ this.scanner = scanner ?? new SessionsScanner(root ? { root } : {});
854
+ this.pricing = new Pricing();
855
+ this.throttleMs = throttleMs;
856
+ this.metaCache = new MetaCache();
857
+ this.activeDay = null;
858
+ this.scanAbort = null;
859
+ this.updateTimer = null;
860
+ this.lastUpdateAt = 0;
861
+ }
862
+ // Always parses the whole file from 0: the parser's running token totals only stamp
863
+ // correctly from a fresh start, so live updates re-read rather than resume at an offset.
864
+ async readSession(sessionId, date = null, granularity = "day") {
865
+ const meta = this.metaCache.byId(sessionId);
866
+ if (!meta) return null;
867
+ const range = date ? periodBounds(date, granularity) : null;
868
+ const reader = new SessionFile(meta.filePath);
869
+ const parser = new SessionParser({ sessionId: reader.sessionId, parentSessionId: reader.parentSessionId, filePath: reader.filePath, mtime: meta.mtime, range });
870
+ const items = [];
871
+ await reader.stream((obj) => {
872
+ if (parser.feed(obj)) items.push(obj);
873
+ });
874
+ items.sort((a, b) => a._ts - b._ts);
875
+ for (const o of items) delete o._ts;
876
+ const instructions = await new RequestFile(reader.sessionId).readInstructions();
877
+ return { items, instructions };
878
+ }
879
+ // Captured API requests for a session (see bin/proxy.mjs), period-filtered like readSession.
880
+ async readRequests(sessionId, date = null, granularity = "day") {
881
+ if (!/^[\w-]+$/.test(sessionId)) return [];
882
+ const range = date ? periodBounds(date, granularity) : null;
883
+ return new RequestFile(sessionId).read(range);
884
+ }
885
+ // Scan `period` ({ start, end } epoch ms, optional `key`/`date`; see periodBounds). watch:true (UI) streams 'update' events per dir batch and returns the current cache immediately; watch:false (CLIs/tests) awaits completion and returns the deduped sessions with no emits.
886
+ async scan(period, { watch = false } = {}) {
887
+ const day = period.key ? period : { ...period, key: `${period.start}|${period.end}` };
888
+ this.metaCache.setPeriod(day.key);
889
+ this.activeDay = day;
890
+ this.scanAbort?.abort();
891
+ this.scanAbort = new AbortController();
892
+ const fresh = () => this.activeDay?.key === day.key;
893
+ const scan = this.scanner.scan(day, {
894
+ onFile: (filePath, stat) => this._processFile(filePath, day, stat),
895
+ signal: this.scanAbort.signal,
896
+ ...watch && {
897
+ onBatchDone: () => {
898
+ if (fresh()) this._scheduleUpdate();
899
+ },
900
+ onProgress: (p) => {
901
+ if (fresh()) this.emit("progress", p);
902
+ }
903
+ }
904
+ });
905
+ if (watch) scan.catch(console.error);
906
+ else await scan;
907
+ return this._dedupedDay(day);
908
+ }
909
+ // IPC/UI entry: build the period from date+granularity and watch it.
910
+ list(date, granularity = "day") {
911
+ const key = `${granularity}|${date}`;
912
+ return this.scan({ ...periodBounds(date, granularity), date, key }, { watch: true });
913
+ }
914
+ start() {
915
+ this.pricing.on("update", () => this._reprice());
916
+ this.scanner.watch({
917
+ onChange: (p, stat) => this._refresh(p, stat),
918
+ onUnlink: (p) => this._evict(p)
919
+ });
920
+ return this;
921
+ }
922
+ stop() {
923
+ this.pricing.removeAllListeners("update");
924
+ this.scanner.stop();
925
+ this.scanAbort?.abort();
926
+ if (this.updateTimer) {
927
+ clearTimeout(this.updateTimer);
928
+ this.updateTimer = null;
929
+ }
930
+ }
931
+ // Stat the file, stream every line through a fresh SessionParser, and finalize.
932
+ // `excludeIds` skips message ids already counted in an earlier session.
933
+ async _scanSession(reader, { range = null, excludeIds = null, stat = null } = {}) {
934
+ if (!stat) stat = await reader.stat();
935
+ if (!stat) return null;
936
+ const parser = new SessionParser({
937
+ sessionId: reader.sessionId,
938
+ parentSessionId: reader.parentSessionId,
939
+ filePath: reader.filePath,
940
+ fileSize: stat.size,
941
+ mtime: stat.mtimeMs,
942
+ range,
943
+ excludeIds,
944
+ pricing: this.pricing
945
+ });
946
+ await reader.stream((obj) => parser.feed(obj));
947
+ return parser.finalize(stat.mtimeMs);
948
+ }
949
+ // Resume/fork copies prior message.ids verbatim. Walk earliest-first; if a
950
+ // session's ids overlap one already counted, rescan it with the overlap
951
+ // excluded so it contributes only its NEW messages.
952
+ async _dedupSessions(metas, range) {
953
+ const sorted = [...metas].sort((a, b) => a.startedAt - b.startedAt);
954
+ const globalSeen = /* @__PURE__ */ new Set();
955
+ const rescans = /* @__PURE__ */ new Map();
956
+ for (const m of sorted) {
957
+ let overlap = null;
958
+ for (const id of m.seenMessageIds || []) {
959
+ if (globalSeen.has(id)) (overlap ||= /* @__PURE__ */ new Set()).add(id);
960
+ globalSeen.add(id);
961
+ }
962
+ if (overlap) rescans.set(m.sessionId, this._scanSession(new SessionFile(m.filePath), { range, excludeIds: overlap }));
963
+ }
964
+ const sessions = await Promise.all(metas.map(async (m) => {
965
+ const rescanned = await rescans.get(m.sessionId);
966
+ return stripInternal(rescanned ? { ...m, ...rescanned } : m);
967
+ }));
968
+ const labels = suffixLabels(sessions.map((m) => m.project));
969
+ for (const m of sessions) m.projectShort = labels.get(m.project);
970
+ return sessions;
971
+ }
972
+ _dedupedDay(day) {
973
+ const metas = [...this.metaCache.values()].filter((m) => intersectsDay(m, day)).sort((a, b) => b.lastActivityAt - a.lastActivityAt);
974
+ return this._dedupSessions(metas, day);
975
+ }
976
+ _processFile(filePath, day, stat) {
977
+ const parse = (range) => this._scanSession(new SessionFile(filePath), { range, stat });
978
+ return this.metaCache.resolve(filePath, stat, day, parse);
979
+ }
980
+ async _refresh(filePath, stat) {
981
+ const day = this.activeDay;
982
+ if (!day || !stat) return;
983
+ const cached = this.metaCache.get(filePath);
984
+ const isChanged = !cached || cached.fileSize !== stat.size || cached.mtime !== stat.mtimeMs;
985
+ if (!isChanged) return;
986
+ const meta = await this._processFile(filePath, day, stat);
987
+ if (meta && intersectsDay(meta, day)) this._scheduleUpdate();
988
+ }
989
+ _evict(filePath) {
990
+ if (this.metaCache.evict(filePath)) this._scheduleUpdate();
991
+ }
992
+ // Prices changed after metas were parsed (first launch races the LiteLLM refresh) — reparse with the new rates.
993
+ _reprice() {
994
+ if (!this.activeDay) return;
995
+ this.metaCache.clear();
996
+ this.scan(this.activeDay, { watch: true }).catch(console.error);
997
+ }
998
+ // Leading+trailing throttle: the first call emits immediately, calls within throttleMs
999
+ // coalesce into one trailing emit — so scans paint fast but bursts cost one update.
1000
+ _scheduleUpdate() {
1001
+ if (this.updateTimer) return;
1002
+ const emit = async () => {
1003
+ this.updateTimer = null;
1004
+ this.lastUpdateAt = Date.now();
1005
+ const day = this.activeDay;
1006
+ try {
1007
+ if (day) this.emit("update", await this._dedupedDay(day));
1008
+ } catch (err) {
1009
+ console.error("sessions update emit failed:", err);
1010
+ }
1011
+ };
1012
+ const wait = this.lastUpdateAt + this.throttleMs - Date.now();
1013
+ if (wait <= 0) emit();
1014
+ else this.updateTimer = setTimeout(emit, wait);
1015
+ }
1016
+ }
1017
+ const DEFAULT = { work_hours: { start: "09:00", end: "17:00" } };
1018
+ class WorkHours {
1019
+ read() {
1020
+ const { work_hours } = config.read();
1021
+ return work_hours ? { work_hours } : DEFAULT;
1022
+ }
1023
+ write(data) {
1024
+ config.save({ work_hours: data.work_hours });
1025
+ return data;
1026
+ }
1027
+ }
1028
+ class AgentRunner {
1029
+ async run(text, sender, systemTools = false, cache = false) {
1030
+ const send = (chunk) => {
1031
+ if (!sender.isDestroyed()) sender.send("agent:output", chunk);
1032
+ };
1033
+ const response = query({
1034
+ prompt: text,
1035
+ options: {
1036
+ includePartialMessages: true,
1037
+ env: cache ? { ...process.env, FORCE_PROMPT_CACHING_5M: "1" } : { ...process.env, DISABLE_PROMPT_CACHING: "1" },
1038
+ tools: systemTools ? void 0 : [],
1039
+ settingSources: systemTools ? void 0 : []
1040
+ }
1041
+ });
1042
+ for await (const message of response) {
1043
+ if (message.type === "stream_event") {
1044
+ const delta = message.event.delta;
1045
+ if (delta?.type === "text_delta" && delta.text) send(delta.text);
1046
+ } else if (message.type === "result") {
1047
+ return { code: message.is_error ? 1 : 0 };
1048
+ }
1049
+ }
1050
+ return { code: 0 };
1051
+ }
1052
+ }
1053
+ const IS_DEV = !!process.env.ELECTRON_RENDERER_URL;
1054
+ class ClaudeDirSwitch {
1055
+ status() {
1056
+ return { dir: CLAUDE_DIR };
1057
+ }
1058
+ // "Change directory" — browse for a new dir; selecting one restarts the app on it.
1059
+ activate() {
1060
+ return browseForClaudeDir(BrowserWindow.getFocusedWindow());
1061
+ }
1062
+ deactivate() {
1063
+ throw new Error("Nothing to deactivate");
1064
+ }
1065
+ }
1066
+ async function browseForClaudeDir(win) {
1067
+ const r = await dialog.showOpenDialog(win, {
1068
+ title: "Select Claude directory",
1069
+ defaultPath: CLAUDE_DIR,
1070
+ properties: ["openDirectory"]
1071
+ });
1072
+ if (!r.canceled) {
1073
+ switchToClaudeDir(r.filePaths[0]);
1074
+ }
1075
+ }
1076
+ function switchToClaudeDir(claudeDir) {
1077
+ if (!claudeDir || claudeDir === CLAUDE_DIR) return;
1078
+ config.save({ claudeDir, recents: [.../* @__PURE__ */ new Set([...RECENT_CLAUDE_DIRS, claudeDir])] });
1079
+ if (IS_DEV) {
1080
+ dialog.showMessageBoxSync({ type: "info", message: "Restart required", detail: "Stop and re-run `npm run dev` to apply the new directory." });
1081
+ app.quit();
1082
+ } else {
1083
+ app.relaunch();
1084
+ BrowserWindow.getAllWindows().forEach((w) => w.close());
1085
+ app.exit(0);
1086
+ }
1087
+ }
1088
+ function buildAppMenu({ onFind, onEscape } = {}) {
1089
+ return Menu.buildFromTemplate([
1090
+ ...process.platform === "darwin" ? [{ role: "appMenu" }] : [],
1091
+ // @macOS
1092
+ {
1093
+ label: "File",
1094
+ submenu: [
1095
+ { label: "Change directory…", click: (_i, win) => browseForClaudeDir(win) },
1096
+ { type: "separator" },
1097
+ ...RECENT_CLAUDE_DIRS.map((p) => ({
1098
+ label: p,
1099
+ type: "checkbox",
1100
+ checked: p === CLAUDE_DIR,
1101
+ click: () => switchToClaudeDir(p)
1102
+ })),
1103
+ { type: "separator" },
1104
+ { role: "quit" }
1105
+ ]
1106
+ },
1107
+ {
1108
+ // @macOS Cmd+C/V/A have no key equivalents without an Edit menu; Windows/Linux get these from Chromium
1109
+ label: "Edit",
1110
+ submenu: [
1111
+ { role: "copy" },
1112
+ { role: "paste" },
1113
+ { role: "selectAll" },
1114
+ { label: "Find…", accelerator: "CmdOrCtrl+F", click: () => onFind?.() },
1115
+ { label: "Deselect", accelerator: "Escape", click: (_i, win) => onEscape?.(win) }
1116
+ ]
1117
+ },
1118
+ {
1119
+ label: "View",
1120
+ submenu: [
1121
+ { role: "reload" },
1122
+ { role: "forceReload" },
1123
+ { role: "toggleDevTools" }
1124
+ ]
1125
+ }
1126
+ ]);
1127
+ }
1128
+ const VIEWABLE_EXT = /* @__PURE__ */ new Set([".md", ".txt", ".json", ".jsonl", ".log", ".csv", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".pdf"]);
1129
+ async function openLinkSafely(href, baseFile) {
1130
+ if (/^(https?:|mailto:)/i.test(href)) return shell.openExternal(href);
1131
+ if (!baseFile) return;
1132
+ let decoded;
1133
+ try {
1134
+ decoded = decodeURIComponent(href);
1135
+ } catch {
1136
+ return;
1137
+ }
1138
+ const baseDir = path.dirname(path.resolve(baseFile));
1139
+ const abs = path.resolve(baseDir, decoded);
1140
+ const rel = path.relative(baseDir, abs);
1141
+ if (rel.startsWith("..") || path.isAbsolute(rel)) return;
1142
+ if (!VIEWABLE_EXT.has(path.extname(abs).toLowerCase())) return shell.showItemInFolder(abs);
1143
+ const err = await shell.openPath(abs);
1144
+ if (err) shell.showItemInFolder(abs);
1145
+ return err;
1146
+ }
1147
+ const DEV_ORIGIN = process.env.ELECTRON_RENDERER_URL && new URL(process.env.ELECTRON_RENDERER_URL).origin;
1148
+ const APP_PAGES = pathToFileURL(path.join(import.meta.dirname, "../renderer/")).href;
1149
+ function lockNavigation(contents) {
1150
+ const toBrowser = (url) => /^(https?|mailto):/i.test(url) && shell.openExternal(url);
1151
+ const block = (e, url) => {
1152
+ const { href, origin } = new URL(url);
1153
+ if (href.startsWith(APP_PAGES) || origin === DEV_ORIGIN) return;
1154
+ e.preventDefault();
1155
+ toBrowser(url);
1156
+ };
1157
+ contents.setWindowOpenHandler(({ url }) => {
1158
+ toBrowser(url);
1159
+ return { action: "deny" };
1160
+ });
1161
+ contents.on("will-navigate", block);
1162
+ contents.on("will-redirect", block);
1163
+ }
1164
+ const __dirname$2 = path.dirname(fileURLToPath(import.meta.url));
1165
+ const DEV_URL$1 = process.env.ELECTRON_RENDERER_URL;
1166
+ const WIDTH = 320, HEIGHT = 56, MARGIN = 6;
1167
+ class FindBar {
1168
+ constructor(win) {
1169
+ this.win = win;
1170
+ this.view = new WebContentsView({
1171
+ webPreferences: {
1172
+ preload: path.join(__dirname$2, "../preload/findPreload.cjs"),
1173
+ contextIsolation: true,
1174
+ nodeIntegration: false,
1175
+ sandbox: true
1176
+ }
1177
+ });
1178
+ this.view.setBackgroundColor("#00000000");
1179
+ lockNavigation(this.view.webContents);
1180
+ win.contentView.addChildView(this.view);
1181
+ this.view.setVisible(false);
1182
+ if (DEV_URL$1) this.view.webContents.loadURL(`${DEV_URL$1}/find/find.html`);
1183
+ else this.view.webContents.loadFile(path.join(__dirname$2, "../renderer/find/find.html"));
1184
+ win.webContents.on("found-in-page", (_e, r) => {
1185
+ return this.view.webContents.send("find:result", { active: r.activeMatchOrdinal, total: r.matches, id: r.requestId });
1186
+ });
1187
+ win.on("resize", () => {
1188
+ if (this.visible) this.layout();
1189
+ });
1190
+ }
1191
+ get visible() {
1192
+ return this.view.getVisible();
1193
+ }
1194
+ layout() {
1195
+ const [w] = this.win.getContentSize();
1196
+ this.view.setBounds({ x: Math.max(0, w - WIDTH - MARGIN), y: MARGIN, width: WIDTH, height: HEIGHT });
1197
+ }
1198
+ query(text, options) {
1199
+ this.win.webContents.findInPage(text, options);
1200
+ }
1201
+ stop() {
1202
+ this.win.webContents.stopFindInPage("clearSelection");
1203
+ }
1204
+ show() {
1205
+ this.layout();
1206
+ this.view.setVisible(true);
1207
+ this.view.webContents.focus();
1208
+ this.view.webContents.send("find:open");
1209
+ this.win.webContents.send("find:active", true);
1210
+ }
1211
+ hide() {
1212
+ if (!this.visible) return;
1213
+ this.view.setVisible(false);
1214
+ this.stop();
1215
+ this.win.webContents.focus();
1216
+ this.win.webContents.send("find:active", false);
1217
+ }
1218
+ }
1219
+ const __dirname$1 = path.dirname(fileURLToPath(import.meta.url));
1220
+ const DEV_URL = process.env.ELECTRON_RENDERER_URL;
1221
+ class MainWindow {
1222
+ constructor() {
1223
+ this.win = null;
1224
+ }
1225
+ create() {
1226
+ Menu.setApplicationMenu(buildAppMenu({
1227
+ onFind: () => this.findBar?.show(),
1228
+ onEscape: (win) => this.findBar?.visible ? this.findBar.hide() : win?.webContents.unselect()
1229
+ }));
1230
+ const state = windowStateKeeper({ defaultWidth: 1500, defaultHeight: 900 });
1231
+ this.win = new BrowserWindow({
1232
+ x: state.x,
1233
+ y: state.y,
1234
+ width: state.width,
1235
+ height: state.height,
1236
+ backgroundColor: "#0b0d12",
1237
+ autoHideMenuBar: true,
1238
+ title: `Claude Discover v${app.getVersion()}`,
1239
+ webPreferences: {
1240
+ preload: path.join(__dirname$1, "../preload/preload.cjs"),
1241
+ contextIsolation: true,
1242
+ nodeIntegration: false,
1243
+ sandbox: true
1244
+ }
1245
+ });
1246
+ state.manage(this.win);
1247
+ this.win.on("page-title-updated", (e) => e.preventDefault());
1248
+ lockNavigation(this.win.webContents);
1249
+ contextMenu({ window: this.win, showSelectAll: true, showCopyImage: true, showCopyLink: true, showInspectElement: true });
1250
+ if (DEV_URL) {
1251
+ this.win.loadURL(DEV_URL);
1252
+ } else {
1253
+ this.win.loadFile(path.join(__dirname$1, "../renderer/index.html"));
1254
+ }
1255
+ this.findBar = new FindBar(this.win);
1256
+ return this.win;
1257
+ }
1258
+ send(channel, payload) {
1259
+ if (this.win && !this.win.isDestroyed()) this.win.webContents.send(channel, payload);
1260
+ }
1261
+ }
1262
+ const mtimeOf = (path2) => fs.statSync(path2, { throwIfNoEntry: false })?.mtimeMs;
1263
+ class ClaudeSettings {
1264
+ constructor() {
1265
+ this.cfg = {};
1266
+ this.mtime = mtimeOf(CLAUDE_SETTINGS);
1267
+ if (this.mtime !== void 0) {
1268
+ try {
1269
+ this.cfg = JSON.parse(fs.readFileSync(CLAUDE_SETTINGS, "utf8"));
1270
+ } catch (err) {
1271
+ console.warn(`Failed to read ${CLAUDE_SETTINGS}: ${err.message}`);
1272
+ this.loadFailed = true;
1273
+ }
1274
+ }
1275
+ }
1276
+ hooks(eventName) {
1277
+ return (this.cfg.hooks?.[eventName] ?? []).flatMap((g) => g.hooks ?? []);
1278
+ }
1279
+ // The hook entry on this event whose command mentions `needle` (basename, so stale absolute paths still match), or undefined.
1280
+ findHook(eventName, needle) {
1281
+ return this.hooks(eventName).find((h) => h.command?.includes(needle));
1282
+ }
1283
+ // Removes every hook on this event whose command mentions `needle` (basename, so stale
1284
+ // absolute paths still match); groups left empty are dropped. Returns the removed commands.
1285
+ removeHook(eventName, needle) {
1286
+ const matches = (h) => h.command?.includes(needle);
1287
+ const groups = this.cfg.hooks?.[eventName] ?? [];
1288
+ const removed = groups.flatMap((g) => (g.hooks ?? []).filter(matches).map((h) => h.command));
1289
+ if (removed.length) {
1290
+ for (const g of groups) g.hooks = (g.hooks ?? []).filter((h) => !matches(h));
1291
+ this.cfg.hooks[eventName] = groups.filter((g) => g.hooks.length);
1292
+ }
1293
+ return removed;
1294
+ }
1295
+ // Days Claude Code keeps transcripts before auto-deleting them; undefined when unset (its default is 30).
1296
+ get cleanupPeriodDays() {
1297
+ return this.cfg.cleanupPeriodDays;
1298
+ }
1299
+ set cleanupPeriodDays(days) {
1300
+ if (days === void 0) delete this.cfg.cleanupPeriodDays;
1301
+ else this.cfg.cleanupPeriodDays = days;
1302
+ }
1303
+ // Claude Code's status line config: { type: 'command', command }; undefined when unset.
1304
+ get statusLine() {
1305
+ return this.cfg.statusLine;
1306
+ }
1307
+ set statusLine(value) {
1308
+ if (value === void 0) delete this.cfg.statusLine;
1309
+ else this.cfg.statusLine = value;
1310
+ }
1311
+ // Environment variables Claude Code applies to every session; undefined when unset.
1312
+ get env() {
1313
+ return this.cfg.env;
1314
+ }
1315
+ setEnv(name, value) {
1316
+ this.cfg.env ??= {};
1317
+ this.cfg.env[name] = value;
1318
+ }
1319
+ deleteEnv(name) {
1320
+ delete this.cfg.env?.[name];
1321
+ }
1322
+ addHook(eventName, command) {
1323
+ this.cfg.hooks ??= {};
1324
+ this.cfg.hooks[eventName] ??= [];
1325
+ this.cfg.hooks[eventName].push({ hooks: [{ type: "command", command }] });
1326
+ }
1327
+ save() {
1328
+ if (this.loadFailed) throw new Error(`Refusing to save ${CLAUDE_SETTINGS}: settings did not load cleanly`);
1329
+ if (mtimeOf(CLAUDE_SETTINGS) !== this.mtime) throw new Error(`Refusing to save ${CLAUDE_SETTINGS}: file changed on disk since load`);
1330
+ fs.mkdirSync(dirname(CLAUDE_SETTINGS), { recursive: true });
1331
+ sync(CLAUDE_SETTINGS, JSON.stringify(this.cfg, null, 2));
1332
+ }
1333
+ }
1334
+ const HOOK_BASENAME = "hooks.mjs";
1335
+ const HOOK_COMMAND = `node "${CLAUDE_HOOKS_PATH}"`;
1336
+ class ProxySwitch {
1337
+ async status() {
1338
+ return {
1339
+ running: await this.#running(),
1340
+ configured: new ClaudeSettings().env?.ANTHROPIC_BASE_URL === PROXY_URL
1341
+ };
1342
+ }
1343
+ // Spawn the proxy detached (it outlives the app) and point Claude Code at it only once it's
1344
+ // confirmed listening — a failed start never changes settings.json. The proxy verifies the
1345
+ // upstream itself before listening (exit code 2 when unreachable), so a successful start
1346
+ // proves end-to-end connectivity. Failures throw; Switchers maps them to { error }.
1347
+ async activate() {
1348
+ const settings = new ClaudeSettings();
1349
+ const baseUrl = settings.env?.ANTHROPIC_BASE_URL;
1350
+ if (baseUrl && baseUrl !== PROXY_URL)
1351
+ throw new Error(`Leaving your existing env.ANTHROPIC_BASE_URL in place (${baseUrl}) — remove it from settings.json to enable capture.`);
1352
+ const child = spawn(process.execPath, [PROXY_PATH, "--restart"], {
1353
+ detached: true,
1354
+ stdio: "ignore",
1355
+ windowsHide: true,
1356
+ env: { ...process.env, ELECTRON_RUN_AS_NODE: "1" }
1357
+ });
1358
+ const exited = new Promise((resolve) => child.once("exit", resolve));
1359
+ child.unref();
1360
+ const outcome = await Promise.race([exited, this.#settle(true).then((ok) => ok ? "up" : "unresponsive")]);
1361
+ if (outcome !== "up")
1362
+ throw new Error(outcome === 2 ? "Cannot reach api.anthropic.com — not enabling capture. Check your network and try again." : `Proxy did not start — see ${ERROR_LOG_PATH}`);
1363
+ const hook = settings.findHook("SessionStart", HOOK_BASENAME);
1364
+ if (!baseUrl || settings.env?.ENABLE_TOOL_SEARCH !== "true" || hook?.command !== HOOK_COMMAND) {
1365
+ settings.setEnv("ANTHROPIC_BASE_URL", PROXY_URL);
1366
+ settings.setEnv("ENABLE_TOOL_SEARCH", "true");
1367
+ if (hook?.command !== HOOK_COMMAND) {
1368
+ settings.removeHook("SessionStart", HOOK_BASENAME);
1369
+ settings.addHook("SessionStart", HOOK_COMMAND);
1370
+ }
1371
+ settings.save();
1372
+ }
1373
+ }
1374
+ // Exit the proxy via its control route and remove the env keys + revive hook — env keys only
1375
+ // when the base URL is ours, so a foreign gateway config is never touched.
1376
+ async deactivate() {
1377
+ try {
1378
+ await fetch(`${PROXY_URL}${EXIT_ROUTE}`, { method: "POST", signal: AbortSignal.timeout(1e3) });
1379
+ } catch {
1380
+ }
1381
+ const settings = new ClaudeSettings();
1382
+ const ours = settings.env?.ANTHROPIC_BASE_URL === PROXY_URL;
1383
+ const removedHook = settings.removeHook("SessionStart", HOOK_BASENAME).length > 0;
1384
+ if (ours) {
1385
+ settings.deleteEnv("ANTHROPIC_BASE_URL");
1386
+ settings.deleteEnv("ENABLE_TOOL_SEARCH");
1387
+ }
1388
+ if (ours || removedHook) settings.save();
1389
+ await this.#settle(false);
1390
+ }
1391
+ async #running() {
1392
+ try {
1393
+ const res = await fetch(`${PROXY_URL}${PING_ROUTE}`, { signal: AbortSignal.timeout(1e3) });
1394
+ return res.ok && await res.text() === PING_RESPONSE;
1395
+ } catch {
1396
+ return false;
1397
+ }
1398
+ }
1399
+ // Poll (≤8s — the proxy's startup upstream check can take 5s) until ping matches `target`
1400
+ async #settle(target) {
1401
+ for (let i = 0; i < 40; i++) {
1402
+ if (await this.#running() === target) return true;
1403
+ await new Promise((r) => setTimeout(r, 200));
1404
+ }
1405
+ return false;
1406
+ }
1407
+ }
1408
+ const STATUS_FILE = basename(STATUSLINE_PATH);
1409
+ const STATUS_CMD = `node "${STATUSLINE_PATH}"`;
1410
+ class StatuslineSwitch {
1411
+ status() {
1412
+ return { installed: new ClaudeSettings().statusLine?.command?.includes(STATUS_FILE) ?? false };
1413
+ }
1414
+ // Install our status line, repairing a stale absolute path in place (matched by basename).
1415
+ // A different already-configured status line is never overwritten — that throws instead.
1416
+ activate() {
1417
+ const settings = new ClaudeSettings();
1418
+ const command = settings.statusLine?.command;
1419
+ if (command && !command.includes(STATUS_FILE))
1420
+ throw new Error(`Leaving your existing status line in place (${command}) — remove it from settings.json to use ours.`);
1421
+ if (command !== STATUS_CMD) {
1422
+ settings.statusLine = { type: "command", command: STATUS_CMD };
1423
+ settings.save();
1424
+ }
1425
+ }
1426
+ // Remove the statusLine entry — only when it's ours, so a foreign status line is never touched.
1427
+ deactivate() {
1428
+ const settings = new ClaudeSettings();
1429
+ if (settings.statusLine?.command?.includes(STATUS_FILE)) {
1430
+ settings.statusLine = void 0;
1431
+ settings.save();
1432
+ }
1433
+ }
1434
+ }
1435
+ const DEFAULT_DAYS = 30;
1436
+ const RETAIN_DAYS = 365;
1437
+ class RetentionSwitch {
1438
+ status() {
1439
+ const days = new ClaudeSettings().cleanupPeriodDays ?? DEFAULT_DAYS;
1440
+ return { days, raised: days >= RETAIN_DAYS };
1441
+ }
1442
+ // Raise retention to RETAIN_DAYS; an already-higher value is left as is.
1443
+ activate() {
1444
+ const settings = new ClaudeSettings();
1445
+ if ((settings.cleanupPeriodDays ?? DEFAULT_DAYS) < RETAIN_DAYS) {
1446
+ settings.cleanupPeriodDays = RETAIN_DAYS;
1447
+ settings.save();
1448
+ }
1449
+ }
1450
+ // Remove the setting — back to Claude Code's default.
1451
+ deactivate() {
1452
+ const settings = new ClaudeSettings();
1453
+ if (settings.cleanupPeriodDays !== void 0) {
1454
+ settings.cleanupPeriodDays = void 0;
1455
+ settings.save();
1456
+ }
1457
+ }
1458
+ }
1459
+ class Switchers {
1460
+ #switches = {
1461
+ proxy: new ProxySwitch(),
1462
+ statusline: new StatuslineSwitch(),
1463
+ retention: new RetentionSwitch(),
1464
+ claudedir: new ClaudeDirSwitch()
1465
+ };
1466
+ #keepActive = { proxy: true, statusline: true };
1467
+ // toggle feature persisting on app quit — starts on, so a switch this run didn't activate is never undone by it
1468
+ async status(name) {
1469
+ const s = await this.#get(name).status();
1470
+ return name in this.#keepActive ? { ...s, keepActive: this.#keepActive[name], ephemeral: IS_EPHEMERAL } : s;
1471
+ }
1472
+ activate(name) {
1473
+ return this.#attempt(name, async (s) => {
1474
+ await s.activate();
1475
+ this.#keepActive[name] &&= !IS_EPHEMERAL;
1476
+ });
1477
+ }
1478
+ // an npx-cache run undoes what it activated
1479
+ deactivate(name) {
1480
+ return this.#attempt(name, (s) => s.deactivate());
1481
+ }
1482
+ setKeepActive(name, value) {
1483
+ return this.#attempt(name, () => this.#keepActive[name] = !!value);
1484
+ }
1485
+ // Deactivate every switch not marked keep-active
1486
+ async deactivateOnQuit() {
1487
+ for (const name in this.#keepActive) {
1488
+ if (!this.#keepActive[name]) await this.deactivate(name);
1489
+ }
1490
+ }
1491
+ // Resolved outside #attempt's try — an unknown name is a bug, thrown raw to IPC, not a toggle error
1492
+ #get(name) {
1493
+ if (!this.#switches[name]) throw new Error(`Unknown switch: ${name}`);
1494
+ return this.#switches[name];
1495
+ }
1496
+ async #attempt(name, fn) {
1497
+ const sw = this.#get(name);
1498
+ try {
1499
+ await fn(sw);
1500
+ return this.status(name);
1501
+ } catch (err) {
1502
+ return { ...await this.status(name), error: String(err?.message || err) };
1503
+ }
1504
+ }
1505
+ }
1506
+ app.whenReady().then(() => {
1507
+ const win = new MainWindow();
1508
+ const agentRunner = new AgentRunner();
1509
+ const workHours = new WorkHours();
1510
+ const sessionsService = new SessionsService();
1511
+ sessionsService.on("update", (sessions) => win.send("sessions:update", sessions));
1512
+ sessionsService.on("progress", (p) => win.send("sessions:scan-progress", p));
1513
+ sessionsService.start();
1514
+ win.create();
1515
+ app.on("activate", () => {
1516
+ if (BrowserWindow.getAllWindows().length === 0) win.create();
1517
+ });
1518
+ ipcMain.handle("sessions:list", (_e, date, granularity) => sessionsService.list(date, granularity));
1519
+ ipcMain.handle("sessions:read", (_e, sessionId, date, granularity) => sessionsService.readSession(sessionId, date, granularity));
1520
+ ipcMain.handle("sessions:read-requests", (_e, sessionId, date, granularity) => sessionsService.readRequests(sessionId, date, granularity));
1521
+ ipcMain.handle("work-hours:get", () => workHours.read());
1522
+ ipcMain.handle("work-hours:set", (_e, data) => workHours.write(data));
1523
+ ipcMain.handle("agent:run", (e, text, systemTools, cache) => agentRunner.run(text, e.sender, systemTools, cache));
1524
+ ipcMain.handle("shell:open-link", (_e, href, baseFile) => openLinkSafely(href, baseFile));
1525
+ ipcMain.on("find:query", (_e, text, options) => win.findBar?.query(text, options));
1526
+ ipcMain.on("find:stop", () => win.findBar?.stop());
1527
+ ipcMain.on("find:close", () => win.findBar?.hide());
1528
+ ipcMain.on("claude-settings:get", (e) => e.returnValue = { claudeDir: CLAUDE_DIR });
1529
+ const switchers = new Switchers();
1530
+ app.on("will-quit", async (e) => {
1531
+ e.preventDefault();
1532
+ await switchers.deactivateOnQuit();
1533
+ app.exit();
1534
+ });
1535
+ ipcMain.handle("switch:status", (_e, name) => switchers.status(name));
1536
+ ipcMain.handle("switch:activate", (_e, name) => switchers.activate(name));
1537
+ ipcMain.handle("switch:deactivate", (_e, name) => switchers.deactivate(name));
1538
+ ipcMain.handle("switch:keep-active", (_e, name, value) => switchers.setKeepActive(name, value));
1539
+ });
1540
+ app.on("window-all-closed", () => {
1541
+ if (process.platform !== "darwin") app.quit();
1542
+ });