claude-discover 1.9.2 → 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,15 +1,86 @@
1
- import { BrowserWindow, dialog, app, Menu, shell, WebContentsView, utilityProcess, 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 which from "which";
11
+ import { execFile } from "node:child_process";
12
+ import { promisify } from "node:util";
10
13
  import { pathToFileURL, fileURLToPath } from "node:url";
11
14
  import windowStateKeeper from "electron-window-state";
12
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
+ }
13
84
  const millisecondsInMinute = 6e4;
14
85
  const millisecondsInHour = 36e5;
15
86
  const constructFromSymbol = /* @__PURE__ */ Symbol.for("constructDateFrom");
@@ -247,7 +318,9 @@ class SessionFile {
247
318
  constructor(filePath) {
248
319
  this.filePath = filePath;
249
320
  const parts = path.dirname(filePath).split(path.sep);
250
- 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;
251
324
  const base = path.basename(filePath, ".jsonl");
252
325
  this.sessionId = base === "journal" ? `${parts.at(-1)}-journal` : base;
253
326
  }
@@ -289,41 +362,14 @@ class SessionFile {
289
362
  const HOST = "127.0.0.1";
290
363
  const PORT = process.env.CLAUDE_DISCOVER_PORT || 41414;
291
364
  const PROXY_URL = `http://${HOST}:${PORT}`;
292
- process.env.CLAUDE_DISCOVER_UPSTREAM || "https://api.anthropic.com";
365
+ const UPSTREAM = process.env.CLAUDE_DISCOVER_UPSTREAM || "https://api.anthropic.com";
293
366
  const EXIT_ROUTE = "/claude-discover/exit";
294
367
  const PING_ROUTE = "/claude-discover/ping";
295
368
  const PING_RESPONSE = "claude-discover-proxy";
296
- const DATA_DIR$1 = process.env.CLAUDE_DISCOVER_DIR || path.join(os.homedir(), ".claude-discover");
297
- const REQUESTS_DIR = path.join(DATA_DIR$1, "requests");
298
- const ERROR_LOG_PATH = path.join(DATA_DIR$1, "proxy.error.log");
299
- const jsonBytes = (x) => x == null ? 0 : Buffer.byteLength(JSON.stringify(x));
300
- const contentHash = (s) => crypto.createHash("sha256").update(s).digest("base64url").slice(0, 16);
301
- const MEMORY_LABELS = {
302
- "user's private global instructions for all projects": "User",
303
- "project instructions, checked into the codebase": "Project",
304
- "user's private project instructions, not checked in": "Local",
305
- "user's auto-memory, persists across conversations": "Auto"
306
- };
307
- const MEMORY_FILES = { User: "CLAUDE.md", Project: "CLAUDE.md", Local: "CLAUDE.local.md", Auto: "MEMORY.md" };
308
- function parseClaudeMd(text) {
309
- const marker = "\n# claudeMd\n";
310
- const start2 = text.indexOf(marker);
311
- if (start2 === -1) return [];
312
- let region = text.slice(start2 + marker.length);
313
- 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);
314
- if (ends.length) region = region.slice(0, Math.min(...ends));
315
- const heads = [...region.matchAll(/^Contents of (.+?) \(([^)]*)\):\r?\n/gm)];
316
- return heads.map((h, i) => {
317
- const memory_type = MEMORY_LABELS[h[2]] ?? h[2];
318
- const base = h[1].split(/[\\/]/).pop();
319
- return {
320
- file_path: h[1],
321
- name: base === MEMORY_FILES[memory_type] ? base : h[1],
322
- memory_type,
323
- content: region.slice(h.index + h[0].length, heads[i + 1]?.index ?? region.length).trim()
324
- };
325
- });
326
- }
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;
327
373
  const classifyRequest = (req, url) => {
328
374
  if (url?.includes("/count_tokens")) return ["count", "Token count"];
329
375
  if (!req) return null;
@@ -337,25 +383,95 @@ const classifyRequest = (req, url) => {
337
383
  const tail = (blocks?.findLast((b) => b?.type === "text")?.text ?? user).slice(0, 600);
338
384
  if (req.max_tokens === 1 || user.trim() === "quota") return ["quota", "Quota probe"];
339
385
  if (sysText.includes("You are a security monitor") || user.startsWith("<transcript>")) return ["security", "Auto-mode check"];
340
- 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"];
341
387
  if (sysText.includes("performing a web search")) return ["web", "Web search"];
342
388
  if (user.startsWith("[SUGGESTION MODE")) return ["suggest", "Suggestions"];
343
389
  if (user.startsWith("Web page content")) return ["web", "Web fetch"];
344
390
  if (user.startsWith("Describe your most recent action")) return ["status", "Status blurb"];
345
391
  if (tail.includes("detailed summary of the conversation")) return ["compact", "Compact"];
346
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
+ }
347
398
  if (!sysText.includes("You are Claude Code")) return null;
348
399
  const continues = !last || blocks?.some((b) => b?.type === "tool_result");
349
400
  return continues ? ["tools", "Tool turn"] : ["main", "User message"];
350
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__");
351
452
  const stripKind = (req) => {
352
453
  const kind = classifyRequest(req);
353
454
  return !kind || kind[0] === "main" || kind[0] === "tools" ? ["main", null] : kind;
354
455
  };
456
+ const toStrip = (rec, source, file_path, label, content) => {
457
+ const [kind, kindLabel] = stripKind(rec.request);
458
+ return {
459
+ source,
460
+ file_path,
461
+ memory_type: [kindLabel, label].filter(Boolean).join(", "),
462
+ model: rec.request.model,
463
+ content,
464
+ hash: contentHash(content),
465
+ kind
466
+ };
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));
355
470
  class RequestParser {
356
471
  constructor() {
357
472
  this.hashes = /* @__PURE__ */ new Map();
358
473
  this.seenTools = /* @__PURE__ */ new Set();
474
+ this.deferred = null;
359
475
  }
360
476
  // Resolves $hash/$ref dedup wrappers at any depth — children first, so stored and returned
361
477
  // values are always fully resolved. Feed records in file order. When `seen` is given, the
@@ -394,42 +510,90 @@ class RequestParser {
394
510
  return rec;
395
511
  }
396
512
  // The request's system prompt, from a resolveRefs'd record — a plain string or an array of
397
- // 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.
398
515
  systemPrompt(rec) {
399
516
  const sys = rec.request?.system;
400
517
  if (sys == null || sys.$ref) return null;
401
- const content = typeof sys === "string" ? sys : sys.map((b) => b?.text ?? "").join("\n\n");
402
- const [kind, kindLabel] = stripKind(rec.request);
403
- return { file_path: "System Prompt", memory_type: kindLabel || "", model: rec.request.model, content, hash: contentHash(content), kind };
404
- }
405
- // The request's tool definitions, from a resolveRefs'd record. The array grows as deferred
406
- // tools load mid-session, so only tools this parser hasn't seen yet are returned — the first
407
- // request yields the full set, later arrays just the additions.
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);
520
+ }
521
+ // The deferred-tool roster Claude Code announces in the environment block of the first user
522
+ // message: names it can call but whose schemas are left out of `tools` until ToolSearch pulls one
523
+ // in. The list never appears in `tools`, so it is the only record of what a session declared but
524
+ // never paid for. One roster per session — cached on first sight.
525
+ deferredTools(rec) {
526
+ if (this.deferred) return this.deferred;
527
+ for (const m of rec.request?.messages || []) {
528
+ const content = m?.content;
529
+ for (const part of Array.isArray(content) ? content : [{ text: content }]) {
530
+ const text = part?.text;
531
+ if (typeof text !== "string") continue;
532
+ const intro = text.match(DEFERRED_INTRO);
533
+ if (!intro) continue;
534
+ const names = [];
535
+ for (const line of text.slice(intro.index + intro[0].length).split(/\r?\n/)) {
536
+ if (!/^[A-Za-z_]\w*$/.test(line)) break;
537
+ names.push(line);
538
+ }
539
+ return this.deferred = names;
540
+ }
541
+ }
542
+ return [];
543
+ }
544
+ // The roster as a strip of its own. Names only — a deferred tool has no schema in the request, so
545
+ // this costs what the environment block's list costs, not what the tools would. Kept in announced
546
+ // order, which already runs native names before MCP ones, and fenced so markdown keeps one name
547
+ // per line instead of reflowing them all into one paragraph.
548
+ deferredStrip(rec) {
549
+ const names = this.deferredTools(rec);
550
+ if (!names.length) return null;
551
+ const mcp = names.filter(isMcp).length;
552
+ const count = (n, what) => n && `${n} deferred ${what} tool${n === 1 ? "" : "s"}`;
553
+ const label = [count(names.length - mcp, "system"), count(mcp, "MCP")].filter(Boolean).join(", ");
554
+ return toStrip(rec, "message", "Deferred Tools", label, "```\n" + names.join("\n") + "\n```");
555
+ }
556
+ // The request's tool definitions, from a resolveRefs'd record, as a native and an MCP strip —
557
+ // MCP schemas are often the bulk of a prompt, so they are worth costing on their own. The array
558
+ // grows as deferred tools load mid-session, so only tools this parser hasn't seen yet are
559
+ // returned — the first request yields the full set, later arrays just the additions.
408
560
  systemTools(rec) {
409
561
  const tools = rec.request?.tools;
410
- if (!Array.isArray(tools)) return null;
562
+ if (!Array.isArray(tools)) return [];
411
563
  const fresh = tools.filter((t) => t?.name && !this.seenTools.has(t.name));
412
- if (!fresh.length) return null;
413
564
  for (const t of fresh) this.seenTools.add(t.name);
414
565
  const schema = (t) => t.input_schema ? "```json\n" + JSON.stringify(t.input_schema, null, 2) + "\n```" : "";
415
- const content = fresh.map((t) => [`## ${t.name}`, t.description || "", schema(t)].filter(Boolean).join("\n\n")).join("\n\n");
416
- const [kind, kindLabel] = stripKind(rec.request);
417
- const label = [kindLabel, `${fresh.length} tool${fresh.length === 1 ? "" : "s"}`].filter(Boolean).join(", ");
418
- return { file_path: "System Tools", memory_type: label, model: rec.request.model, content, hash: contentHash(content), kind };
566
+ const strip = (file_path, pick) => {
567
+ const list = fresh.filter((t) => pick(t.name));
568
+ if (!list.length) return null;
569
+ const content = list.map((t) => [`## ${t.name}`, t.description || "", schema(t)].filter(Boolean).join("\n\n")).join("\n\n");
570
+ return toStrip(rec, "tools", file_path, `${list.length} tool${list.length === 1 ? "" : "s"}`, content);
571
+ };
572
+ return [strip("System Tools", (n) => !isMcp(n)), strip("MCP Tools", isMcp)].filter(Boolean);
419
573
  }
