claude-discover 1.9.1 → 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 +281 -103
- package/out/renderer/assets/{index-03LlBvFr.css → index-B1hu0QHl.css} +15 -50
- package/out/renderer/assets/{index-BKTnG3v8.js → index-B4cvR_aa.js} +66 -49
- 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 || []) {
|
|
@@ -432,13 +484,14 @@ class RequestParser {
|
|
|
432
484
|
return files;
|
|
433
485
|
}
|
|
434
486
|
}
|
|
487
|
+
const agentKey = (id) => id?.includes("@") ? id.slice(0, id.indexOf("@")) : id?.replace(/^a/, "").replace(/-[0-9a-f]{16}$/, "") ?? null;
|
|
435
488
|
class RequestFile {
|
|
436
489
|
constructor(sessionId, { dir = REQUESTS_DIR, agentId = null } = {}) {
|
|
437
490
|
this.filePath = join(dir, `${sessionId}.requests.jsonl`);
|
|
438
|
-
this.
|
|
491
|
+
this.agentKey = agentKey(agentId);
|
|
439
492
|
}
|
|
440
493
|
#mine(rec) {
|
|
441
|
-
return (rec.requestHeaders?.["x-claude-code-agent-id"] ?? null) === this.
|
|
494
|
+
return agentKey(rec.requestHeaders?.["x-claude-code-agent-id"] ?? null) === this.agentKey;
|
|
442
495
|
}
|
|
443
496
|
async lines() {
|
|
444
497
|
const raw = await fsp.readFile(this.filePath, "utf8").catch(() => "");
|
|
@@ -466,12 +519,9 @@ class RequestFile {
|
|
|
466
519
|
return t >= range.start && t <= range.end;
|
|
467
520
|
});
|
|
468
521
|
}
|
|
469
|
-
// System prompts
|
|
470
|
-
//
|
|
471
|
-
//
|
|
472
|
-
// Works on read()'s output, so dedup wrappers and the agent scope are already handled.
|
|
473
|
-
// Returns one record per unique system prompt / tool batch / file_path; readSession ships them
|
|
474
|
-
// 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.
|
|
475
525
|
async readInstructions() {
|
|
476
526
|
const parser = new RequestParser();
|
|
477
527
|
const files = /* @__PURE__ */ new Map();
|
|
@@ -479,8 +529,9 @@ class RequestFile {
|
|
|
479
529
|
const record = (f) => ({ timestamp: rec.timestamp, ...f });
|
|
480
530
|
const sys = parser.systemPrompt(rec);
|
|
481
531
|
if (sys && !files.has(sys.hash)) files.set(sys.hash, record(sys));
|
|
482
|
-
const
|
|
483
|
-
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));
|
|
484
535
|
for (const f of parser.memoryFiles(rec)) {
|
|
485
536
|
if (!files.has(f.file_path)) files.set(f.file_path, record(f));
|
|
486
537
|
}
|
|
@@ -655,13 +706,9 @@ class SessionParser {
|
|
|
655
706
|
obj._tokenDelta = (obj._tokenDelta || 0) + output;
|
|
656
707
|
obj._tokenTotal = this.tokenTotal;
|
|
657
708
|
}
|
|
658
|
-
// One assistant reply
|
|
659
|
-
//
|
|
660
|
-
//
|
|
661
|
-
// line), so the totals land on the final value — what ccusage bills. The
|
|
662
|
-
// per-item running-total stamps and the stream-stable metadata come only from
|
|
663
|
-
// the first line; ids in `excludeIds` (already counted in an earlier
|
|
664
|
-
// 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.
|
|
665
712
|
_recordUsage(obj) {
|
|
666
713
|
const meta = this.meta;
|
|
667
714
|
const msg = obj.message;
|
|
@@ -768,7 +815,6 @@ const CLAUDE_SETTINGS = join(CLAUDE_DIR, "settings.json");
|
|
|
768
815
|
const IS_EPHEMERAL = import.meta.dirname.split(/[\\/]/).includes("_npx");
|
|
769
816
|
const STATUSLINE_PATH = join(import.meta.dirname, "../../bin/claude/statusline.mjs");
|
|
770
817
|
const PROXY_PATH = join(import.meta.dirname, "../../bin/proxy.mjs");
|
|
771
|
-
const CLAUDE_HOOKS_PATH = join(import.meta.dirname, "../../bin/claude/hooks.mjs");
|
|
772
818
|
class StatCache {
|
|
773
819
|
constructor() {
|
|
774
820
|
this.stats = /* @__PURE__ */ new Map();
|
|
@@ -813,10 +859,8 @@ class SessionsScanner {
|
|
|
813
859
|
const entries = await fsp.readdir(p, { withFileTypes: true }).catch(() => []);
|
|
814
860
|
return entries.filter((e) => e.isDirectory()).map((e) => path.join(e.parentPath, e.name));
|
|
815
861
|
}
|
|
816
|
-
// Every .jsonl under `dir`, at any depth — subagent transcripts
|
|
817
|
-
//
|
|
818
|
-
// by directory mtime, because on Windows appending to an existing transcript does NOT
|
|
819
|
-
// 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.
|
|
820
864
|
async _listJsonl(dir) {
|
|
821
865
|
const entries = await fsp.readdir(dir, { recursive: true, withFileTypes: true }).catch(() => []);
|
|
822
866
|
return entries.filter((e) => e.isFile() && isJsonl(e.name)).map((e) => path.join(e.parentPath, e.name));
|
|
@@ -827,12 +871,10 @@ class SessionsScanner {
|
|
|
827
871
|
if (stat.mtimeMs < day.start) return false;
|
|
828
872
|
return !(stat.birthtimeMs > day.end && stat.birthtimeMs + 2e3 <= stat.mtimeMs);
|
|
829
873
|
}
|
|
830
|
-
//
|
|
831
|
-
//
|
|
832
|
-
//
|
|
833
|
-
//
|
|
834
|
-
// scan) stops the walk: no further stats, onFile calls or emits. Once a walk has completed
|
|
835
|
-
// 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.
|
|
836
878
|
async scan(day, opts = {}) {
|
|
837
879
|
if (this.statCache.complete && this.watcher) {
|
|
838
880
|
return this.statCache.scan((stat) => this._inPeriod(stat, day), opts);
|
|
@@ -956,11 +998,16 @@ class MetaCache {
|
|
|
956
998
|
}
|
|
957
999
|
}
|
|
958
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 },
|
|
959
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 },
|
|
960
1007
|
"claude-sonnet-5": { "input": 2, "output": 10, "cacheRead": 0.2, "cacheWrite": 2.5, "cacheWrite1h": 4 },
|
|
961
1008
|
"claude-opus-4-8": { "input": 5, "output": 25, "cacheRead": 0.5, "cacheWrite": 6.25, "cacheWrite1h": 10, "fastModeMplr": 2 },
|
|
962
|
-
"claude-opus-4-7": { "input": 5, "output": 25, "cacheRead": 0.5, "cacheWrite": 6.25, "cacheWrite1h": 10
|
|
963
|
-
"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 },
|
|
964
1011
|
"claude-opus-4-5": { "input": 5, "output": 25, "cacheRead": 0.5, "cacheWrite": 6.25, "cacheWrite1h": 10 },
|
|
965
1012
|
"claude-opus-4-1": { "input": 15, "output": 75, "cacheRead": 1.5, "cacheWrite": 18.75, "cacheWrite1h": 30 },
|
|
966
1013
|
"claude-opus-4": { "input": 15, "output": 75, "cacheRead": 1.5, "cacheWrite": 18.75, "cacheWrite1h": 30 },
|
|
@@ -973,9 +1020,9 @@ const seed = {
|
|
|
973
1020
|
"claude-3-7-sonnet": { "input": 3, "output": 15, "cacheRead": 0.3, "cacheWrite": 3.75, "cacheWrite1h": 6 },
|
|
974
1021
|
"claude-3-5-sonnet": { "input": 3, "output": 15, "cacheRead": 0.3, "cacheWrite": 3.75 },
|
|
975
1022
|
"claude-3-5-haiku": { "input": 0.8, "output": 4, "cacheRead": 0.08, "cacheWrite": 1 },
|
|
976
|
-
"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 },
|
|
977
1024
|
"claude-3-sonnet": { "input": 3, "output": 15, "cacheRead": 0.3, "cacheWrite": 3.75 },
|
|
978
|
-
"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 }
|
|
979
1026
|
};
|
|
980
1027
|
const CURRENT_PATH = path.join(DATA_DIR, "prices.current.json");
|
|
981
1028
|
const LITELLM_URL = "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json";
|
|
@@ -1126,7 +1173,7 @@ class SessionsService extends EventEmitter {
|
|
|
1126
1173
|
}
|
|
1127
1174
|
// Subagents send the parent's session id, so the proxy logs their requests into the parent's
|
|
1128
1175
|
// file, tagged x-claude-code-agent-id — route their reads there. The transcript filename is
|
|
1129
|
-
// `agent-<id
|
|
1176
|
+
// `agent-<id>`; RequestFile normalizes that id against the header's own spelling.
|
|
1130
1177
|
#requestFile(sessionId, parentSessionId) {
|
|
1131
1178
|
const agentId = parentSessionId && sessionId.startsWith("agent-") ? sessionId.slice("agent-".length) : null;
|
|
1132
1179
|
return agentId ? new RequestFile(parentSessionId, { agentId }) : new RequestFile(sessionId);
|
|
@@ -1529,22 +1576,18 @@ class ClaudeSettings {
|
|
|
1529
1576
|
}
|
|
1530
1577
|
}
|
|
1531
1578
|
}
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
removeHook(eventName, needle) {
|
|
1542
|
-
const matches = (h) => h.command?.includes(needle);
|
|
1543
|
-
const groups = this.cfg.hooks?.[eventName] ?? [];
|
|
1544
|
-
const removed = groups.flatMap((g) => (g.hooks ?? []).filter(matches).map((h) => h.command));
|
|
1545
|
-
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);
|
|
1546
1588
|
for (const g of groups) g.hooks = (g.hooks ?? []).filter((h) => !matches(h));
|
|
1547
|
-
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];
|
|
1548
1591
|
}
|
|
1549
1592
|
return removed;
|
|
1550
1593
|
}
|
|
@@ -1575,11 +1618,6 @@ class ClaudeSettings {
|
|
|
1575
1618
|
deleteEnv(name) {
|
|
1576
1619
|
delete this.cfg.env?.[name];
|
|
1577
1620
|
}
|
|
1578
|
-
addHook(eventName, command) {
|
|
1579
|
-
this.cfg.hooks ??= {};
|
|
1580
|
-
this.cfg.hooks[eventName] ??= [];
|
|
1581
|
-
this.cfg.hooks[eventName].push({ hooks: [{ type: "command", command }] });
|
|
1582
|
-
}
|
|
1583
1621
|
save() {
|
|
1584
1622
|
if (this.loadFailed) throw new Error(`Refusing to save ${CLAUDE_SETTINGS}: settings did not load cleanly`);
|
|
1585
1623
|
if (mtimeOf(CLAUDE_SETTINGS) !== this.mtime) throw new Error(`Refusing to save ${CLAUDE_SETTINGS}: file changed on disk since load`);
|
|
@@ -1587,55 +1625,195 @@ class ClaudeSettings {
|
|
|
1587
1625
|
sync(CLAUDE_SETTINGS, JSON.stringify(this.cfg, null, 2));
|
|
1588
1626
|
}
|
|
1589
1627
|
}
|
|
1590
|
-
|
|
1591
|
-
|
|
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";
|
|
1592
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
|
+
}
|
|
1593
1770
|
async status() {
|
|
1594
1771
|
return {
|
|
1595
1772
|
running: await this.#running(),
|
|
1596
1773
|
configured: new ClaudeSettings().env?.ANTHROPIC_BASE_URL === PROXY_URL
|
|
1597
1774
|
};
|
|
1598
1775
|
}
|
|
1599
|
-
//
|
|
1600
|
-
//
|
|
1601
|
-
// upstream itself before listening (exit code 2 when unreachable), so a successful start
|
|
1602
|
-
// 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 }).
|
|
1603
1778
|
async activate() {
|
|
1604
1779
|
const settings = new ClaudeSettings();
|
|
1605
1780
|
const baseUrl = settings.env?.ANTHROPIC_BASE_URL;
|
|
1606
1781
|
if (baseUrl && baseUrl !== PROXY_URL)
|
|
1607
1782
|
throw new Error(`Leaving your existing env.ANTHROPIC_BASE_URL in place (${baseUrl}) — remove it from settings.json to enable capture.`);
|
|
1608
|
-
const launcher = utilityProcess.fork(PROXY_PATH, ["--restart"], { stdio: "ignore" });
|
|
1609
|
-
const exited = new Promise((resolve) => launcher.once("message", resolve));
|
|
1610
|
-
const outcome = await Promise.race([exited, this.#settle(true).then((ok) => ok ? "up" : "unresponsive")]).finally(() => launcher.kill());
|
|
1611
|
-
if (outcome !== "up")
|
|
1612
|
-
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}`);
|
|
1613
|
-
const hook = settings.findHook("SessionStart", HOOK_BASENAME);
|
|
1614
|
-
if (!baseUrl || settings.env?.ENABLE_TOOL_SEARCH !== "true" || hook?.command !== HOOK_COMMAND) {
|
|
1615
|
-
settings.setEnv("ANTHROPIC_BASE_URL", PROXY_URL);
|
|
1616
|
-
settings.setEnv("ENABLE_TOOL_SEARCH", "true");
|
|
1617
|
-
if (hook?.command !== HOOK_COMMAND) {
|
|
1618
|
-
settings.removeHook("SessionStart", HOOK_BASENAME);
|
|
1619
|
-
settings.addHook("SessionStart", HOOK_COMMAND);
|
|
1620
|
-
}
|
|
1621
|
-
settings.save();
|
|
1622
|
-
}
|
|
1623
|
-
}
|
|
1624
|
-
// Exit the proxy via its control route and remove the env keys + revive hook — env keys only
|
|
1625
|
-
// when the base URL is ours, so a foreign gateway config is never touched.
|
|
1626
|
-
async deactivate() {
|
|
1627
1783
|
try {
|
|
1628
|
-
await fetch(
|
|
1784
|
+
await fetch(new URL("/v1/models", UPSTREAM), { signal: AbortSignal.timeout(5e3) });
|
|
1629
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}`);
|
|
1630
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();
|
|
1631
1803
|
const settings = new ClaudeSettings();
|
|
1632
|
-
|
|
1633
|
-
const removedHook = settings.removeHook("SessionStart", HOOK_BASENAME).length > 0;
|
|
1634
|
-
if (ours) {
|
|
1804
|
+
if (settings.env?.ANTHROPIC_BASE_URL === PROXY_URL) {
|
|
1635
1805
|
settings.deleteEnv("ANTHROPIC_BASE_URL");
|
|
1636
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;
|
|
1637
1816
|
}
|
|
1638
|
-
if (ours || removedHook) settings.save();
|
|
1639
1817
|
await this.#settle(false);
|
|
1640
1818
|
}
|
|
1641
1819
|
async #running() {
|
|
@@ -1646,7 +1824,7 @@ class ProxySwitch {
|
|
|
1646
1824
|
return false;
|
|
1647
1825
|
}
|
|
1648
1826
|
}
|
|
1649
|
-
// Poll
|
|
1827
|
+
// Poll up to 8s until ping matches `target` (the service takes a moment to start it)
|
|
1650
1828
|
async #settle(target) {
|
|
1651
1829
|
for (let i = 0; i < 40; i++) {
|
|
1652
1830
|
if (await this.#running() === target) return true;
|