copilot-reverse 0.4.0 → 0.5.1

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();
@@ -50,13 +50,21 @@ export function responsesRequestToCanonical(req) {
50
50
  }
51
51
  return {
52
52
  model: req.model, stream: Boolean(req.stream), temperature: req.temperature, maxTokens: req.max_output_tokens,
53
- tools: req.tools?.filter((t) => t.type === "function" && t.name).map((t) => ({ name: t.name, description: t.description, parameters: t.parameters ?? {} })),
54
- // Hosted tools (web_search etc.) Codex requests for Copilot to run server-side. Keep them so the
55
- // outbound /responses translator forwards them verbatim, instead of dropping them like before.
56
- hostedTools: req.tools?.filter((t) => t.type !== "function" && t.type).map((t) => t.type),
53
+ // Function tools and `custom` tools (e.g. Codex's apply_patch) both carry a name keep them as
54
+ // named tools so Copilot doesn't reject a nameless tool. Only the KNOWN nameless server-side tools
55
+ // pass through as hostedTools; an unrecognized nameless tool is dropped rather than forwarded as a
56
+ // bare {type} (which makes Copilot 400 "Missing required parameter: tools[N].name" and kills the
57
+ // whole stream — surfaced to the Codex CLI as "stream closed before response.completed").
58
+ tools: req.tools?.filter((t) => (t.type === "function" || t.type === "custom") && t.name).map((t) => ({ name: t.name, description: t.description, parameters: t.parameters ?? {} })),
59
+ hostedTools: req.tools?.filter((t) => HOSTED_TOOL_TYPES.has(t.type ?? "")).map((t) => t.type),
57
60
  messages,
58
61
  };
59
62
  }
63
+ // Copilot's /responses accepts these as standalone nameless hosted tools. NOTE: `tool_search` is
64
+ // deliberately excluded — Copilot rejects it unless the request also defines "deferred" tools
65
+ // ("tools.tool_search requires at least one deferred tool"), which we can't satisfy, so forwarding it
66
+ // 400s the whole request. web_search is the one Codex hosted tool we can pass straight through.
67
+ const HOSTED_TOOL_TYPES = new Set(["web_search", "web_search_preview"]);
60
68
  // Build the non-stream Responses object: text -> an output_text message item, tool_use -> function_call items.
