github-router 0.3.12 → 0.3.13

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/dist/main.js CHANGED
@@ -6,6 +6,8 @@ import os from "node:os";
6
6
  import path from "node:path";
7
7
  import { randomBytes, randomUUID } from "node:crypto";
8
8
  import process$1 from "node:process";
9
+ import fs$1 from "node:fs";
10
+ import { Writable } from "node:stream";
9
11
  import { execFileSync, spawn } from "node:child_process";
10
12
  import { serve } from "srvx";
11
13
  import { getProxyForUrl } from "proxy-from-env";
@@ -19,9 +21,11 @@ import clipboard from "clipboardy";
19
21
  //#region src/lib/paths.ts
20
22
  const APP_DIR = path.join(os.homedir(), ".local", "share", "github-router");
21
23
  const GITHUB_TOKEN_PATH = path.join(APP_DIR, "github_token");
24
+ const ERROR_LOG_PATH = path.join(APP_DIR, "error.log");
22
25
  const PATHS = {
23
26
  APP_DIR,
24
- GITHUB_TOKEN_PATH
27
+ GITHUB_TOKEN_PATH,
28
+ ERROR_LOG_PATH
25
29
  };
26
30
  async function ensurePaths() {
27
31
  await fs.mkdir(PATHS.APP_DIR, { recursive: true });
@@ -462,6 +466,100 @@ const checkUsage = defineCommand({
462
466
  }
463
467
  });
464
468
 
469
+ //#endregion
470
+ //#region src/lib/file-log-reporter.ts
471
+ const MAX_LOG_BYTES = 1024 * 1024;
472
+ const DEDUP_MAX = 1e3;
473
+ const ARG_MAX_LEN = 2048;
474
+ const DEDUP_KEY_MAX_LEN = 200;
475
+ const CREDENTIAL_RE = /\b(eyJ[A-Za-z0-9_-]{20,}(?:\.[A-Za-z0-9_-]+){0,2}|gh[opsu]_[A-Za-z0-9_]{20,}|Bearer\s+\S{20,})\b/g;
476
+ const ALLOWED_TYPES = new Set([
477
+ "fatal",
478
+ "error",
479
+ "warn"
480
+ ]);
481
+ function sanitize(line) {
482
+ return line.replace(CREDENTIAL_RE, "[REDACTED]");
483
+ }
484
+ function serializeArg(arg) {
485
+ if (typeof arg === "string") return arg;
486
+ if (arg instanceof Error) {
487
+ const parts = [arg.message];
488
+ if (arg.stack) parts.push(arg.stack);
489
+ return parts.join("\n");
490
+ }
491
+ return String(arg);
492
+ }
493
+ function formatLogLine(logObj) {
494
+ return sanitize(`${logObj.date.toISOString()} [${(logObj.type ?? "error").toUpperCase()}] ${logObj.args.map((a) => {
495
+ const s = serializeArg(a);
496
+ return s.length > ARG_MAX_LEN ? s.slice(0, ARG_MAX_LEN) + "…" : s;
497
+ }).join(" ").replace(/\r\n|\r|\n/g, "\\n")}\n`);
498
+ }
499
+ function makeDedupeKey(logObj) {
500
+ const firstArg = logObj.args.length > 0 ? serializeArg(logObj.args[0]) : "";
501
+ const key = `${logObj.type}:${firstArg}`;
502
+ return key.length > DEDUP_KEY_MAX_LEN ? key.slice(0, DEDUP_KEY_MAX_LEN) : key;
503
+ }
504
+ function rotateIfNeeded(filePath) {
505
+ let size;
506
+ try {
507
+ size = fs$1.statSync(filePath).size;
508
+ } catch {
509
+ return;
510
+ }
511
+ if (size <= MAX_LOG_BYTES) return;
512
+ try {
513
+ fs$1.renameSync(filePath, filePath + ".1");
514
+ } catch {}
515
+ }
516
+ var FileLogReporter = class {
517
+ filePath;
518
+ seen = /* @__PURE__ */ new Set();
519
+ writing = false;
520
+ constructor(filePath) {
521
+ this.filePath = filePath;
522
+ rotateIfNeeded(filePath);
523
+ }
524
+ log(logObj, _ctx) {
525
+ if (!ALLOWED_TYPES.has(logObj.type)) return;
526
+ if (this.writing) return;
527
+ const key = makeDedupeKey(logObj);
528
+ if (this.seen.has(key)) return;
529
+ if (this.seen.size >= DEDUP_MAX) this.seen.clear();
530
+ this.seen.add(key);
531
+ const line = formatLogLine(logObj);
532
+ this.writing = true;
533
+ try {
534
+ const fd = fs$1.openSync(this.filePath, "a", 384);
535
+ fs$1.writeSync(fd, line);
536
+ fs$1.closeSync(fd);
537
+ } catch {} finally {
538
+ this.writing = false;
539
+ }
540
+ }
541
+ };
542
+ const nullStream = new Writable({ write(_chunk, _encoding, cb) {
543
+ cb();
544
+ } });
545
+ /**
546
+ * Switch consola to file-only mode for TUI sessions.
547
+ * Removes the terminal reporter and installs a file reporter that
548
+ * persists errors and warnings to disk with dedup and credential scrubbing.
549
+ *
550
+ * Also sinks consola's stdout/stderr streams as belt-and-suspenders:
551
+ * even if a terminal reporter is re-added, it cannot write to the terminal.
552
+ * Crash handlers that call process.stderr.write() directly are unaffected.
553
+ * FileLogReporter uses fs.writeSync() directly and is also unaffected.
554
+ */
555
+ function enableFileLogging() {
556
+ const reporter = new FileLogReporter(PATHS.ERROR_LOG_PATH);
557
+ consola.options.throttle = 0;
558
+ consola.setReporters([reporter]);
559
+ consola.options.stdout = nullStream;
560
+ consola.options.stderr = nullStream;
561
+ }
562
+
465
563
  //#endregion
