claude-discover 1.9.4 → 1.9.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/out/main/main.js CHANGED
@@ -1,18 +1,86 @@
1
- import { BrowserWindow, dialog, app, Menu, shell, WebContentsView, ipcMain } from "electron";
1
+ import { app, dialog, BrowserWindow, shell, WebContentsView, Menu, ipcMain } from "electron";
2
2
  import { EventEmitter } from "node:events";
3
+ import os, { homedir } from "node:os";
4
+ import path, { join, dirname, basename } from "node:path";
3
5
  import fs from "node:fs";
6
+ import { sync } from "write-file-atomic";
4
7
  import fsp from "node:fs/promises";
5
- import path, { join, dirname, basename } from "node:path";
6
- import os, { homedir } from "node:os";
7
8
  import crypto from "node:crypto";
8
9
  import chokidar from "chokidar";
9
- import { sync } from "write-file-atomic";
10
- import { pathToFileURL, fileURLToPath } from "node:url";
11
- import windowStateKeeper from "electron-window-state";
12
- import contextMenu from "electron-context-menu";
13
10
  import which from "which";
14
11
  import { execFile } from "node:child_process";
15
12
  import { promisify } from "node:util";
13
+ import { pathToFileURL, fileURLToPath } from "node:url";
14
+ import windowStateKeeper from "electron-window-state";
15
+ import contextMenu from "electron-context-menu";
16
+ class ConfigFile {
17
+ dataDir = join(homedir(), ".claude-discover");
18
+ configFile = join(this.dataDir, "config.json");
19
+ read() {
20
+ try {
21
+ return JSON.parse(fs.readFileSync(this.configFile, "utf8"));
22
+ } catch (err) {
23
+ if (err.code !== "ENOENT") console.warn(`Ignoring unreadable ${this.configFile}: ${err.message}`);
24
+ return {};
25
+ }
26
+ }
27
+ save(data) {
28
+ fs.mkdirSync(this.dataDir, { recursive: true });
29
+ sync(this.configFile, JSON.stringify({ ...this.read(), ...data }, null, 2));
30
+ }
31
+ }
32
+ const config = new ConfigFile();
33
+ const local = config.read();
34
+ const DATA_DIR$1 = config.dataDir;
35
+ const CLAUDE_DIR = local.claudeDir || process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude");
36
+ const RECENT_CLAUDE_DIRS = local.recents || [CLAUDE_DIR];
37
+ const CLAUDE_PROJECTS_DIR = join(CLAUDE_DIR, "projects");
38
+ const CLAUDE_SETTINGS = join(CLAUDE_DIR, "settings.json");
39
+ const IS_EPHEMERAL = import.meta.dirname.split(/[\\/]/).includes("_npx");
40
+ const STATUSLINE_PATH = join(import.meta.dirname, "../../bin/claude/statusline.mjs");
41
+ const PROXY_PATH = join(import.meta.dirname, "../../bin/proxy.mjs");
42
+ const SCHEME = "claude-discover";
43
+ class DeepLink extends EventEmitter {
44
+ #pending = null;
45
+ #live = false;
46
+ // the renderer has pulled the cold-start link, so later links can be pushed to it
47
+ // Call in the first instance before app.whenReady(), so cold-start URLs can be parked.
48
+ activate() {
49
+ app.on("open-url", (e, url) => {
50
+ e.preventDefault();
51
+ this.#deliver(findTarget([url]));
52
+ });
53
+ this.#register();
54
+ this.#pending = findTarget(process.argv);
55
+ }
56
+ open(target) {
57
+ if (target) this.emit("open", target);
58
+ }
59
+ takePending() {
60
+ this.#live = true;
61
+ const target = this.#pending;
62
+ this.#pending = null;
63
+ return target;
64
+ }
65
+ // @macOS Launch Services reuses the running bundle instead of launching a second process, so links
66
+ // come as events — and a cold-start one fires before the renderer exists, hence the park.
67
+ #deliver(target) {
68
+ if (!target) return;
69
+ if (this.#live) this.emit("open", target);
70
+ else this.#pending = target;
71
+ }
72
+ // @windows Rewritten on every launch — idempotent, and the last launched checkout wins.
73
+ #register() {
74
+ if (process.platform !== "win32") return;
75
+ if (IS_EPHEMERAL) return;
76
+ app.setAsDefaultProtocolClient(SCHEME, process.execPath, [app.getAppPath()]);
77
+ }
78
+ }
79
+ function findTarget(argv) {
80
+ const link = URL.parse(argv.find((a) => a.startsWith(`${SCHEME}://`)) ?? "");
81
+ const id = link?.searchParams.get("id");
82
+ return id ? { id, date: link.searchParams.get("date") } : null;
83
+ }
16
84
  const millisecondsInMinute = 6e4;
17
85
  const millisecondsInHour = 36e5;
18
86
  const constructFromSymbol = /* @__PURE__ */ Symbol.for("constructDateFrom");
