copilot-reverse 0.5.0 → 0.5.2

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/cli/index.js CHANGED
@@ -10,7 +10,7 @@ import { probeSupervisor } from "../daemon/lifecycle.js";
10
10
  import { startSupervisor } from "../supervisor/index.js";
11
11
  import { runAssistantTurn } from "../tui/assistant/runtime.js";
12
12
  import { makeOnChat } from "../tui/assistant/on-chat.js";
13
- import { readGhToken, clearGhToken } from "../shared/creds.js";
13
+ import { readGhToken, clearGhToken, hasGhTokenFile } from "../shared/creds.js";
14
14
  import { writeWebIqKey, readWebIqKey, clearWebIqKey, readWebSearchMode, writeWebSearchMode, resolveWebSearchBackend } from "../shared/webiq-key.js";
15
15
  import { readClientSetup, writeClientSetup } from "../shared/client-setup.js";
16
16
  import { readChatModel, writeChatModel } from "../shared/prefs.js";
@@ -24,13 +24,42 @@ import { claudeCopilotReverseEnv } from "../tui/setup/clients.js";
24
24
  import { dataDir } from "../shared/paths.js";
25
25
  import { defaultConfig } from "../shared/config.js";
26
26
  import { APP_VERSION } from "../version.js";
27
+ import { appendCrashLog } from "../shared/crash-log.js";
27
28
  const delay = (ms) => new Promise((r) => setTimeout(r, ms));
28
29
  const DEFAULT_MODEL = "gpt-4o"; // a valid Copilot model id; pass-through routing uses it as-is
29
30
  // Conservative context budget that drives the assistant's auto-compaction. Sized below the
30
31
  // common Copilot prompt window (gpt-4o ≈ 128K) so the engine compacts before the upstream
31
32
  // rejects an over-long turn. TODO: read each model's real max_prompt_tokens from /models.
32
33
  const DEFAULT_MAX_INPUT_TOKENS = 110_000;