420
- // Memory files (CLAUDE.md / MEMORY.md / …) carried by a resolveRefs'd record's messages inside
421
- // `# claudeMd` 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.
422
577
  memoryFiles(rec) {
423
- const files = [];
424
- for (const m of rec.request?.messages || []) {
425
- const content = m?.content;
426
- for (const part of Array.isArray(content) ? content : [{ text: content }]) {
427
- const text = part?.text;
428
- if (typeof text !== "string" || !text.includes("<system-reminder>")) continue;
429
- 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));
430
594
  }
431
595
  }
432
- return files;
596
+ return strips;
433
597
  }
434
598
  }
435
599
  const agentKey = (id) => id?.includes("@") ? id.slice(0, id.indexOf("@")) : id?.replace(/^a/, "").replace(/-[0-9a-f]{16}$/, "") ?? null;
@@ -467,12 +631,10 @@ class RequestFile {
467
631
  return t >= range.start && t <= range.end;
468
632
  });
469
633
  }
470
- // System prompts (request.system), tool definitions (request.tools) and memory files
471
- // (CLAUDE.md / MEMORY.md / … inside a user message's `# claudeMd` system-reminder) — none of
472
- // which Claude Code records in the session transcript, so the request log is the only source.
473
- // Works on read()'s output, so dedup wrappers and the agent scope are already handled.
474
- // Returns one record per unique system prompt / tool batch / file_path; readSession ships them
475
- // alongside the transcript items.
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.
476
638
  async readInstructions() {
477
639
  const parser = new RequestParser();
478
640
  const files = /* @__PURE__ */ new Map();
@@ -480,11 +642,15 @@ class RequestFile {
480
642
  const record = (f) => ({ timestamp: rec.timestamp, ...f });
481
643
  const sys = parser.systemPrompt(rec);
482
644
  if (sys && !files.has(sys.hash)) files.set(sys.hash, record(sys));
483
- const tools = parser.systemTools(rec);
484
- if (tools) files.set(tools.hash, record(tools));
645
+ const deferred = parser.deferredStrip(rec);
646
+ if (deferred && !files.has(deferred.hash)) files.set(deferred.hash, record(deferred));
647
+ for (const tools of parser.systemTools(rec)) files.set(tools.hash, record(tools));
485
648
  for (const f of parser.memoryFiles(rec)) {
486
649
  if (!files.has(f.file_path)) files.set(f.file_path, record(f));
487
650
  }
651
+ for (const r of parser.reminderStrips(rec)) {
652
+ if (!files.has(r.hash)) files.set(r.hash, record(r));
653
+ }
488
654
  }
489
655
  return [...files.values()];
490
656
  }
@@ -518,11 +684,12 @@ function classifySource(meta) {
518
684
  if (meta.hasScheduledTask) return "scheduled";
519
685
  return (meta.entrypoint || "").toLowerCase() || "other";
520
686
  }
