claude-discover 1.9.4 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/out/main/main.js CHANGED
@@ -1,18 +1,85 @@
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
- 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
9
  import which from "which";
14
10
  import { execFile } from "node:child_process";
15
11
  import { promisify } from "node:util";
12
+ import { pathToFileURL, fileURLToPath } from "node:url";
13
+ import windowStateKeeper from "electron-window-state";
14
+ import contextMenu from "electron-context-menu";
15
+ class ConfigFile {
16
+ dataDir = join(homedir(), ".claude-discover");
17
+ configFile = join(this.dataDir, "config.json");
18
+ read() {
19
+ try {
20
+ return JSON.parse(fs.readFileSync(this.configFile, "utf8"));
21
+ } catch (err) {
22
+ if (err.code !== "ENOENT") console.warn(`Ignoring unreadable ${this.configFile}: ${err.message}`);
23
+ return {};
24
+ }
25
+ }
26
+ save(data) {
27
+ fs.mkdirSync(this.dataDir, { recursive: true });
28
+ sync(this.configFile, JSON.stringify({ ...this.read(), ...data }, null, 2));
29
+ }
30
+ }
31
+ const config = new ConfigFile();
32
+ const local = config.read();
33
+ const DATA_DIR$1 = config.dataDir;
34
+ const CLAUDE_DIR = local.claudeDir || process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude");
35
+ const RECENT_CLAUDE_DIRS = local.recents || [CLAUDE_DIR];
36
+ const CLAUDE_PROJECTS_DIR = join(CLAUDE_DIR, "projects");
37
+ const CLAUDE_SETTINGS = join(CLAUDE_DIR, "settings.json");
38
+ const IS_EPHEMERAL = import.meta.dirname.split(/[\\/]/).includes("_npx");
39
+ const STATUSLINE_PATH = join(import.meta.dirname, "../../bin/claude/statusline.mjs");
40
+ const PROXY_PATH = join(import.meta.dirname, "../../bin/proxy.mjs");
41
+ const SCHEME = "claude-discover";
42
+ class DeepLink extends EventEmitter {
43
+ #pending = null;
44
+ #live = false;
45
+ // the renderer has pulled the cold-start link, so later links can be pushed to it
46
+ // Call in the first instance before app.whenReady(), so cold-start URLs can be parked.
47
+ activate() {
48
+ app.on("open-url", (e, url) => {
49
+ e.preventDefault();
50
+ this.#deliver(findTarget([url]));
51
+ });
52
+ this.#register();
53
+ this.#pending = findTarget(process.argv);
54
+ }
55
+ open(target) {
56
+ if (target) this.emit("open", target);
57
+ }
58
+ takePending() {
59
+ this.#live = true;
60
+ const target = this.#pending;
61
+ this.#pending = null;
62
+ return target;
63
+ }
64
+ // @macOS Launch Services reuses the running bundle instead of launching a second process, so links
65
+ // come as events — and a cold-start one fires before the renderer exists, hence the park.
66
+ #deliver(target) {
67
+ if (!target) return;
68
+ if (this.#live) this.emit("open", target);
69
+ else this.#pending = target;
70
+ }
71
+ // @windows Rewritten on every launch — idempotent, and the last launched checkout wins.
72
+ #register() {
73
+ if (process.platform !== "win32") return;
74
+ if (IS_EPHEMERAL) return;
75
+ app.setAsDefaultProtocolClient(SCHEME, process.execPath, [app.getAppPath()]);
76
+ }
77
+ }
78
+ function findTarget(argv) {
79
+ const link = URL.parse(argv.find((a) => a.startsWith(`${SCHEME}://`)) ?? "");
80
+ const id = link?.searchParams.get("id");
81
+ return id ? { id, date: link.searchParams.get("date") } : null;
82
+ }
16
83
  const millisecondsInMinute = 6e4;
17
84
  const millisecondsInHour = 36e5;
18
85
  const constructFromSymbol = /* @__PURE__ */ Symbol.for("constructDateFrom");