34
+ // Process-level backstop. The TUI and the supervisor run in ONE process, so a stray throw or an
35
+ // unhandled rejection anywhere (a dead SSE socket, a bad creds read, an SDK stream) would otherwise
36
+ // terminate the whole app and drop the user back to the shell — especially on Node ≥15, where an
37
+ // unhandled rejection is fatal by default. We log to a file (Ink owns stdout, so console writes would
38
+ // corrupt the render) and keep running; the specific throw sites are also guarded at their source.
39
+ //
40
+ // uncaughtException is treated differently from unhandledRejection: Node documents the process as
41
+ // being in an undefined state afterward, so swallowing it indefinitely risks spinning forever on a
42
+ // recurring throw against corrupted state. A circuit breaker counts exceptions in a short window and
43
+ // lets the process die once they storm, so a user/supervisor restart gets a clean process — while a
44
+ // lone transient exception is still survived.
45
+ const UNCAUGHT_STORM_COUNT = 5;
46
+ const UNCAUGHT_STORM_WINDOW_MS = 10_000;
47
+ function installProcessBackstop() {
48
+ process.on("unhandledRejection", (reason) => appendCrashLog("unhandledRejection", reason));
49
+ let recent = [];
50
+ process.on("uncaughtException", (err) => {
51
+ appendCrashLog("uncaughtException", err);
52
+ const now = Date.now();
53
+ recent = recent.filter((t) => now - t < UNCAUGHT_STORM_WINDOW_MS);
54
+ recent.push(now);
55
+ if (recent.length >= UNCAUGHT_STORM_COUNT) {
56
+ appendCrashLog("uncaughtException", `${UNCAUGHT_STORM_COUNT} exceptions within ${UNCAUGHT_STORM_WINDOW_MS}ms — exiting for a clean restart`);
57
+ process.exit(1);
58
+ }
59
+ });
60
+ }
33
61
  async function launchTui() {
62
+ installProcessBackstop();
34
63
  const cfg = defaultConfig();
35
64
  const existingToken = readGhToken(dataDir());
36
65
  if (!existingToken) {
@@ -90,15 +119,19 @@ async function launchTui() {
90
119
  const { code, complete } = await beginDeviceLogin(dataDir());
91
120
  show([`Open ${code.verification_uri} and enter code: ${code.user_code}`, "waiting for authorization…"]);
92
121
  await complete();
93
- // Re-point the token store at the freshly written GitHub token; the old store still holds the
94
- // expired one and would 401 once its cached Copilot token rotates, breaking the model picker.
95
- tokenStore = new CopilotTokenStore(readGhToken(dataDir()));
122
+ // Drop the cached Copilot token so the next get() does a fresh exchange with the just-written
123
+ // GitHub token (the store re-reads the token on each exchange, so a new instance isn't required —
124
+ // but resetting clears any Copilot token cached against the old login).
125
+ tokenStore = new CopilotTokenStore(() => readGhToken(dataDir()));
96
126
  await client.restart().catch(() => { });
97
127
  return ["GitHub authorization complete — worker restarting with the new token"];
98
128
  };
99
129
  // Filled in below once we have a token; the assistant prefers a model's real window over the default.
100
130
  const modelLimits = {};
101
- let tokenStore = new CopilotTokenStore(readGhToken(dataDir()));
131
+ // Provider form: the store re-reads the GitHub token on each exchange, so a transient unreadable
132
+ // creds.json (Windows lock / partial write) can't poison the store for the session — it recovers on
133
+ // the next clean read, and a genuinely absent token surfaces as a 401 instead of a `token null` send.
134
+ let tokenStore = new CopilotTokenStore(() => readGhToken(dataDir()));
102
135
  const loadModels = async () => {
103
136
  const token = await tokenStore.get();
104
137
  const [ids, limits] = await Promise.all([fetchCopilotModels(token), fetchModelLimits(token)]);
@@ -137,7 +170,7 @@ async function launchTui() {
137
170
  // hangs until the turn timeout. Reuses the long-lived tokenStore so a valid login is a cached,
138
171
  // round-trip-free check between message bursts (its get() caches with a 60s skew).
139
172
  async () => {
140
- if (!readGhToken(dataDir()))
173
+ if (!hasGhTokenFile(dataDir()))
141
174
  return "you're signed out — run /login to sign in before chatting";
142
175
  try {
143
176
  await tokenStore.get();
@@ -12,24 +12,33 @@ export class CopilotAuthError extends Error {
12
12
  }
13
13
  }
14
14
  export class CopilotTokenStore {
15
- ghToken;
16
15
  fetchFn;
17
16
  nowMs;
18
17
  cached;
18
+ readGhToken;
19
+ // Accepts either a fixed token string or a provider that is re-read on each exchange. The provider
20
+ // form matters when the GitHub token can change or be momentarily unreadable: a captured-once null
21
+ // (e.g. from a transient locked-file read at construction) would otherwise poison the store for its
22
+ // whole lifetime, sending `authorization: token null` forever. Re-reading recovers on the next call.
19
23
  constructor(ghToken, fetchFn = fetch, nowMs = () => Date.now()) {
20
- this.ghToken = ghToken;
21
24
  this.fetchFn = fetchFn;
22
25
  this.nowMs = nowMs;
26
+ this.readGhToken = typeof ghToken === "function" ? ghToken : () => ghToken;
23
27
  }
24
28
  async get() {
25
29
  const skewMs = 60_000;
26
30
  if (this.cached && this.cached.expiresAtMs - skewMs > this.nowMs())
27
31
  return this.cached.token;
32
+ const ghToken = this.readGhToken();
33
+ // No GitHub token (absent / unreadable on this read) is an auth failure, not a request with a
34
+ // literal "null" credential — surface it the same way an expired token would be.
35
+ if (!ghToken)
36
+ throw new CopilotAuthError(401);
28
37
  const ctrl = new AbortController();
29
38
  const timer = setTimeout(() => ctrl.abort(), 8000);
30
39
  let res;
31
40
  try {
32
- res = await this.fetchFn(COPILOT_TOKEN_URL, { headers: { authorization: `token ${this.ghToken}`, accept: "application/json" }, signal: ctrl.signal });
41
+ res = await this.fetchFn(COPILOT_TOKEN_URL, { headers: { authorization: `token ${ghToken}`, accept: "application/json" }, signal: ctrl.signal });
33
42
  }
34
43
  finally {
35
44
  clearTimeout(timer);
@@ -3,7 +3,7 @@ export function defaultConfig() {
3
3
  bindHost: "127.0.0.1",
4
4
  supervisorPort: 7890,
5
5
  workerPort: 7891,
6
- restart: { maxCrashes: 5, windowMs: 60_000, baseBackoffMs: 500, maxBackoffMs: 8_000 },
6
+ restart: { maxCrashes: 5, windowMs: 60_000, baseBackoffMs: 500, maxBackoffMs: 8_000, unhealthyCooldownMs: 30_000 },
7
7
  // Empty = pass the requested model straight through to Copilot. Add entries (or "*") to remap.
8
8
  modelMap: {},
9
9
  // Set MAESTRO_REPORT_REPO=owner/repo to override where /report files diagnostics issues.
@@ -0,0 +1,28 @@
1
+ import { appendFileSync, mkdirSync, renameSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { dataDir } from "./paths.js";
4
+ export const CRASH_LOG_NAME = "crash.log";
5
+ // Cap the log so a high-frequency error source can't fill the disk: at the limit we roll the file to
6
+ // `crash.log.1` (one generation kept) and start fresh. Sized small — this is a diagnostics tail, not
7
+ // an archive.
8
+ export const CRASH_LOG_MAX_BYTES = 1_000_000;
9
+ function rollIfTooBig(path) {
10
+ try {
11
+ if (statSync(path).size >= CRASH_LOG_MAX_BYTES)
12
+ renameSync(path, `${path}.1`);
13
+ }
14
+ catch { /* file absent or stat/rename raced — nothing to roll */ }
15
+ }
16
+ // Append one diagnostics line to ~/.copilot-reverse/crash.log. Best-effort and never throws: logging
17
+ // must never itself crash a backstop or a swallowed-error path. Rotates at CRASH_LOG_MAX_BYTES. The
18
+ // dir is injectable for tests; production uses the real data dir.
19
+ export function appendCrashLog(kind, err, dir = dataDir()) {
20
+ const detail = err instanceof Error ? `${err.message}\n${err.stack ?? ""}` : String(err);
21
+ try {
22
+ mkdirSync(dir, { recursive: true });
23
+ const path = join(dir, CRASH_LOG_NAME);
24
+ rollIfTooBig(path);
25
+ appendFileSync(path, `[${new Date().toISOString()}] ${kind}: ${detail}\n`);
26
+ }
27
+ catch { /* logging is best-effort */ }
28
+ }
@@ -10,7 +10,23 @@ export function writeGhToken(token, dir) {
10
10
  export function readGhToken(dir) {
11
11
  if (!existsSync(file(dir)))
12
12
  return null;
13
- return JSON.parse(readFileSync(file(dir), "utf8")).ghToken ?? null;
13
+ // A corrupt creds.json (partial write) or a transient read failure (Windows EBUSY/EPERM when an
14
+ // antivirus or a concurrent /login write holds the file) must not throw: this is called from the
15
+ // 60s heartbeat tick whose rejection would otherwise reach the process top level and kill the TUI.
16
+ // Treat an unreadable file as "no token" — the next clean read recovers it.
17
+ try {
18
+ return JSON.parse(readFileSync(file(dir), "utf8")).ghToken ?? null;
19
+ }
20
+ catch {
21
+ return null;
22
+ }
23
+ }
24
+ // Whether a stored-token file exists at all — distinct from readGhToken, which also returns null when
25
+ // the file is present but momentarily unreadable. The "are you signed out?" gate wants existence (a
26
+ // transient lock on a real login should not read as signed out); the actual token validity is checked
27
+ // separately by exchanging it.
28
+ export function hasGhTokenFile(dir) {
29
+ return existsSync(file(dir));
14
30
  }
15
31
  // Remove the stored token (logout). No-op if there's nothing to remove.
16
32
  export function clearGhToken(dir) {
@@ -15,9 +15,24 @@ export function createControlApp(deps) {
15
15
  res.setHeader("content-type", "text/event-stream");
16
16
  res.setHeader("cache-control", "no-cache");
17
17
  res.flushHeaders?.();
18
- const send = (event, data) => res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
18
+ let off = () => { };
19
+ // Writing to a socket that died between broadcasts throws synchronously (ERR_STREAM_DESTROYED /
20
+ // EPIPE). emit() calls this on the worker-message path, so an uncaught throw would crash the
21
+ // in-process supervisor + TUI. Swallow the write error and unsubscribe — a dead connection should
22
+ // be dropped, not retried.
23
+ const send = (event, data) => {
24
+ try {
25
+ res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
26
+ }
27
+ catch {
28
+ off();
29
+ }
30
+ };
31
+ // Subscribe BEFORE the first write so that if the hello frame throws (socket already dead), the
32
+ // catch's off() refers to the real unsubscribe rather than the no-op default — otherwise a dead
33
+ // connection would stay subscribed until the next emit or 'close'.
34
+ off = deps.subscribe(send);
19
35
  send("hello", { state: deps.getState() });
20
- const off = deps.subscribe(send);
21
36
  req.on("close", off);
22
37
  });
23
38
  return app;
@@ -1,6 +1,18 @@
1
1
  export class EventBus {
2
2
  listeners = new Set();
3
3
  subscribe(fn) { this.listeners.add(fn); return () => this.listeners.delete(fn); }
4
- emit(event, data) { for (const fn of this.listeners)
5
- fn(event, data); }
4
+ // Isolate each listener: a throwing subscriber (e.g. an SSE write to a socket that died between
5
+ // broadcasts) must not abort the broadcast to the others, nor escape to the process top level —
6
+ // emit() is called synchronously from worker-message handling, so an uncaught throw here would
7
+ // kill the in-process supervisor + TUI. A faulting listener is dropped so it isn't retried.
8
+ emit(event, data) {
9
+ for (const fn of this.listeners) {
10
+ try {
11
+ fn(event, data);
12
+ }
13
+ catch {
14
+ this.listeners.delete(fn);
15
+ }
16
+ }
17
+ }
6
18
  }
@@ -1,4 +1,5 @@
1
1
  import { probeGithubAuth } from "../providers/copilot/token.js";
2
+ import { appendCrashLog } from "../shared/crash-log.js";
2
3
  // How often the supervisor re-checks the GitHub token. Token failure is rare (revoke / re-auth) and
3
4
  // GitHub rate-limits, so a slow cadence is plenty; an initial short delay populates the status soon
4
5
  // after boot without racing worker startup.
@@ -54,6 +55,14 @@ export class GithubHeartbeat {
54
55
  return; // a late result after stop() must not resurrect the timer/state
55
56
  this.status = nextGithubStatus(this.status, Boolean(token), probe, this.now());
56
57
  }
58
+ catch (e) {
59
+ // Defense in depth: readToken()/probe() are not expected to throw (readGhToken returns null on a
60
+ // bad read, probeGithubAuth never throws), but the timer fires this as `void runOnce()` — an
61
+ // unhandled rejection here would kill the in-process supervisor + TUI. Keep the last-known status,
62
+ // but log it: a throw here means a real (unexpected) defect, and swallowing it silently would
63
+ // freeze the badge with no trace.
64
+ appendCrashLog("github-heartbeat", e);
65
+ }
57
66
  finally {
58
67
  this.inFlight = false;
59
68
  }
@@ -10,6 +10,7 @@ import { dataDir, dbPath } from "../shared/paths.js";
10
10
  import { readGhToken } from "../shared/creds.js";
11
11
  import { probeGithubAuth } from "../providers/copilot/token.js";
12
12
  import { GithubHeartbeat, SIGNED_OUT_DETAIL } from "./github-heartbeat.js";
13
+ import { appendCrashLog } from "../shared/crash-log.js";
13
14
  export function startSupervisor() {
14
15
  const config = defaultConfig();
15
16
  mkdirSync(dataDir(), { recursive: true });
@@ -21,6 +22,9 @@ export function startSupervisor() {
21
22
  onStateChange: (s) => { state = s; bus.emit("state", { state: s }); },
22
23
  onCrash: (d, exitCode, stderrTail) => {
23
24
  recordRestart(db, { ts: Date.now(), reason: d.markedUnhealthy ? "unhealthy" : "crash", exitCode, stderrTail, backoffMs: d.backoffMs, markedUnhealthy: d.markedUnhealthy ? 1 : 0 });
25
+ // Also persist to crash.log so a worker crash is diagnosable post-mortem — the DB stderrTail can
26
+ // be empty if the worker died before flushing; this keeps whatever it did emit.
27
+ appendCrashLog("worker-crash", `exit=${exitCode} unhealthy=${d.markedUnhealthy} backoff=${d.backoffMs}ms\n${stderrTail || "(no stderr captured)"}`);
24
28
  bus.emit("crash", { exitCode, ...d });
25
29
  },
26
30
  onWorkerMessage: (m) => {
@@ -16,7 +16,7 @@ export class RestartController {
16
16
  const backoffMs = Math.min(this.policy.baseBackoffMs * 2 ** (this.consecutive - 1), this.policy.maxBackoffMs);
17
17
  return { backoffMs, markedUnhealthy: this.crashTimes.length >= this.policy.maxCrashes, crashesInWindow: this.crashTimes.length };
18
18
  }
19
- reset() { this.consecutive = 0; }
19
+ reset() { this.consecutive = 0; this.crashTimes = []; }
20
20
  }
21
21
  export class WorkerMonitor {
22
22
  config;
@@ -58,7 +58,14 @@ export class WorkerMonitor {
58
58
  const d = this.controller.onCrash();
59
59
  this.hooks.onCrash(d, code, this.stderrTail);
60
60
  if (d.markedUnhealthy) {
61
+ // Don't give up forever: a transient crash burst (token rotation, a flaky upstream) shouldn't
62
+ // leave the daemon permanently dead. Mark unhealthy, then after a cooldown reset the window and
63
+ // try once more — recovering on its own if the cause has passed.
61
64
  this.set("unhealthy");
65
+ setTimeout(() => { if (!this.stopped) {
66
+ this.controller.reset();
67
+ this.spawn();
68
+ } }, this.config.restart.unhealthyCooldownMs);
62
69
  return;
63
70
  }
64
71
  this.set("crashed");
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // AUTO-GENERATED by scripts/gen-version.mjs from package.json — do not edit.
2
- export const APP_VERSION = "0.5.0";
2
+ export const APP_VERSION = "0.5.2";
@@ -9,8 +9,13 @@ import { makeGatewayRunner } from "../core/server-tools.js";
9
9
  import { borrowSearch } from "../providers/copilot/borrow-search.js";
10
10
  import { dataDir } from "../shared/paths.js";
11
11
  import { defaultConfig } from "../shared/config.js";
12
- function send(msg) { if (process.send)
13
- process.send(msg); }
12
+ // Sending after the parent tore down the IPC channel throws ERR_IPC_CHANNEL_CLOSED; guard it so a
13
+ // crash-time report can't itself become a second, masking crash.
14
+ function send(msg) { try {
15
+ if (process.connected)
16
+ process.send?.(msg);
17
+ }
18
+ catch { /* channel gone */ } }
14
19
  const cfg = defaultConfig();
15
20
  const port = Number(process.env.WORKER_PORT ?? cfg.workerPort);
16
21
  const host = process.env.BIND_HOST ?? cfg.bindHost;
@@ -47,4 +52,18 @@ process.on("message", (m) => { if (m?.type === "shutdown") {
47
52
  clearInterval(hb);
48
53
  server.close(() => process.exit(0));
49
54
  } });
50
- process.on("uncaughtException", (e) => { send({ type: "error", message: e.message, stack: e.stack }); process.exit(1); });
55
+ // Crash diagnostics: write to STDERR FIRST so the supervisor's stderr capture (and crash.log) records
56
+ // the reason even when the IPC channel is already gone; the IPC send is a best-effort extra. Without
57
+ // the unhandledRejection handler, a stray floating rejection silently kills the worker on Node ≥15 —
58
+ // the exact "exit 1, empty stderr" crash loop we kept seeing.
59
+ function fatal(kind, e) {
60
+ const detail = e instanceof Error ? `${e.message}\n${e.stack ?? ""}` : String(e);
61
+ try {
62
+ process.stderr.write(`worker ${kind}: ${detail}\n`);
63
+ }
64
+ catch { /* nothing more we can do */ }
65
+ send({ type: "error", message: e instanceof Error ? e.message : String(e), stack: e instanceof Error ? e.stack : undefined });
66
+ process.exit(1);
67
+ }
68
+ process.on("uncaughtException", (e) => fatal("uncaughtException", e));
69
+ process.on("unhandledRejection", (e) => fatal("unhandledRejection", e));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "copilot-reverse",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "Interactive terminal app that exposes your GitHub Copilot subscription as local OpenAI- and Anthropic-compatible endpoints, with a self-healing daemon and a built-in assistant.",
5
5
  "type": "module",
6
6
  "license": "MIT",