521
- function freshMeta({ sessionId, parentSessionId, filePath, fileSize, mtime }) {
687
+ function freshMeta({ sessionId, parentSessionId, filePath, parentFilePath, fileSize, mtime }) {
522
688
  return {
523
689
  sessionId,
524
690
  parentSessionId,
525
691
  filePath,
692
+ parentFilePath,
526
693
  fileSize,
527
694
  mtime,
528
695
  startedAt: null,
@@ -549,6 +716,7 @@ function freshMeta({ sessionId, parentSessionId, filePath, fileSize, mtime }) {
549
716
  messageCount: 0,
550
717
  workflowAgents: 0,
551
718
  toolCalls: 0,
719
+ skillCalls: 0,
552
720
  tokens: emptyBucket(),
553
721
  tokensByModel: {},
554
722
  tokensByModelFast: {},
@@ -561,11 +729,11 @@ function freshMeta({ sessionId, parentSessionId, filePath, fileSize, mtime }) {
561
729
  };
562
730
  }
563
731
  class SessionParser {
564
- 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 }) {
565
733
  this.range = range;
566
734
  this.excludeIds = excludeIds;
567
735
  this.pricing = pricing;
568
- this.meta = freshMeta({ sessionId, parentSessionId, filePath, fileSize, mtime });
736
+ this.meta = freshMeta({ sessionId, parentSessionId, filePath, parentFilePath, fileSize, mtime });
569
737
  this.prevMessage = null;
570
738
  this.tokenTotal = 0;
571
739
  this.lastSeenTs = null;
@@ -609,6 +777,7 @@ class SessionParser {
609
777
  meta.activityPeriods.push({ start: ts, end: ts });
610
778
  }
611
779
  if (t === "user") {
780
+ if (obj.turnCompanion && !obj.sourceToolUseID) meta.skillCalls += 1;
612
781
  this._feedUser(obj);
613
782
  } else if (t === "assistant") {
614
783
  const msg = obj.message;
@@ -618,7 +787,11 @@ class SessionParser {
618
787
  if (!meta.models.includes(msg.model)) meta.models.push(msg.model);
619
788
  }
620
789
  if (Array.isArray(msg.content)) {
621
- 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
+ }
622
795
  }
623
796
  this._recordUsage(obj);
624
797
  }
@@ -640,7 +813,7 @@ class SessionParser {
640
813
  obj.isMeta = true;
641
814
  } else if (text.startsWith("<scheduled-task")) {
642
815
  meta.hasScheduledTask = true;
643
- } else if (text) {
816
+ } else if (text && !obj.isMeta) {
644
817
  if (!meta.firstUserPrompt) meta.firstUserPrompt = text.slice(0, 300);
645
818
  }
646
819
  }
@@ -656,13 +829,9 @@ class SessionParser {
656
829
  obj._tokenDelta = (obj._tokenDelta || 0) + output;
657
830
  obj._tokenTotal = this.tokenTotal;
658
831
  }
659
- // One assistant reply is written as several jsonl lines sharing message.id:
660
- // input/cache stay fixed while output_tokens grows toward its final value. We
661
- // add each line's *growth* over the previous one (the full usage on the first
662
- // line), so the totals land on the final value — what ccusage bills. The
663
- // per-item running-total stamps and the stream-stable metadata come only from
664
- // the first line; ids in `excludeIds` (already counted in an earlier
665
- // resumed-from session) stamp but never aggregate.
832
+ // One assistant reply spans several jsonl lines sharing message.id, with output_tokens growing
833
+ // toward its final value. Counting each line's *growth* lands the totals on that final value —
834
+ // what ccusage bills. Ids in `excludeIds` (already counted in a resumed-from session) never aggregate.
666
835
  _recordUsage(obj) {
667
836
  const meta = this.meta;
668
837
  const msg = obj.message;
@@ -743,33 +912,6 @@ class SessionParser {
743
912
  return meta;
744
913
  }
745
914
  }
746
- class ConfigFile {
747
- dataDir = join(homedir(), ".claude-discover");
748
- configFile = join(this.dataDir, "config.json");
749
- read() {
750
- try {
751
- return JSON.parse(fs.readFileSync(this.configFile, "utf8"));
752
- } catch (err) {
753
- if (err.code !== "ENOENT") console.warn(`Ignoring unreadable ${this.configFile}: ${err.message}`);
754
- return {};
755
- }
756
- }
757
- save(data) {
758
- fs.mkdirSync(this.dataDir, { recursive: true });
759
- sync(this.configFile, JSON.stringify({ ...this.read(), ...data }, null, 2));
760
- }
761
- }
762
- const config = new ConfigFile();
763
- const local = config.read();
764
- const DATA_DIR = config.dataDir;
765
- const CLAUDE_DIR = local.claudeDir || process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude");
766
- const RECENT_CLAUDE_DIRS = local.recents || [CLAUDE_DIR];
767
- const CLAUDE_PROJECTS_DIR = join(CLAUDE_DIR, "projects");
768
- const CLAUDE_SETTINGS = join(CLAUDE_DIR, "settings.json");
769
- const IS_EPHEMERAL = import.meta.dirname.split(/[\\/]/).includes("_npx");
770
- const STATUSLINE_PATH = join(import.meta.dirname, "../../bin/claude/statusline.mjs");
771
- const PROXY_PATH = join(import.meta.dirname, "../../bin/proxy.mjs");
772
- const CLAUDE_HOOKS_PATH = join(import.meta.dirname, "../../bin/claude/hooks.mjs");
773
915
  class StatCache {
774
916
  constructor() {
775
917
  this.stats = /* @__PURE__ */ new Map();
@@ -814,10 +956,8 @@ class SessionsScanner {
814
956
  const entries = await fsp.readdir(p, { withFileTypes: true }).catch(() => []);
815
957
  return entries.filter((e) => e.isDirectory()).map((e) => path.join(e.parentPath, e.name));
816
958
  }
817
- // Every .jsonl under `dir`, at any depth — subagent transcripts live arbitrarily deep
818
- // (e.g. <session>/subagents/workflows/<wf>/agent-*.jsonl). The walk can't prune subtrees
819
- // by directory mtime, because on Windows appending to an existing transcript does NOT
820
- // bump its parent dir's mtime.
959
+ // Every .jsonl under `dir`, at any depth — subagent transcripts nest arbitrarily deep. Subtrees
960
+ // can't be pruned by directory mtime: on Windows, appending to a transcript doesn't bump its parent dir.
821
961
  async _listJsonl(dir) {
822
962
  const entries = await fsp.readdir(dir, { recursive: true, withFileTypes: true }).catch(() => []);
823
963
  return entries.filter((e) => e.isFile() && isJsonl(e.name)).map((e) => path.join(e.parentPath, e.name));
@@ -828,12 +968,10 @@ class SessionsScanner {
828
968
  if (stat.mtimeMs < day.start) return false;
829
969
  return !(stat.birthtimeMs > day.end && stat.birthtimeMs + 2e3 <= stat.mtimeMs);
830
970
  }
831
- // Walks each project subtree in full. For each .jsonl whose stat passes _inPeriod
832
- // (file mtime IS updated on append, unlike dir mtime), calls onFile(filePath, stat).
833
- // After each project's files are processed, calls onBatchDone() if any onFile returned
834
- // truthy — lets callers flush UI updates incrementally. An aborted `signal` (a superseded
835
- // scan) stops the walk: no further stats, onFile calls or emits. Once a walk has completed
836
- // with the watcher live, the StatCache mirrors the disk and serves later scans in memory.
971
+ // Calls onFile(filePath, stat) for every .jsonl passing _inPeriod, then onBatchDone() per project
972
+ // if any onFile returned truthy — so callers can flush UI updates incrementally. An aborted
973
+ // `signal` (a superseded scan) stops everything; once complete with the watcher live, the
974
+ // StatCache mirrors disk and serves later scans in memory.
837
975
  async scan(day, opts = {}) {
838
976
  if (this.statCache.complete && this.watcher) {
839
977
  return this.statCache.scan((stat) => this._inPeriod(stat, day), opts);
@@ -920,9 +1058,6 @@ class MetaCache {
920
1058
  get(filePath) {
921
1059
  return this.periodMetas.get(filePath);
922
1060
  }
923
- byId(sessionId) {
924
- return [...this.periodMetas.values()].find((m) => m.sessionId === sessionId);
925
- }
926
1061
  values() {
927
1062
  return this.periodMetas.values();
928
1063
  }
@@ -957,11 +1092,16 @@ class MetaCache {
957
1092
  }
958
1093
  }
959
1094
  const seed = {
1095
+ "claude-opus-5": { "input": 5, "output": 25, "cacheRead": 0.5, "cacheWrite": 6.25, "cacheWrite1h": 10, "fastModeMplr": 2 },
1096
+ "claude-fable-5-1": { "input": 10, "output": 50, "cacheRead": 0.25, "cacheWrite": 12.5, "cacheWrite1h": 20 },
960
1097
  "claude-fable-5": { "input": 10, "output": 50, "cacheRead": 1, "cacheWrite": 12.5, "cacheWrite1h": 20 },
1098
+ "claude-mythos-5-1": { "input": 10, "output": 50, "cacheRead": 0.25, "cacheWrite": 12.5, "cacheWrite1h": 20 },
1099
+ "claude-mythos-5": { "input": 10, "output": 50, "cacheRead": 1, "cacheWrite": 12.5, "cacheWrite1h": 20 },
1100
+ "claude-mythos-preview": { "input": 10, "output": 50, "cacheRead": 1, "cacheWrite": 12.5, "cacheWrite1h": 20 },
961
1101
  "claude-sonnet-5": { "input": 2, "output": 10, "cacheRead": 0.2, "cacheWrite": 2.5, "cacheWrite1h": 4 },
962
1102
  "claude-opus-4-8": { "input": 5, "output": 25, "cacheRead": 0.5, "cacheWrite": 6.25, "cacheWrite1h": 10, "fastModeMplr": 2 },
963
- "claude-opus-4-7": { "input": 5, "output": 25, "cacheRead": 0.5, "cacheWrite": 6.25, "cacheWrite1h": 10, "fastModeMplr": 6 },
964
- "claude-opus-4-6": { "input": 5, "output": 25, "cacheRead": 0.5, "cacheWrite": 6.25, "cacheWrite1h": 10, "fastModeMplr": 6 },
1103
+ "claude-opus-4-7": { "input": 5, "output": 25, "cacheRead": 0.5, "cacheWrite": 6.25, "cacheWrite1h": 10 },
1104
+ "claude-opus-4-6": { "input": 5, "output": 25, "cacheRead": 0.5, "cacheWrite": 6.25, "cacheWrite1h": 10 },
965
1105
  "claude-opus-4-5": { "input": 5, "output": 25, "cacheRead": 0.5, "cacheWrite": 6.25, "cacheWrite1h": 10 },
966
1106
  "claude-opus-4-1": { "input": 15, "output": 75, "cacheRead": 1.5, "cacheWrite": 18.75, "cacheWrite1h": 30 },
967
1107
  "claude-opus-4": { "input": 15, "output": 75, "cacheRead": 1.5, "cacheWrite": 18.75, "cacheWrite1h": 30 },
@@ -974,11 +1114,11 @@ const seed = {
974
1114
  "claude-3-7-sonnet": { "input": 3, "output": 15, "cacheRead": 0.3, "cacheWrite": 3.75, "cacheWrite1h": 6 },
975
1115
  "claude-3-5-sonnet": { "input": 3, "output": 15, "cacheRead": 0.3, "cacheWrite": 3.75 },
976
1116
  "claude-3-5-haiku": { "input": 0.8, "output": 4, "cacheRead": 0.08, "cacheWrite": 1 },
977
- "claude-3-opus": { "input": 15, "output": 75, "cacheRead": 1.5, "cacheWrite": 18.75, "cacheWrite1h": 6 },
1117
+ "claude-3-opus": { "input": 15, "output": 75, "cacheRead": 1.5, "cacheWrite": 18.75, "cacheWrite1h": 30 },
978
1118
  "claude-3-sonnet": { "input": 3, "output": 15, "cacheRead": 0.3, "cacheWrite": 3.75 },
979
- "claude-3-haiku": { "input": 0.25, "output": 1.25, "cacheRead": 0.03, "cacheWrite": 0.3, "cacheWrite1h": 6 }
1119
+ "claude-3-haiku": { "input": 0.25, "output": 1.25, "cacheRead": 0.03, "cacheWrite": 0.3, "cacheWrite1h": 0.5 }
980
1120
  };
981
- const CURRENT_PATH = path.join(DATA_DIR, "prices.current.json");
1121
+ const CURRENT_PATH = path.join(DATA_DIR$1, "prices.current.json");
982
1122
  const LITELLM_URL = "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json";
983
1123
  const DAY_MS = 24 * 3600 * 1e3;
984
1124
  const MILLION = 1e6;
@@ -1067,7 +1207,7 @@ class Pricing extends EventEmitter {
1067
1207
  if (typeof fast === "number") out["claude-" + name].fastModeMplr = fast;
1068
1208
  }
1069
1209
  if (Object.keys(out).length) {
1070
- fs.mkdirSync(DATA_DIR, { recursive: true });
1210
+ fs.mkdirSync(DATA_DIR$1, { recursive: true });
1071
1211
  fs.writeFileSync(CURRENT_PATH, JSON.stringify(out, null, 2) + "\n");
1072
1212
  const before = JSON.stringify(this.#prices);
1073
1213
  this.load();
@@ -1104,8 +1244,10 @@ class SessionsService extends EventEmitter {
1104
1244
  }
1105
1245
  // Always parses the whole file from 0: the parser's running token totals only stamp
1106
1246
  // correctly from a fresh start, so live updates re-read rather than resume at an offset.
1107
- async readSession(sessionId, date = null, granularity = "day") {
1108
- 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);
1109
1251
  if (!meta) return null;
1110
1252
  const range = date ? periodBounds(date, granularity) : null;
1111
1253
  const reader = new SessionFile(meta.filePath);
@@ -1120,10 +1262,11 @@ class SessionsService extends EventEmitter {
1120
1262
  return { items, instructions };
1121
1263
  }
1122
1264
  // Captured API requests for a session (see bin/proxy.mjs), period-filtered like readSession.
1123
- async readRequests(sessionId, date = null, granularity = "day") {
1124
- 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 [];
1125
1268
  const range = date ? periodBounds(date, granularity) : null;
1126
- return this.#requestFile(sessionId, this.metaCache.byId(sessionId)?.parentSessionId).read(range);
1269
+ return this.#requestFile(meta.sessionId, meta.parentSessionId).read(range);
1127
1270
  }
1128
1271
  // Subagents send the parent's session id, so the proxy logs their requests into the parent's
1129
1272
  // file, tagged x-claude-code-agent-id — route their reads there. The transcript filename is
@@ -1187,6 +1330,7 @@ class SessionsService extends EventEmitter {
1187
1330
  sessionId: reader.sessionId,
1188
1331
  parentSessionId: reader.parentSessionId,
1189
1332
  filePath: reader.filePath,
1333
+ parentFilePath: reader.parentFilePath,
1190
1334
  fileSize: stat.size,
1191
1335
  mtime: stat.mtimeMs,
1192
1336
  range,
@@ -1209,10 +1353,10 @@ class SessionsService extends EventEmitter {
1209
1353
  if (globalSeen.has(id)) (overlap ||= /* @__PURE__ */ new Set()).add(id);
1210
1354
  globalSeen.add(id);
1211
1355
  }
1212
- 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 }));
1213
1357
  }
1214
1358
  const sessions = await Promise.all(metas.map(async (m) => {
1215
- const rescanned = await rescans.get(m.sessionId);
1359
+ const rescanned = await rescans.get(m.filePath);
1216
1360
  return stripInternal(rescanned ? { ...m, ...rescanned } : m);
1217
1361
  }));
1218
1362
  const labels = suffixLabels(sessions.map((m) => m.project));
@@ -1301,342 +1445,258 @@ class AgentRunner {
1301
1445
  return { code: 0 };
1302
1446
  }
1303
1447
  }
1304
- const IS_DEV = !!process.env.ELECTRON_RENDERER_URL;
1305
- class ClaudeDirSwitch {
1306
- status() {
1307
- 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
+ }
1308
1461
  }
1309
- // "Change directory" — browse for a new dir; selecting one restarts the app on it.
1310
- activate() {
1311
- 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;
1312
1476
  }
1313
- deactivate() {
1314
- 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;
1315
1480
  }
1316
- }
1317
- async function browseForClaudeDir(win) {
1318
- const r = await dialog.showOpenDialog(win, {
1319
- title: "Select Claude directory",
1320
- defaultPath: CLAUDE_DIR,
1321
- properties: ["openDirectory"]
1322
- });
1323
- if (!r.canceled) {
1324
- switchToClaudeDir(r.filePaths[0]);
1481
+ set cleanupPeriodDays(days) {
1482
+ if (days === void 0) delete this.cfg.cleanupPeriodDays;
1483
+ else this.cfg.cleanupPeriodDays = days;
1325
1484
  }
1326
- }
1327
- function switchToClaudeDir(claudeDir) {
1328
- if (!claudeDir || claudeDir === CLAUDE_DIR) return;
1329
- config.save({ claudeDir, recents: [.../* @__PURE__ */ new Set([...RECENT_CLAUDE_DIRS, claudeDir])] });
1330
- if (IS_DEV) {
1331
- dialog.showMessageBoxSync({ type: "info", message: "Restart required", detail: "Stop and re-run `npm run dev` to apply the new directory." });
1332
- app.quit();
1333
- } else {
1334
- app.relaunch();
1335
- BrowserWindow.getAllWindows().forEach((w) => w.close());
1336
- 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));
1337
1509
  }
1338
1510
  }
1339
- function buildAppMenu({ onFind, onEscape } = {}) {
1340
- return Menu.buildFromTemplate([
1341
- ...process.platform === "darwin" ? [{ role: "appMenu" }] : [],
1342
- // @macOS
1343
- {
1344
- label: "File",
1345
- submenu: [
1346
- { label: "Change directory…", click: (_i, win) => browseForClaudeDir(win) },
1347
- { type: "separator" },
1348
- ...RECENT_CLAUDE_DIRS.map((p) => ({
1349
- label: p,
1350
- type: "checkbox",
1351
- checked: p === CLAUDE_DIR,
1352
- click: () => switchToClaudeDir(p)
1353
- })),
1354
- { type: "separator" },
1355
- { role: "quit" }
1356
- ]
1357
- },
1358
- {
1359
- // @macOS Cmd+C/V/A have no key equivalents without an Edit menu; Windows/Linux get these from Chromium
1360
- label: "Edit",
1361
- submenu: [
1362
- { role: "copy" },
1363
- { role: "paste" },
1364
- { role: "selectAll" },
1365
- { label: "Find…", accelerator: "CmdOrCtrl+F", click: () => onFind?.() },
1366
- { label: "Deselect", accelerator: "Escape", click: (_i, win) => onEscape?.(win) }
1367
- ]
1368
- },
1369
- {
1370
- label: "View",
1371
- submenu: [
1372
- { role: "reload" },
1373
- { role: "forceReload" },
1374
- { role: "toggleDevTools" }
1375
- ]
1376
- }
1377
- ]);
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
+ }
1378
1524
  }
1379
- const VIEWABLE_EXT = /* @__PURE__ */ new Set([".md", ".txt", ".json", ".jsonl", ".log", ".csv", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".pdf"]);
1380
- async function openLinkSafely(href, baseFile) {
1381
- if (/^(https?:|mailto:)/i.test(href)) return shell.openExternal(href);
1382
- if (!baseFile) return;
1383
- let decoded;
1525
+ async function exec(file, args) {
1384
1526
  try {
1385
- decoded = decodeURIComponent(href);
1386
- } catch {
1387
- return;
1527
+ return await promisify(execFile)(file, args, { windowsHide: true });
1528
+ } catch (err) {
1529
+ throw new Error(err.stderr?.trim() || err.message);
1530
+ }
1531
+ }
1532
+ class TaskScheduler {
1533
+ static #SYSTEM32 = path.join(process.env.SystemRoot ?? "C:\\Windows", "System32");
1534
+ #name;
1535
+ #command;
1536
+ constructor({ name, command }) {
1537
+ this.#name = name;
1538
+ this.#command = command;
1539
+ }
1540
+ // Without admin rights only an interactive task can be registered, and that would open a console
1541
+ // window — `conhost --headless` runs the command without one. conhost hides the exit code, so a
1542
+ // minutely trigger restarts the process if it died (IgnoreNew: no-op while it runs). Started by
1543
+ // the scheduler, the process is outside any terminal's job object, so closing a terminal can't
1544
+ // kill it. The default settings would skip starting on battery and stop the task after 72h.
1545
+ async install() {
1546
+ const q = (s) => s.replace(/'/g, "''");
1547
+ const conhost = path.join(TaskScheduler.#SYSTEM32, "conhost.exe");
1548
+ await this.#ps(`
1549
+ $user = "$env:USERDOMAIN\\$env:USERNAME"
1550
+ $action = New-ScheduledTaskAction -Execute '${q(conhost)}' -Argument '--headless ${this.#command.map((s) => `"${q(s)}"`).join(" ")}'
1551
+ $logon = New-ScheduledTaskTrigger -AtLogOn -User $user
1552
+ $revive = New-ScheduledTaskTrigger -Once -At (Get-Date) -RepetitionInterval (New-TimeSpan -Minutes 1)
1553
+ $principal = New-ScheduledTaskPrincipal -UserId $user -LogonType Interactive -RunLevel Limited
1554
+ $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit ([TimeSpan]::Zero) -MultipleInstances IgnoreNew
1555
+ $null = Register-ScheduledTask -TaskName '${this.#name}' -Action $action -Trigger $logon, $revive -Principal $principal -Settings $settings -Force
1556
+ Start-ScheduledTask -TaskName '${this.#name}'
1557
+ `);
1558
+ }
1559
+ uninstall() {
1560
+ return this.#ps(`Unregister-ScheduledTask -TaskName '${this.#name}' -Confirm:$false -ErrorAction SilentlyContinue`);
1561
+ }
1562
+ #ps(script) {
1563
+ const powershell = path.join(TaskScheduler.#SYSTEM32, "WindowsPowerShell", "v1.0", "powershell.exe");
1564
+ const wrapped = `$ErrorActionPreference = 'Stop'
1565
+ $ProgressPreference = 'SilentlyContinue'
1566
+ try {${script}} catch { [Console]::Error.WriteLine($_.Exception.Message); exit 1 }
1567
+ exit 0`;
1568
+ const encoded = Buffer.from(wrapped, "utf16le").toString("base64");
1569
+ return exec(powershell, ["-NoProfile", "-NonInteractive", "-EncodedCommand", encoded]);
1570
+ }
1571
+ }
1572
+ class LaunchAgent {
1573
+ // @macOS
1574
+ #name;
1575
+ #command;
1576
+ #plist;
1577
+ #domain = `gui/${process.getuid()}`;
1578
+ constructor({ name, command }) {
1579
+ this.#name = name;
1580
+ this.#command = command;
1581
+ this.#plist = path.join(os.homedir(), "Library", "LaunchAgents", `${name}.plist`);
1582
+ }
1583
+ // RunAtLoad starts it right away; KeepAlive restarts it only after a crash, so a clean exit stays stopped
1584
+ async install() {
1585
+ fs.mkdirSync(path.dirname(this.#plist), { recursive: true });
1586
+ fs.writeFileSync(this.#plist, `
1587
+ <?xml version="1.0" encoding="UTF-8"?>
1588
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1589
+ <plist version="1.0">
1590
+ <dict>
1591
+ <key>Label</key><string>${this.#name}</string>
1592
+ <key>ProgramArguments</key><array>${this.#command.map((s) => `<string>${s}</string>`).join("")}</array>
1593
+ <key>RunAtLoad</key><true/>
1594
+ <key>KeepAlive</key><dict><key>SuccessfulExit</key><false/></dict>
1595
+ </dict>
1596
+ </plist>
1597
+ `.trim());
1598
+ await exec("launchctl", ["bootout", this.#domain, this.#plist]).catch(() => {
1599
+ });
1600
+ await exec("launchctl", ["bootstrap", this.#domain, this.#plist]);
1601
+ }
1602
+ async uninstall() {
1603
+ await exec("launchctl", ["bootout", this.#domain, this.#plist]).catch(() => {
1604
+ });
1605
+ fs.rmSync(this.#plist, { force: true });
1606
+ }
1607
+ }
1608
+ class SystemdUnit {
1609
+ // @linux
1610
+ #name;
1611
+ #command;
1612
+ #unit;
1613
+ constructor({ name, command }) {
1614
+ this.#name = name;
1615
+ this.#command = command;
1616
+ this.#unit = path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"), "systemd", "user", `${name}.service`);
1617
+ }
1618
+ async install() {
1619
+ fs.mkdirSync(path.dirname(this.#unit), { recursive: true });
1620
+ fs.writeFileSync(this.#unit, `
1621
+ [Unit]
1622
+ Description=${this.#name}
1623
+
1624
+ [Service]
1625
+ ExecStart=${this.#command.map((s) => `"${s}"`).join(" ")}
1626
+ Restart=on-failure
1627
+ RestartSec=10
1628
+
1629
+ [Install]
1630
+ WantedBy=default.target
1631
+ `.trim());
1632
+ await exec("systemctl", ["--user", "daemon-reload"]);
1633
+ await exec("systemctl", ["--user", "enable", "--now", this.#name]);
1634
+ }
1635
+ async uninstall() {
1636
+ await exec("systemctl", ["--user", "disable", "--now", this.#name]).catch(() => {
1637
+ });
1638
+ fs.rmSync(this.#unit, { force: true });
1639
+ await exec("systemctl", ["--user", "daemon-reload"]).catch(() => {
1640
+ });
1388
1641
  }
1389
- const baseDir = path.dirname(path.resolve(baseFile));
1390
- const abs = path.resolve(baseDir, decoded);
1391
- const rel = path.relative(baseDir, abs);
1392
- if (rel.startsWith("..") || path.isAbsolute(rel)) return;
1393
- if (!VIEWABLE_EXT.has(path.extname(abs).toLowerCase())) return shell.showItemInFolder(abs);
1394
- const err = await shell.openPath(abs);
1395
- if (err) shell.showItemInFolder(abs);
1396
- return err;
1397
1642
  }
1398
- const DEV_ORIGIN = process.env.ELECTRON_RENDERER_URL && new URL(process.env.ELECTRON_RENDERER_URL).origin;
1399
- const APP_PAGES = pathToFileURL(path.join(import.meta.dirname, "../renderer/")).href;
1400
- function lockNavigation(contents) {
1401
- const toBrowser = (url) => /^(https?|mailto):/i.test(url) && shell.openExternal(url);
1402
- const block = (e, url) => {
1403
- const { href, origin } = new URL(url);
1404
- if (href.startsWith(APP_PAGES) || origin === DEV_ORIGIN) return;
1405
- e.preventDefault();
1406
- toBrowser(url);
1407
- };
1408
- contents.setWindowOpenHandler(({ url }) => {
1409
- toBrowser(url);
1410
- return { action: "deny" };
1411
- });
1412
- contents.on("will-navigate", block);
1413
- contents.on("will-redirect", block);
1414
- }
1415
- const __dirname$2 = path.dirname(fileURLToPath(import.meta.url));
1416
- const DEV_URL$1 = process.env.ELECTRON_RENDERER_URL;
1417
- const WIDTH = 320, HEIGHT = 56, MARGIN = 6;
1418
- class FindBar {
1419
- constructor(win) {
1420
- this.win = win;
1421
- this.view = new WebContentsView({
1422
- webPreferences: {
1423
- preload: path.join(__dirname$2, "../preload/findPreload.cjs"),
1424
- contextIsolation: true,
1425
- nodeIntegration: false,
1426
- sandbox: true
1427
- }
1428
- });
1429
- this.view.setBackgroundColor("#00000000");
1430
- lockNavigation(this.view.webContents);
1431
- win.contentView.addChildView(this.view);
1432
- this.view.setVisible(false);
1433
- if (DEV_URL$1) this.view.webContents.loadURL(`${DEV_URL$1}/find/find.html`);
1434
- else this.view.webContents.loadFile(path.join(__dirname$2, "../renderer/find/find.html"));
1435
- win.webContents.on("found-in-page", (_e, r) => {
1436
- return this.view.webContents.send("find:result", { active: r.activeMatchOrdinal, total: r.matches, id: r.requestId });
1437
- });
1438
- win.on("resize", () => {
1439
- if (this.visible) this.layout();
1440
- });
1441
- }
1442
- get visible() {
1443
- return this.view.getVisible();
1444
- }
1445
- layout() {
1446
- const [w] = this.win.getContentSize();
1447
- this.view.setBounds({ x: Math.max(0, w - WIDTH - MARGIN), y: MARGIN, width: WIDTH, height: HEIGHT });
1448
- }
1449
- query(text, options) {
1450
- this.win.webContents.findInPage(text, options);
1451
- }
1452
- stop() {
1453
- this.win.webContents.stopFindInPage("clearSelection");
1454
- }
1455
- show() {
1456
- this.layout();
1457
- this.view.setVisible(true);
1458
- this.view.webContents.focus();
1459
- this.view.webContents.send("find:open");
1460
- this.win.webContents.send("find:active", true);
1461
- }
1462
- hide() {
1463
- if (!this.visible) return;
1464
- this.view.setVisible(false);
1465
- this.stop();
1466
- this.win.webContents.focus();
1467
- this.win.webContents.send("find:active", false);
1468
- }
1469
- }
1470
- const __dirname$1 = path.dirname(fileURLToPath(import.meta.url));
1471
- const DEV_URL = process.env.ELECTRON_RENDERER_URL;
1472
- class MainWindow {
1473
- constructor() {
1474
- this.win = null;
1475
- }
1476
- create() {
1477
- Menu.setApplicationMenu(buildAppMenu({
1478
- onFind: () => this.findBar?.show(),
1479
- onEscape: (win) => this.findBar?.visible ? this.findBar.hide() : win?.webContents.unselect()
1480
- }));
1481
- const state = windowStateKeeper({ defaultWidth: 1500, defaultHeight: 900 });
1482
- this.win = new BrowserWindow({
1483
- x: state.x,
1484
- y: state.y,
1485
- width: state.width,
1486
- height: state.height,
1487
- backgroundColor: "#0b0d12",
1488
- autoHideMenuBar: true,
1489
- title: `Claude Discover v${app.getVersion()}`,
1490
- webPreferences: {
1491
- preload: path.join(__dirname$1, "../preload/preload.cjs"),
1492
- contextIsolation: true,
1493
- nodeIntegration: false,
1494
- sandbox: true
1495
- }
1496
- });
1497
- state.manage(this.win);
1498
- this.win.on("page-title-updated", (e) => e.preventDefault());
1499
- lockNavigation(this.win.webContents);
1500
- contextMenu({ window: this.win, showSelectAll: true, showCopyImage: true, showCopyLink: true, showInspectElement: true });
1501
- if (DEV_URL) {
1502
- this.win.loadURL(DEV_URL);
1503
- } else {
1504
- this.win.loadFile(path.join(__dirname$1, "../renderer/index.html"));
1505
- }
1506
- this.findBar = new FindBar(this.win);
1507
- return this.win;
1508
- }
1509
- send(channel, payload) {
1510
- if (this.win && !this.win.isDestroyed()) this.win.webContents.send(channel, payload);
1511
- }
1512
- focus() {
1513
- if (!this.win || this.win.isDestroyed()) return;
1514
- if (this.win.isMinimized()) this.win.restore();
1515
- this.win.show();
1516
- this.win.focus();
1517
- }
1518
- }
1519
- const mtimeOf = (path2) => fs.statSync(path2, { throwIfNoEntry: false })?.mtimeMs;
1520
- class ClaudeSettings {
1643
+ const NODE = await which("node", { nothrow: true }) ?? "node";
1644
+ class ProxySwitch {
1645
+ #service = new LoginService({ name: "claude-discover-proxy", command: [NODE, PROXY_PATH] });
1521
1646
  constructor() {
1522
- this.cfg = {};
1523
- this.mtime = mtimeOf(CLAUDE_SETTINGS);
1524
- if (this.mtime !== void 0) {
1525
- try {
1526
- this.cfg = JSON.parse(fs.readFileSync(CLAUDE_SETTINGS, "utf8"));
1527
- } catch (err) {
1528
- console.warn(`Failed to read ${CLAUDE_SETTINGS}: ${err.message}`);
1529
- this.loadFailed = true;
1530
- }
1531
- }
1532
- }
1533
- hooks(eventName) {
1534
- return (this.cfg.hooks?.[eventName] ?? []).flatMap((g) => g.hooks ?? []);
1535
- }
1536
- // The hook entry on this event whose command mentions `needle` (basename, so stale absolute paths still match), or undefined.
1537
- findHook(eventName, needle) {
1538
- return this.hooks(eventName).find((h) => h.command?.includes(needle));
1539
- }
1540
- // Removes every hook on this event whose command mentions `needle` (basename, so stale
1541
- // absolute paths still match); groups left empty are dropped. Returns the removed commands.
1542
- removeHook(eventName, needle) {
1543
- const matches = (h) => h.command?.includes(needle);
1544
- const groups = this.cfg.hooks?.[eventName] ?? [];
1545
- const removed = groups.flatMap((g) => (g.hooks ?? []).filter(matches).map((h) => h.command));
1546
- if (removed.length) {
1547
- for (const g of groups) g.hooks = (g.hooks ?? []).filter((h) => !matches(h));
1548
- this.cfg.hooks[eventName] = groups.filter((g) => g.hooks.length);
1647
+ try {
1648
+ const settings = new ClaudeSettings();
1649
+ if (settings.removeHooks(/claude-discover/).length) settings.save();
1650
+ } catch {
1549
1651
  }
1550
- return removed;
1551
- }
1552
- // Days Claude Code keeps transcripts before auto-deleting them; undefined when unset (its default is 30).
1553
- get cleanupPeriodDays() {
1554
- return this.cfg.cleanupPeriodDays;
1555
- }
1556
- set cleanupPeriodDays(days) {
1557
- if (days === void 0) delete this.cfg.cleanupPeriodDays;
1558
- else this.cfg.cleanupPeriodDays = days;
1559
- }
1560
- // Claude Code's status line config: { type: 'command', command }; undefined when unset.
1561
- get statusLine() {
1562
- return this.cfg.statusLine;
1563
1652
  }
1564
- set statusLine(value) {
1565
- if (value === void 0) delete this.cfg.statusLine;
1566
- else this.cfg.statusLine = value;
1567
- }
1568
- // Environment variables Claude Code applies to every session; undefined when unset.
1569
- get env() {
1570
- return this.cfg.env;
1571
- }
1572
- setEnv(name, value) {
1573
- this.cfg.env ??= {};
1574
- this.cfg.env[name] = value;
1575
- }
1576
- deleteEnv(name) {
1577
- delete this.cfg.env?.[name];
1578
- }
1579
- addHook(eventName, command) {
1580
- this.cfg.hooks ??= {};
1581
- this.cfg.hooks[eventName] ??= [];
1582
- this.cfg.hooks[eventName].push({ hooks: [{ type: "command", command }] });
1583
- }
1584
- save() {
1585
- if (this.loadFailed) throw new Error(`Refusing to save ${CLAUDE_SETTINGS}: settings did not load cleanly`);
1586
- if (mtimeOf(CLAUDE_SETTINGS) !== this.mtime) throw new Error(`Refusing to save ${CLAUDE_SETTINGS}: file changed on disk since load`);
1587
- fs.mkdirSync(dirname(CLAUDE_SETTINGS), { recursive: true });
1588
- sync(CLAUDE_SETTINGS, JSON.stringify(this.cfg, null, 2));
1589
- }
1590
- }
1591
- const HOOK_BASENAME = "hooks.mjs";
1592
- const HOOK_COMMAND = `node "${CLAUDE_HOOKS_PATH}"`;
1593
- class ProxySwitch {
1594
1653
  async status() {
1595
1654
  return {
1596
1655
  running: await this.#running(),
1597
1656
  configured: new ClaudeSettings().env?.ANTHROPIC_BASE_URL === PROXY_URL
1598
1657
  };
1599
1658
  }
1600
- // Spawn the proxy detached (it outlives the app) and point Claude Code at it only once it's
1601
- // confirmed listening — a failed start never changes settings.json. The proxy verifies the
1602
- // upstream itself before listening (exit code 2 when unreachable), so a successful start
1603
- // proves end-to-end connectivity. Failures throw; Switchers maps them to { error }.
1659
+ // Install the proxy as a login service, then point Claude Code at it — only once it answers, so
1660
+ // a failed start never touches settings.json. Throws on failure (Switchers turns that into { error }).
1604
1661
  async activate() {
1605
1662
  const settings = new ClaudeSettings();
1606
1663
  const baseUrl = settings.env?.ANTHROPIC_BASE_URL;
1607
1664
  if (baseUrl && baseUrl !== PROXY_URL)
1608
1665
  throw new Error(`Leaving your existing env.ANTHROPIC_BASE_URL in place (${baseUrl}) — remove it from settings.json to enable capture.`);
1609
- const launcher = utilityProcess.fork(PROXY_PATH, ["--restart"], { stdio: "ignore" });
1610
- const exited = new Promise((resolve) => launcher.once("message", resolve));
1611
- const outcome = await Promise.race([exited, this.#settle(true).then((ok) => ok ? "up" : "unresponsive")]).finally(() => launcher.kill());
1612
- if (outcome !== "up")
1613
- 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}`);
1614
- const hook = settings.findHook("SessionStart", HOOK_BASENAME);
1615
- if (!baseUrl || settings.env?.ENABLE_TOOL_SEARCH !== "true" || hook?.command !== HOOK_COMMAND) {
1616
- settings.setEnv("ANTHROPIC_BASE_URL", PROXY_URL);
1617
- settings.setEnv("ENABLE_TOOL_SEARCH", "true");
1618
- if (hook?.command !== HOOK_COMMAND) {
1619
- settings.removeHook("SessionStart", HOOK_BASENAME);
1620
- settings.addHook("SessionStart", HOOK_COMMAND);
1621
- }
1622
- settings.save();
1623
- }
1624
- }
1625
- // Exit the proxy via its control route and remove the env keys + revive hook — env keys only
1626
- // when the base URL is ours, so a foreign gateway config is never touched.
1627
- async deactivate() {
1628
1666
  try {
1629
- await fetch(`${PROXY_URL}${EXIT_ROUTE}`, { method: "POST", signal: AbortSignal.timeout(1e3) });
1667
+ await fetch(new URL("/v1/models", UPSTREAM), { signal: AbortSignal.timeout(5e3) });
1630
1668
  } catch {
1669
+ throw new Error("Cannot reach api.anthropic.com — not enabling capture. Check your network and try again.");
1631
1670
  }
1671
+ await this.#exit();
1672
+ await this.#service.install();
1673
+ if (!await this.#settle(true)) {
1674
+ await this.#service.uninstall().catch(() => {
1675
+ });
1676
+ throw new Error(`Proxy did not start — see ${ERROR_LOG_PATH}`);
1677
+ }
1678
+ settings.setEnv("ANTHROPIC_BASE_URL", PROXY_URL);
1679
+ settings.setEnv("ENABLE_TOOL_SEARCH", "true");
1680
+ settings.save();
1681
+ }
1682
+ // Stop the proxy, remove the service and our env keys — a foreign base URL is left alone
1683
+ async deactivate() {
1684
+ await this.#exit();
1685
+ await this.#service.uninstall();
1632
1686
  const settings = new ClaudeSettings();
1633
- const ours = settings.env?.ANTHROPIC_BASE_URL === PROXY_URL;
1634
- const removedHook = settings.removeHook("SessionStart", HOOK_BASENAME).length > 0;
1635
- if (ours) {
1687
+ if (settings.env?.ANTHROPIC_BASE_URL === PROXY_URL) {
1636
1688
  settings.deleteEnv("ANTHROPIC_BASE_URL");
1637
1689
  settings.deleteEnv("ENABLE_TOOL_SEARCH");
1690
+ settings.save();
1691
+ }
1692
+ }
1693
+ // Ask a running instance to exit via its control route and wait for the port to free up
1694
+ async #exit() {
1695
+ try {
1696
+ await fetch(`${PROXY_URL}${EXIT_ROUTE}`, { method: "POST", signal: AbortSignal.timeout(1e3) });
1697
+ } catch {
1698
+ return;
1638
1699
  }
1639
- if (ours || removedHook) settings.save();
1640
1700
  await this.#settle(false);
1641
1701
  }
1642
1702
  async #running() {
@@ -1647,7 +1707,7 @@ class ProxySwitch {
1647
1707
  return false;
1648
1708
  }
1649
1709
  }
1650
- // Poll (≤8s — the proxy's startup upstream check can take 5s) until ping matches `target`
1710
+ // Poll up to 8s until ping matches `target` (the service takes a moment to start it)
1651
1711
  async #settle(target) {
1652
1712
  for (let i = 0; i < 40; i++) {
1653
1713
  if (await this.#running() === target) return true;
@@ -1707,22 +1767,51 @@ class RetentionSwitch {
1707
1767
  }
1708
1768
  }
1709
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
+ }
1710
1796
  class Switchers {
1711
- #switches = {
1712
- proxy: new ProxySwitch(),
1713
- statusline: new StatuslineSwitch(),
1714
- retention: new RetentionSwitch(),
1715
- claudedir: new ClaudeDirSwitch()
1716
- };
1797
+ #switches;
1717
1798
  #keepActive = { proxy: true, statusline: true };
1718
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
+ }
1719
1808
  async status(name) {
1720
1809
  const s = await this.#get(name).status();
1721
1810
  return name in this.#keepActive ? { ...s, keepActive: this.#keepActive[name], ephemeral: IS_EPHEMERAL } : s;
1722
1811
  }
1723
- activate(name) {
1812
+ activate(name, ...args) {
1724
1813
  return this.#attempt(name, async (s) => {
1725
- await s.activate();
1814
+ await s.activate(...args);
1726
1815
  this.#keepActive[name] &&= !IS_EPHEMERAL;
1727
1816
  });
1728
1817
  }
@@ -1754,95 +1843,260 @@ class Switchers {
1754
1843
  }
1755
1844
  }
1756
1845
  }
1757
- const SCHEME = "claude-discover";
1758
- class DeepLink extends EventEmitter {
1759
- #pending = null;
1760
- #live = false;
1761
- // the renderer has pulled the cold-start link, so later links can be pushed to it
1762
- // Call at startup, before app.whenReady(). Returns false when another instance already holds the
1763
- // lock — the caller must then quit, our argv has been forwarded to the window that owns it.
1764
- requestLock() {
1765
- if (!app.requestSingleInstanceLock()) return false;
1766
- app.on("second-instance", (_e, argv) => this.emit("open", findTarget(argv)));
1767
- app.on("open-url", (e, url) => {
1768
- e.preventDefault();
1769
- 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();
1770
1907
  });
1771
- this.#register();
1772
- this.#pending = findTarget(process.argv);
1773
- return true;
1774
1908
  }
1775
- takePending() {
1776
- this.#live = true;
1777
- const target = this.#pending;
1778
- this.#pending = null;
1779
- return target;
1909
+ get visible() {
1910
+ return this.view.getVisible();
1780
1911
  }
1781
- // @macOS Launch Services reuses the running bundle instead of launching a second process, so links
1782
- // come as events — and a cold-start one fires before the renderer exists, hence the park.
1783
- #deliver(target) {
1784
- if (!target) return;
1785
- if (this.#live) this.emit("open", target);
1786
- 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 });
1787
1915
  }
1788
- // @windows Rewritten on every launch — idempotent, and the last launched checkout wins.
1789
- #register() {
1790
- if (process.platform !== "win32") return;
1791
- if (IS_EPHEMERAL) return;
1792
- 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);
1793
1935
  }