@@ -250,7 +317,9 @@ class SessionFile {
250
317
  constructor(filePath) {
251
318
  this.filePath = filePath;
252
319
  const parts = path.dirname(filePath).split(path.sep);
253
- this.parentSessionId = parts[parts.indexOf("subagents") - 1] ?? null;
320
+ const i = parts.indexOf("subagents");
321
+ this.parentSessionId = i > 0 ? parts[i - 1] : null;
322
+ this.parentFilePath = i > 0 ? parts.slice(0, i).join(path.sep) + ".jsonl" : null;
254
323
  const base = path.basename(filePath, ".jsonl");
255
324
  this.sessionId = base === "journal" ? `${parts.at(-1)}-journal` : base;
256
325
  }
@@ -296,37 +365,10 @@ const UPSTREAM = process.env.CLAUDE_DISCOVER_UPSTREAM || "https://api.anthropic.
296
365
  const EXIT_ROUTE = "/claude-discover/exit";
297
366
  const PING_ROUTE = "/claude-discover/ping";
298
367
  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
- }
368
+ const DATA_DIR = process.env.CLAUDE_DISCOVER_DIR || path.join(os.homedir(), ".claude-discover");
369
+ const REQUESTS_DIR = path.join(DATA_DIR, "requests");
370
+ const ERROR_LOG_PATH = path.join(DATA_DIR, "proxy.error.log");
371
+ const headline = (line) => line.length > 34 ? line.slice(0, 33).trimEnd() + "…" : line;
330
372
  const classifyRequest = (req, url) => {
331
373
  if (url?.includes("/count_tokens")) return ["count", "Token count"];
332
374
  if (!req) return null;
@@ -340,24 +382,80 @@ const classifyRequest = (req, url) => {
340
382
  const tail = (blocks?.findLast((b) => b?.type === "text")?.text ?? user).slice(0, 600);
341
383
  if (req.max_tokens === 1 || user.trim() === "quota") return ["quota", "Quota probe"];
342
384
  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"];
385
+ if (sysText.includes("Generate a concise, sentence-case title") || sysText.includes("You are naming a coding session")) return ["title", "Session title"];
344
386
  if (sysText.includes("performing a web search")) return ["web", "Web search"];
345
387
  if (user.startsWith("[SUGGESTION MODE")) return ["suggest", "Suggestions"];
346
388
  if (user.startsWith("Web page content")) return ["web", "Web fetch"];
347
389
  if (user.startsWith("Describe your most recent action")) return ["status", "Status blurb"];
348
390
  if (tail.includes("detailed summary of the conversation")) return ["compact", "Compact"];
349
391
  if (sysText.includes("built on Anthropic's Claude Agent SDK")) return ["agent", "Subagent"];
392
+ const tools = un(req.tools);
393
+ if (sysText && Array.isArray(tools) && !tools.length) {
394
+ const line = sysText.split("\n").map((l) => l.trim()).find((l) => l && !l.startsWith("x-anthropic-billing-header") && !l.startsWith("You are Claude Code"));
395
+ return ["aux", line ? headline(line) : "Side channel"];
396
+ }
350
397
  if (!sysText.includes("You are Claude Code")) return null;
351
398
  const continues = !last || blocks?.some((b) => b?.type === "tool_result");
352
399
  return continues ? ["tools", "Tool turn"] : ["main", "User message"];
353
400
  };
401
+ const REMINDER_LABELS = [
402
+ ["As you answer the user", "Session Context"],
403
+ ["# Environment", "Environment"],
404
+ ["Attribution for git commits", "Attribution"],
405
+ ["Available agent types", "Agent Types"],
406
+ ["The following skills are available", "Skills"],
407
+ ["# MCP Server Instructions", "MCP Instructions"],
408
+ ["You are powered by the model", "Model"],
409
+ ["Today's date", "Date"],
410
+ ["While auto mode is active", "Auto Mode"],
411
+ ["## Auto Mode Active", "Auto Mode"],
412
+ ["[SYSTEM NOTIFICATION", "Notification"],
413
+ ["Other agents active", "Agents"],
414
+ ["<total_tokens>", "Token Budget"]
415
+ ];
416
+ const reminderLabel = (text) => {
417
+ const line = text.split("\n").map((l) => l.trim()).find(Boolean) ?? "";
418
+ return REMINDER_LABELS.find(([open]) => line.startsWith(open))?.[1] ?? headline(line);
419
+ };
420
+ const DEFERRED_INTRO = /deferred tools are now available[^\n]*:\r?\n/;
421
+ const MEMORY_LABELS = {
422
+ "user's private global instructions for all projects": "User",
423
+ "project instructions, checked into the codebase": "Project",
424
+ "user's private project instructions, not checked in": "Local",
425
+ "user's auto-memory, persists across conversations": "Auto"
426
+ };
427
+ const MEMORY_FILES = { User: "CLAUDE.md", Project: "CLAUDE.md", Local: "CLAUDE.local.md", Auto: "MEMORY.md" };
428
+ function splitClaudeMd(text) {
429
+ const marker = text.match(/\n# claudeMd\r?\n|Codebase and user instructions are shown below\./);
430
+ if (!marker) return { files: [], rest: text };
431
+ const start = marker.index + marker[0].length;
432
+ 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);
433
+ const end = ends.length ? start + Math.min(...ends) : text.length;
434
+ const region = text.slice(start, end);
435
+ const heads = [...region.matchAll(/^Contents of (.+?) \(([^)]*)\):\r?\n/gm)];
436
+ const files = heads.map((h, i) => {
437
+ const memory_type = MEMORY_LABELS[h[2]] ?? h[2];
438
+ const base = h[1].split(/[\\/]/).pop();
439
+ return {
440
+ file_path: h[1],
441
+ name: base === MEMORY_FILES[memory_type] ? base : h[1],
442
+ memory_type,
443
+ content: region.slice(h.index + h[0].length, heads[i + 1]?.index ?? region.length).trim()
444
+ };
445
+ });
446
+ return { files, rest: (text.slice(0, marker.index) + text.slice(end)).trimEnd() };
447
+ }
448
+ const jsonBytes = (x) => x == null ? 0 : Buffer.byteLength(JSON.stringify(x));
449
+ const contentHash = (s) => crypto.createHash("sha256").update(s).digest("base64url").slice(0, 16);
450
+ const isMcp = (name) => name.startsWith("mcp__");
354
451
  const stripKind = (req) => {
355
452
  const kind = classifyRequest(req);
356
453
  return !kind || kind[0] === "main" || kind[0] === "tools" ? ["main", null] : kind;
357
454
  };
358
- const toStrip = (rec, file_path, label, content) => {
455
+ const toStrip = (rec, source, file_path, label, content) => {
359
456
  const [kind, kindLabel] = stripKind(rec.request);
360
457
  return {
458
+ source,
361
459
  file_path,
362
460
  memory_type: [kindLabel, label].filter(Boolean).join(", "),
363
461
  model: rec.request.model,
@@ -366,6 +464,8 @@ const toStrip = (rec, file_path, label, content) => {
366
464
  kind
367
465
  };
368
466
  };
467
+ const texts = (c) => (Array.isArray(c) ? c.map((p) => p?.text) : [c]).filter((t) => typeof t === "string");
468
+ const injectedTexts = (req) => (req?.messages || []).filter((m) => m?.role !== "assistant").flatMap((m) => texts(m.content));
369
469
  class RequestParser {
370
470
  constructor() {
371
471
  this.hashes = /* @__PURE__ */ new Map();
@@ -409,12 +509,13 @@ class RequestParser {
409
509
  return rec;
410
510
  }
411
511
  // 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.
512
+ // text blocks, minus any CLAUDE.md section (those files are strips of their own, see memoryFiles).
513
+ // The content hash is the dedup key: repeats collapse in the caller's map.
413
514
  systemPrompt(rec) {
414
515
  const sys = rec.request?.system;
415
516
  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);
517
+ const content = typeof sys === "string" ? splitClaudeMd(sys).rest : sys.map((b) => splitClaudeMd(b?.text ?? "").rest).join("\n\n");
518
+ return toStrip(rec, "system", "System Prompt", "", content);
418
519
  }
419
520
  // The deferred-tool roster Claude Code announces in the environment block of the first user
420
521
  // message: names it can call but whose schemas are left out of `tools` until ToolSearch pulls one
@@ -427,7 +528,7 @@ class RequestParser {
427
528
  for (const part of Array.isArray(content) ? content : [{ text: content }]) {
428
529
  const text = part?.text;
429
530
  if (typeof text !== "string") continue;
430
- const intro = text.match(/deferred tools are now available[^\n]*:\r?\n/);
531
+ const intro = text.match(DEFERRED_INTRO);
431
532
  if (!intro) continue;
432
533
  const names = [];
433
534
  for (const line of text.slice(intro.index + intro[0].length).split(/\r?\n/)) {
@@ -449,7 +550,7 @@ class RequestParser {
449
550
  const mcp = names.filter(isMcp).length;
450
551
  const count = (n, what) => n && `${n} deferred ${what} tool${n === 1 ? "" : "s"}`;
451
552
  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```");
553
+ return toStrip(rec, "message", "Deferred Tools", label, "```\n" + names.join("\n") + "\n```");
453
554
  }
454
555
  // The request's tool definitions, from a resolveRefs'd record, as a native and an MCP strip —
455
556
  // MCP schemas are often the bulk of a prompt, so they are worth costing on their own. The array
@@ -462,26 +563,36 @@ class RequestParser {
462
563
  for (const t of fresh) this.seenTools.add(t.name);
463
564
  const schema = (t) => t.input_schema ? "```json\n" + JSON.stringify(t.input_schema, null, 2) + "\n```" : "";
464
565
  const strip = (file_path, pick) => {
465
- const list = fresh.filter((t) => pick(t.name));
566
+ const list = fresh.filter((t) => pick(t.name)).map((t) => ({ name: t.name, content: [t.description || "", schema(t)].filter(Boolean).join("\n\n") }));
466
567
  if (!list.length) return null;
467
- 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);
568
+ const content = list.map((t) => [`## ${t.name}`, t.content].filter(Boolean).join("\n\n")).join("\n\n");
569
+ return { ...toStrip(rec, "tools", file_path, `${list.length} tool${list.length === 1 ? "" : "s"}`, content), tools: list };
469
570
  };
470
571
  return [strip("System Tools", (n) => !isMcp(n)), strip("MCP Tools", isMcp)].filter(Boolean);
471
572
  }
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.
573
+ // Memory files (CLAUDE.md / MEMORY.md / …) carried by a resolveRefs'd record — one
574
+ // { source, file_path, memory_type, content } per file listed. Claude Code ships them in a user
575
+ // message's system-reminder; an Agent SDK app can append the same section to the system prompt.
474
576
  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));
577
+ const req = rec.request;
578
+ const reminders = injectedTexts(req).filter((t) => t.includes("<system-reminder>"));
579
+ const from = (source, list) => list.flatMap((t) => splitClaudeMd(t).files).map((f) => ({ source, ...f }));
580
+ return [...from("system", texts(req?.system)), ...from("message", reminders)];
581
+ }
582
+ // Every other <system-reminder> the harness injected into the messages — session context, git
583
+ // status, environment, attribution rules, … — one strip each, minus what is already a strip of
584
+ // its own: the CLAUDE.md files (cut out, which empties the reminder that only wraps them) and
585
+ // the deferred-tool roster. Nothing the model was sent stays hidden.
586
+ reminderStrips(rec) {
587
+ const strips = [];
588
+ for (const text of injectedTexts(rec.request)) {
589
+ for (const [, inner] of text.matchAll(/<system-reminder>([\s\S]*?)<\/system-reminder>/g)) {
590
+ if (DEFERRED_INTRO.test(inner)) continue;
591
+ const content = splitClaudeMd(inner).rest.trim();
592
+ if (content) strips.push(toStrip(rec, "message", reminderLabel(content), "system-reminder", content));
482
593
  }
483
594
  }
484
- return files;
595
+ return strips;
485
596
  }
486
597
  }
487
598
  const agentKey = (id) => id?.includes("@") ? id.slice(0, id.indexOf("@")) : id?.replace(/^a/, "").replace(/-[0-9a-f]{16}$/, "") ?? null;
@@ -519,9 +630,10 @@ class RequestFile {
519
630
  return t >= range.start && t <= range.end;
520
631
  });
