copilot-reverse 0.5.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 +39 -6
- package/dist/providers/copilot/token.js +12 -3
- package/dist/shared/crash-log.js +28 -0
- package/dist/shared/creds.js +17 -1
- package/dist/supervisor/api.js +17 -2
- package/dist/supervisor/events.js +14 -2
- package/dist/supervisor/github-heartbeat.js +9 -0
- package/dist/version.js +1 -1
- package/package.json +1 -1
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
|
-
//
|
|
94
|
-
//
|
|
95
|
-
|
|
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
|
-
|
|
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 (!
|
|
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 ${
|
|
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);
|
|
@@ -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
|
+
}
|
package/dist/shared/creds.js
CHANGED
|
@@ -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
|
-
|
|
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) {
|
package/dist/supervisor/api.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
5
|
-
|
|
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
|
}
|
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.
|
|
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.5.
|
|
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",
|