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/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 || []) {
@@ -467,12 +519,9 @@ class RequestFile {
467
519
  return t >= range.start && t <= range.end;
468
520
  });
469
521
  }
470
- // System prompts (request.system), tool definitions (request.tools) and memory files
471
- // (CLAUDE.md / MEMORY.md / … inside a user message's `# claudeMd` system-reminder) — none of
472
- // which Claude Code records in the session transcript, so the request log is the only source.
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 tools = parser.systemTools(rec);
484
- 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));
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 is written as several jsonl lines sharing message.id:
660
- // input/cache stay fixed while output_tokens grows toward its final value. We
661
- // add each line's *growth* over the previous one (the full usage on the first
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 live arbitrarily deep
818
- // (e.g. <session>/subagents/workflows/<wf>/agent-*.jsonl). The walk can't prune subtrees
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
- // Walks each project subtree in full. For each .jsonl whose stat passes _inPeriod
832
- // (file mtime IS updated on append, unlike dir mtime), calls onFile(filePath, stat).
833
- // After each project's files are processed, calls onBatchDone() if any onFile returned
834
- // truthy — lets callers flush UI updates incrementally. An aborted `signal` (a superseded
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, "fastModeMplr": 6 },
964
- "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 },
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": 6 },
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": 6 }
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
- hooks(eventName) {
1534
- return (this.cfg.hooks?.[eventName] ?? []).flatMap((g) => g.hooks ?? []);
1535
- }
1536
- // The hook entry on this event whose command mentions `needle` (basename, so stale absolute paths still match), or undefined.
1537
- findHook(eventName, needle) {
1538
- return this.hooks(eventName).find((h) => h.command?.includes(needle));
1539
- }
1540
- // Removes every hook on this event whose command mentions `needle` (basename, so stale
1541
- // absolute paths still match); groups left empty are dropped. Returns the removed commands.
1542
- removeHook(eventName, needle) {
1543
- const matches = (h) => h.command?.includes(needle);
1544
- const groups = this.cfg.hooks?.[eventName] ?? [];
1545
- const removed = groups.flatMap((g) => (g.hooks ?? []).filter(matches).map((h) => h.command));
1546
- if (removed.length) {
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[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];
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
- const HOOK_BASENAME = "hooks.mjs";
1592
- 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";
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
- // Spawn the proxy detached (it outlives the app) and point Claude Code at it only once it's
1601
- // confirmed listening — a failed start never changes settings.json. The proxy verifies the
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(`${PROXY_URL}${EXIT_ROUTE}`, { method: "POST", signal: AbortSignal.timeout(1e3) });
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
- const ours = settings.env?.ANTHROPIC_BASE_URL === PROXY_URL;
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 (≤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)
1651
1828
  async #settle(target) {
1652
1829
  for (let i = 0; i < 40; i++) {
1653
1830
  if (await this.#running() === target) return true;