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/out/main/main.js CHANGED
@@ -1,4 +1,4 @@
1
- import { BrowserWindow, dialog, app, Menu, shell, WebContentsView, utilityProcess, ipcMain } from "electron";
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 = "\n# claudeMd\n";
310
- const start2 = text.indexOf(marker);
311
- if (start2 === -1) return [];
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
- const [kind, kindLabel] = stripKind(rec.request);
403
- return { file_path: "System Prompt", memory_type: kindLabel || "", model: rec.request.model, content, hash: contentHash(content), kind };
404
- }
405
- // The request's tool definitions, from a resolveRefs'd record. The array grows as deferred
406
- // tools load mid-session, so only tools this parser hasn't seen yet are returned — the first
407
- // request yields the full set, later arrays just the additions.
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 null;
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 content = fresh.map((t) => [`## ${t.name}`, t.description || "", schema(t)].filter(Boolean).join("\n\n")).join("\n\n");
416
- const [kind, kindLabel] = stripKind(rec.request);
417
- const label = [kindLabel, `${fresh.length} tool${fresh.length === 1 ? "" : "s"}`].filter(Boolean).join(", ");
418
- return { file_path: "System Tools", memory_type: label, model: rec.request.model, content, hash: contentHash(content), kind };
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
- // `# claudeMd` system-reminders — one { file_path, memory_type, content } per file listed.
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.agentId = agentId;
491
+ this.agentKey = agentKey(agentId);
439
492
  }
440
493
  #mine(rec) {
441
- return (rec.requestHeaders?.["x-claude-code-agent-id"] ?? null) === this.agentId;
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 (request.system), tool definitions (request.tools) and memory files
470
- // (CLAUDE.md / MEMORY.md / … inside a user message's `# claudeMd` system-reminder) — none of
471
- // which Claude Code records in the session transcript, so the request log is the only source.
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 tools = parser.systemTools(rec);
483
- if (tools) files.set(tools.hash, record(tools));
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 is written as several jsonl lines sharing message.id:
659
- // input/cache stay fixed while output_tokens grows toward its final value. We
660
- // add each line's *growth* over the previous one (the full usage on the first
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 live arbitrarily deep
817
- // (e.g. <session>/subagents/workflows/<wf>/agent-*.jsonl). The walk can't prune subtrees
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
- // Walks each project subtree in full. For each .jsonl whose stat passes _inPeriod
831
- // (file mtime IS updated on append, unlike dir mtime), calls onFile(filePath, stat).
832
- // After each project's files are processed, calls onBatchDone() if any onFile returned
833
- // truthy — lets callers flush UI updates incrementally. An aborted `signal` (a superseded
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, "fastModeMplr": 6 },
963
- "claude-opus-4-6": { "input": 5, "output": 25, "cacheRead": 0.5, "cacheWrite": 6.25, "cacheWrite1h": 10, "fastModeMplr": 6 },
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": 6 },
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": 6 }
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>` while the header carries the bare <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
- hooks(eventName) {
1533
- return (this.cfg.hooks?.[eventName] ?? []).flatMap((g) => g.hooks ?? []);
1534
- }
1535
- // The hook entry on this event whose command mentions `needle` (basename, so stale absolute paths still match), or undefined.
1536
- findHook(eventName, needle) {
1537
- return this.hooks(eventName).find((h) => h.command?.includes(needle));
1538
- }
1539
- // Removes every hook on this event whose command mentions `needle` (basename, so stale
1540
- // absolute paths still match); groups left empty are dropped. Returns the removed commands.
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[eventName] = groups.filter((g) => g.hooks.length);
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
- const HOOK_BASENAME = "hooks.mjs";
1591
- const HOOK_COMMAND = `node "${CLAUDE_HOOKS_PATH}"`;
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
- // Spawn the proxy detached (it outlives the app) and point Claude Code at it only once it's
1600
- // confirmed listening — a failed start never changes settings.json. The proxy verifies the
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(`${PROXY_URL}${EXIT_ROUTE}`, { method: "POST", signal: AbortSignal.timeout(1e3) });
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
- const ours = settings.env?.ANTHROPIC_BASE_URL === PROXY_URL;
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 (≤8s — the proxy's startup upstream check can take 5s) until ping matches `target`
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;