61
69
  export function canonicalToResponsesResponse(r) {
62
70
  const output = [];
@@ -84,6 +92,7 @@ export class ResponsesSSE {
84
92
  nextIndex = 0;
85
93
  textIndex;
86
94
  textItemId;
95
+ accumulatedText = ""; // the full assistant text, replayed in the terminal done events
87
96
  toolIndex = new Map();
88
97
  constructor(responseId, model) {
89
98
  this.responseId = responseId;
@@ -107,6 +116,7 @@ export class ResponsesSSE {
107
116
  out.push(this.ev("response.content_part.added", { item_id: this.textItemId, output_index: this.textIndex, content_index: 0, part: { type: "output_text", text: "", annotations: [] } }));
108
117
  }
109
118
  out.push(this.ev("response.output_text.delta", { item_id: this.textItemId, output_index: this.textIndex, content_index: 0, delta }));
119
+ this.accumulatedText += delta;
110
120
  return out;
111
121
  }
112
122
  toolStart(copilotIdx, callId, name) {
@@ -127,9 +137,10 @@ export class ResponsesSSE {
127
137
  finish(usage, _finishReason, argsByIdx) {
128
138
  const out = [];
129
139
  if (this.textIndex !== undefined) {
130
- out.push(this.ev("response.output_text.done", { item_id: this.textItemId, output_index: this.textIndex, content_index: 0, text: "" }));
131
- out.push(this.ev("response.content_part.done", { item_id: this.textItemId, output_index: this.textIndex, content_index: 0, part: { type: "output_text", text: "", annotations: [] } }));
132
- out.push(this.ev("response.output_item.done", { output_index: this.textIndex, item: { type: "message", id: this.textItemId, role: "assistant", status: "completed", content: [] } }));
140
+ const text = this.accumulatedText;
141
+ out.push(this.ev("response.output_text.done", { item_id: this.textItemId, output_index: this.textIndex, content_index: 0, text }));
142
+ out.push(this.ev("response.content_part.done", { item_id: this.textItemId, output_index: this.textIndex, content_index: 0, part: { type: "output_text", text, annotations: [] } }));
143
+ out.push(this.ev("response.output_item.done", { output_index: this.textIndex, item: { type: "message", id: this.textItemId, role: "assistant", status: "completed", content: [{ type: "output_text", text, annotations: [] }] } }));
133
144
  }
134
145
  for (const [copilotIdx, t] of this.toolIndex) {
135
146
  const args = argsByIdx?.get(copilotIdx) ?? "";
@@ -137,7 +148,14 @@ export class ResponsesSSE {
137
148
  out.push(this.ev("response.output_item.done", { output_index: t.outputIndex, item: { type: "function_call", id: t.itemId, status: "completed" } }));
138
149
  }
139
150
  const u = usage ? { input_tokens: usage.promptTokens, output_tokens: usage.completionTokens, total_tokens: usage.promptTokens + usage.completionTokens } : undefined;
140
- out.push(this.ev("response.completed", { response: { ...this.envelope("completed"), ...(u ? { usage: u } : {}) } }));
151
+ // Spec-correct clients reconstruct the final response from response.completed.response.output, so
152
+ // include the finished items (the text message + any function calls), not just an empty envelope.
153
+ const output = [];
154
+ if (this.textIndex !== undefined)
155
+ output.push({ type: "message", id: this.textItemId, role: "assistant", status: "completed", content: [{ type: "output_text", text: this.accumulatedText, annotations: [] }] });
156
+ for (const [copilotIdx, t] of this.toolIndex)
157
+ output.push({ type: "function_call", id: t.itemId, call_id: t.itemId.replace(/^fc_/, ""), arguments: argsByIdx?.get(copilotIdx) ?? "", status: "completed" });
158
+ out.push(this.ev("response.completed", { response: { ...this.envelope("completed"), output, ...(u ? { usage: u } : {}) } }));
141
159
  return out;
142
160
  }
143
161
  }
@@ -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);
@@ -41,13 +50,23 @@ export class CopilotTokenStore {
41
50
  return data.token;
42
51
  }
43
52
  }
44
- // True if the stored GitHub token still exchanges for a Copilot token.
53
+ // True if the stored GitHub token still exchanges for a Copilot token. A thin wrapper over
54
+ // probeGithubAuth so the token-exchange logic lives in exactly one place.
45
55
  export async function isCopilotTokenValid(ghToken, fetchFn = fetch) {
56
+ return (await probeGithubAuth(ghToken, fetchFn)).ok;
57
+ }
58
+ export async function probeGithubAuth(ghToken, fetchFn = fetch) {
46
59
  try {
47
60
  await new CopilotTokenStore(ghToken, fetchFn).get();
48
- return true;
61
+ return { ok: true, transient: false, detail: "token valid" };
49
62
  }
50
- catch {
51
- return false;
63
+ catch (e) {
64
+ // CopilotTokenStore throws CopilotAuthError(status) for any non-ok response, and other errors
65
+ // (AbortError on timeout, network failures) for the rest. We treat 401/403 as definitive auth
66
+ // failures; everything else is transient. See the limitations noted above.
67
+ if (e instanceof CopilotAuthError && (e.status === 401 || e.status === 403)) {
68
+ return { ok: false, transient: false, detail: e.message };
69
+ }
70
+ return { ok: false, transient: true, detail: e instanceof Error ? e.message : String(e) };
52
71
  }
53
72
  }
@@ -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) {
@@ -5,7 +5,7 @@ export function createControlApp(deps) {
5
5
  const app = express();
6
6
  app.use(express.json());
7
7
  app.get("/", (_req, res) => res.type("html").send(dashboardHtml()));
8
- app.get("/api/status", (_req, res) => res.json({ workerState: deps.getState(), restarts: listRestarts(deps.db, 50) }));
8
+ app.get("/api/status", (_req, res) => res.json({ workerState: deps.getState(), restarts: listRestarts(deps.db, 50), github: deps.github() }));
9
9
  app.post("/api/restart", (_req, res) => { deps.restart(); res.json({ ok: true }); });
10
10
  app.post("/api/stop", (_req, res) => { deps.stop(); res.json({ ok: true }); });
11
11
  app.post("/api/start", (_req, res) => { deps.start(); res.json({ ok: true }); });
@@ -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
  }
@@ -0,0 +1,90 @@
1
+ import { probeGithubAuth } from "../providers/copilot/token.js";
2
+ import { appendCrashLog } from "../shared/crash-log.js";
3
+ // How often the supervisor re-checks the GitHub token. Token failure is rare (revoke / re-auth) and
4
+ // GitHub rate-limits, so a slow cadence is plenty; an initial short delay populates the status soon
5
+ // after boot without racing worker startup.
6
+ export const GITHUB_HEARTBEAT_INTERVAL_MS = 60_000;
7
+ export const GITHUB_HEARTBEAT_INITIAL_DELAY_MS = 2_000;
8
+ // Shared so /doctor and the heartbeat show the same remediation hint for the signed-out state.
9
+ export const SIGNED_OUT_DETAIL = "not logged in — run /login";
10
+ // Pure reducer: given the prior cached status, whether a token is on disk, and the latest probe
11
+ // result, decide the next cached status. Transient errors are sticky — they keep the prior status —
12
+ // so a brief blip doesn't flip a connected session to "expired". Caveat (see probeGithubAuth): the
13
+ // stickiness is unbounded, and if the FIRST probe is transient (prev still undefined) the status stays
14
+ // undefined / "pending", so /api/status omits `github` and the HUD shows no badge until a non-transient
15
+ // result lands.
16
+ export function nextGithubStatus(prev, hasToken, probe, now) {
17
+ if (!hasToken)
18
+ return { ok: false, hasToken: false, checkedAt: now, detail: SIGNED_OUT_DETAIL };
19
+ if (probe && probe.transient)
20
+ return prev; // keep last-known-good (or stay pending if none yet)
21
+ if (!probe)
22
+ return prev;
23
+ return { ok: probe.ok, hasToken: true, checkedAt: now, detail: probe.detail };
24
+ }
25
+ // Periodically probes the GitHub token in the supervisor process and caches a GithubStatus the control
26
+ // API exposes via /api/status. Dependencies are injected for testing (token reader, probe, clock).
27
+ export class GithubHeartbeat {
28
+ readToken;
29
+ probe;
30
+ now;
31
+ status;
32
+ timer;
33
+ stopped = false;
34
+ inFlight = false;
35
+ intervalMs;
36
+ initialDelayMs;
37
+ constructor(readToken, probe = probeGithubAuth, now = () => Date.now(), opts = {}) {
38
+ this.readToken = readToken;
39
+ this.probe = probe;
40
+ this.now = now;
41
+ this.intervalMs = opts.intervalMs ?? GITHUB_HEARTBEAT_INTERVAL_MS;
42
+ this.initialDelayMs = opts.initialDelayMs ?? GITHUB_HEARTBEAT_INITIAL_DELAY_MS;
43
+ }
44
+ current() { return this.status; }
45
+ // One probe cycle. Reads the token first: no token → signed-out, and the network probe is skipped.
46
+ // Guarded so a slow probe (up to ~8s) can't overlap the next tick.
47
+ async runOnce() {
48
+ if (this.inFlight)
49
+ return;
50
+ this.inFlight = true;
51
+ try {
52
+ const token = this.readToken();
53
+ const probe = token ? await this.probe(token) : null;
54
+ if (this.stopped)
55
+ return; // a late result after stop() must not resurrect the timer/state
56
+ this.status = nextGithubStatus(this.status, Boolean(token), probe, this.now());
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
+ }
66
+ finally {
67
+ this.inFlight = false;
68
+ }
69
+ }
70
+ start() {
71
+ if (this.timer)
72
+ return; // idempotent: don't leak a second timer if start() is called twice
73
+ this.stopped = false;
74
+ const tick = () => { void this.runOnce(); };
75
+ this.timer = setTimeout(() => {
76
+ tick();
77
+ this.timer = setInterval(tick, this.intervalMs);
78
+ }, this.initialDelayMs);
79
+ }
80
+ stop() {
81
+ this.stopped = true;
82
+ // The timer handle is either the initial setTimeout or the later setInterval; clearing both kinds
83
+ // is safe with either function in Node.
84
+ if (this.timer) {
85
+ clearTimeout(this.timer);
86
+ clearInterval(this.timer);
87
+ this.timer = undefined;
88
+ }
89
+ }
90
+ }
@@ -8,7 +8,8 @@ import { createControlApp } from "./api.js";
8
8
  import { defaultConfig } from "../shared/config.js";
9
9
  import { dataDir, dbPath } from "../shared/paths.js";
10
10
  import { readGhToken } from "../shared/creds.js";
11
- import { CopilotTokenStore } from "../providers/copilot/token.js";
11
+ import { probeGithubAuth } from "../providers/copilot/token.js";
12
+ import { GithubHeartbeat, SIGNED_OUT_DETAIL } from "./github-heartbeat.js";
12
13
  export function startSupervisor() {
13
14
  const config = defaultConfig();
14
15
  mkdirSync(dataDir(), { recursive: true });
@@ -34,32 +35,33 @@ export function startSupervisor() {
34
35
  const gh = readGhToken(dataDir());
35
36
  let auth;
36
37
  if (!gh) {
37
- auth = { name: "github-auth", ok: false, detail: "not logged in — restart copilot-reverse to log in" };
38
+ auth = { name: "github-auth", ok: false, detail: SIGNED_OUT_DETAIL };
38
39
  }
39
40
  else {
40
- // Validate the token actually exchanges, not just that it exists on disk.
41
- try {
42
- await new CopilotTokenStore(gh).get();
43
- auth = { name: "github-auth", ok: true, detail: "token valid" };
44
- }
45
- catch (e) {
46
- auth = { name: "github-auth", ok: false, detail: e instanceof Error ? e.message : String(e) };
47
- }
41
+ // Validate the token actually exchanges, not just that it exists on disk. Shares the heartbeat's
42
+ // classifier so on-demand /doctor and the periodic probe agree.
43
+ const probe = await probeGithubAuth(gh);
44
+ auth = { name: "github-auth", ok: probe.ok, detail: probe.detail };
48
45
  }
49
46
  return [auth, { name: "worker", ok: state === "ready", detail: `worker is ${state}` }];
50
47
  };
48
+ // Periodically re-check the GitHub token so the UI reflects an expired/revoked login within ~60s,
49
+ // instead of only on the next failed request or a manual /status.
50
+ const heartbeat = new GithubHeartbeat(() => readGhToken(dataDir()));
51
51
  const app = createControlApp({
52
52
  db, getState: () => state,
53
53
  restart: () => monitor.restartManually(),
54
54
  stop: () => monitor.stop(),
55
55
  start: () => monitor.start(),
56
56
  doctor,
57
+ github: () => heartbeat.current(),
57
58
  subscribe: (send) => bus.subscribe(send),
58
59
  });
59
60
  app.listen(config.supervisorPort, config.bindHost, () => monitor.start());
60
- process.on("SIGINT", () => { monitor.stop(); process.exit(0); });
61
- process.on("SIGTERM", () => { monitor.stop(); process.exit(0); });
62
- return { stop: () => monitor.stop() };
61
+ heartbeat.start();
62
+ process.on("SIGINT", () => { heartbeat.stop(); monitor.stop(); process.exit(0); });
63
+ process.on("SIGTERM", () => { heartbeat.stop(); monitor.stop(); process.exit(0); });
64
+ return { stop: () => { heartbeat.stop(); monitor.stop(); } };
63
65
  }
64
66
  // Allow `node dist/supervisor/index.js` to boot the daemon directly.
65
67
  if (process.argv[1] && process.argv[1].endsWith(join("supervisor", "index.js")))
package/dist/tui/app.js CHANGED
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { useEffect, useRef, useState } from "react";
3
3
  import { Box, Text, useInput } from "ink";
4
4
  import { loadingVerb } from "../shared/format.js";
@@ -7,7 +7,7 @@ import { SetupWizard } from "./setup/wizard.js";
7
7
  import { ModelScreen } from "./screens/model.js";
8
8
  import { ConfigScreen } from "./screens/config.js";
9
9
  import { WebIqKeyScreen } from "./screens/webiq-key.js";
10
- import { summarizeStatus } from "./status-summary.js";
10
+ import { summarizeStatus, githubLoginState } from "./status-summary.js";
11
11
  import { theme } from "./theme.js";
12
12
  const stateColor = {
13
13
  ready: theme.ready, starting: theme.starting, crashed: theme.crashed, unhealthy: theme.unhealthy,
@@ -63,6 +63,8 @@ export function App({ registry, title, workerState = "starting", initialModel =
63
63
  const [state, setState] = useState(workerState);
64
64
  const [status, setStatus] = useState(() => readStatus?.() ?? EMPTY_STATUS);
65
65
  const [webBackend, setWebBackend] = useState(() => webSearchBackend?.() ?? "unavailable");
66
+ // GitHub login state, kept fresh by the supervisor heartbeat surfaced through the 2s status poll.
67
+ const [github, setGithub] = useState(startupStatus?.github);
66
68
  const [model, setModel] = useState(initialModel);
67
69
  const [screen, setScreen] = useState(pickModelOnStart && loadModels ? { kind: "model" } : null);
68
70
  const [, setNow] = useState(0); // ticks the live loading line while the assistant streams
@@ -82,8 +84,11 @@ export function App({ registry, title, workerState = "starting", initialModel =
82
84
  const tick = async () => {
83
85
  try {
84
86
  const s = await statusSource?.();
85
- if (alive && s)
87
+ if (alive && s) {
86
88
  setState(s.workerState);
89
+ if (s.github)
90
+ setGithub(githubLoginState(s.github.hasToken, s.github.ok)); // live login badge
91
+ }
87
92
  }
88
93
  catch { /* daemon momentarily down */ }
89
94
  if (alive)
@@ -127,7 +132,9 @@ export function App({ registry, title, workerState = "starting", initialModel =
127
132
  }
128
133
  if (t === "/status" && (startupStatus || githubStatus || webSearchBackend)) {
129
134
  // Render the live status overview (same card as startup), then the worker restart history.
130
- const github = githubStatus ? await githubStatus() : (startupStatus?.github ?? "signed-out");
135
+ // /status is an explicit "is my login OK right now?" do the live check when wired (the cached
136
+ // heartbeat can be up to ~60s stale), falling back to the cached/seed value only if it isn't.
137
+ const ghState = githubStatus ? await githubStatus() : (github ?? startupStatus?.github ?? "signed-out");
131
138
  let worker = state, restarts = [];
132
139
  try {
133
140
  const s = await statusSource?.();
@@ -138,7 +145,7 @@ export function App({ registry, title, workerState = "starting", initialModel =
138
145
  }
139
146
  catch { /* daemon momentarily down — show what we have */ }
140
147
  const summary = summarizeStatus({
141
- hasToken: github !== "signed-out", tokenValid: github === "connected",
148
+ hasToken: ghState !== "signed-out", tokenValid: ghState === "connected",
142
149
  webSearch: webSearchBackend?.() ?? webBackend, worker,
143
150
  clients: { claude: status.claude.user || status.claude.project, codex: status.codex.user || status.codex.project },
144
151
  });
@@ -253,5 +260,5 @@ export function App({ registry, title, workerState = "starting", initialModel =
253
260
  return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: theme.accent, children: ["\u273D ", _jsxs(Text, { color: theme.muted, children: [frame, " ", loadingVerb(elapsed), "\u2026 (esc to interrupt \u00B7 ", fmtElapsed(elapsed), " \u00B7 \u2193 ", fmtTokens(tokens), " tokens \u00B7 thinking)"] })] }), e.text ? _jsx(Text, { color: color, children: e.text }) : null] }, i));
254
261
  }
255
262
  return _jsx(Text, { color: color, children: e.text }, i);
256
- }) }), body, _jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsxs(Box, { children: [_jsx(Text, { color: theme.muted, children: "model " }), _jsx(Text, { color: theme.accent, children: model }), _jsx(Text, { color: theme.muted, children: " \u00B7 daemon " }), _jsx(Text, { color: stateColor[state], children: state }), _jsx(Text, { color: theme.muted, children: " \u00B7 web " }), _jsx(Text, { color: webBackend === "unavailable" ? theme.muted : theme.ready, children: webBackend === "webiq" ? "✓ webiq" : webBackend === "copilot" ? "✓ copilot" : "✗ /webiq" })] }), _jsxs(Box, { children: [_jsx(ClientBadge, { name: "claude", status: status.claude }), _jsx(Text, { color: theme.muted, children: " " }), _jsx(ClientBadge, { name: "codex", status: status.codex }), _jsx(Text, { color: theme.muted, children: " \u00B7 /help" })] })] })] }));
263
+ }) }), body, _jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsxs(Box, { children: [github && _jsxs(_Fragment, { children: [_jsx(Text, { color: theme.muted, children: "github " }), _jsx(Text, { color: github === "connected" ? theme.ready : theme.error, children: github === "connected" ? "✓" : "✗ /login" })] }), _jsxs(Text, { color: theme.muted, children: [github ? " · " : "", "daemon "] }), _jsx(Text, { color: stateColor[state], children: state })] }), _jsxs(Box, { children: [_jsx(Text, { color: theme.muted, children: "web " }), _jsx(Text, { color: webBackend === "unavailable" ? theme.muted : theme.ready, children: webBackend === "webiq" ? "✓ webiq" : webBackend === "copilot" ? "✓ copilot" : "✗ /webiq" }), _jsx(Text, { color: theme.muted, children: " \u00B7 " }), _jsx(ClientBadge, { name: "claude", status: status.claude }), _jsx(Text, { color: theme.muted, children: " " }), _jsx(ClientBadge, { name: "codex", status: status.codex }), _jsx(Text, { color: theme.muted, children: " \u00B7 /help" })] })] })] }));
257
264
  }
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.4.0";
2
+ export const APP_VERSION = "0.5.1";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "copilot-reverse",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
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",