466
564
  //#region src/lib/port.ts
467
565
  const DEFAULT_PORT = 8787;
@@ -506,7 +604,9 @@ function launchChild(target, server$1) {
506
604
  const { cmd, env } = buildLaunchCommand(target);
507
605
  const executable = cmd[0];
508
606
  if (!commandExists(executable)) {
509
- consola.error(`"${executable}" not found on PATH. Install it first, then try again.`);
607
+ const msg = `"${executable}" not found on PATH. Install it first, then try again.`;
608
+ consola.error(msg);
609
+ process$1.stderr.write(msg + "\n");
510
610
  process$1.exit(1);
511
611
  }
512
612
  let child;
@@ -521,7 +621,9 @@ function launchChild(target, server$1) {
521
621
  stdio: "inherit"
522
622
  });
523
623
  } catch (error) {
524
- consola.error(`Failed to launch ${executable}:`, error instanceof Error ? error.message : String(error));
624
+ const msg = `Failed to launch ${executable}: ${error instanceof Error ? error.message : String(error)}`;
625
+ consola.error(msg);
626
+ process$1.stderr.write(msg + "\n");
525
627
  server$1.close(true).catch(() => {});
526
628
  process$1.exit(1);
527
629
  }
@@ -549,10 +651,13 @@ function launchChild(target, server$1) {
549
651
  };
550
652
  process$1.on("SIGINT", onSignal);
551
653
  process$1.on("SIGTERM", onSignal);
552
- child.on("exit", (exitCode) => {
553
- cleanup().then(() => exit(exitCode ?? 0)).catch(() => exit(1));
654
+ child.on("exit", (exitCode, signal) => {
655
+ const code = exitCode ?? (signal ? 128 : 1);
656
+ cleanup().then(() => exit(code)).catch(() => exit(1));
657
+ });
658
+ child.on("error", () => {
659
+ cleanup().then(() => exit(1)).catch(() => exit(1));
554
660
  });
555
- child.on("error", () => exit(1));
556
661
  }
557
662
 
558
663
  //#endregion
@@ -1776,31 +1881,99 @@ function extractUserQuery(input) {
1776
1881
  }
1777
1882
  }
1778
1883
  }
