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