@@ -250,7 +318,9 @@ class SessionFile {
250
318
  constructor(filePath) {
251
319
  this.filePath = filePath;
252
320
  const parts = path.dirname(filePath).split(path.sep);
253
- this.parentSessionId = parts[parts.indexOf("subagents") - 1] ?? null;
321
+ const i = parts.indexOf("subagents");
322
+ this.parentSessionId = i > 0 ? parts[i - 1] : null;
323
+ this.parentFilePath = i > 0 ? parts.slice(0, i).join(path.sep) + ".jsonl" : null;
254
324
  const base = path.basename(filePath, ".jsonl");
255
325
  this.sessionId = base === "journal" ? `${parts.at(-1)}-journal` : base;
256
326
  }
@@ -296,37 +366,10 @@ const UPSTREAM = process.env.CLAUDE_DISCOVER_UPSTREAM || "https://api.anthropic.
296
366
  const EXIT_ROUTE = "/claude-discover/exit";
297
367
  const PING_ROUTE = "/claude-discover/ping";
298
368
  const PING_RESPONSE = "claude-discover-proxy";
299
- const DATA_DIR$1 = process.env.CLAUDE_DISCOVER_DIR || path.join(os.homedir(), ".claude-discover");
300
- const REQUESTS_DIR = path.join(DATA_DIR$1, "requests");
301
- const ERROR_LOG_PATH = path.join(DATA_DIR$1, "proxy.error.log");
302
- const jsonBytes = (x) => x == null ? 0 : Buffer.byteLength(JSON.stringify(x));
303
- const contentHash = (s) => crypto.createHash("sha256").update(s).digest("base64url").slice(0, 16);
304
- const isMcp = (name) => name.startsWith("mcp__");
305
- const MEMORY_LABELS = {
306
- "user's private global instructions for all projects": "User",
307
- "project instructions, checked into the codebase": "Project",
308
- "user's private project instructions, not checked in": "Local",
309
- "user's auto-memory, persists across conversations": "Auto"
310
- };
311
- const MEMORY_FILES = { User: "CLAUDE.md", Project: "CLAUDE.md", Local: "CLAUDE.local.md", Auto: "MEMORY.md" };
312
- function parseClaudeMd(text) {
313
- const marker = text.match(/\n# claudeMd\r?\n|Codebase and user instructions are shown below\./);
314
- if (!marker) return [];
315
- let region = text.slice(marker.index + marker[0].length);
316
- 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);
317
- if (ends.length) region = region.slice(0, Math.min(...ends));
318
- const heads = [...region.matchAll(/^Contents of (.+?) \(([^)]*)\):\r?\n/gm)];
319
- return heads.map((h, i) => {
320
- const memory_type = MEMORY_LABELS[h[2]] ?? h[2];
321
- const base = h[1].split(/[\\/]/).pop();
322
- return {
323
- file_path: h[1],
324
- name: base === MEMORY_FILES[memory_type] ? base : h[1],
325
- memory_type,
326
- content: region.slice(h.index + h[0].length, heads[i + 1]?.index ?? region.length).trim()
327
- };
328
- });
329
- }
369
+ const DATA_DIR = process.env.CLAUDE_DISCOVER_DIR || path.join(os.homedir(), ".claude-discover");
370
+ const REQUESTS_DIR = path.join(DATA_DIR, "requests");
371
+ const ERROR_LOG_PATH = path.join(DATA_DIR, "proxy.error.log");
372
+ const headline = (line) => line.length > 34 ? line.slice(0, 33).trimEnd() + "…" : line;
330
373
  const classifyRequest = (req, url) => {
331
374
  if (url?.includes("/count_tokens")) return ["count", "Token count"];
332
375
  if (!req) return null;
@@ -340,24 +383,80 @@ const classifyRequest = (req, url) => {
340
383
  const tail = (blocks?.findLast((b) => b?.type === "text")?.text ?? user).slice(0, 600);
341
384
  if (req.max_tokens === 1 || user.trim() === "quota") return ["quota", "Quota probe"];
342
385
  if (sysText.includes("You are a security monitor") || user.startsWith("<transcript>")) return ["security", "Auto-mode check"];
343
- if (sysText.includes("Generate a concise, sentence-case title")) return ["title", "Session title"];
386
+ if (sysText.includes("Generate a concise, sentence-case title") || sysText.includes("You are naming a coding session")) return ["title", "Session title"];
344
387
  if (sysText.includes("performing a web search")) return ["web", "Web search"];
345
388
  if (user.startsWith("[SUGGESTION MODE")) return ["suggest", "Suggestions"];
346
389
  if (user.startsWith("Web page content")) return ["web", "Web fetch"];
347
390
  if (user.startsWith("Describe your most recent action")) return ["status", "Status blurb"];
348
391
  if (tail.includes("detailed summary of the conversation")) return ["compact", "Compact"];
349
392
  if (sysText.includes("built on Anthropic's Claude Agent SDK")) return ["agent", "Subagent"];
393
+ const tools = un(req.tools);
394
+ if (sysText && Array.isArray(tools) && !tools.length) {
395
+ const line = sysText.split("\n").map((l) => l.trim()).find((l) => l && !l.startsWith("x-anthropic-billing-header") && !l.startsWith("You are Claude Code"));
396
+ return ["aux", line ? headline(line) : "Side channel"];
397
+ }
350
398
  if (!sysText.includes("You are Claude Code")) return null;
351
399
  const continues = !last || blocks?.some((b) => b?.type === "tool_result");
352
400
  return continues ? ["tools", "Tool turn"] : ["main", "User message"];
353
401
  };
402
+ const REMINDER_LABELS = [
403
+ ["As you answer the user", "Session Context"],
404
+ ["# Environment", "Environment"],
405
+ ["Attribution for git commits", "Attribution"],
406
+ ["Available agent types", "Agent Types"],
407
+ ["The following skills are available", "Skills"],
408
+ ["# MCP Server Instructions", "MCP Instructions"],
409
+ ["You are powered by the model", "Model"],
410
+ ["Today's date", "Date"],
411
+ ["While auto mode is active", "Auto Mode"],
412
+ ["## Auto Mode Active", "Auto Mode"],
413
+ ["[SYSTEM NOTIFICATION", "Notification"],
414
+ ["Other agents active", "Agents"],
415
+ ["<total_tokens>", "Token Budget"]
416
+ ];
417
+ const reminderLabel = (text) => {
418
+ const line = text.split("\n").map((l) => l.trim()).find(Boolean) ?? "";
419
+ return REMINDER_LABELS.find(([open]) => line.startsWith(open))?.[1] ?? headline(line);
420
+ };
421
+ const DEFERRED_INTRO = /deferred tools are now available[^\n]*:\r?\n/;
422
+ const MEMORY_LABELS = {
423
+ "user's private global instructions for all projects": "User",
424
+ "project instructions, checked into the codebase": "Project",
425
+ "user's private project instructions, not checked in": "Local",
426
+ "user's auto-memory, persists across conversations": "Auto"
427
+ };
428
+ const MEMORY_FILES = { User: "CLAUDE.md", Project: "CLAUDE.md", Local: "CLAUDE.local.md", Auto: "MEMORY.md" };
429
+ function splitClaudeMd(text) {
430
+ const marker = text.match(/\n# claudeMd\r?\n|Codebase and user instructions are shown below\./);
431
+ if (!marker) return { files: [], rest: text };
432
+ const start = marker.index + marker[0].length;
433
+ const ends = [/\n# [a-z][a-zA-Z0-9]*\r?\n/, /\n\s*IMPORTANT: this context/, /<\/system-reminder>/].map((re) => text.slice(start).search(re)).filter((i) => i !== -1);
434
+ const end = ends.length ? start + Math.min(...ends) : text.length;
435
+ const region = text.slice(start, end);
436
+ const heads = [...region.matchAll(/^Contents of (.+?) \(([^)]*)\):\r?\n/gm)];
437
+ const files = heads.map((h, i) => {
438
+ const memory_type = MEMORY_LABELS[h[2]] ?? h[2];
439
+ const base = h[1].split(/[\\/]/).pop();
440
+ return {
441
+ file_path: h[1],
442
+ name: base === MEMORY_FILES[memory_type] ? base : h[1],
443
+ memory_type,
444
+ content: region.slice(h.index + h[0].length, heads[i + 1]?.index ?? region.length).trim()
445
+ };
446
+ });
447
+ return { files, rest: (text.slice(0, marker.index) + text.slice(end)).trimEnd() };
448
+ }
449
+ const jsonBytes = (x) => x == null ? 0 : Buffer.byteLength(JSON.stringify(x));
450
+ const contentHash = (s) => crypto.createHash("sha256").update(s).digest("base64url").slice(0, 16);
451
+ const isMcp = (name) => name.startsWith("mcp__");
354
452
  const stripKind = (req) => {
355
453
  const kind = classifyRequest(req);
356
454
  return !kind || kind[0] === "main" || kind[0] === "tools" ? ["main", null] : kind;
357
455
  };
358
- const toStrip = (rec, file_path, label, content) => {
456
+ const toStrip = (rec, source, file_path, label, content) => {
359
457
  const [kind, kindLabel] = stripKind(rec.request);
360
458
  return {
459
+ source,
361
460
  file_path,
362
461
  memory_type: [kindLabel, label].filter(Boolean).join(", "),
363
462
  model: rec.request.model,
@@ -366,6 +465,8 @@ const toStrip = (rec, file_path, label, content) => {
366
465
  kind
367
466
  };
368
467
  };
468
+ const texts = (c) => (Array.isArray(c) ? c.map((p) => p?.text) : [c]).filter((t) => typeof t === "string");
469
+ const injectedTexts = (req) => (req?.messages || []).filter((m) => m?.role !== "assistant").flatMap((m) => texts(m.content));
369
470
  class RequestParser {
370
471
  constructor() {
371
472
  this.hashes = /* @__PURE__ */ new Map();
@@ -409,12 +510,13 @@ class RequestParser {
409
510
  return rec;
410
511
  }
411
512
  // The request's system prompt, from a resolveRefs'd record — a plain string or an array of
412
- // text blocks. The content hash is the dedup key: repeats collapse in the caller's map.
513
+ // text blocks, minus any CLAUDE.md section (those files are strips of their own, see memoryFiles).
514
+ // The content hash is the dedup key: repeats collapse in the caller's map.
413
515
  systemPrompt(rec) {
414
516
  const sys = rec.request?.system;
415
517
  if (sys == null || sys.$ref) return null;
416
- const content = typeof sys === "string" ? sys : sys.map((b) => b?.text ?? "").join("\n\n");
417
- return toStrip(rec, "System Prompt", "", content);
518
+ const content = typeof sys === "string" ? splitClaudeMd(sys).rest : sys.map((b) => splitClaudeMd(b?.text ?? "").rest).join("\n\n");
519
+ return toStrip(rec, "system", "System Prompt", "", content);
418
520
  }
419
521
  // The deferred-tool roster Claude Code announces in the environment block of the first user
420
522
  // message: names it can call but whose schemas are left out of `tools` until ToolSearch pulls one
@@ -427,7 +529,7 @@ class RequestParser {
427
529
  for (const part of Array.isArray(content) ? content : [{ text: content }]) {
428
530
  const text = part?.text;
429
531
  if (typeof text !== "string") continue;
430
- const intro = text.match(/deferred tools are now available[^\n]*:\r?\n/);
532
+ const intro = text.match(DEFERRED_INTRO);
431
533
  if (!intro) continue;
432
534
  const names = [];
433
535
  for (const line of text.slice(intro.index + intro[0].length).split(/\r?\n/)) {
@@ -449,7 +551,7 @@ class RequestParser {
449
551
  const mcp = names.filter(isMcp).length;
450
552
  const count = (n, what) => n && `${n} deferred ${what} tool${n === 1 ? "" : "s"}`;
451
553
  const label = [count(names.length - mcp, "system"), count(mcp, "MCP")].filter(Boolean).join(", ");
452
- return toStrip(rec, "Deferred Tools", label, "```\n" + names.join("\n") + "\n```");
554
+ return toStrip(rec, "message", "Deferred Tools", label, "```\n" + names.join("\n") + "\n```");
453
555
  }
454
556
  // The request's tool definitions, from a resolveRefs'd record, as a native and an MCP strip —
455
557
  // MCP schemas are often the bulk of a prompt, so they are worth costing on their own. The array
@@ -465,23 +567,33 @@ class RequestParser {
465
567
  const list = fresh.filter((t) => pick(t.name));
466
568
  if (!list.length) return null;
467
569
  const content = list.map((t) => [`## ${t.name}`, t.description || "", schema(t)].filter(Boolean).join("\n\n")).join("\n\n");
468
- return toStrip(rec, file_path, `${list.length} tool${list.length === 1 ? "" : "s"}`, content);
570
+ return toStrip(rec, "tools", file_path, `${list.length} tool${list.length === 1 ? "" : "s"}`, content);
469
571
  };
470
572
  return [strip("System Tools", (n) => !isMcp(n)), strip("MCP Tools", isMcp)].filter(Boolean);
471
573
  }
472
- // Memory files (CLAUDE.md / MEMORY.md / …) carried by a resolveRefs'd record's messages inside
473
- // system-reminders — one { file_path, memory_type, content } per file listed.
574
+ // Memory files (CLAUDE.md / MEMORY.md / …) carried by a resolveRefs'd record — one
575
+ // { source, file_path, memory_type, content } per file listed. Claude Code ships them in a user
576
+ // message's system-reminder; an Agent SDK app can append the same section to the system prompt.
474
577
  memoryFiles(rec) {
475
- const files = [];
476
- for (const m of rec.request?.messages || []) {
477
- const content = m?.content;
478
- for (const part of Array.isArray(content) ? content : [{ text: content }]) {
479
- const text = part?.text;
480
- if (typeof text !== "string" || !text.includes("<system-reminder>")) continue;
481
- files.push(...parseClaudeMd(text));
578
+ const req = rec.request;
579
+ const reminders = injectedTexts(req).filter((t) => t.includes("<system-reminder>"));
580
+ const from = (source, list) => list.flatMap((t) => splitClaudeMd(t).files).map((f) => ({ source, ...f }));
581
+ return [...from("system", texts(req?.system)), ...from("message", reminders)];
582
+ }
583
+ // Every other <system-reminder> the harness injected into the messages — session context, git
584
+ // status, environment, attribution rules, … — one strip each, minus what is already a strip of
585
+ // its own: the CLAUDE.md files (cut out, which empties the reminder that only wraps them) and
586
+ // the deferred-tool roster. Nothing the model was sent stays hidden.
587
+ reminderStrips(rec) {
588
+ const strips = [];
589
+ for (const text of injectedTexts(rec.request)) {
590
+ for (const [, inner] of text.matchAll(/<system-reminder>([\s\S]*?)<\/system-reminder>/g)) {
591
+ if (DEFERRED_INTRO.test(inner)) continue;
592
+ const content = splitClaudeMd(inner).rest.trim();
593
+ if (content) strips.push(toStrip(rec, "message", reminderLabel(content), "system-reminder", content));
482
594
  }
483
595
  }
484
- return files;
596
+ return strips;
485
597
  }
486
598
  }
487
599
  const agentKey = (id) => id?.includes("@") ? id.slice(0, id.indexOf("@")) : id?.replace(/^a/, "").replace(/-[0-9a-f]{16}$/, "") ?? null;
@@ -519,9 +631,10 @@ class RequestFile {
519
631
  return t >= range.start && t <= range.end;
520
632
  });
521
633
  }
522
- // System prompts, tool definitions and memory files (CLAUDE.md / MEMORY.md / … from a user
523
- // message's instructions system-reminder) — none of which the transcript records, so the request
524
- // log is the only source. Returns one record per unique prompt / tool batch / file_path.
634
+ // System prompts, tool definitions, memory files (CLAUDE.md / MEMORY.md / … from a user
635
+ // message's instructions system-reminder) and every other injected system-reminder — none of
636
+ // which the transcript records, so the request log is the only source. Returns one record per
637
+ // unique prompt / tool batch / file_path / reminder.
525
638
  async readInstructions() {
526
639
  const parser = new RequestParser();
527
640
  const files = /* @__PURE__ */ new Map();
@@ -535,6 +648,9 @@ class RequestFile {
535
648
  for (const f of parser.memoryFiles(rec)) {
536
649
  if (!files.has(f.file_path)) files.set(f.file_path, record(f));
537
650
  }
651
+ for (const r of parser.reminderStrips(rec)) {
652
+ if (!files.has(r.hash)) files.set(r.hash, record(r));
653
+ }
538
654
  }
539
655
  return [...files.values()];
540
656
  }
@@ -568,11 +684,12 @@ function classifySource(meta) {
568
684
  if (meta.hasScheduledTask) return "scheduled";
569
685
  return (meta.entrypoint || "").toLowerCase() || "other";
570
686
  }
571
- function freshMeta({ sessionId, parentSessionId, filePath, fileSize, mtime }) {
687
+ function freshMeta({ sessionId, parentSessionId, filePath, parentFilePath, fileSize, mtime }) {
572
688
  return {
573
689
  sessionId,
574
690
  parentSessionId,
575
691
  filePath,
692
+ parentFilePath,
576
693
  fileSize,
577
694
  mtime,
578
695
  startedAt: null,
@@ -599,6 +716,7 @@ function freshMeta({ sessionId, parentSessionId, filePath, fileSize, mtime }) {
599
716
  messageCount: 0,
600
717
  workflowAgents: 0,
601
718
  toolCalls: 0,
719
+ skillCalls: 0,
602
720
  tokens: emptyBucket(),
603
721
  tokensByModel: {},
604
722
  tokensByModelFast: {},
@@ -611,11 +729,11 @@ function freshMeta({ sessionId, parentSessionId, filePath, fileSize, mtime }) {
611
729
  };
612
730
  }
613
731
  class SessionParser {
614
- constructor({ sessionId, parentSessionId, filePath, fileSize = 0, mtime = 0, pricing = null, range = null, excludeIds = null }) {
732
+ constructor({ sessionId, parentSessionId, filePath, parentFilePath = null, fileSize = 0, mtime = 0, pricing = null, range = null, excludeIds = null }) {
615
733
  this.range = range;
616
734
  this.excludeIds = excludeIds;
617
735
  this.pricing = pricing;
618
- this.meta = freshMeta({ sessionId, parentSessionId, filePath, fileSize, mtime });
736
+ this.meta = freshMeta({ sessionId, parentSessionId, filePath, parentFilePath, fileSize, mtime });
619
737
  this.prevMessage = null;
620
738
  this.tokenTotal = 0;
621
739
  this.lastSeenTs = null;
@@ -659,6 +777,7 @@ class SessionParser {
659
777
  meta.activityPeriods.push({ start: ts, end: ts });
660
778
  }
661
779
  if (t === "user") {
780
+ if (obj.turnCompanion && !obj.sourceToolUseID) meta.skillCalls += 1;
662
781
  this._feedUser(obj);
663
782
  } else if (t === "assistant") {
664
783
  const msg = obj.message;
@@ -668,7 +787,11 @@ class SessionParser {
668
787
  if (!meta.models.includes(msg.model)) meta.models.push(msg.model);
669
788
  }
670
789
  if (Array.isArray(msg.content)) {
671
- for (const b of msg.content) if (b?.type === "tool_use") meta.toolCalls += 1;
790
+ for (const b of msg.content) {
791
+ if (b?.type !== "tool_use") continue;
792
+ meta.toolCalls += 1;
793
+ if (b.name === "Skill") meta.skillCalls += 1;
794
+ }
672
795
  }
673
796
  this._recordUsage(obj);
674
797
  }
@@ -690,7 +813,7 @@ class SessionParser {
690
813
  obj.isMeta = true;
691
814
  } else if (text.startsWith("<scheduled-task")) {
692
815
  meta.hasScheduledTask = true;
693
- } else if (text) {
816
+ } else if (text && !obj.isMeta) {
694
817
  if (!meta.firstUserPrompt) meta.firstUserPrompt = text.slice(0, 300);
695
818
  }
696
819
  }
@@ -789,32 +912,6 @@ class SessionParser {
789
912
  return meta;
790
913
  }
791
914
  }
792
- class ConfigFile {
793
- dataDir = join(homedir(), ".claude-discover");
794
- configFile = join(this.dataDir, "config.json");
795
- read() {
796
- try {
797
- return JSON.parse(fs.readFileSync(this.configFile, "utf8"));
798
- } catch (err) {
799
- if (err.code !== "ENOENT") console.warn(`Ignoring unreadable ${this.configFile}: ${err.message}`);
800
- return {};
801
- }
802
- }
803
- save(data) {
804
- fs.mkdirSync(this.dataDir, { recursive: true });
805
- sync(this.configFile, JSON.stringify({ ...this.read(), ...data }, null, 2));
806
- }
807
- }
808
- const config = new ConfigFile();
809
- const local = config.read();
810
- const DATA_DIR = config.dataDir;
811
- const CLAUDE_DIR = local.claudeDir || process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude");
812
- const RECENT_CLAUDE_DIRS = local.recents || [CLAUDE_DIR];
813
- const CLAUDE_PROJECTS_DIR = join(CLAUDE_DIR, "projects");
814
- const CLAUDE_SETTINGS = join(CLAUDE_DIR, "settings.json");
815
- const IS_EPHEMERAL = import.meta.dirname.split(/[\\/]/).includes("_npx");
816
- const STATUSLINE_PATH = join(import.meta.dirname, "../../bin/claude/statusline.mjs");
817
- const PROXY_PATH = join(import.meta.dirname, "../../bin/proxy.mjs");
818
915
  class StatCache {
819
916
  constructor() {
820
917
  this.stats = /* @__PURE__ */ new Map();
@@ -961,9 +1058,6 @@ class MetaCache {
961
1058
  get(filePath) {
962
1059
  return this.periodMetas.get(filePath);
963
1060
  }
964
- byId(sessionId) {
965
- return [...this.periodMetas.values()].find((m) => m.sessionId === sessionId);
966
- }
967
1061
  values() {
968
1062
  return this.periodMetas.values();
969
1063
  }
@@ -1024,7 +1118,7 @@ const seed = {
1024
1118
  "claude-3-sonnet": { "input": 3, "output": 15, "cacheRead": 0.3, "cacheWrite": 3.75 },
1025
1119
  "claude-3-haiku": { "input": 0.25, "output": 1.25, "cacheRead": 0.03, "cacheWrite": 0.3, "cacheWrite1h": 0.5 }
1026
1120
  };
1027
- const CURRENT_PATH = path.join(DATA_DIR, "prices.current.json");
1121
+ const CURRENT_PATH = path.join(DATA_DIR$1, "prices.current.json");
1028
1122
  const LITELLM_URL = "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json";
1029
1123
  const DAY_MS = 24 * 3600 * 1e3;
1030
1124
  const MILLION = 1e6;
@@ -1113,7 +1207,7 @@ class Pricing extends EventEmitter {
1113
1207
  if (typeof fast === "number") out["claude-" + name].fastModeMplr = fast;
1114
1208
  }
1115
1209
  if (Object.keys(out).length) {
1116
- fs.mkdirSync(DATA_DIR, { recursive: true });
1210
+ fs.mkdirSync(DATA_DIR$1, { recursive: true });
1117
1211
  fs.writeFileSync(CURRENT_PATH, JSON.stringify(out, null, 2) + "\n");
1118
1212
  const before = JSON.stringify(this.#prices);
1119
1213
  this.load();
@@ -1150,8 +1244,10 @@ class SessionsService extends EventEmitter {
1150
1244
  }
1151
1245
  // Always parses the whole file from 0: the parser's running token totals only stamp
1152
1246
  // correctly from a fresh start, so live updates re-read rather than resume at an offset.
1153
- async readSession(sessionId, date = null, granularity = "day") {
1154
- const meta = this.metaCache.byId(sessionId);
1247
+ // Keyed by file path, not session id: Claude Code reuses one id across cwds, writing a
1248
+ // separate transcript per project dir.
1249
+ async readSession(filePath, date = null, granularity = "day") {
1250
+ const meta = this.metaCache.get(filePath);
1155
1251
  if (!meta) return null;
1156
1252
  const range = date ? periodBounds(date, granularity) : null;
1157
1253
  const reader = new SessionFile(meta.filePath);
@@ -1166,10 +1262,11 @@ class SessionsService extends EventEmitter {
1166
1262
  return { items, instructions };
1167
1263
  }
1168
1264
  // Captured API requests for a session (see bin/proxy.mjs), period-filtered like readSession.
1169
- async readRequests(sessionId, date = null, granularity = "day") {
1170
- if (!/^[\w-]+$/.test(sessionId)) return [];
1265
+ async readRequests(filePath, date = null, granularity = "day") {
1266
+ const meta = this.metaCache.get(filePath);
1267
+ if (!meta || !/^[\w-]+$/.test(meta.sessionId)) return [];
1171
1268
  const range = date ? periodBounds(date, granularity) : null;
1172
- return this.#requestFile(sessionId, this.metaCache.byId(sessionId)?.parentSessionId).read(range);
1269
+ return this.#requestFile(meta.sessionId, meta.parentSessionId).read(range);
1173
1270
  }
1174
1271
  // Subagents send the parent's session id, so the proxy logs their requests into the parent's
1175
1272
  // file, tagged x-claude-code-agent-id — route their reads there. The transcript filename is
@@ -1233,6 +1330,7 @@ class SessionsService extends EventEmitter {
1233
1330
  sessionId: reader.sessionId,
1234
1331
  parentSessionId: reader.parentSessionId,
1235
1332
  filePath: reader.filePath,
1333
+ parentFilePath: reader.parentFilePath,
1236
1334
  fileSize: stat.size,
1237
1335
  mtime: stat.mtimeMs,
1238
1336
  range,
@@ -1255,10 +1353,10 @@ class SessionsService extends EventEmitter {
1255
1353
  if (globalSeen.has(id)) (overlap ||= /* @__PURE__ */ new Set()).add(id);
1256
1354
  globalSeen.add(id);
1257
1355
  }
1258
- if (overlap) rescans.set(m.sessionId, this._scanSession(new SessionFile(m.filePath), { range, excludeIds: overlap }));
1356
+ if (overlap) rescans.set(m.filePath, this._scanSession(new SessionFile(m.filePath), { range, excludeIds: overlap }));
1259
1357
  }
1260
1358
  const sessions = await Promise.all(metas.map(async (m) => {
1261
- const rescanned = await rescans.get(m.sessionId);
1359
+ const rescanned = await rescans.get(m.filePath);
1262
1360
  return stripInternal(rescanned ? { ...m, ...rescanned } : m);
1263
1361
  }));
1264
1362
  const labels = suffixLabels(sessions.map((m) => m.project));
@@ -1347,303 +1445,88 @@ class AgentRunner {
1347
1445
  return { code: 0 };
1348
1446
  }
1349
1447
  }
1350
- const IS_DEV = !!process.env.ELECTRON_RENDERER_URL;
1351
- class ClaudeDirSwitch {
1352
- status() {
1353
- return { dir: CLAUDE_DIR };
1448
+ const mtimeOf = (path2) => fs.statSync(path2, { throwIfNoEntry: false })?.mtimeMs;
1449
+ class ClaudeSettings {
1450
+ constructor() {
1451
+ this.cfg = {};
1452
+ this.mtime = mtimeOf(CLAUDE_SETTINGS);
1453
+ if (this.mtime !== void 0) {
1454
+ try {
1455
+ this.cfg = JSON.parse(fs.readFileSync(CLAUDE_SETTINGS, "utf8"));
1456
+ } catch (err) {
1457
+ console.warn(`Failed to read ${CLAUDE_SETTINGS}: ${err.message}`);
1458
+ this.loadFailed = true;
1459
+ }
1460
+ }
1354
1461
  }
1355
- // "Change directory" — browse for a new dir; selecting one restarts the app on it.
1356
- activate() {
1357
- return browseForClaudeDir(BrowserWindow.getFocusedWindow());
1462
+ // Removes every hook (any event) whose command matches `pattern`, dropping groups and events
1463
+ // left empty. Returns the removed commands.
1464
+ removeHooks(pattern) {
1465
+ const matches = (h) => pattern.test(h.command ?? "");
1466
+ const removed = [];
1467
+ for (const [event, groups] of Object.entries(this.cfg.hooks ?? {})) {
1468
+ const hit = groups.flatMap((g) => (g.hooks ?? []).filter(matches).map((h) => h.command));
1469
+ if (!hit.length) continue;
1470
+ removed.push(...hit);
1471
+ for (const g of groups) g.hooks = (g.hooks ?? []).filter((h) => !matches(h));
1472
+ this.cfg.hooks[event] = groups.filter((g) => g.hooks.length);
1473
+ if (!this.cfg.hooks[event].length) delete this.cfg.hooks[event];
1474
+ }
1475
+ return removed;
1358
1476
  }
1359
- deactivate() {
1360
- throw new Error("Nothing to deactivate");
1477
+ // Days Claude Code keeps transcripts before auto-deleting them; undefined when unset (its default is 30).
1478
+ get cleanupPeriodDays() {
1479
+ return this.cfg.cleanupPeriodDays;
1361
1480
  }
1362
- }
1363
- async function browseForClaudeDir(win) {
1364
- const r = await dialog.showOpenDialog(win, {
1365
- title: "Select Claude directory",
1366
- defaultPath: CLAUDE_DIR,
1367
- properties: ["openDirectory"]
1368
- });
1369
- if (!r.canceled) {
1370
- switchToClaudeDir(r.filePaths[0]);
1481
+ set cleanupPeriodDays(days) {
1482
+ if (days === void 0) delete this.cfg.cleanupPeriodDays;
1483
+ else this.cfg.cleanupPeriodDays = days;
1371
1484
  }
1372
- }
1373
- function switchToClaudeDir(claudeDir) {
1374
- if (!claudeDir || claudeDir === CLAUDE_DIR) return;
1375
- config.save({ claudeDir, recents: [.../* @__PURE__ */ new Set([...RECENT_CLAUDE_DIRS, claudeDir])] });
1376
- if (IS_DEV) {
1377
- dialog.showMessageBoxSync({ type: "info", message: "Restart required", detail: "Stop and re-run `npm run dev` to apply the new directory." });
1378
- app.quit();
1379
- } else {
1380
- app.relaunch();
1381
- BrowserWindow.getAllWindows().forEach((w) => w.close());
1382
- app.exit(0);
1485
+ // Claude Code's status line config: { type: 'command', command }; undefined when unset.
1486
+ get statusLine() {
1487
+ return this.cfg.statusLine;
1488
+ }
1489
+ set statusLine(value) {
1490
+ if (value === void 0) delete this.cfg.statusLine;
1491
+ else this.cfg.statusLine = value;
1492
+ }
1493
+ // Environment variables Claude Code applies to every session; undefined when unset.
1494
+ get env() {
1495
+ return this.cfg.env;
1496
+ }
1497
+ setEnv(name, value) {
1498
+ this.cfg.env ??= {};
1499
+ this.cfg.env[name] = value;
1500
+ }
1501
+ deleteEnv(name) {
1502
+ delete this.cfg.env?.[name];
1503
+ }
1504
+ save() {
1505
+ if (this.loadFailed) throw new Error(`Refusing to save ${CLAUDE_SETTINGS}: settings did not load cleanly`);
1506
+ if (mtimeOf(CLAUDE_SETTINGS) !== this.mtime) throw new Error(`Refusing to save ${CLAUDE_SETTINGS}: file changed on disk since load`);
1507
+ fs.mkdirSync(dirname(CLAUDE_SETTINGS), { recursive: true });
1508
+ sync(CLAUDE_SETTINGS, JSON.stringify(this.cfg, null, 2));
1383
1509
  }
1384
1510
  }
1385
- function buildAppMenu({ onFind, onEscape } = {}) {
1386
- return Menu.buildFromTemplate([
1387
- ...process.platform === "darwin" ? [{ role: "appMenu" }] : [],
1388
- // @macOS
1389
- {
1390
- label: "File",
1391
- submenu: [
1392
- { label: "Change directory…", click: (_i, win) => browseForClaudeDir(win) },
1393
- { type: "separator" },
1394
- ...RECENT_CLAUDE_DIRS.map((p) => ({
1395
- label: p,
1396
- type: "checkbox",
1397
- checked: p === CLAUDE_DIR,
1398
- click: () => switchToClaudeDir(p)
1399
- })),
1400
- { type: "separator" },
1401
- { role: "quit" }
1402
- ]
1403
- },
1404
- {
1405
- // @macOS Cmd+C/V/A have no key equivalents without an Edit menu; Windows/Linux get these from Chromium
1406
- label: "Edit",
1407
- submenu: [
1408
- { role: "copy" },
1409
- { role: "paste" },
1410
- { role: "selectAll" },
1411
- { label: "Find…", accelerator: "CmdOrCtrl+F", click: () => onFind?.() },
1412
- { label: "Deselect", accelerator: "Escape", click: (_i, win) => onEscape?.(win) }
1413
- ]
1414
- },
1415
- {
1416
- label: "View",
1417
- submenu: [
1418
- { role: "reload" },
1419
- { role: "forceReload" },
1420
- { role: "toggleDevTools" }
1421
- ]
1422
- }
1423
- ]);
1511
+ class LoginService {
1512
+ #service;
1513
+ constructor({ name, command }) {
1514
+ if (process.platform === "win32") this.#service = new TaskScheduler({ name, command });
1515
+ else if (process.platform === "darwin") this.#service = new LaunchAgent({ name, command });
1516
+ else this.#service = new SystemdUnit({ name, command });
1517
+ }
1518
+ install() {
1519
+ return this.#service.install();
1520
+ }
1521
+ uninstall() {
1522
+ return this.#service.uninstall();
1523
+ }
1424
1524
  }
1425
- const VIEWABLE_EXT = /* @__PURE__ */ new Set([".md", ".txt", ".json", ".jsonl", ".log", ".csv", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".pdf"]);
1426
- async function openLinkSafely(href, baseFile) {
1427
- if (/^(https?:|mailto:)/i.test(href)) return shell.openExternal(href);
1428
- if (!baseFile) return;
1429
- let decoded;
1525
+ async function exec(file, args) {
1430
1526
  try {
1431
- decoded = decodeURIComponent(href);
1432
- } catch {
1433
- return;
1434
- }
1435
- const baseDir = path.dirname(path.resolve(baseFile));
1436
- const abs = path.resolve(baseDir, decoded);
1437
- const rel = path.relative(baseDir, abs);
1438
- if (rel.startsWith("..") || path.isAbsolute(rel)) return;
1439
- if (!VIEWABLE_EXT.has(path.extname(abs).toLowerCase())) return shell.showItemInFolder(abs);
1440
- const err = await shell.openPath(abs);
1441
- if (err) shell.showItemInFolder(abs);
1442
- return err;
1443
- }
1444
- const DEV_ORIGIN = process.env.ELECTRON_RENDERER_URL && new URL(process.env.ELECTRON_RENDERER_URL).origin;
1445
- const APP_PAGES = pathToFileURL(path.join(import.meta.dirname, "../renderer/")).href;
1446
- function lockNavigation(contents) {
1447
- const toBrowser = (url) => /^(https?|mailto):/i.test(url) && shell.openExternal(url);
1448
- const block = (e, url) => {
1449
- const { href, origin } = new URL(url);
1450
- if (href.startsWith(APP_PAGES) || origin === DEV_ORIGIN) return;
1451
- e.preventDefault();
1452
- toBrowser(url);
1453
- };
1454
- contents.setWindowOpenHandler(({ url }) => {
1455
- toBrowser(url);
1456
- return { action: "deny" };
1457
- });
1458
- contents.on("will-navigate", block);
1459
- contents.on("will-redirect", block);
1460
- }
1461
- const __dirname$2 = path.dirname(fileURLToPath(import.meta.url));
1462
- const DEV_URL$1 = process.env.ELECTRON_RENDERER_URL;
1463
- const WIDTH = 320, HEIGHT = 56, MARGIN = 6;
1464
- class FindBar {
1465
- constructor(win) {
1466
- this.win = win;
1467
- this.view = new WebContentsView({
1468
- webPreferences: {
1469
- preload: path.join(__dirname$2, "../preload/findPreload.cjs"),
1470
- contextIsolation: true,
1471
- nodeIntegration: false,
1472
- sandbox: true
1473
- }
1474
- });
1475
- this.view.setBackgroundColor("#00000000");
1476
- lockNavigation(this.view.webContents);
1477
- win.contentView.addChildView(this.view);
1478
- this.view.setVisible(false);
1479
- if (DEV_URL$1) this.view.webContents.loadURL(`${DEV_URL$1}/find/find.html`);
1480
- else this.view.webContents.loadFile(path.join(__dirname$2, "../renderer/find/find.html"));
1481
- win.webContents.on("found-in-page", (_e, r) => {
1482
- return this.view.webContents.send("find:result", { active: r.activeMatchOrdinal, total: r.matches, id: r.requestId });
1483
- });
1484
- win.on("resize", () => {
1485
- if (this.visible) this.layout();
1486
- });
1487
- }
1488
- get visible() {
1489
- return this.view.getVisible();
1490
- }
1491
- layout() {
1492
- const [w] = this.win.getContentSize();
1493
- this.view.setBounds({ x: Math.max(0, w - WIDTH - MARGIN), y: MARGIN, width: WIDTH, height: HEIGHT });
1494
- }
1495
- query(text, options) {
1496
- this.win.webContents.findInPage(text, options);
1497
- }
1498
- stop() {
1499
- this.win.webContents.stopFindInPage("clearSelection");
1500
- }
1501
- show() {
1502
- this.layout();
1503
- this.view.setVisible(true);
1504
- this.view.webContents.focus();
1505
- this.view.webContents.send("find:open");
1506
- this.win.webContents.send("find:active", true);
1507
- }
1508
- hide() {
1509
- if (!this.visible) return;
1510
- this.view.setVisible(false);
1511
- this.stop();
1512
- this.win.webContents.focus();
1513
- this.win.webContents.send("find:active", false);
1514
- }
1515
- }
1516
- const __dirname$1 = path.dirname(fileURLToPath(import.meta.url));
1517
- const DEV_URL = process.env.ELECTRON_RENDERER_URL;
1518
- class MainWindow {
1519
- constructor() {
1520
- this.win = null;
1521
- }
1522
- create() {
1523
- Menu.setApplicationMenu(buildAppMenu({
1524
- onFind: () => this.findBar?.show(),
1525
- onEscape: (win) => this.findBar?.visible ? this.findBar.hide() : win?.webContents.unselect()
1526
- }));
1527
- const state = windowStateKeeper({ defaultWidth: 1500, defaultHeight: 900 });
1528
- this.win = new BrowserWindow({
1529
- x: state.x,
1530
- y: state.y,
1531
- width: state.width,
1532
- height: state.height,
1533
- backgroundColor: "#0b0d12",
1534
- autoHideMenuBar: true,
1535
- title: `Claude Discover v${app.getVersion()}`,
1536
- webPreferences: {
1537
- preload: path.join(__dirname$1, "../preload/preload.cjs"),
1538
- contextIsolation: true,
1539
- nodeIntegration: false,
1540
- sandbox: true
1541
- }
1542
- });
1543
- state.manage(this.win);
1544
- this.win.on("page-title-updated", (e) => e.preventDefault());
1545
- lockNavigation(this.win.webContents);
1546
- contextMenu({ window: this.win, showSelectAll: true, showCopyImage: true, showCopyLink: true, showInspectElement: true });
1547
- if (DEV_URL) {
1548
- this.win.loadURL(DEV_URL);
1549
- } else {
1550
- this.win.loadFile(path.join(__dirname$1, "../renderer/index.html"));
1551
- }
1552
- this.findBar = new FindBar(this.win);
1553
- return this.win;
1554
- }
1555
- send(channel, payload) {
1556
- if (this.win && !this.win.isDestroyed()) this.win.webContents.send(channel, payload);
1557
- }
1558
- focus() {
1559
- if (!this.win || this.win.isDestroyed()) return;
1560
- if (this.win.isMinimized()) this.win.restore();
1561
- this.win.show();
1562
- this.win.focus();
1563
- }
1564
- }
1565
- const mtimeOf = (path2) => fs.statSync(path2, { throwIfNoEntry: false })?.mtimeMs;
1566
- class ClaudeSettings {
1567
- constructor() {
1568
- this.cfg = {};
1569
- this.mtime = mtimeOf(CLAUDE_SETTINGS);
1570
- if (this.mtime !== void 0) {
1571
- try {
1572
- this.cfg = JSON.parse(fs.readFileSync(CLAUDE_SETTINGS, "utf8"));
1573
- } catch (err) {
1574
- console.warn(`Failed to read ${CLAUDE_SETTINGS}: ${err.message}`);
1575
- this.loadFailed = true;
1576
- }
1577
- }
1578
- }
1579
- // Removes every hook (any event) whose command matches `pattern`, dropping groups and events
1580
- // left empty. Returns the removed commands.
1581
- removeHooks(pattern) {
1582
- const matches = (h) => pattern.test(h.command ?? "");
1583
- const removed = [];
1584
- for (const [event, groups] of Object.entries(this.cfg.hooks ?? {})) {
1585
- const hit = groups.flatMap((g) => (g.hooks ?? []).filter(matches).map((h) => h.command));
1586
- if (!hit.length) continue;
1587
- removed.push(...hit);
1588
- for (const g of groups) g.hooks = (g.hooks ?? []).filter((h) => !matches(h));
1589
- this.cfg.hooks[event] = groups.filter((g) => g.hooks.length);
1590
- if (!this.cfg.hooks[event].length) delete this.cfg.hooks[event];
1591
- }
1592
- return removed;
1593
- }
1594
- // Days Claude Code keeps transcripts before auto-deleting them; undefined when unset (its default is 30).
1595
- get cleanupPeriodDays() {
1596
- return this.cfg.cleanupPeriodDays;
1597
- }
1598
- set cleanupPeriodDays(days) {
1599
- if (days === void 0) delete this.cfg.cleanupPeriodDays;
1600
- else this.cfg.cleanupPeriodDays = days;
1601
- }
1602
- // Claude Code's status line config: { type: 'command', command }; undefined when unset.
1603
- get statusLine() {
1604
- return this.cfg.statusLine;
1605
- }
1606
- set statusLine(value) {
1607
- if (value === void 0) delete this.cfg.statusLine;
1608
- else this.cfg.statusLine = value;
1609
- }
1610
- // Environment variables Claude Code applies to every session; undefined when unset.
1611
- get env() {
1612
- return this.cfg.env;
1613
- }
1614
- setEnv(name, value) {
1615
- this.cfg.env ??= {};
1616
- this.cfg.env[name] = value;
1617
- }
1618
- deleteEnv(name) {
1619
- delete this.cfg.env?.[name];
1620
- }
1621
- save() {
1622
- if (this.loadFailed) throw new Error(`Refusing to save ${CLAUDE_SETTINGS}: settings did not load cleanly`);
1623
- if (mtimeOf(CLAUDE_SETTINGS) !== this.mtime) throw new Error(`Refusing to save ${CLAUDE_SETTINGS}: file changed on disk since load`);
1624
- fs.mkdirSync(dirname(CLAUDE_SETTINGS), { recursive: true });
1625
- sync(CLAUDE_SETTINGS, JSON.stringify(this.cfg, null, 2));
1626
- }
1627
- }
1628
- class LoginService {
1629
- #service;
1630
- constructor({ name, command }) {
1631
- if (process.platform === "win32") this.#service = new TaskScheduler({ name, command });
1632
- else if (process.platform === "darwin") this.#service = new LaunchAgent({ name, command });
1633
- else this.#service = new SystemdUnit({ name, command });
1634
- }
1635
- install() {
1636
- return this.#service.install();
1637
- }
1638
- uninstall() {
1639
- return this.#service.uninstall();
1640
- }
1641
- }
1642
- async function exec(file, args) {
1643
- try {
1644
- return await promisify(execFile)(file, args, { windowsHide: true });
1645
- } catch (err) {
1646
- throw new Error(err.stderr?.trim() || err.message);
1527
+ return await promisify(execFile)(file, args, { windowsHide: true });
1528
+ } catch (err) {
1529
+ throw new Error(err.stderr?.trim() || err.message);
1647
1530
  }
1648
1531
  }
1649
1532
  class TaskScheduler {
@@ -1884,22 +1767,51 @@ class RetentionSwitch {
1884
1767
  }
1885
1768
  }
1886
1769
  }
1770
+ class ClaudeDirSwitch {
1771
+ constructor({ restart }) {
1772
+ this.restart = restart;
1773
+ }
1774
+ status() {
1775
+ return { dir: CLAUDE_DIR };
1776
+ }
1777
+ // Switch to `claudeDir`, or browse for one when none is given; the app restarts on it
1778
+ async activate(claudeDir) {
1779
+ claudeDir ??= await browseForClaudeDir();
1780
+ if (!claudeDir || claudeDir === CLAUDE_DIR) return;
1781
+ config.save({ claudeDir, recents: [.../* @__PURE__ */ new Set([...RECENT_CLAUDE_DIRS, claudeDir])] });
1782
+ this.restart();
1783
+ }
1784
+ deactivate() {
1785
+ throw new Error("Nothing to deactivate");
1786
+ }
1787
+ }
1788
+ async function browseForClaudeDir() {
1789
+ const r = await dialog.showOpenDialog(BrowserWindow.getFocusedWindow(), {
1790
+ title: "Select Claude directory",
1791
+ defaultPath: CLAUDE_DIR,
1792
+ properties: ["openDirectory"]
1793
+ });
1794
+ return r.canceled ? null : r.filePaths[0];
1795
+ }
1887
1796
  class Switchers {
1888
- #switches = {
1889
- proxy: new ProxySwitch(),
1890
- statusline: new StatuslineSwitch(),
1891
- retention: new RetentionSwitch(),
1892
- claudedir: new ClaudeDirSwitch()
1893
- };
1797
+ #switches;
1894
1798
  #keepActive = { proxy: true, statusline: true };
1895
1799
  // toggle feature persisting on app quit — starts on, so a switch this run didn't activate is never undone by it
1800
+ constructor({ restart }) {
1801
+ this.#switches = {
1802
+ proxy: new ProxySwitch(),
1803
+ statusline: new StatuslineSwitch(),
1804
+ retention: new RetentionSwitch(),
1805
+ claudedir: new ClaudeDirSwitch({ restart })
1806
+ };
1807
+ }
1896
1808
  async status(name) {
1897
1809
  const s = await this.#get(name).status();
1898
1810
  return name in this.#keepActive ? { ...s, keepActive: this.#keepActive[name], ephemeral: IS_EPHEMERAL } : s;
1899
1811
  }
1900
- activate(name) {
1812
+ activate(name, ...args) {
1901
1813
  return this.#attempt(name, async (s) => {
1902
- await s.activate();
1814
+ await s.activate(...args);
1903
1815
  this.#keepActive[name] &&= !IS_EPHEMERAL;
1904
1816
  });
1905
1817
  }
@@ -1931,95 +1843,260 @@ class Switchers {
1931
1843
  }
1932
1844
  }
1933
1845
  }
1934
- const SCHEME = "claude-discover";
1935
- class DeepLink extends EventEmitter {
1936
- #pending = null;
1937
- #live = false;
1938
- // the renderer has pulled the cold-start link, so later links can be pushed to it
1939
- // Call at startup, before app.whenReady(). Returns false when another instance already holds the
1940
- // lock — the caller must then quit, our argv has been forwarded to the window that owns it.
1941
- requestLock() {
1942
- if (!app.requestSingleInstanceLock()) return false;
1943
- app.on("second-instance", (_e, argv) => this.emit("open", findTarget(argv)));
1944
- app.on("open-url", (e, url) => {
1945
- e.preventDefault();
1946
- this.#deliver(findTarget([url]));
1846
+ const VIEWABLE_EXT = /* @__PURE__ */ new Set([".md", ".txt", ".json", ".jsonl", ".log", ".csv", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".pdf"]);
1847
+ async function openLinkSafely(href, baseFile) {
1848
+ if (/^(https?:|mailto:)/i.test(href)) return shell.openExternal(href);
1849
+ if (!baseFile) return;
1850
+ let decoded;
1851
+ try {
1852
+ decoded = decodeURIComponent(href);
1853
+ } catch {
1854
+ return;
1855
+ }
1856
+ const baseDir = path.dirname(path.resolve(baseFile));
1857
+ const abs = path.resolve(baseDir, decoded);
1858
+ const rel = path.relative(baseDir, abs);
1859
+ if (rel.startsWith("..") || path.isAbsolute(rel)) return;
1860
+ if (!VIEWABLE_EXT.has(path.extname(abs).toLowerCase())) return shell.showItemInFolder(abs);
1861
+ const err = await shell.openPath(abs);
1862
+ if (err) shell.showItemInFolder(abs);
1863
+ return err;
1864
+ }
1865
+ const DEV_ORIGIN = process.env.ELECTRON_RENDERER_URL && new URL(process.env.ELECTRON_RENDERER_URL).origin;
1866
+ const APP_PAGES = pathToFileURL(path.join(import.meta.dirname, "../renderer/")).href;
1867
+ function lockNavigation(contents) {
1868
+ const toBrowser = (url) => /^(https?|mailto):/i.test(url) && shell.openExternal(url);
1869
+ const block = (e, url) => {
1870
+ const { href, origin } = new URL(url);
1871
+ if (href.startsWith(APP_PAGES) || origin === DEV_ORIGIN) return;
1872
+ e.preventDefault();
1873
+ toBrowser(url);
1874
+ };
1875
+ contents.setWindowOpenHandler(({ url }) => {
1876
+ toBrowser(url);
1877
+ return { action: "deny" };
1878
+ });
1879
+ contents.on("will-navigate", block);
1880
+ contents.on("will-redirect", block);
1881
+ }
1882
+ const __dirname$2 = path.dirname(fileURLToPath(import.meta.url));
1883
+ const DEV_URL$1 = process.env.ELECTRON_RENDERER_URL;
1884
+ const WIDTH = 320, HEIGHT = 56, MARGIN = 6;
1885
+ class FindBar {
1886
+ constructor(win) {
1887
+ this.win = win;
1888
+ this.view = new WebContentsView({
1889
+ webPreferences: {
1890
+ preload: path.join(__dirname$2, "../preload/findPreload.cjs"),
1891
+ contextIsolation: true,
1892
+ nodeIntegration: false,
1893
+ sandbox: true
1894
+ }
1895
+ });
1896
+ this.view.setBackgroundColor("#00000000");
1897
+ lockNavigation(this.view.webContents);
1898
+ win.contentView.addChildView(this.view);
1899
+ this.view.setVisible(false);
1900
+ if (DEV_URL$1) this.view.webContents.loadURL(`${DEV_URL$1}/find/find.html`);
1901
+ else this.view.webContents.loadFile(path.join(__dirname$2, "../renderer/find/find.html"));
1902
+ win.webContents.on("found-in-page", (_e, r) => {
1903
+ return this.view.webContents.send("find:result", { active: r.activeMatchOrdinal, total: r.matches, id: r.requestId });
1904
+ });
1905
+ win.on("resize", () => {
1906
+ if (this.visible) this.layout();
1947
1907
  });
1948
- this.#register();
1949
- this.#pending = findTarget(process.argv);
1950
- return true;
1951
1908
  }
1952
- takePending() {
1953
- this.#live = true;
1954
- const target = this.#pending;
1955
- this.#pending = null;
1956
- return target;
1909
+ get visible() {
1910
+ return this.view.getVisible();
1957
1911
  }
1958
- // @macOS Launch Services reuses the running bundle instead of launching a second process, so links
1959
- // come as events — and a cold-start one fires before the renderer exists, hence the park.
1960
- #deliver(target) {
1961
- if (!target) return;
1962
- if (this.#live) this.emit("open", target);
1963
- else this.#pending = target;
1912
+ layout() {
1913
+ const [w] = this.win.getContentSize();
1914
+ this.view.setBounds({ x: Math.max(0, w - WIDTH - MARGIN), y: MARGIN, width: WIDTH, height: HEIGHT });
1964
1915
  }
1965
- // @windows Rewritten on every launch — idempotent, and the last launched checkout wins.
1966
- #register() {
1967
- if (process.platform !== "win32") return;
1968
- if (IS_EPHEMERAL) return;
1969
- app.setAsDefaultProtocolClient(SCHEME, process.execPath, [app.getAppPath()]);
1916
+ query(text, options) {
1917
+ this.win.webContents.findInPage(text, options);
1918
+ }
1919
+ stop() {
1920
+ this.win.webContents.stopFindInPage("clearSelection");
1921
+ }
1922
+ show() {
1923
+ this.layout();
1924
+ this.view.setVisible(true);
1925
+ this.view.webContents.focus();
1926
+ this.view.webContents.send("find:open");
1927
+ this.win.webContents.send("find:active", true);
1928
+ }
1929
+ hide() {
1930
+ if (!this.visible) return;
1931
+ this.view.setVisible(false);
1932
+ this.stop();
1933
+ this.win.webContents.focus();
1934
+ this.win.webContents.send("find:active", false);
1970
1935
  }
1971
1936
  }
1972
- function findTarget(argv) {
1973
- const link = URL.parse(argv.find((a) => a.startsWith(`${SCHEME}://`)) ?? "");
1974
- const id = link?.searchParams.get("id");
1975
- return id ? { id, date: link.searchParams.get("date") } : null;
1937
+ const __dirname$1 = path.dirname(fileURLToPath(import.meta.url));
1938
+ const DEV_URL = process.env.ELECTRON_RENDERER_URL;
1939
+ class MainWindow {
1940
+ constructor() {
1941
+ this.win = null;
1942
+ }
1943
+ create() {
1944
+ const state = windowStateKeeper({ defaultWidth: 1500, defaultHeight: 900 });
1945
+ this.win = new BrowserWindow({
1946
+ x: state.x,
1947
+ y: state.y,
1948
+ width: state.width,
1949
+ height: state.height,
1950
+ backgroundColor: "#0b0d12",
1951
+ autoHideMenuBar: true,
1952
+ title: `Claude Discover v${app.getVersion()}`,
1953
+ webPreferences: {
1954
+ preload: path.join(__dirname$1, "../preload/preload.cjs"),
1955
+ contextIsolation: true,
1956
+ nodeIntegration: false,
1957
+ sandbox: true
1958
+ }
1959
+ });
1960
+ state.manage(this.win);
1961
+ this.win.on("page-title-updated", (e) => e.preventDefault());
1962
+ lockNavigation(this.win.webContents);
1963
+ contextMenu({ window: this.win, showSelectAll: true, showCopyImage: true, showCopyLink: true, showInspectElement: true });
1964
+ if (DEV_URL) {
1965
+ this.win.loadURL(DEV_URL);
1966
+ } else {
1967
+ this.win.loadFile(path.join(__dirname$1, "../renderer/index.html"));
1968
+ }
1969
+ this.findBar = new FindBar(this.win);
1970
+ return this.win;
1971
+ }
1972
+ send(channel, payload) {
1973
+ if (this.win && !this.win.isDestroyed()) this.win.webContents.send(channel, payload);
1974
+ }
1975
+ focus() {
1976
+ if (!this.win || this.win.isDestroyed()) return;
1977
+ if (this.win.isMinimized()) this.win.restore();
1978
+ this.win.show();
1979
+ this.win.focus();
1980
+ }
1976
1981
  }
1977
- const deepLink = new DeepLink();
1978
- function start() {
1979
- const win = new MainWindow();
1980
- const agentRunner = new AgentRunner();
1981
- const workHours = new WorkHours();
1982
- const sessionsService = new SessionsService();
1983
- sessionsService.on("update", (sessions) => win.send("sessions:update", sessions));
1984
- sessionsService.on("progress", (p) => win.send("sessions:scan-progress", p));
1985
- sessionsService.start();
1986
- deepLink.on("open", (target) => {
1987
- win.focus();
1988
- if (target) win.send("deeplink:open-session", target);
1989
- });
1990
- win.create();
1991
- app.on("activate", () => {
1992
- if (BrowserWindow.getAllWindows().length === 0) win.create();
1982
+ class Application {
1983
+ constructor({ deepLink }) {
1984
+ this.deepLink = deepLink;
1985
+ this.win = new MainWindow();
1986
+ this.agentRunner = new AgentRunner();
1987
+ this.workHours = new WorkHours();
1988
+ this.sessionsService = new SessionsService();
1989
+ this.switchers = new Switchers({ restart: () => this.restart() });
1990
+ }
1991
+ start() {
1992
+ const { deepLink, win, agentRunner, workHours, sessionsService, switchers } = this;
1993
+ Menu.setApplicationMenu(this.#buildMenu());
1994
+ sessionsService.on("update", (sessions) => win.send("sessions:update", sessions));
1995
+ sessionsService.on("progress", (p) => win.send("sessions:scan-progress", p));
1996
+ deepLink.on("open", (target) => {
1997
+ win.focus();
1998
+ win.send("deeplink:open-session", target);
1999
+ });
2000
+ ipcMain.handle("sessions:list", (_e, date, granularity) => sessionsService.list(date, granularity));
2001
+ ipcMain.handle("sessions:read", (_e, filePath, date, granularity) => sessionsService.readSession(filePath, date, granularity));
2002
+ ipcMain.handle("sessions:read-requests", (_e, filePath, date, granularity) => sessionsService.readRequests(filePath, date, granularity));
2003
+ ipcMain.handle("switch:status", (_e, name) => switchers.status(name));
2004
+ ipcMain.handle("switch:activate", (_e, name) => switchers.activate(name));
2005
+ ipcMain.handle("switch:deactivate", (_e, name) => switchers.deactivate(name));
2006
+ ipcMain.handle("switch:keep-active", (_e, name, value) => switchers.setKeepActive(name, value));
2007
+ ipcMain.handle("work-hours:get", () => workHours.read());
2008
+ ipcMain.handle("work-hours:set", (_e, data) => workHours.write(data));
2009
+ ipcMain.handle("agent:run", (e, text, systemTools, cache) => agentRunner.run(text, e.sender, systemTools, cache));
2010
+ ipcMain.handle("shell:open-link", (_e, href, baseFile) => openLinkSafely(href, baseFile));
2011
+ ipcMain.handle("deeplink:take-pending", () => deepLink.takePending());
2012
+ ipcMain.on("find:query", (_e, text, options) => win.findBar?.query(text, options));
2013
+ ipcMain.on("find:stop", () => win.findBar?.stop());
2014
+ ipcMain.on("find:close", () => win.findBar?.hide());
2015
+ ipcMain.on("claude-settings:get", (e) => e.returnValue = { claudeDir: CLAUDE_DIR });
2016
+ app.on("activate", () => {
2017
+ if (BrowserWindow.getAllWindows().length === 0) win.create();
2018
+ });
2019
+ app.on("window-all-closed", () => {
2020
+ if (process.platform !== "darwin") app.quit();
2021
+ });
2022
+ app.on("will-quit", async (e) => {
2023
+ e.preventDefault();
2024
+ await switchers.deactivateOnQuit();
2025
+ app.exit();
2026
+ });
2027
+ sessionsService.start();
2028
+ win.create();
2029
+ }
2030
+ // app.exit skips will-quit, so switches stay active across the restart
2031
+ restart() {
2032
+ {
2033
+ app.relaunch();
2034
+ }
2035
+ BrowserWindow.getAllWindows().forEach((w) => w.close());
2036
+ app.exit(0);
2037
+ }
2038
+ #buildMenu() {
2039
+ const { win, switchers } = this;
2040
+ const changeDir = (dir) => switchers.activate("claudedir", dir);
2041
+ return Menu.buildFromTemplate([
2042
+ ...process.platform === "darwin" ? [{ role: "appMenu" }] : [],
2043
+ // @macOS
2044
+ {
2045
+ label: "File",
2046
+ submenu: [
2047
+ { label: "Change directory…", click: () => changeDir() },
2048
+ { type: "separator" },
2049
+ ...RECENT_CLAUDE_DIRS.map((p) => ({
2050
+ label: p,
2051
+ type: "checkbox",
2052
+ checked: p === CLAUDE_DIR,
2053
+ click: () => changeDir(p)
2054
+ })),
2055
+ { type: "separator" },
2056
+ { role: "quit" }
2057
+ ]
2058
+ },
2059
+ {
2060
+ // @macOS Cmd+C/V/A have no key equivalents without an Edit menu; Windows/Linux get these from Chromium
2061
+ label: "Edit",
2062
+ submenu: [
2063
+ { role: "copy" },
2064
+ { role: "paste" },
2065
+ { role: "selectAll" },
2066
+ { label: "Find…", accelerator: "CmdOrCtrl+F", click: () => win.findBar?.show() },
2067
+ { label: "Deselect", accelerator: "Escape", click: (_i, w) => win.findBar?.visible ? win.findBar.hide() : w?.webContents.unselect() }
2068
+ ]
2069
+ },
2070
+ {
2071
+ label: "View",
2072
+ submenu: [
2073
+ { role: "reload" },
2074
+ { role: "forceReload" },
2075
+ { role: "toggleDevTools" }
2076
+ ]
2077
+ }
2078
+ ]);
2079
+ }
2080
+ }
2081
+ const isFirstInstance = app.requestSingleInstanceLock();
2082
+ if (isFirstInstance) {
2083
+ let application;
2084
+ const deepLink = new DeepLink();
2085
+ deepLink.activate();
2086
+ app.whenReady().then(() => {
2087
+ application = new Application({ deepLink });
2088
+ application.start();
1993
2089
  });
1994
- ipcMain.handle("sessions:list", (_e, date, granularity) => sessionsService.list(date, granularity));
1995
- ipcMain.handle("sessions:read", (_e, sessionId, date, granularity) => sessionsService.readSession(sessionId, date, granularity));
1996
- ipcMain.handle("sessions:read-requests", (_e, sessionId, date, granularity) => sessionsService.readRequests(sessionId, date, granularity));
1997
- ipcMain.handle("work-hours:get", () => workHours.read());
1998
- ipcMain.handle("work-hours:set", (_e, data) => workHours.write(data));
1999
- ipcMain.handle("agent:run", (e, text, systemTools, cache) => agentRunner.run(text, e.sender, systemTools, cache));
2000
- ipcMain.handle("shell:open-link", (_e, href, baseFile) => openLinkSafely(href, baseFile));
2001
- ipcMain.handle("deeplink:take-pending", () => deepLink.takePending());
2002
- ipcMain.on("find:query", (_e, text, options) => win.findBar?.query(text, options));
2003
- ipcMain.on("find:stop", () => win.findBar?.stop());
2004
- ipcMain.on("find:close", () => win.findBar?.hide());
2005
- ipcMain.on("claude-settings:get", (e) => e.returnValue = { claudeDir: CLAUDE_DIR });
2006
- const switchers = new Switchers();
2007
- app.on("will-quit", async (e) => {
2008
- e.preventDefault();
2009
- await switchers.deactivateOnQuit();
2010
- app.exit();
2090
+ app.on("second-instance", (_e, argv) => {
2091
+ const target = findTarget(argv);
2092
+ if (argv.includes("--restart")) {
2093
+ application?.restart();
2094
+ } else if (target) {
2095
+ deepLink.open(target);
2096
+ } else {
2097
+ application?.win.focus();
2098
+ }
2011
2099
  });
2012
- ipcMain.handle("switch:status", (_e, name) => switchers.status(name));
2013
- ipcMain.handle("switch:activate", (_e, name) => switchers.activate(name));
2014
- ipcMain.handle("switch:deactivate", (_e, name) => switchers.deactivate(name));
2015
- ipcMain.handle("switch:keep-active", (_e, name, value) => switchers.setKeepActive(name, value));
2016
- }
2017
- app.on("window-all-closed", () => {
2018
- if (process.platform !== "darwin") app.quit();
2019
- });
2020
- const isFirstInstance = deepLink.requestLock();
2021
- if (isFirstInstance) {
2022
- app.whenReady().then(start);
2023
2100
  } else {
2024
2101
  app.exit(0);
2025
2102
  }