claude-discover 1.9.2 → 1.9.4
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 +277 -100
- package/out/renderer/assets/{index-03LlBvFr.css → index-B1hu0QHl.css} +15 -50
- package/out/renderer/assets/{index-BugPzqD2.js → index-B4cvR_aa.js} +26 -30
- package/out/renderer/assets/{vendors-S1fS5xz9.js → vendors-JM3FOKMR.js} +3173 -1199
- package/out/renderer/index.html +3 -3
- package/package.json +11 -10
- package/bin/claude/hooks.mjs +0 -102
package/out/main/main.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { BrowserWindow, dialog, app, Menu, shell, WebContentsView,
|
|
1
|
+
import { BrowserWindow, dialog, app, Menu, shell, WebContentsView, ipcMain } from "electron";
|
|
2
2
|
import { EventEmitter } from "node:events";
|
|
3
3
|
import fs from "node:fs";
|
|
4
4
|
import fsp from "node:fs/promises";
|
|
@@ -10,6 +10,9 @@ import { sync } from "write-file-atomic";
|
|
|
10
10
|
import { pathToFileURL, fileURLToPath } from "node:url";
|
|
11
11
|
import windowStateKeeper from "electron-window-state";
|
|
12
12
|
import contextMenu from "electron-context-menu";
|
|
13
|
+
import which from "which";
|
|
14
|
+
import { execFile } from "node:child_process";
|
|
15
|
+
import { promisify } from "node:util";
|
|
13
16
|
const millisecondsInMinute = 6e4;
|
|
14
17
|
const millisecondsInHour = 36e5;
|
|
15
18
|
const constructFromSymbol = /* @__PURE__ */ Symbol.for("constructDateFrom");
|
|
@@ -289,7 +292,7 @@ class SessionFile {
|
|
|
289
292
|
const HOST = "127.0.0.1";
|
|
290
293
|
const PORT = process.env.CLAUDE_DISCOVER_PORT || 41414;
|
|
291
294
|
const PROXY_URL = `http://${HOST}:${PORT}`;
|
|
292
|
-
process.env.CLAUDE_DISCOVER_UPSTREAM || "https://api.anthropic.com";
|
|
295
|
+
const UPSTREAM = process.env.CLAUDE_DISCOVER_UPSTREAM || "https://api.anthropic.com";
|
|
293
296
|
const EXIT_ROUTE = "/claude-discover/exit";
|
|
294
297
|
const PING_ROUTE = "/claude-discover/ping";
|
|
295
298
|
const PING_RESPONSE = "claude-discover-proxy";
|
|
@@ -298,6 +301,7 @@ const REQUESTS_DIR = path.join(DATA_DIR$1, "requests");
|
|
|
298
301
|
const ERROR_LOG_PATH = path.join(DATA_DIR$1, "proxy.error.log");
|
|
299
302
|
const jsonBytes = (x) => x == null ? 0 : Buffer.byteLength(JSON.stringify(x));
|
|
300
303
|
const contentHash = (s) => crypto.createHash("sha256").update(s).digest("base64url").slice(0, 16);
|
|
304
|
+
const isMcp = (name) => name.startsWith("mcp__");
|
|
301
305
|
const MEMORY_LABELS = {
|
|
302
306
|
"user's private global instructions for all projects": "User",
|
|
303
307
|
"project instructions, checked into the codebase": "Project",
|
|
@@ -306,10 +310,9 @@ const MEMORY_LABELS = {
|
|
|
306
310
|
};
|
|
307
311
|
const MEMORY_FILES = { User: "CLAUDE.md", Project: "CLAUDE.md", Local: "CLAUDE.local.md", Auto: "MEMORY.md" };
|
|
308
312
|
function parseClaudeMd(text) {
|
|
309
|
-
const marker =
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
let region = text.slice(start2 + marker.length);
|
|
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);
|
|
313
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);
|
|
314
317
|
if (ends.length) region = region.slice(0, Math.min(...ends));
|
|
315
318
|
const heads = [...region.matchAll(/^Contents of (.+?) \(([^)]*)\):\r?\n/gm)];
|
|
@@ -352,10 +355,22 @@ const stripKind = (req) => {
|
|
|
352
355
|
const kind = classifyRequest(req);
|
|
353
356
|
return !kind || kind[0] === "main" || kind[0] === "tools" ? ["main", null] : kind;
|
|
354
357
|
};
|
|
358
|
+
const toStrip = (rec, file_path, label, content) => {
|
|
359
|
+
const [kind, kindLabel] = stripKind(rec.request);
|
|
360
|
+
return {
|
|
361
|
+
file_path,
|
|
362
|
+
memory_type: [kindLabel, label].filter(Boolean).join(", "),
|
|
363
|
+
model: rec.request.model,
|
|
364
|
+
content,
|
|
365
|
+
hash: contentHash(content),
|
|
366
|
+
kind
|
|
367
|
+
};
|
|
368
|
+
};
|
|
355
369
|
class RequestParser {
|
|
356
370
|
constructor() {
|
|
357
371
|
this.hashes = /* @__PURE__ */ new Map();
|
|
358
372
|
this.seenTools = /* @__PURE__ */ new Set();
|
|
373
|
+
this.deferred = null;
|
|
359
374
|
}
|
|
360
375
|
// Resolves $hash/$ref dedup wrappers at any depth — children first, so stored and returned
|
|
361
376
|
// values are always fully resolved. Feed records in file order. When `seen` is given, the
|
|
@@ -399,26 +414,63 @@ class RequestParser {
|
|
|
399
414
|
const sys = rec.request?.system;
|
|
400
415
|
if (sys == null || sys.$ref) return null;
|
|
401
416
|
const content = typeof sys === "string" ? sys : sys.map((b) => b?.text ?? "").join("\n\n");
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
//
|
|
406
|
-
//
|
|
407
|
-
//
|
|
417
|
+
return toStrip(rec, "System Prompt", "", content);
|
|
418
|
+
}
|
|
419
|
+
// The deferred-tool roster Claude Code announces in the environment block of the first user
|
|
420
|
+
// message: names it can call but whose schemas are left out of `tools` until ToolSearch pulls one
|
|
421
|
+
// in. The list never appears in `tools`, so it is the only record of what a session declared but
|
|
422
|
+
// never paid for. One roster per session — cached on first sight.
|
|
423
|
+
deferredTools(rec) {
|
|
424
|
+
if (this.deferred) return this.deferred;
|
|
425
|
+
for (const m of rec.request?.messages || []) {
|
|
426
|
+
const content = m?.content;
|
|
427
|
+
for (const part of Array.isArray(content) ? content : [{ text: content }]) {
|
|
428
|
+
const text = part?.text;
|
|
429
|
+
if (typeof text !== "string") continue;
|
|
430
|
+
const intro = text.match(/deferred tools are now available[^\n]*:\r?\n/);
|
|
431
|
+
if (!intro) continue;
|
|
432
|
+
const names = [];
|
|
433
|
+
for (const line of text.slice(intro.index + intro[0].length).split(/\r?\n/)) {
|
|
434
|
+
if (!/^[A-Za-z_]\w*$/.test(line)) break;
|
|
435
|
+
names.push(line);
|
|
436
|
+
}
|
|
437
|
+
return this.deferred = names;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
return [];
|
|
441
|
+
}
|
|
442
|
+
// The roster as a strip of its own. Names only — a deferred tool has no schema in the request, so
|
|
443
|
+
// this costs what the environment block's list costs, not what the tools would. Kept in announced
|
|
444
|
+
// order, which already runs native names before MCP ones, and fenced so markdown keeps one name
|
|
445
|
+
// per line instead of reflowing them all into one paragraph.
|
|
446
|
+
deferredStrip(rec) {
|
|
447
|
+
const names = this.deferredTools(rec);
|
|
448
|
+
if (!names.length) return null;
|
|
449
|
+
const mcp = names.filter(isMcp).length;
|
|
450
|
+
const count = (n, what) => n && `${n} deferred ${what} tool${n === 1 ? "" : "s"}`;
|
|
451
|
+
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```");
|
|
453
|
+
}
|
|
454
|
+
// The request's tool definitions, from a resolveRefs'd record, as a native and an MCP strip —
|
|
455
|
+
// MCP schemas are often the bulk of a prompt, so they are worth costing on their own. The array
|
|
456
|
+
// grows as deferred tools load mid-session, so only tools this parser hasn't seen yet are
|
|
457
|
+
// returned — the first request yields the full set, later arrays just the additions.
|
|
408
458
|
systemTools(rec) {
|
|
409
459
|
const tools = rec.request?.tools;
|
|
410
|
-
if (!Array.isArray(tools)) return
|
|
460
|
+
if (!Array.isArray(tools)) return [];
|
|
411
461
|
const fresh = tools.filter((t) => t?.name && !this.seenTools.has(t.name));
|
|
412
|
-
if (!fresh.length) return null;
|
|
413
462
|
for (const t of fresh) this.seenTools.add(t.name);
|
|
414
463
|
const schema = (t) => t.input_schema ? "```json\n" + JSON.stringify(t.input_schema, null, 2) + "\n```" : "";
|
|
415
|
-
const
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
464
|
+
const strip = (file_path, pick) => {
|
|
465
|
+
const list = fresh.filter((t) => pick(t.name));
|
|
466
|
+
if (!list.length) return null;
|
|
467
|
+
const content = list.map((t) => [`## ${t.name}`, t.description || "", schema(t)].filter(Boolean).join("\n\n")).join("\n\n");
|
|
468
|
+
return toStrip(rec, file_path, `${list.length} tool${list.length === 1 ? "" : "s"}`, content);
|
|
469
|
+
};
|
|
470
|
+
return [strip("System Tools", (n) => !isMcp(n)), strip("MCP Tools", isMcp)].filter(Boolean);
|
|
419
471
|
}
|
|
420
472
|
// Memory files (CLAUDE.md / MEMORY.md / …) carried by a resolveRefs'd record's messages inside
|
|
421
|
-
//
|
|
473
|
+
// system-reminders — one { file_path, memory_type, content } per file listed.
|
|
422
474
|
memoryFiles(rec) {
|
|
423
475
|
const files = [];
|
|
424
476
|
for (const m of rec.request?.messages || []) {
|
|
@@ -467,12 +519,9 @@ class RequestFile {
|
|
|
467
519
|
return t >= range.start && t <= range.end;
|
|
468
520
|
});
|
|
469
521
|
}
|
|
470
|
-
// System prompts
|
|
471
|
-
//
|
|
472
|
-
//
|
|
473
|
-
// Works on read()'s output, so dedup wrappers and the agent scope are already handled.
|
|
474
|
-
// Returns one record per unique system prompt / tool batch / file_path; readSession ships them
|
|
475
|
-
// alongside the transcript items.
|
|
522
|
+
// System prompts, tool definitions and memory files (CLAUDE.md / MEMORY.md / … from a user
|
|
523
|
+
// message's instructions system-reminder) — none of which the transcript records, so the request
|
|
524
|
+
// log is the only source. Returns one record per unique prompt / tool batch / file_path.
|
|
476
525
|
async readInstructions() {
|
|
477
526
|
const parser = new RequestParser();
|
|
478
527
|
const files = /* @__PURE__ */ new Map();
|
|
@@ -480,8 +529,9 @@ class RequestFile {
|
|
|
480
529
|
const record = (f) => ({ timestamp: rec.timestamp, ...f });
|
|
481
530
|
const sys = parser.systemPrompt(rec);
|
|
482
531
|
if (sys && !files.has(sys.hash)) files.set(sys.hash, record(sys));
|
|
483
|
-
const
|
|
484
|
-
if (
|
|
532
|
+
const deferred = parser.deferredStrip(rec);
|
|
533
|
+
if (deferred && !files.has(deferred.hash)) files.set(deferred.hash, record(deferred));
|
|
534
|
+
for (const tools of parser.systemTools(rec)) files.set(tools.hash, record(tools));
|
|
485
535
|
for (const f of parser.memoryFiles(rec)) {
|
|
486
536
|
if (!files.has(f.file_path)) files.set(f.file_path, record(f));
|
|
487
537
|
}
|
|
@@ -656,13 +706,9 @@ class SessionParser {
|
|
|
656
706
|
obj._tokenDelta = (obj._tokenDelta || 0) + output;
|
|
657
707
|
obj._tokenTotal = this.tokenTotal;
|
|
658
708
|
}
|
|
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.
|
|
709
|
+
// One assistant reply spans several jsonl lines sharing message.id, with output_tokens growing
|
|
710
|
+
// toward its final value. Counting each line's *growth* lands the totals on that final value —
|
|
711
|
+
// what ccusage bills. Ids in `excludeIds` (already counted in a resumed-from session) never aggregate.
|
|
666
712
|
_recordUsage(obj) {
|
|
667
713
|
const meta = this.meta;
|
|
668
714
|
const msg = obj.message;
|
|
@@ -769,7 +815,6 @@ const CLAUDE_SETTINGS = join(CLAUDE_DIR, "settings.json");
|
|
|
769
815
|
const IS_EPHEMERAL = import.meta.dirname.split(/[\\/]/).includes("_npx");
|
|
770
816
|
const STATUSLINE_PATH = join(import.meta.dirname, "../../bin/claude/statusline.mjs");
|
|
771
817
|
const PROXY_PATH = join(import.meta.dirname, "../../bin/proxy.mjs");
|
|
772
|
-
const CLAUDE_HOOKS_PATH = join(import.meta.dirname, "../../bin/claude/hooks.mjs");
|
|
773
818
|
class StatCache {
|
|
774
819
|
constructor() {
|
|
775
820
|
this.stats = /* @__PURE__ */ new Map();
|
|
@@ -814,10 +859,8 @@ class SessionsScanner {
|
|
|
814
859
|
const entries = await fsp.readdir(p, { withFileTypes: true }).catch(() => []);
|
|
815
860
|
return entries.filter((e) => e.isDirectory()).map((e) => path.join(e.parentPath, e.name));
|
|
816
861
|
}
|
|
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.
|
|
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.
|
|
821
864
|
async _listJsonl(dir) {
|
|
822
865
|
const entries = await fsp.readdir(dir, { recursive: true, withFileTypes: true }).catch(() => []);
|
|
823
866
|
return entries.filter((e) => e.isFile() && isJsonl(e.name)).map((e) => path.join(e.parentPath, e.name));
|
|
@@ -828,12 +871,10 @@ class SessionsScanner {
|
|
|
828
871
|
if (stat.mtimeMs < day.start) return false;
|
|
829
872
|
return !(stat.birthtimeMs > day.end && stat.birthtimeMs + 2e3 <= stat.mtimeMs);
|
|
830
873
|
}
|
|
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.
|
|
874
|
+
// Calls onFile(filePath, stat) for every .jsonl passing _inPeriod, then onBatchDone() per project
|
|
875
|
+
// if any onFile returned truthy — so callers can flush UI updates incrementally. An aborted
|
|
876
|
+
// `signal` (a superseded scan) stops everything; once complete with the watcher live, the
|
|
877
|
+
// StatCache mirrors disk and serves later scans in memory.
|
|
837
878
|
async scan(day, opts = {}) {
|
|
838
879
|
if (this.statCache.complete && this.watcher) {
|
|
839
880
|
return this.statCache.scan((stat) => this._inPeriod(stat, day), opts);
|
|
@@ -957,11 +998,16 @@ class MetaCache {
|
|
|
957
998
|
}
|
|
958
999
|
}
|
|
959
1000
|
const seed = {
|
|
1001
|
+
"claude-opus-5": { "input": 5, "output": 25, "cacheRead": 0.5, "cacheWrite": 6.25, "cacheWrite1h": 10, "fastModeMplr": 2 },
|
|
1002
|
+
"claude-fable-5-1": { "input": 10, "output": 50, "cacheRead": 0.25, "cacheWrite": 12.5, "cacheWrite1h": 20 },
|
|
960
1003
|
"claude-fable-5": { "input": 10, "output": 50, "cacheRead": 1, "cacheWrite": 12.5, "cacheWrite1h": 20 },
|
|
1004
|
+
"claude-mythos-5-1": { "input": 10, "output": 50, "cacheRead": 0.25, "cacheWrite": 12.5, "cacheWrite1h": 20 },
|
|
1005
|
+
"claude-mythos-5": { "input": 10, "output": 50, "cacheRead": 1, "cacheWrite": 12.5, "cacheWrite1h": 20 },
|
|
1006
|
+
"claude-mythos-preview": { "input": 10, "output": 50, "cacheRead": 1, "cacheWrite": 12.5, "cacheWrite1h": 20 },
|
|
961
1007
|
"claude-sonnet-5": { "input": 2, "output": 10, "cacheRead": 0.2, "cacheWrite": 2.5, "cacheWrite1h": 4 },
|
|
962
1008
|
"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
|
|
1009
|
+
"claude-opus-4-7": { "input": 5, "output": 25, "cacheRead": 0.5, "cacheWrite": 6.25, "cacheWrite1h": 10 },
|
|
1010
|
+
"claude-opus-4-6": { "input": 5, "output": 25, "cacheRead": 0.5, "cacheWrite": 6.25, "cacheWrite1h": 10 },
|
|
965
1011
|
"claude-opus-4-5": { "input": 5, "output": 25, "cacheRead": 0.5, "cacheWrite": 6.25, "cacheWrite1h": 10 },
|
|
966
1012
|
"claude-opus-4-1": { "input": 15, "output": 75, "cacheRead": 1.5, "cacheWrite": 18.75, "cacheWrite1h": 30 },
|
|
967
1013
|
"claude-opus-4": { "input": 15, "output": 75, "cacheRead": 1.5, "cacheWrite": 18.75, "cacheWrite1h": 30 },
|
|
@@ -974,9 +1020,9 @@ const seed = {
|
|
|
974
1020
|
"claude-3-7-sonnet": { "input": 3, "output": 15, "cacheRead": 0.3, "cacheWrite": 3.75, "cacheWrite1h": 6 },
|
|
975
1021
|
"claude-3-5-sonnet": { "input": 3, "output": 15, "cacheRead": 0.3, "cacheWrite": 3.75 },
|
|
976
1022
|
"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":
|
|
1023
|
+
"claude-3-opus": { "input": 15, "output": 75, "cacheRead": 1.5, "cacheWrite": 18.75, "cacheWrite1h": 30 },
|
|
978
1024
|
"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":
|
|
1025
|
+
"claude-3-haiku": { "input": 0.25, "output": 1.25, "cacheRead": 0.03, "cacheWrite": 0.3, "cacheWrite1h": 0.5 }
|
|
980
1026
|
};
|
|
981
1027
|
const CURRENT_PATH = path.join(DATA_DIR, "prices.current.json");
|
|
982
1028
|
const LITELLM_URL = "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json";
|
|
@@ -1530,22 +1576,18 @@ class ClaudeSettings {
|
|
|
1530
1576
|
}
|
|
1531
1577
|
}
|
|
1532
1578
|
}
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
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) {
|
|
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);
|
|
1547
1588
|
for (const g of groups) g.hooks = (g.hooks ?? []).filter((h) => !matches(h));
|
|
1548
|
-
this.cfg.hooks[
|
|
1589
|
+
this.cfg.hooks[event] = groups.filter((g) => g.hooks.length);
|
|
1590
|
+
if (!this.cfg.hooks[event].length) delete this.cfg.hooks[event];
|
|
1549
1591
|
}
|
|
1550
1592
|
return removed;
|
|
1551
1593
|
}
|
|
@@ -1576,11 +1618,6 @@ class ClaudeSettings {
|
|
|
1576
1618
|
deleteEnv(name) {
|
|
1577
1619
|
delete this.cfg.env?.[name];
|
|
1578
1620
|
}
|
|
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
1621
|
save() {
|
|
1585
1622
|
if (this.loadFailed) throw new Error(`Refusing to save ${CLAUDE_SETTINGS}: settings did not load cleanly`);
|
|
1586
1623
|
if (mtimeOf(CLAUDE_SETTINGS) !== this.mtime) throw new Error(`Refusing to save ${CLAUDE_SETTINGS}: file changed on disk since load`);
|
|
@@ -1588,55 +1625,195 @@ class ClaudeSettings {
|
|
|
1588
1625
|
sync(CLAUDE_SETTINGS, JSON.stringify(this.cfg, null, 2));
|
|
1589
1626
|
}
|
|
1590
1627
|
}
|
|
1591
|
-
|
|
1592
|
-
|
|
1628
|
+
class LoginService {
|
|
1629
|
+
#service;
|
|
1630
|
+
constructor({ name, command }) {
|
|
1631
|
+
if (process.platform === "win32") this.#service = new TaskScheduler({ name, command });
|
|
1632
|
+
else if (process.platform === "darwin") this.#service = new LaunchAgent({ name, command });
|
|
1633
|
+
else this.#service = new SystemdUnit({ name, command });
|
|
1634
|
+
}
|
|
1635
|
+
install() {
|
|
1636
|
+
return this.#service.install();
|
|
1637
|
+
}
|
|
1638
|
+
uninstall() {
|
|
1639
|
+
return this.#service.uninstall();
|
|
1640
|
+
}
|
|
1641
|
+
}
|
|
1642
|
+
async function exec(file, args) {
|
|
1643
|
+
try {
|
|
1644
|
+
return await promisify(execFile)(file, args, { windowsHide: true });
|
|
1645
|
+
} catch (err) {
|
|
1646
|
+
throw new Error(err.stderr?.trim() || err.message);
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1649
|
+
class TaskScheduler {
|
|
1650
|
+
static #SYSTEM32 = path.join(process.env.SystemRoot ?? "C:\\Windows", "System32");
|
|
1651
|
+
#name;
|
|
1652
|
+
#command;
|
|
1653
|
+
constructor({ name, command }) {
|
|
1654
|
+
this.#name = name;
|
|
1655
|
+
this.#command = command;
|
|
1656
|
+
}
|
|
1657
|
+
// Without admin rights only an interactive task can be registered, and that would open a console
|
|
1658
|
+
// window — `conhost --headless` runs the command without one. conhost hides the exit code, so a
|
|
1659
|
+
// minutely trigger restarts the process if it died (IgnoreNew: no-op while it runs). Started by
|
|
1660
|
+
// the scheduler, the process is outside any terminal's job object, so closing a terminal can't
|
|
1661
|
+
// kill it. The default settings would skip starting on battery and stop the task after 72h.
|
|
1662
|
+
async install() {
|
|
1663
|
+
const q = (s) => s.replace(/'/g, "''");
|
|
1664
|
+
const conhost = path.join(TaskScheduler.#SYSTEM32, "conhost.exe");
|
|
1665
|
+
await this.#ps(`
|
|
1666
|
+
$user = "$env:USERDOMAIN\\$env:USERNAME"
|
|
1667
|
+
$action = New-ScheduledTaskAction -Execute '${q(conhost)}' -Argument '--headless ${this.#command.map((s) => `"${q(s)}"`).join(" ")}'
|
|
1668
|
+
$logon = New-ScheduledTaskTrigger -AtLogOn -User $user
|
|
1669
|
+
$revive = New-ScheduledTaskTrigger -Once -At (Get-Date) -RepetitionInterval (New-TimeSpan -Minutes 1)
|
|
1670
|
+
$principal = New-ScheduledTaskPrincipal -UserId $user -LogonType Interactive -RunLevel Limited
|
|
1671
|
+
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit ([TimeSpan]::Zero) -MultipleInstances IgnoreNew
|
|
1672
|
+
$null = Register-ScheduledTask -TaskName '${this.#name}' -Action $action -Trigger $logon, $revive -Principal $principal -Settings $settings -Force
|
|
1673
|
+
Start-ScheduledTask -TaskName '${this.#name}'
|
|
1674
|
+
`);
|
|
1675
|
+
}
|
|
1676
|
+
uninstall() {
|
|
1677
|
+
return this.#ps(`Unregister-ScheduledTask -TaskName '${this.#name}' -Confirm:$false -ErrorAction SilentlyContinue`);
|
|
1678
|
+
}
|
|
1679
|
+
#ps(script) {
|
|
1680
|
+
const powershell = path.join(TaskScheduler.#SYSTEM32, "WindowsPowerShell", "v1.0", "powershell.exe");
|
|
1681
|
+
const wrapped = `$ErrorActionPreference = 'Stop'
|
|
1682
|
+
$ProgressPreference = 'SilentlyContinue'
|
|
1683
|
+
try {${script}} catch { [Console]::Error.WriteLine($_.Exception.Message); exit 1 }
|
|
1684
|
+
exit 0`;
|
|
1685
|
+
const encoded = Buffer.from(wrapped, "utf16le").toString("base64");
|
|
1686
|
+
return exec(powershell, ["-NoProfile", "-NonInteractive", "-EncodedCommand", encoded]);
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1689
|
+
class LaunchAgent {
|
|
1690
|
+
// @macOS
|
|
1691
|
+
#name;
|
|
1692
|
+
#command;
|
|
1693
|
+
#plist;
|
|
1694
|
+
#domain = `gui/${process.getuid()}`;
|
|
1695
|
+
constructor({ name, command }) {
|
|
1696
|
+
this.#name = name;
|
|
1697
|
+
this.#command = command;
|
|
1698
|
+
this.#plist = path.join(os.homedir(), "Library", "LaunchAgents", `${name}.plist`);
|
|
1699
|
+
}
|
|
1700
|
+
// RunAtLoad starts it right away; KeepAlive restarts it only after a crash, so a clean exit stays stopped
|
|
1701
|
+
async install() {
|
|
1702
|
+
fs.mkdirSync(path.dirname(this.#plist), { recursive: true });
|
|
1703
|
+
fs.writeFileSync(this.#plist, `
|
|
1704
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
1705
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
1706
|
+
<plist version="1.0">
|
|
1707
|
+
<dict>
|
|
1708
|
+
<key>Label</key><string>${this.#name}</string>
|
|
1709
|
+
<key>ProgramArguments</key><array>${this.#command.map((s) => `<string>${s}</string>`).join("")}</array>
|
|
1710
|
+
<key>RunAtLoad</key><true/>
|
|
1711
|
+
<key>KeepAlive</key><dict><key>SuccessfulExit</key><false/></dict>
|
|
1712
|
+
</dict>
|
|
1713
|
+
</plist>
|
|
1714
|
+
`.trim());
|
|
1715
|
+
await exec("launchctl", ["bootout", this.#domain, this.#plist]).catch(() => {
|
|
1716
|
+
});
|
|
1717
|
+
await exec("launchctl", ["bootstrap", this.#domain, this.#plist]);
|
|
1718
|
+
}
|
|
1719
|
+
async uninstall() {
|
|
1720
|
+
await exec("launchctl", ["bootout", this.#domain, this.#plist]).catch(() => {
|
|
1721
|
+
});
|
|
1722
|
+
fs.rmSync(this.#plist, { force: true });
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
class SystemdUnit {
|
|
1726
|
+
// @linux
|
|
1727
|
+
#name;
|
|
1728
|
+
#command;
|
|
1729
|
+
#unit;
|
|
1730
|
+
constructor({ name, command }) {
|
|
1731
|
+
this.#name = name;
|
|
1732
|
+
this.#command = command;
|
|
1733
|
+
this.#unit = path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"), "systemd", "user", `${name}.service`);
|
|
1734
|
+
}
|
|
1735
|
+
async install() {
|
|
1736
|
+
fs.mkdirSync(path.dirname(this.#unit), { recursive: true });
|
|
1737
|
+
fs.writeFileSync(this.#unit, `
|
|
1738
|
+
[Unit]
|
|
1739
|
+
Description=${this.#name}
|
|
1740
|
+
|
|
1741
|
+
[Service]
|
|
1742
|
+
ExecStart=${this.#command.map((s) => `"${s}"`).join(" ")}
|
|
1743
|
+
Restart=on-failure
|
|
1744
|
+
RestartSec=10
|
|
1745
|
+
|
|
1746
|
+
[Install]
|
|
1747
|
+
WantedBy=default.target
|
|
1748
|
+
`.trim());
|
|
1749
|
+
await exec("systemctl", ["--user", "daemon-reload"]);
|
|
1750
|
+
await exec("systemctl", ["--user", "enable", "--now", this.#name]);
|
|
1751
|
+
}
|
|
1752
|
+
async uninstall() {
|
|
1753
|
+
await exec("systemctl", ["--user", "disable", "--now", this.#name]).catch(() => {
|
|
1754
|
+
});
|
|
1755
|
+
fs.rmSync(this.#unit, { force: true });
|
|
1756
|
+
await exec("systemctl", ["--user", "daemon-reload"]).catch(() => {
|
|
1757
|
+
});
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
const NODE = await which("node", { nothrow: true }) ?? "node";
|
|
1593
1761
|
class ProxySwitch {
|
|
1762
|
+
#service = new LoginService({ name: "claude-discover-proxy", command: [NODE, PROXY_PATH] });
|
|
1763
|
+
constructor() {
|
|
1764
|
+
try {
|
|
1765
|
+
const settings = new ClaudeSettings();
|
|
1766
|
+
if (settings.removeHooks(/claude-discover/).length) settings.save();
|
|
1767
|
+
} catch {
|
|
1768
|
+
}
|
|
1769
|
+
}
|
|
1594
1770
|
async status() {
|
|
1595
1771
|
return {
|
|
1596
1772
|
running: await this.#running(),
|
|
1597
1773
|
configured: new ClaudeSettings().env?.ANTHROPIC_BASE_URL === PROXY_URL
|
|
1598
1774
|
};
|
|
1599
1775
|
}
|
|
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 }.
|
|
1776
|
+
// Install the proxy as a login service, then point Claude Code at it — only once it answers, so
|
|
1777
|
+
// a failed start never touches settings.json. Throws on failure (Switchers turns that into { error }).
|
|
1604
1778
|
async activate() {
|
|
1605
1779
|
const settings = new ClaudeSettings();
|
|
1606
1780
|
const baseUrl = settings.env?.ANTHROPIC_BASE_URL;
|
|
1607
1781
|
if (baseUrl && baseUrl !== PROXY_URL)
|
|
1608
1782
|
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
1783
|
try {
|
|
1629
|
-
await fetch(
|
|
1784
|
+
await fetch(new URL("/v1/models", UPSTREAM), { signal: AbortSignal.timeout(5e3) });
|
|
1630
1785
|
} catch {
|
|
1786
|
+
throw new Error("Cannot reach api.anthropic.com — not enabling capture. Check your network and try again.");
|
|
1787
|
+
}
|
|
1788
|
+
await this.#exit();
|
|
1789
|
+
await this.#service.install();
|
|
1790
|
+
if (!await this.#settle(true)) {
|
|
1791
|
+
await this.#service.uninstall().catch(() => {
|
|
1792
|
+
});
|
|
1793
|
+
throw new Error(`Proxy did not start — see ${ERROR_LOG_PATH}`);
|
|
1631
1794
|
}
|
|
1795
|
+
settings.setEnv("ANTHROPIC_BASE_URL", PROXY_URL);
|
|
1796
|
+
settings.setEnv("ENABLE_TOOL_SEARCH", "true");
|
|
1797
|
+
settings.save();
|
|
1798
|
+
}
|
|
1799
|
+
// Stop the proxy, remove the service and our env keys — a foreign base URL is left alone
|
|
1800
|
+
async deactivate() {
|
|
1801
|
+
await this.#exit();
|
|
1802
|
+
await this.#service.uninstall();
|
|
1632
1803
|
const settings = new ClaudeSettings();
|
|
1633
|
-
|
|
1634
|
-
const removedHook = settings.removeHook("SessionStart", HOOK_BASENAME).length > 0;
|
|
1635
|
-
if (ours) {
|
|
1804
|
+
if (settings.env?.ANTHROPIC_BASE_URL === PROXY_URL) {
|
|
1636
1805
|
settings.deleteEnv("ANTHROPIC_BASE_URL");
|
|
1637
1806
|
settings.deleteEnv("ENABLE_TOOL_SEARCH");
|
|
1807
|
+
settings.save();
|
|
1808
|
+
}
|
|
1809
|
+
}
|
|
1810
|
+
// Ask a running instance to exit via its control route and wait for the port to free up
|
|
1811
|
+
async #exit() {
|
|
1812
|
+
try {
|
|
1813
|
+
await fetch(`${PROXY_URL}${EXIT_ROUTE}`, { method: "POST", signal: AbortSignal.timeout(1e3) });
|
|
1814
|
+
} catch {
|
|
1815
|
+
return;
|
|
1638
1816
|
}
|
|
1639
|
-
if (ours || removedHook) settings.save();
|
|
1640
1817
|
await this.#settle(false);
|
|
1641
1818
|
}
|
|
1642
1819
|
async #running() {
|
|
@@ -1647,7 +1824,7 @@ class ProxySwitch {
|
|
|
1647
1824
|
return false;
|
|
1648
1825
|
}
|
|
1649
1826
|
}
|
|
1650
|
-
// Poll
|
|
1827
|
+
// Poll up to 8s until ping matches `target` (the service takes a moment to start it)
|
|
1651
1828
|
async #settle(target) {
|
|
1652
1829
|
for (let i = 0; i < 40; i++) {
|
|
1653
1830
|
if (await this.#running() === target) return true;
|