1794
1936
  }
1795
- function findTarget(argv) {
1796
- const link = URL.parse(argv.find((a) => a.startsWith(`${SCHEME}://`)) ?? "");
1797
- const id = link?.searchParams.get("id");
1798
- 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
+ }
1799
1981
  }
1800
- const deepLink = new DeepLink();
1801
- function start() {
1802
- const win = new MainWindow();
1803
- const agentRunner = new AgentRunner();
1804
- const workHours = new WorkHours();
1805
- const sessionsService = new SessionsService();
1806
- sessionsService.on("update", (sessions) => win.send("sessions:update", sessions));
1807
- sessionsService.on("progress", (p) => win.send("sessions:scan-progress", p));
1808
- sessionsService.start();
1809
- deepLink.on("open", (target) => {
1810
- win.focus();
1811
- if (target) win.send("deeplink:open-session", target);
1812
- });
1813
- win.create();
1814
- app.on("activate", () => {
1815
- 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();
1816
2089
  });
1817
- ipcMain.handle("sessions:list", (_e, date, granularity) => sessionsService.list(date, granularity));
1818
- ipcMain.handle("sessions:read", (_e, sessionId, date, granularity) => sessionsService.readSession(sessionId, date, granularity));
1819
- ipcMain.handle("sessions:read-requests", (_e, sessionId, date, granularity) => sessionsService.readRequests(sessionId, date, granularity));
1820
- ipcMain.handle("work-hours:get", () => workHours.read());
1821
- ipcMain.handle("work-hours:set", (_e, data) => workHours.write(data));
1822
- ipcMain.handle("agent:run", (e, text, systemTools, cache) => agentRunner.run(text, e.sender, systemTools, cache));
1823
- ipcMain.handle("shell:open-link", (_e, href, baseFile) => openLinkSafely(href, baseFile));
1824
- ipcMain.handle("deeplink:take-pending", () => deepLink.takePending());
1825
- ipcMain.on("find:query", (_e, text, options) => win.findBar?.query(text, options));
1826
- ipcMain.on("find:stop", () => win.findBar?.stop());
1827
- ipcMain.on("find:close", () => win.findBar?.hide());
1828
- ipcMain.on("claude-settings:get", (e) => e.returnValue = { claudeDir: CLAUDE_DIR });
1829
- const switchers = new Switchers();
1830
- app.on("will-quit", async (e) => {
1831
- e.preventDefault();
1832
- await switchers.deactivateOnQuit();
1833
- 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
+ }
1834
2099
  });
1835
- ipcMain.handle("switch:status", (_e, name) => switchers.status(name));
1836
- ipcMain.handle("switch:activate", (_e, name) => switchers.activate(name));
1837
- ipcMain.handle("switch:deactivate", (_e, name) => switchers.deactivate(name));
1838
- ipcMain.handle("switch:keep-active", (_e, name, value) => switchers.setKeepActive(name, value));
1839
- }
1840
- app.on("window-all-closed", () => {
1841
- if (process.platform !== "darwin") app.quit();
1842
- });
1843
- const isFirstInstance = deepLink.requestLock();
1844
- if (isFirstInstance) {
1845
- app.whenReady().then(start);
1846
2100
  } else {
1847
2101
  app.exit(0);
1848
2102
  }