1884
+ /**
1885
+ * Compaction prompt used when GitHub Copilot API does not support
1886
+ * /responses/compact natively. Matches the prompt Codex CLI uses for
1887
+ * local (non-OpenAI) compaction.
1888
+ */
1889
+ const COMPACTION_PROMPT = `You are performing a CONTEXT CHECKPOINT COMPACTION. Create a handoff summary for another LLM that will resume the task.
1890
+
1891
+ Include:
1892
+ - Current progress and key decisions made
1893
+ - Important context, constraints, or user preferences
1894
+ - What remains to be done (clear next steps)
1895
+ - Any critical data, examples, or references needed to continue
1896
+
1897
+ Be concise, structured, and focused on helping the next LLM seamlessly continue the work.`;
1779
1898
  async function handleResponsesCompact(c) {
1780
1899
  const startTime = Date.now();
1781
1900
  await checkRateLimit(state);
1782
1901
  if (!state.copilotToken) throw new Error("Copilot token not found");
1783
1902
  if (state.manualApprove) await awaitApproval();
1784
- const body = await c.req.text();
1903
+ const body = await c.req.json();
1785
1904
  const response = await fetch(`${copilotBaseUrl(state)}/responses/compact`, {
1786
1905
  method: "POST",
1787
1906
  headers: copilotHeaders(state),
1788
- body
1907
+ body: JSON.stringify(body)
1789
1908
  });
1790
- if (!response.ok) {
1909
+ if (response.ok) {
1791
1910
  logRequest({
1792
1911
  method: "POST",
1793
1912
  path: c.req.path,
1794
- status: response.status
1913
+ status: 200
1914
+ }, void 0, startTime);
1915
+ return c.json(await response.json());
1916
+ }
1917
+ if (response.status === 404) {
1918
+ consola.debug("Copilot API does not support /responses/compact, using synthetic compaction");
1919
+ return await syntheticCompact(c, body, startTime);
1920
+ }
1921
+ logRequest({
1922
+ method: "POST",
1923
+ path: c.req.path,
1924
+ status: response.status
1925
+ }, void 0, startTime);
1926
+ throw new HTTPError("Copilot responses/compact request failed", response);
1927
+ }
1928
+ /**
1929
+ * Synthetic compaction: sends the conversation history to Copilot's
1930
+ * regular /responses endpoint with a compaction prompt appended,
1931
+ * then returns the model's summary in the compact response format.
1932
+ */
1933
+ async function syntheticCompact(c, body, startTime) {
1934
+ const input = Array.isArray(body.input) ? [...body.input] : [];
1935
+ input.push({
1936
+ type: "message",
1937
+ role: "user",
1938
+ content: [{
1939
+ type: "input_text",
1940
+ text: COMPACTION_PROMPT
1941
+ }]
1942
+ });
1943
+ const payload = {
1944
+ model: body.model,
1945
+ input,
1946
+ instructions: body.instructions,
1947
+ stream: false,
1948
+ store: false
1949
+ };
1950
+ let result;
1951
+ try {
1952
+ result = await createResponses(payload);
1953
+ } catch (error) {
1954
+ if (error instanceof HTTPError) logRequest({
1955
+ method: "POST",
1956
+ path: c.req.path,
1957
+ status: error.response.status
1795
1958
  }, void 0, startTime);
1796
- throw new HTTPError("Copilot responses/compact request failed", response);
1959
+ throw error;
1797
1960
  }
1798
1961
  logRequest({
1799
1962
  method: "POST",
1800
1963
  path: c.req.path,
1801
1964
  status: 200
1802
1965
  }, void 0, startTime);
1803
- return c.json(await response.json());
1966
+ return c.json({
1967
+ id: `resp_compact_${randomUUID().replace(/-/g, "").slice(0, 24)}`,
1968
+ object: "response.compaction",
1969
+ created_at: Math.floor(Date.now() / 1e3),
1970
+ output: result.output,
1971
+ usage: result.usage ?? {
1972
+ input_tokens: 0,
1973
+ output_tokens: 0,
1974
+ total_tokens: 0
1975
+ }
1976
+ });
1804
1977
  }
1805
1978
 
1806
1979
  //#endregion
@@ -2081,6 +2254,7 @@ const claude = defineCommand({
2081
2254
  consola.error("Failed to start server:", error instanceof Error ? error.message : error);
2082
2255
  process$1.exit(1);
2083
2256
  }
2257
+ enableFileLogging();
2084
2258
  let resolvedModel;
2085
2259
  if (args.model) {
2086
2260
  resolvedModel = resolveModel(args.model);
@@ -2090,8 +2264,7 @@ const claude = defineCommand({
2090
2264
  consola.warn(`Model "${resolvedModel}" not found. Available claude models: ${available.join(", ")}`);
2091
2265
  }
2092
2266
  }
2093
- consola.success(`Server ready on ${serverUrl}, launching Claude Code...`);
2094
- consola.level = 1;
2267
+ process$1.stderr.write(`Server ready on ${serverUrl}, launching Claude Code...\n`);
2095
2268
  launchChild({
2096
2269
  kind: "claude-code",
2097
2270
  envVars: getClaudeCodeEnvVars(serverUrl, resolvedModel ?? args.model),
@@ -2137,6 +2310,7 @@ const codex = defineCommand({
2137
2310
  process$1.exit(1);
2138
2311
  }
2139
2312
  const requestedModel = args.model ?? DEFAULT_CODEX_MODEL;
2313
+ enableFileLogging();
2140
2314
  const codexModel = resolveCodexModel(requestedModel);
2141
2315
  if (codexModel !== requestedModel) consola.info(`Model "${requestedModel}" resolved to "${codexModel}"`);
2142
2316
  const modelEntry = state.models?.data.find((m) => m.id === codexModel);
@@ -2147,8 +2321,7 @@ const codex = defineCommand({
2147
2321
  const ctx = modelEntry.capabilities?.limits?.max_context_window_tokens;
2148
2322
  if (ctx) consola.info(`Model context window: ${ctx.toLocaleString()} tokens`);
2149
2323
  }
2150
- consola.success(`Server ready on ${serverUrl}, launching Codex CLI (${codexModel})...`);
2151
- consola.level = 1;
2324
+ process$1.stderr.write(`Server ready on ${serverUrl}, launching Codex CLI (${codexModel})...\n`);
2152
2325
  launchChild({
2153
2326
  kind: "codex",
2154
2327
  envVars: getCodexEnvVars(serverUrl),