521
632
  }
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.
633
+ // System prompts, tool definitions, memory files (CLAUDE.md / MEMORY.md / … from a user
634
+ // message's instructions system-reminder) and every other injected system-reminder — none of
635
+ // which the transcript records, so the request log is the only source. Returns one record per
636
+ // unique prompt / tool batch / file_path / reminder.
525
637
  async readInstructions() {
526
638
  const parser = new RequestParser();
527
639
  const files = /* @__PURE__ */ new Map();
@@ -535,6 +647,9 @@ class RequestFile {
535
647
  for (const f of parser.memoryFiles(rec)) {
536
648
  if (!files.has(f.file_path)) files.set(f.file_path, record(f));
537
649
  }
650
+ for (const r of parser.reminderStrips(rec)) {
651
+ if (!files.has(r.hash)) files.set(r.hash, record(r));
652
+ }
538
653
  }
539
654
  return [...files.values()];
540
655
  }
@@ -568,11 +683,12 @@ function classifySource(meta) {
568
683
  if (meta.hasScheduledTask) return "scheduled";
569
684
  return (meta.entrypoint || "").toLowerCase() || "other";
570
685
  }
571
- function freshMeta({ sessionId, parentSessionId, filePath, fileSize, mtime }) {
686
+ function freshMeta({ sessionId, parentSessionId, filePath, parentFilePath, fileSize, mtime }) {
572
687
  return {
573
688
  sessionId,
574
689
  parentSessionId,
575
690
  filePath,
691
+ parentFilePath,
576
692
  fileSize,
577
693
  mtime,
578
694
  startedAt: null,
@@ -593,12 +709,14 @@ function freshMeta({ sessionId, parentSessionId, filePath, fileSize, mtime }) {
593
709
  aiTitle: null,
594
710
  customTitle: null,
595
711
  agentName: null,
712
+ tag: null,
596
713
  firstUserPrompt: null,
597
714
  firstUserCommand: null,
598
715
  forkedFrom: null,
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;
@@ -629,6 +747,7 @@ class SessionParser {
629
747
  if (t === "ai-title" && obj.aiTitle) meta.aiTitle = obj.aiTitle;
630
748
  if (t === "custom-title" && obj.customTitle) meta.customTitle = obj.customTitle;
631
749
  if (t === "agent-name" && obj.agentName) meta.agentName = obj.agentName;
750
+ if (t === "tag") meta.tag = obj.tag || null;
632
751
  if (t === "queue-operation" && obj.content?.includes("<scheduled-task")) meta.hasScheduledTask = true;
633
752
  if (t === "started" && obj.key && obj.agentId) meta.workflowAgents += 1;
634
753
  if (obj.cwd && !meta.project) meta.project = obj.cwd;
@@ -659,6 +778,7 @@ class SessionParser {
659
778
  meta.activityPeriods.push({ start: ts, end: ts });
660
779
  }
661
780
  if (t === "user") {
781
+ if (obj.turnCompanion && !obj.sourceToolUseID) meta.skillCalls += 1;
662
782
  this._feedUser(obj);
663
783
  } else if (t === "assistant") {
664
784
  const msg = obj.message;
@@ -668,7 +788,11 @@ class SessionParser {
668
788
  if (!meta.models.includes(msg.model)) meta.models.push(msg.model);
669
789
  }
670
790
  if (Array.isArray(msg.content)) {
671
- for (const b of msg.content) if (b?.type === "tool_use") meta.toolCalls += 1;
791
+ for (const b of msg.content) {
792
+ if (b?.type !== "tool_use") continue;
793
+ meta.toolCalls += 1;
794
+ if (b.name === "Skill") meta.skillCalls += 1;
795
+ }
672
796
  }
673
797
  this._recordUsage(obj);
674
798
  }
@@ -690,7 +814,7 @@ class SessionParser {
690
814
  obj.isMeta = true;
691
815
  } else if (text.startsWith("<scheduled-task")) {
692
816
  meta.hasScheduledTask = true;
693
- } else if (text) {
817
+ } else if (text && !obj.isMeta) {
694
818
  if (!meta.firstUserPrompt) meta.firstUserPrompt = text.slice(0, 300);
695
819
  }
696
820
  }
@@ -789,32 +913,6 @@ class SessionParser {
789
913
  return meta;
790
914
  }
791
915
  }
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
916
  class StatCache {
819
917
  constructor() {
820
918
  this.stats = /* @__PURE__ */ new Map();
@@ -846,25 +944,130 @@ class StatCache {
846
944
  onProgress?.({ done: 1, total: 1, scanning: false });
847
945
  }
848
946
  }
947
+ async function listFiles(dir, filter) {
948
+ const entries = await fsp.readdir(dir, { recursive: true, withFileTypes: true });
949
+ return entries.filter((e) => e.isFile() && filter(e.name)).map((e) => path.join(e.parentPath, e.name));
950
+ }
951
+ class FileWatcher {
952
+ #root;
953
+ #opts;
954
+ #watcher = null;
955
+ #closed = false;
956
+ constructor(root, opts) {
957
+ this.#root = root;
958
+ this.#opts = opts;
959
+ try {
960
+ this.#watcher = new EventFileWatcher(root, { ...opts, onFail: (err) => this.#fallBack(err) });
961
+ } catch (err) {
962
+ this.#fallBack(err);
963
+ }
964
+ }
965
+ get mode() {
966
+ return this.#watcher instanceof IntervalFileWatcher ? "interval" : "event";
967
+ }
968
+ // Its first diff against the baseline also catches whatever changed while events were down
969
+ #fallBack(err) {
970
+ if (this.#closed) return;
971
+ console.warn("[watch] checking on an interval instead:", err.message);
972
+ this.#watcher = new IntervalFileWatcher(this.#root, this.#opts);
973
+ }
974
+ close() {
975
+ this.#closed = true;
976
+ this.#watcher?.close();
977
+ }
978
+ }
979
+ class EventFileWatcher {
980
+ #opts;
981
+ #handle = null;
982
+ #timers = /* @__PURE__ */ new Map();
983
+ // filePath -> pending settle timer
984
+ #closed = false;
985
+ // Throws if the OS refuses the watch, e.g. on \\wsl.localhost\...
986
+ constructor(root, opts) {
987
+ this.#opts = { settleMs: 100, ...opts };
988
+ this.#handle = fs.watch(root, { recursive: true }, (_type, name) => {
989
+ if (!name) return this.#opts.onDrop();
990
+ if (this.#opts.filter(name)) this.#settle(path.join(root, name));
991
+ });
992
+ this.#handle.on("error", (err) => {
993
+ this.close();
994
+ this.#opts.onFail(err);
995
+ });
996
+ }
997
+ // Events carry no stat or kind, so stat each path once its writes settle
998
+ #settle(filePath) {
999
+ const { onChange, onUnlink, settleMs } = this.#opts;
1000
+ clearTimeout(this.#timers.get(filePath));
1001
+ this.#timers.set(filePath, setTimeout(async () => {
1002
+ this.#timers.delete(filePath);
1003
+ const stat = await fsp.stat(filePath).catch(() => null);
1004
+ if (this.#closed) return;
1005
+ if (!stat) onUnlink(filePath);
1006
+ else if (stat.isFile()) onChange(filePath, stat);
1007
+ }, settleMs));
1008
+ }
1009
+ close() {
1010
+ this.#closed = true;
1011
+ for (const timer of this.#timers.values()) {
1012
+ clearTimeout(timer);
1013
+ }
1014
+ this.#timers.clear();
1015
+ this.#handle?.close();
1016
+ }
1017
+ }
1018
+ class IntervalFileWatcher {
1019
+ #root;
1020
+ #opts;
1021
+ #timer = null;
1022
+ #checks = 0;
1023
+ #closed = false;
1024
+ // Stats on a share are the bulk of a check, so each one stats only new files and those written within hotMs;
1025
+ // every sweepEvery-th check stats them all, catching an idle transcript that grows again (e.g. a resumed session)
1026
+ constructor(root, opts) {
1027
+ this.#root = root;
1028
+ this.#opts = { intervalMs: 2e3, hotMs: 10 * 6e4, sweepEvery: 30, ...opts };
1029
+ this.#schedule();
1030
+ }
1031
+ // Timed from the end of the last check, so a slow share can't pile them up
1032
+ #schedule() {
1033
+ this.#timer = setTimeout(() => this.#check().finally(() => this.#closed || this.#schedule()), this.#opts.intervalMs);
1034
+ }
1035
+ async #check() {
1036
+ const { filter, onChange, onUnlink, baseline } = this.#opts;
1037
+ const known = baseline();
1038
+ if (!known) return;
1039
+ const files = await listFiles(this.#root, filter).catch(() => null);
1040
+ if (!files) return;
1041
+ const sweep = ++this.#checks % this.#opts.sweepEvery === 0;
1042
+ const now = Date.now();
1043
+ await Promise.all(files.map(async (fp) => {
1044
+ const old = known.get(fp);
1045
+ if (!sweep && old && now - old.mtimeMs >= this.#opts.hotMs) return;
1046
+ const stat = await fsp.stat(fp).catch(() => null);
1047
+ if (stat && !this.#closed && (!old || old.size !== stat.size || old.mtimeMs !== stat.mtimeMs)) onChange(fp, stat);
1048
+ }));
1049
+ if (this.#closed) return;
1050
+ const listed = new Set(files);
1051
+ for (const fp of known.keys()) {
1052
+ if (!listed.has(fp)) onUnlink(fp);
1053
+ }
1054
+ }
1055
+ close() {
1056
+ this.#closed = true;
1057
+ clearTimeout(this.#timer);
1058
+ }
1059
+ }
849
1060
  const isJsonl = (p) => p.endsWith(".jsonl");
850
1061
  class SessionsScanner {
851
1062
  constructor({ root = CLAUDE_PROJECTS_DIR } = {}) {
852
1063
  this.root = root;
853
1064
  this.watcher = null;
854
- this.watchWanted = false;
855
- this.walkDone = Promise.withResolvers();
856
1065
  this.statCache = new StatCache();
857
1066
  }
858
1067
  async _listDirs(p) {
859
1068
  const entries = await fsp.readdir(p, { withFileTypes: true }).catch(() => []);
860
1069
  return entries.filter((e) => e.isDirectory()).map((e) => path.join(e.parentPath, e.name));
861
1070
  }
862
- // Every .jsonl under `dir`, at any depth — subagent transcripts nest arbitrarily deep. Subtrees
863
- // can't be pruned by directory mtime: on Windows, appending to a transcript doesn't bump its parent dir.
864
- async _listJsonl(dir) {
865
- const entries = await fsp.readdir(dir, { recursive: true, withFileTypes: true }).catch(() => []);
866
- return entries.filter((e) => e.isFile() && isJsonl(e.name)).map((e) => path.join(e.parentPath, e.name));
867
- }
868
1071
  // Append-only transcripts span [birthtime, mtime], so files outside the period are skipped
869
1072
  // Note birthtime is trusted only when well older than mtime, since copies/restores (git checkout, scp, zip) get a fresh birthtime over old content.
870
1073
  _inPeriod(stat, day) {
@@ -876,14 +1079,10 @@ class SessionsScanner {
876
1079
  // `signal` (a superseded scan) stops everything; once complete with the watcher live, the
877
1080
  // StatCache mirrors disk and serves later scans in memory.
878
1081
  async scan(day, opts = {}) {
879
- if (this.statCache.complete && this.watcher) {
1082
+ if (this.statCache.complete) {
880
1083
  return this.statCache.scan((stat) => this._inPeriod(stat, day), opts);
881
1084
  }
882
- try {
883
- await this._walk(day, opts);
884
- } finally {
885
- this.walkDone.resolve();
886
- }
1085
+ return this._walk(day, opts);
887
1086
  }
888
1087
  async _walk(day, { onFile, onBatchDone, onProgress, signal } = {}) {
889
1088
  const projects = await this._listDirs(this.root);
@@ -892,7 +1091,8 @@ class SessionsScanner {
892
1091
  progress();
893
1092
  await Promise.all(projects.map(async (projDir) => {
894
1093
  if (signal?.aborted) return;
895
- const results = await Promise.all((await this._listJsonl(projDir)).map(async (fp) => {
1094
+ const files = await listFiles(projDir, isJsonl).catch(() => []);
1095
+ const results = await Promise.all(files.map(async (fp) => {
896
1096
  const stat = signal?.aborted ? null : await fsp.stat(fp).catch(() => null);
897
1097
  this.statCache.record(fp, stat);
898
1098
  return stat && this._inPeriod(stat, day) && !signal?.aborted ? onFile?.(fp, stat) : null;
@@ -907,38 +1107,28 @@ class SessionsScanner {
907
1107
  if (onBatchDone) await onBatchDone();
908
1108
  progress();
909
1109
  }
910
- // Starts chokidar once a scan has finished, and resolves when it's live. Its setup walks the whole
911
- // projects dir to register watchers, and on a cold disk those stats are the bulk of startup, so it
912
- // waits instead of racing our walk. The gap is self-healing: `ignoreInitial` skips files present at
913
- // startup anyway, and a session still being written emits on its next append.
914
- async watch({ onChange, onUnlink } = {}) {
915
- this.watchWanted = true;
916
- await this.walkDone.promise;
917
- if (!this.watchWanted) return;
918
- const isUNC = this.root.startsWith("\\\\") || this.root.startsWith("//");
919
- this.watcher = chokidar.watch(this.root, {
920
- ignoreInitial: true,
921
- alwaysStat: true,
922
- awaitWriteFinish: { stabilityThreshold: 400, pollInterval: 100 },
923
- ignored: (p, stats) => !!stats?.isFile() && !isJsonl(p),
924
- ...isUNC && { usePolling: true, interval: 1e3 }
1110
+ // Starts before the first walk, so that walk already completes the StatCache
1111
+ watch({ onChange, onUnlink }) {
1112
+ try {
1113
+ fs.mkdirSync(this.root, { recursive: true });
1114
+ } catch {
1115
+ }
1116
+ this.watcher = new FileWatcher(this.root, {
1117
+ filter: isJsonl,
1118
+ onChange: (p, stat) => {
1119
+ this.statCache.record(p, stat);
1120
+ onChange(p, stat);
1121
+ },
1122
+ onUnlink: (p) => {
1123
+ this.statCache.remove(p);
1124
+ onUnlink(p);
1125
+ },
1126
+ onDrop: () => this.statCache.clear(),
1127
+ baseline: () => this.statCache.complete ? this.statCache.stats : null
1128
+ // null until a full walk exists to diff against
925
1129
  });
926
- const changed = (p, stat) => {
927
- if (!isJsonl(p)) return;
928
- this.statCache.record(p, stat);
929
- onChange?.(p, stat);
930
- };
931
- const removed = (p) => {
932
- if (!isJsonl(p)) return;
933
- this.statCache.remove(p);
934
- onUnlink?.(p);
935
- };
936
- this.watcher.on("add", changed);
937
- this.watcher.on("change", changed);
938
- this.watcher.on("unlink", removed);
939
1130
  }
940
1131
  stop() {
941
- this.watchWanted = false;
942
1132
  if (this.watcher) {
943
1133
  this.watcher.close();
944
1134
  this.watcher = null;
@@ -961,9 +1151,6 @@ class MetaCache {
961
1151
  get(filePath) {
962
1152
  return this.periodMetas.get(filePath);
963
1153
  }
964
- byId(sessionId) {
965
- return [...this.periodMetas.values()].find((m) => m.sessionId === sessionId);
966
- }
967
1154
  values() {
968
1155
  return this.periodMetas.values();
969
1156
  }
@@ -1024,7 +1211,7 @@ const seed = {
1024
1211
  "claude-3-sonnet": { "input": 3, "output": 15, "cacheRead": 0.3, "cacheWrite": 3.75 },
1025
1212
  "claude-3-haiku": { "input": 0.25, "output": 1.25, "cacheRead": 0.03, "cacheWrite": 0.3, "cacheWrite1h": 0.5 }
1026
1213
  };
1027
- const CURRENT_PATH = path.join(DATA_DIR, "prices.current.json");
1214
+ const CURRENT_PATH = path.join(DATA_DIR$1, "prices.current.json");
1028
1215
  const LITELLM_URL = "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json";
1029
1216
  const DAY_MS = 24 * 3600 * 1e3;
1030
1217
  const MILLION = 1e6;
@@ -1113,7 +1300,7 @@ class Pricing extends EventEmitter {
1113
1300
  if (typeof fast === "number") out["claude-" + name].fastModeMplr = fast;
1114
1301
  }
1115
1302
  if (Object.keys(out).length) {
1116
- fs.mkdirSync(DATA_DIR, { recursive: true });
1303
+ fs.mkdirSync(DATA_DIR$1, { recursive: true });
1117
1304
  fs.writeFileSync(CURRENT_PATH, JSON.stringify(out, null, 2) + "\n");
1118
1305
  const before = JSON.stringify(this.#prices);
1119
1306
  this.load();
@@ -1150,8 +1337,10 @@ class SessionsService extends EventEmitter {
1150
1337
  }
1151
1338
  // Always parses the whole file from 0: the parser's running token totals only stamp
1152
1339
  // 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);
1340
+ // Keyed by file path, not session id: Claude Code reuses one id across cwds, writing a
1341
+ // separate transcript per project dir.
1342
+ async readSession(filePath, date = null, granularity = "day") {
1343
+ const meta = this.metaCache.get(filePath);
1155
1344
  if (!meta) return null;
1156
1345
  const range = date ? periodBounds(date, granularity) : null;
1157
1346
  const reader = new SessionFile(meta.filePath);
@@ -1166,10 +1355,11 @@ class SessionsService extends EventEmitter {
1166
1355
  return { items, instructions };
1167
1356
  }
1168
1357
  // 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 [];
1358
+ async readRequests(filePath, date = null, granularity = "day") {
1359
+ const meta = this.metaCache.get(filePath);
1360
+ if (!meta || !/^[\w-]+$/.test(meta.sessionId)) return [];
1171
1361
  const range = date ? periodBounds(date, granularity) : null;
1172
- return this.#requestFile(sessionId, this.metaCache.byId(sessionId)?.parentSessionId).read(range);
1362
+ return this.#requestFile(meta.sessionId, meta.parentSessionId).read(range);
1173
1363
  }
1174
1364
  // Subagents send the parent's session id, so the proxy logs their requests into the parent's
1175
1365
  // file, tagged x-claude-code-agent-id — route their reads there. The transcript filename is
@@ -1233,6 +1423,7 @@ class SessionsService extends EventEmitter {
1233
1423
  sessionId: reader.sessionId,
1234
1424
  parentSessionId: reader.parentSessionId,
1235
1425
  filePath: reader.filePath,
1426
+ parentFilePath: reader.parentFilePath,
1236
1427
  fileSize: stat.size,
1237
1428
  mtime: stat.mtimeMs,
1238
1429
  range,
@@ -1255,10 +1446,10 @@ class SessionsService extends EventEmitter {
1255
1446
  if (globalSeen.has(id)) (overlap ||= /* @__PURE__ */ new Set()).add(id);
1256
1447
  globalSeen.add(id);
1257
1448
  }
1258
- if (overlap) rescans.set(m.sessionId, this._scanSession(new SessionFile(m.filePath), { range, excludeIds: overlap }));
1449
+ if (overlap) rescans.set(m.filePath, this._scanSession(new SessionFile(m.filePath), { range, excludeIds: overlap }));
1259
1450
  }
1260
1451
  const sessions = await Promise.all(metas.map(async (m) => {
1261
- const rescanned = await rescans.get(m.sessionId);
1452
+ const rescanned = await rescans.get(m.filePath);
1262
1453
  return stripInternal(rescanned ? { ...m, ...rescanned } : m);
1263
1454
  }));
1264
1455
  const labels = suffixLabels(sessions.map((m) => m.project));
@@ -1347,265 +1538,50 @@ class AgentRunner {
1347
1538
  return { code: 0 };
1348
1539
  }
1349
1540
  }
1350
- const IS_DEV = !!process.env.ELECTRON_RENDERER_URL;
1351
- class ClaudeDirSwitch {
1352
- status() {
1353
- return { dir: CLAUDE_DIR };
1541
+ const mtimeOf = (path2) => fs.statSync(path2, { throwIfNoEntry: false })?.mtimeMs;
1542
+ class ClaudeSettings {
1543
+ constructor() {
1544
+ this.cfg = {};
1545
+ this.mtime = mtimeOf(CLAUDE_SETTINGS);
1546
+ if (this.mtime !== void 0) {
1547
+ try {
1548
+ this.cfg = JSON.parse(fs.readFileSync(CLAUDE_SETTINGS, "utf8"));
1549
+ } catch (err) {
1550
+ console.warn(`Failed to read ${CLAUDE_SETTINGS}: ${err.message}`);
1551
+ this.loadFailed = true;
1552
+ }
1553
+ }
1354
1554
  }
1355
- // "Change directory" — browse for a new dir; selecting one restarts the app on it.
1356
- activate() {
1357
- return browseForClaudeDir(BrowserWindow.getFocusedWindow());
1555
+ // Removes every hook (any event) whose command matches `pattern`, dropping groups and events
1556
+ // left empty. Returns the removed commands.
1557
+ removeHooks(pattern) {
1558
+ const matches = (h) => pattern.test(h.command ?? "");
1559
+ const removed = [];
1560
+ for (const [event, groups] of Object.entries(this.cfg.hooks ?? {})) {
1561
+ const hit = groups.flatMap((g) => (g.hooks ?? []).filter(matches).map((h) => h.command));
1562
+ if (!hit.length) continue;
1563
+ removed.push(...hit);
1564
+ for (const g of groups) g.hooks = (g.hooks ?? []).filter((h) => !matches(h));
1565
+ this.cfg.hooks[event] = groups.filter((g) => g.hooks.length);
1566
+ if (!this.cfg.hooks[event].length) delete this.cfg.hooks[event];
1567
+ }
1568
+ return removed;
1358
1569
  }
1359
- deactivate() {
1360
- throw new Error("Nothing to deactivate");
1570
+ // Days Claude Code keeps transcripts before auto-deleting them; undefined when unset (its default is 30).
1571
+ get cleanupPeriodDays() {
1572
+ return this.cfg.cleanupPeriodDays;
1361
1573
  }
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]);
1574
+ set cleanupPeriodDays(days) {
1575
+ if (days === void 0) delete this.cfg.cleanupPeriodDays;
1576
+ else this.cfg.cleanupPeriodDays = days;
1371
1577
  }
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);
1578
+ // Claude Code's status line config: { type: 'command', command }; undefined when unset.
1579
+ get statusLine() {
1580
+ return this.cfg.statusLine;
1383
1581
  }
1384
- }
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
- ]);
1424
- }
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;
1430
- 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;
1582
+ set statusLine(value) {
1583
+ if (value === void 0) delete this.cfg.statusLine;
1584
+ else this.cfg.statusLine = value;
1609
1585
  }
1610
1586
  // Environment variables Claude Code applies to every session; undefined when unset.
1611
1587
  get env() {
@@ -1884,22 +1860,51 @@ class RetentionSwitch {
1884
1860
  }
1885
1861
  }
1886
1862
  }
1863
+ class ClaudeDirSwitch {
1864
+ constructor({ restart }) {
1865
+ this.restart = restart;
1866
+ }
1867
+ status() {
1868
+ return { dir: CLAUDE_DIR };
1869
+ }
1870
+ // Switch to `claudeDir`, or browse for one when none is given; the app restarts on it
1871
+ async activate(claudeDir) {
1872
+ claudeDir ??= await browseForClaudeDir();
1873
+ if (!claudeDir || claudeDir === CLAUDE_DIR) return;
1874
+ config.save({ claudeDir, recents: [.../* @__PURE__ */ new Set([...RECENT_CLAUDE_DIRS, claudeDir])] });
1875
+ this.restart();
1876
+ }
1877
+ deactivate() {
1878
+ throw new Error("Nothing to deactivate");
1879
+ }
1880
+ }
1881
+ async function browseForClaudeDir() {
1882
+ const r = await dialog.showOpenDialog(BrowserWindow.getFocusedWindow(), {
1883
+ title: "Select Claude directory",
1884
+ defaultPath: CLAUDE_DIR,
1885
+ properties: ["openDirectory"]
1886
+ });
1887
+ return r.canceled ? null : r.filePaths[0];
1888
+ }
1887
1889
  class Switchers {
1888
- #switches = {
1889
- proxy: new ProxySwitch(),
1890
- statusline: new StatuslineSwitch(),
1891
- retention: new RetentionSwitch(),
1892
- claudedir: new ClaudeDirSwitch()
1893
- };
1890
+ #switches;
1894
1891
  #keepActive = { proxy: true, statusline: true };
1895
1892
  // toggle feature persisting on app quit — starts on, so a switch this run didn't activate is never undone by it
1893
+ constructor({ restart }) {
1894
+ this.#switches = {
1895
+ proxy: new ProxySwitch(),
1896
+ statusline: new StatuslineSwitch(),
1897
+ retention: new RetentionSwitch(),
1898
+ claudedir: new ClaudeDirSwitch({ restart })
1899
+ };
1900
+ }
1896
1901
  async status(name) {
1897
1902
  const s = await this.#get(name).status();
1898
1903
  return name in this.#keepActive ? { ...s, keepActive: this.#keepActive[name], ephemeral: IS_EPHEMERAL } : s;
1899
1904
  }
1900
- activate(name) {
1905
+ activate(name, ...args) {
1901
1906
  return this.#attempt(name, async (s) => {
1902
- await s.activate();
1907
+ await s.activate(...args);
1903
1908
  this.#keepActive[name] &&= !IS_EPHEMERAL;
1904
1909
  });
1905
1910
  }
@@ -1931,95 +1936,260 @@ class Switchers {
1931
1936
  }
1932
1937
  }
1933
1938
  }
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]));
1939
+ const VIEWABLE_EXT = /* @__PURE__ */ new Set([".md", ".txt", ".json", ".jsonl", ".log", ".csv", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".pdf"]);
1940
+ async function openLinkSafely(href, baseFile) {
1941
+ if (/^(https?:|mailto:)/i.test(href)) return shell.openExternal(href);
1942
+ if (!baseFile) return;
1943
+ let decoded;
1944
+ try {
1945
+ decoded = decodeURIComponent(href);
1946
+ } catch {
1947
+ return;
1948
+ }
1949
+ const baseDir = path.dirname(path.resolve(baseFile));
1950
+ const abs = path.resolve(baseDir, decoded);
1951
+ const rel = path.relative(baseDir, abs);
1952
+ if (rel.startsWith("..") || path.isAbsolute(rel)) return;
1953
+ if (!VIEWABLE_EXT.has(path.extname(abs).toLowerCase())) return shell.showItemInFolder(abs);
1954
+ const err = await shell.openPath(abs);
1955
+ if (err) shell.showItemInFolder(abs);
1956
+ return err;
1957
+ }
1958
+ const DEV_ORIGIN = process.env.ELECTRON_RENDERER_URL && new URL(process.env.ELECTRON_RENDERER_URL).origin;
1959
+ const APP_PAGES = pathToFileURL(path.join(import.meta.dirname, "../renderer/")).href;
1960
+ function lockNavigation(contents) {
1961
+ const toBrowser = (url) => /^(https?|mailto):/i.test(url) && shell.openExternal(url);
1962
+ const block = (e, url) => {
1963
+ const { href, origin } = new URL(url);
1964
+ if (href.startsWith(APP_PAGES) || origin === DEV_ORIGIN) return;
1965
+ e.preventDefault();
1966
+ toBrowser(url);
1967
+ };
1968
+ contents.setWindowOpenHandler(({ url }) => {
1969
+ toBrowser(url);
1970
+ return { action: "deny" };
1971
+ });
1972
+ contents.on("will-navigate", block);
1973
+ contents.on("will-redirect", block);
1974
+ }
1975
+ const __dirname$2 = path.dirname(fileURLToPath(import.meta.url));
1976
+ const DEV_URL$1 = process.env.ELECTRON_RENDERER_URL;
1977
+ const WIDTH = 320, HEIGHT = 56, MARGIN = 6;
1978
+ class FindBar {
1979
+ constructor(win) {
1980
+ this.win = win;
1981
+ this.view = new WebContentsView({
1982
+ webPreferences: {
1983
+ preload: path.join(__dirname$2, "../preload/findPreload.cjs"),
1984
+ contextIsolation: true,
1985
+ nodeIntegration: false,
1986
+ sandbox: true
1987
+ }
1988
+ });
1989
+ this.view.setBackgroundColor("#00000000");
1990
+ lockNavigation(this.view.webContents);
1991
+ win.contentView.addChildView(this.view);
1992
+ this.view.setVisible(false);
1993
+ if (DEV_URL$1) this.view.webContents.loadURL(`${DEV_URL$1}/find/find.html`);
1994
+ else this.view.webContents.loadFile(path.join(__dirname$2, "../renderer/find/find.html"));
1995
+ win.webContents.on("found-in-page", (_e, r) => {
1996
+ return this.view.webContents.send("find:result", { active: r.activeMatchOrdinal, total: r.matches, id: r.requestId });
1997
+ });
1998
+ win.on("resize", () => {
1999
+ if (this.visible) this.layout();
1947
2000
  });
1948
- this.#register();
1949
- this.#pending = findTarget(process.argv);
1950
- return true;
1951
2001
  }
1952
- takePending() {
1953
- this.#live = true;
1954
- const target = this.#pending;
1955
- this.#pending = null;
1956
- return target;
2002
+ get visible() {
2003
+ return this.view.getVisible();
1957
2004
  }
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;
2005
+ layout() {
2006
+ const [w] = this.win.getContentSize();
2007
+ this.view.setBounds({ x: Math.max(0, w - WIDTH - MARGIN), y: MARGIN, width: WIDTH, height: HEIGHT });
1964
2008
  }
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()]);
2009
+ query(text, options) {
2010
+ this.win.webContents.findInPage(text, options);
2011
+ }
2012
+ stop() {
2013
+ this.win.webContents.stopFindInPage("clearSelection");
2014
+ }
2015
+ show() {
2016
+ this.layout();
2017
+ this.view.setVisible(true);
2018
+ this.view.webContents.focus();
2019
+ this.view.webContents.send("find:open");
2020
+ this.win.webContents.send("find:active", true);
2021
+ }
2022
+ hide() {
2023
+ if (!this.visible) return;
2024
+ this.view.setVisible(false);
2025
+ this.stop();
2026
+ this.win.webContents.focus();
2027
+ this.win.webContents.send("find:active", false);
1970
2028
  }
1971
2029
  }
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;
2030
+ const __dirname$1 = path.dirname(fileURLToPath(import.meta.url));
2031
+ const DEV_URL = process.env.ELECTRON_RENDERER_URL;
2032
+ class MainWindow {
2033
+ constructor() {
2034
+ this.win = null;
2035
+ }
2036
+ create() {
2037
+ const state = windowStateKeeper({ defaultWidth: 1500, defaultHeight: 900 });
2038
+ this.win = new BrowserWindow({
2039
+ x: state.x,
2040
+ y: state.y,
2041
+ width: state.width,
2042
+ height: state.height,
2043
+ backgroundColor: "#0b0d12",
2044
+ autoHideMenuBar: true,
2045
+ title: `Claude Discover v${app.getVersion()}`,
2046
+ webPreferences: {
2047
+ preload: path.join(__dirname$1, "../preload/preload.cjs"),
2048
+ contextIsolation: true,
2049
+ nodeIntegration: false,
2050
+ sandbox: true
2051
+ }
2052
+ });
2053
+ state.manage(this.win);
2054
+ this.win.on("page-title-updated", (e) => e.preventDefault());
2055
+ lockNavigation(this.win.webContents);
2056
+ contextMenu({ window: this.win, showSelectAll: true, showCopyImage: true, showCopyLink: true, showInspectElement: true });
2057
+ if (DEV_URL) {
2058
+ this.win.loadURL(DEV_URL);
2059
+ } else {
2060
+ this.win.loadFile(path.join(__dirname$1, "../renderer/index.html"));
2061
+ }
2062
+ this.findBar = new FindBar(this.win);
2063
+ return this.win;
2064
+ }
2065
+ send(channel, payload) {
2066
+ if (this.win && !this.win.isDestroyed()) this.win.webContents.send(channel, payload);
2067
+ }
2068
+ focus() {
2069
+ if (!this.win || this.win.isDestroyed()) return;
2070
+ if (this.win.isMinimized()) this.win.restore();
2071
+ this.win.show();
2072
+ this.win.focus();
2073
+ }
1976
2074
  }
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();
2075
+ class Application {
2076
+ constructor({ deepLink }) {
2077
+ this.deepLink = deepLink;
2078
+ this.win = new MainWindow();
2079
+ this.agentRunner = new AgentRunner();
2080
+ this.workHours = new WorkHours();
2081
+ this.sessionsService = new SessionsService();
2082
+ this.switchers = new Switchers({ restart: () => this.restart() });
2083
+ }
2084
+ start() {
2085
+ const { deepLink, win, agentRunner, workHours, sessionsService, switchers } = this;
2086
+ Menu.setApplicationMenu(this.#buildMenu());
2087
+ sessionsService.on("update", (sessions) => win.send("sessions:update", sessions));
2088
+ sessionsService.on("progress", (p) => win.send("sessions:scan-progress", p));
2089
+ deepLink.on("open", (target) => {
2090
+ win.focus();
2091
+ win.send("deeplink:open-session", target);
2092
+ });
2093
+ ipcMain.handle("sessions:list", (_e, date, granularity) => sessionsService.list(date, granularity));
2094
+ ipcMain.handle("sessions:read", (_e, filePath, date, granularity) => sessionsService.readSession(filePath, date, granularity));
2095
+ ipcMain.handle("sessions:read-requests", (_e, filePath, date, granularity) => sessionsService.readRequests(filePath, date, granularity));
2096
+ ipcMain.handle("switch:status", (_e, name) => switchers.status(name));
2097
+ ipcMain.handle("switch:activate", (_e, name) => switchers.activate(name));
2098
+ ipcMain.handle("switch:deactivate", (_e, name) => switchers.deactivate(name));
2099
+ ipcMain.handle("switch:keep-active", (_e, name, value) => switchers.setKeepActive(name, value));
2100
+ ipcMain.handle("work-hours:get", () => workHours.read());
2101
+ ipcMain.handle("work-hours:set", (_e, data) => workHours.write(data));
2102
+ ipcMain.handle("agent:run", (e, text, systemTools, cache) => agentRunner.run(text, e.sender, systemTools, cache));
2103
+ ipcMain.handle("shell:open-link", (_e, href, baseFile) => openLinkSafely(href, baseFile));
2104
+ ipcMain.handle("deeplink:take-pending", () => deepLink.takePending());
2105
+ ipcMain.on("find:query", (_e, text, options) => win.findBar?.query(text, options));
2106
+ ipcMain.on("find:stop", () => win.findBar?.stop());
2107
+ ipcMain.on("find:close", () => win.findBar?.hide());
2108
+ ipcMain.on("claude-settings:get", (e) => e.returnValue = { claudeDir: CLAUDE_DIR });
2109
+ app.on("activate", () => {
2110
+ if (BrowserWindow.getAllWindows().length === 0) win.create();
2111
+ });
2112
+ app.on("window-all-closed", () => {
2113
+ if (process.platform !== "darwin") app.quit();
2114
+ });
2115
+ app.on("will-quit", async (e) => {
2116
+ e.preventDefault();
2117
+ await switchers.deactivateOnQuit();
2118
+ app.exit();
2119
+ });
2120
+ sessionsService.start();
2121
+ win.create();
2122
+ }
2123
+ // app.exit skips will-quit, so switches stay active across the restart
2124
+ restart() {
2125
+ {
2126
+ app.relaunch();
2127
+ }
2128
+ BrowserWindow.getAllWindows().forEach((w) => w.close());
2129
+ app.exit(0);
2130
+ }
2131
+ #buildMenu() {
2132
+ const { win, switchers } = this;
2133
+ const changeDir = (dir) => switchers.activate("claudedir", dir);
2134
+ return Menu.buildFromTemplate([
2135
+ ...process.platform === "darwin" ? [{ role: "appMenu" }] : [],
2136
+ // @macOS
2137
+ {
2138
+ label: "File",
2139
+ submenu: [
2140
+ { label: "Change directory…", click: () => changeDir() },
2141
+ { type: "separator" },
2142
+ ...RECENT_CLAUDE_DIRS.map((p) => ({
2143
+ label: p,
2144
+ type: "checkbox",
2145
+ checked: p === CLAUDE_DIR,
2146
+ click: () => changeDir(p)
2147
+ })),
2148
+ { type: "separator" },
2149
+ { role: "quit" }
2150
+ ]
2151
+ },
2152
+ {
2153
+ // @macOS Cmd+C/V/A have no key equivalents without an Edit menu; Windows/Linux get these from Chromium
2154
+ label: "Edit",
2155
+ submenu: [
2156
+ { role: "copy" },
2157
+ { role: "paste" },
2158
+ { role: "selectAll" },
2159
+ { label: "Find…", accelerator: "CmdOrCtrl+F", click: () => win.findBar?.show() },
2160
+ { label: "Deselect", accelerator: "Escape", click: (_i, w) => win.findBar?.visible ? win.findBar.hide() : w?.webContents.unselect() }
2161
+ ]
2162
+ },
2163
+ {
2164
+ label: "View",
2165
+ submenu: [
2166
+ { role: "reload" },
2167
+ { role: "forceReload" },
2168
+ { role: "toggleDevTools" }
2169
+ ]
2170
+ }
2171
+ ]);
2172
+ }
2173
+ }
2174
+ const isFirstInstance = app.requestSingleInstanceLock();
2175
+ if (isFirstInstance) {
2176
+ let application;
2177
+ const deepLink = new DeepLink();
2178
+ deepLink.activate();
2179
+ app.whenReady().then(() => {
2180
+ application = new Application({ deepLink });
2181
+ application.start();
1993
2182
  });
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();
2183
+ app.on("second-instance", (_e, argv) => {
2184
+ const target = findTarget(argv);
2185
+ if (argv.includes("--restart")) {
2186
+ application?.restart();
2187
+ } else if (target) {
2188
+ deepLink.open(target);
2189
+ } else {
2190
+ application?.win.focus();
2191
+ }
2011
2192
  });
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
2193
  } else {
2024
2194
  app.exit(0);
2025
2195
  }