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 +662 -492
- package/out/preload/preload.cjs +2 -2
- package/out/renderer/assets/{index-B1hu0QHl.css → index-DnYN00T3.css} +26 -4
- package/out/renderer/assets/{index-B4cvR_aa.js → index-x_-ZeUa3.js} +155 -84
- package/out/renderer/assets/{vendors-JM3FOKMR.js → vendors-D2DMOduT.js} +10 -9
- package/out/renderer/index.html +3 -3
- package/package.json +1 -3
package/out/main/main.js
CHANGED
|
@@ -1,18 +1,85 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
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
|
|
300
|
-
const REQUESTS_DIR = path.join(DATA_DIR
|
|
301
|
-
const ERROR_LOG_PATH = path.join(DATA_DIR
|
|
302
|
-
const
|
|
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
|
|
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(
|
|
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.
|
|
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
|
|
473
|
-
//
|
|
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
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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
|
|
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
|
|
523
|
-
// message's instructions system-reminder)
|
|
524
|
-
// log is the only source. Returns one record per
|
|
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)
|
|
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
|
|
1082
|
+
if (this.statCache.complete) {
|
|
880
1083
|
return this.statCache.scan((stat) => this._inPeriod(stat, day), opts);
|
|
881
1084
|
}
|
|
882
|
-
|
|
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
|
|
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
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
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
|
-
|
|
1154
|
-
|
|
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(
|
|
1170
|
-
|
|
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,
|
|
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.
|
|
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.
|
|
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
|
|
1351
|
-
class
|
|
1352
|
-
|
|
1353
|
-
|
|
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
|
-
//
|
|
1356
|
-
|
|
1357
|
-
|
|
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
|
-
|
|
1360
|
-
|
|
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
|
-
|
|
1364
|
-
|
|
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
|
-
|
|
1374
|
-
|
|
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
|
-
|
|
1386
|
-
|
|
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
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
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
|
-
|
|
1953
|
-
this
|
|
1954
|
-
const target = this.#pending;
|
|
1955
|
-
this.#pending = null;
|
|
1956
|
-
return target;
|
|
2002
|
+
get visible() {
|
|
2003
|
+
return this.view.getVisible();
|
|
1957
2004
|
}
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
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
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
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
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
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
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
win
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
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
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
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
|
}
|