copilot-reverse 0.1.0 → 0.2.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/auth.js CHANGED
@@ -1,9 +1,23 @@
1
1
  import { requestDeviceCode, pollForToken } from "../providers/copilot/auth.js";
2
2
  import { writeGhToken } from "../shared/creds.js";
3
- export async function runDeviceLogin(dir, fetchFn = fetch, log = console.log) {
3
+ // Two-phase device login. `beginDeviceLogin` returns the verification code right away so a caller
4
+ // can surface it to the user; `complete()` then blocks on authorization and writes the token.
5
+ // Splitting these is what lets the TUI render the code while the poll is still pending — folding
6
+ // both into one call buffers the code behind the blocking poll, and the user can't authorize a
7
+ // code they can't see.
8
+ export async function beginDeviceLogin(dir, fetchFn = fetch) {
4
9
  const code = await requestDeviceCode(fetchFn);
10
+ return {
11
+ code,
12
+ complete: async () => {
13
+ const token = await pollForToken(code.device_code, code.interval * 1000, fetchFn);
14
+ writeGhToken(token, dir);
15
+ },
16
+ };
17
+ }
18
+ export async function runDeviceLogin(dir, fetchFn = fetch, log = console.log) {
19
+ const { code, complete } = await beginDeviceLogin(dir, fetchFn);
5
20
  log(`\nOpen ${code.verification_uri} and enter code: ${code.user_code}\n`);
6
- const token = await pollForToken(code.device_code, code.interval * 1000, fetchFn);
7
- writeGhToken(token, dir);
21
+ await complete();
8
22
  log("GitHub authorization complete.");
9
23
  }
package/dist/cli/index.js CHANGED
@@ -5,7 +5,7 @@ import { Command } from "commander";
5
5
  import { App } from "../tui/app.js";
6
6
  import { buildRegistry } from "../tui/slash/commands.js";
7
7
  import { DaemonClient } from "../tui/daemon-client.js";
8
- import { runDeviceLogin } from "./auth.js";
8
+ import { runDeviceLogin, beginDeviceLogin } from "./auth.js";
9
9
  import { probeSupervisor } from "../daemon/lifecycle.js";
10
10
  import { startSupervisor } from "../supervisor/index.js";
11
11
  import { runAssistantTurn } from "../tui/assistant/runtime.js";
@@ -21,6 +21,7 @@ import { applyCodexToml } from "../tui/setup/codex-toml.js";
21
21
  import { claudeCopilotReverseEnv } from "../tui/setup/clients.js";
22
22
  import { dataDir } from "../shared/paths.js";
23
23
  import { defaultConfig } from "../shared/config.js";
24
+ import { APP_VERSION } from "../version.js";
24
25
  const delay = (ms) => new Promise((r) => setTimeout(r, ms));
25
26
  const DEFAULT_MODEL = "gpt-4o"; // a valid Copilot model id; pass-through routing uses it as-is
26
27
  // Conservative context budget that drives the assistant's auto-compaction. Sized below the
@@ -70,17 +71,9 @@ async function launchTui() {
70
71
  const registry = buildRegistry({ client, quit }, endpoint, {
71
72
  dashboardUrl: `http://${cfg.bindHost}:${cfg.supervisorPort}/`,
72
73
  reportRepo: cfg.reportRepo,
73
- appVersion: "0.1.0",
74
+ appVersion: APP_VERSION,
74
75
  platform: `${process.platform} node-${process.version}`,
75
76
  resetClient,
76
- // Re-run device-code login, then restart the worker so it picks up the new token.
77
- login: async () => {
78
- const lines = [];
79
- await runDeviceLogin(dataDir(), fetch, (m) => lines.push(m));
80
- await client.restart().catch(() => { });
81
- lines.push("worker restarting with the new token");
82
- return lines;
83
- },
84
77
  // Clear the stored token and restart the worker (it will report unauthenticated until re-login).
85
78
  logout: async () => {
86
79
  clearGhToken(dataDir());
@@ -88,9 +81,22 @@ async function launchTui() {
88
81
  return ["signed out — GitHub token removed", "run /login to sign in again"];
89
82
  },
90
83
  });
84
+ // Two-phase /login for the TUI: surface the device code immediately, poll in the background, then
85
+ // restart the worker so it picks up the new token. The blocking single-call form deadlocked the
86
+ // Repl (the code stayed hidden behind the poll, so the user could never authorize it).
87
+ const doLogin = async (show) => {
88
+ const { code, complete } = await beginDeviceLogin(dataDir());
89
+ show([`Open ${code.verification_uri} and enter code: ${code.user_code}`, "waiting for authorization…"]);
90
+ await complete();
91
+ // Re-point the token store at the freshly written GitHub token; the old store still holds the
92
+ // expired one and would 401 once its cached Copilot token rotates, breaking the model picker.
93
+ tokenStore = new CopilotTokenStore(readGhToken(dataDir()));
94
+ await client.restart().catch(() => { });
95
+ return ["GitHub authorization complete — worker restarting with the new token"];
96
+ };
91
97
  // Filled in below once we have a token; the assistant prefers a model's real window over the default.
92
98
  const modelLimits = {};
93
- const tokenStore = new CopilotTokenStore(readGhToken(dataDir()));
99
+ let tokenStore = new CopilotTokenStore(readGhToken(dataDir()));
94
100
  const loadModels = async () => {
95
101
  const token = await tokenStore.get();
96
102
  const [ids, limits] = await Promise.all([fetchCopilotModels(token), fetchModelLimits(token)]);
@@ -121,7 +127,22 @@ async function launchTui() {
121
127
  maxInputTokens: DEFAULT_MAX_INPUT_TOKENS, modelLimits,
122
128
  listModels: loadModels,
123
129
  setupClient: async (c, s, m) => applyClient(c, s, m),
124
- }, (c, p, print, abort) => runAssistantTurn(c, p, print, undefined, abort));
130
+ }, (c, p, print, abort) => runAssistantTurn(c, p, print, undefined, abort), undefined,
131
+ // Pre-flight auth gate: block a turn (with an actionable hint) when there's no GitHub token, or
132
+ // the stored one no longer exchanges for a Copilot token — instead of firing a request that just
133
+ // hangs until the turn timeout. Reuses the long-lived tokenStore so a valid login is a cached,
134
+ // round-trip-free check between message bursts (its get() caches with a 60s skew).
135
+ async () => {
136
+ if (!readGhToken(dataDir()))
137
+ return "you're signed out — run /login to sign in before chatting";
138
+ try {
139
+ await tokenStore.get();
140
+ return null;
141
+ }
142
+ catch {
143
+ return "your GitHub login has expired — run /login to sign in again";
144
+ }
145
+ });
125
146
  const persistedModel = readChatModel(dataDir());
126
147
  app = render(React.createElement(App, {
127
148
  registry,
@@ -142,10 +163,11 @@ async function launchTui() {
142
163
  },
143
164
  onModelChange: (m) => writeChatModel(dataDir(), m),
144
165
  pickModelOnStart: !persistedModel,
166
+ login: doLogin,
145
167
  }));
146
168
  }
147
169
  const program = new Command();
148
- program.name("copilot-reverse").description("copilot-reverse: interactive Copilot proxy").version("0.1.0");
170
+ program.name("copilot-reverse").description("copilot-reverse: interactive Copilot proxy").version(APP_VERSION);
149
171
  program.command("login").description("GitHub device-code login").action(() => runDeviceLogin(dataDir()));
150
172
  program.action(() => { void launchTui(); });
151
173
  program.parseAsync(process.argv);
@@ -0,0 +1,92 @@
1
+ import { randomUUID } from "node:crypto";
2
+ // Opening sentinels that switch the parser into capture mode. The `antml:` namespaced variants are
3
+ // the un-stripped originals; the bare forms are what survives when the namespace prefix is dropped.
4
+ const TRIGGER_RE = /<(?:antml:)?(?:function_calls>|invoke\b)/;
5
+ // Longest suffix of `s` that is a proper prefix of a trigger token — text we must hold back because
6
+ // it might be the front of a sentinel split across chunk boundaries (e.g. "…<inv" then "oke name=").
7
+ const PREFIX_TOKENS = ["<function_calls>", "<function_calls>", "<invoke", "<invoke"];
8
+ function heldBackLen(s) {
9
+ let max = 0;
10
+ for (const t of PREFIX_TOKENS) {
11
+ for (let k = Math.min(s.length, t.length - 1); k > 0; k--) {
12
+ if (s.endsWith(t.slice(0, k))) {
13
+ if (k > max)
14
+ max = k;
15
+ break;
16
+ }
17
+ }
18
+ }
19
+ return max;
20
+ }
21
+ // Index just past a `</tag>` (or `</tag>`) close, or -1 if not yet present in `s`.
22
+ function closeIndex(s, tag) {
23
+ const m = new RegExp(`</(?:antml:)?${tag}>`).exec(s);
24
+ return m ? m.index + m[0].length : -1;
25
+ }
26
+ // A scalar parameter value is raw text in the XML; recover its intended type by trying JSON, so
27
+ // `42`/`true`/`{"a":1}` become real values while a bare command string stays a string.
28
+ function coerce(raw) {
29
+ const v = raw.replace(/^\n/, "").replace(/\n$/, "");
30
+ try {
31
+ return JSON.parse(v);
32
+ }
33
+ catch {
34
+ return v;
35
+ }
36
+ }
37
+ function parseInvokes(block) {
38
+ const tools = [];
39
+ const invokeRe = /<(?:antml:)?invoke\s+name="([^"]+)"\s*>([\s\S]*?)<\/(?:antml:)?invoke>/g;
40
+ for (let m = invokeRe.exec(block); m; m = invokeRe.exec(block)) {
41
+ const [, name, body] = m;
42
+ const input = {};
43
+ const paramRe = /<(?:antml:)?parameter\s+name="([^"]+)"\s*>([\s\S]*?)<\/(?:antml:)?parameter>/g;
44
+ for (let p = paramRe.exec(body); p; p = paramRe.exec(body))
45
+ input[p[1]] = coerce(p[2]);
46
+ tools.push({ id: `call_${randomUUID().replace(/-/g, "").slice(0, 24)}`, name, input });
47
+ }
48
+ return tools;
49
+ }
50
+ export class ToolCallExtractor {
51
+ buf = "";
52
+ capturing = false;
53
+ feed(chunk) {
54
+ this.buf += chunk;
55
+ const events = [];
56
+ for (;;) {
57
+ if (!this.capturing) {
58
+ const m = TRIGGER_RE.exec(this.buf);
59
+ if (!m) {
60
+ // No trigger; emit everything except a possible partial-sentinel tail.
61
+ const keep = heldBackLen(this.buf);
62
+ const emit = this.buf.slice(0, this.buf.length - keep);
63
+ if (emit)
64
+ events.push({ kind: "text", text: emit });
65
+ this.buf = keep ? this.buf.slice(this.buf.length - keep) : "";
66
+ return events;
67
+ }
68
+ if (m.index > 0)
69
+ events.push({ kind: "text", text: this.buf.slice(0, m.index) });
70
+ this.buf = this.buf.slice(m.index);
71
+ this.capturing = true;
72
+ }
73
+ const isWrapper = /^<(?:antml:)?function_calls>/.test(this.buf);
74
+ const end = closeIndex(this.buf, isWrapper ? "function_calls" : "invoke");
75
+ if (end < 0)
76
+ return events; // incomplete block — wait for more data
77
+ const block = this.buf.slice(0, end);
78
+ for (const tool of parseInvokes(block))
79
+ events.push({ kind: "tool", tool });
80
+ this.buf = this.buf.slice(end);
81
+ this.capturing = false; // a following <invoke> re-triggers via the passthrough branch
82
+ }
83
+ }
84
+ // Stream ended. Anything still buffered is an incomplete block we couldn't parse — emit it as
85
+ // text so nothing is silently dropped.
86
+ flush() {
87
+ const out = this.buf ? [{ kind: "text", text: this.buf }] : [];
88
+ this.buf = "";
89
+ this.capturing = false;
90
+ return out;
91
+ }
92
+ }
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { ToolCallExtractor } from "../../core/tool-xml.js";
2
3
  const CHAT_URL = "https://api.githubcopilot.com/chat/completions";
3
4
  // Canonical messages -> OpenAI wire messages (Copilot is OpenAI-shaped).
4
5
  function toWireMessages(messages) {
@@ -88,6 +89,27 @@ export class CopilotAdapter {
88
89
  let finishReason = "stop";
89
90
  let usage;
90
91
  const mapFinish = (f) => f === "tool_calls" ? "tool_use" : f === "length" ? "length" : "stop";
92
+ // Some models emit a tool call as inline XML text instead of native tool_calls (more likely on
93
+ // long/tool-heavy turns). When the request has tools, route assistant text through an extractor
94
+ // that recovers those blocks into structured tool calls; otherwise text passes straight through.
95
+ const extractor = req.tools?.length ? new ToolCallExtractor() : undefined;
96
+ let extractedTool = false;
97
+ let extIdx = 100; // separate index space so recovered tools never collide with native tool_calls
98
+ const toChunks = (events) => {
99
+ const out = [];
100
+ for (const ev of events) {
101
+ if (ev.kind === "text") {
102
+ if (ev.text)
103
+ out.push({ kind: "text", delta: ev.text, done: false });
104
+ continue;
105
+ }
106
+ const index = extIdx++;
107
+ extractedTool = true;
108
+ out.push({ kind: "tool_use_start", index, id: ev.tool.id, name: ev.tool.name, done: false });
109
+ out.push({ kind: "tool_use_delta", index, argsDelta: JSON.stringify(ev.tool.input), done: false });
110
+ }
111
+ return out;
112
+ };
91
113
  for (;;) {
92
114
  const { value, done } = await reader.read();
93
115
  if (done)
@@ -101,6 +123,11 @@ export class CopilotAdapter {
101
123
  continue;
102
124
  const payload = line.slice(6).trim();
103
125
  if (payload === "[DONE]") {
126
+ if (extractor)
127
+ for (const ch of toChunks(extractor.flush()))
128
+ yield ch;
129
+ if (extractedTool && finishReason === "stop")
130
+ finishReason = "tool_use";
104
131
  yield { kind: "done", done: true, finishReason, usage };
105
132
  return;
106
133
  }
@@ -122,8 +149,14 @@ export class CopilotAdapter {
122
149
  const delta = choice.delta;
123
150
  if (!delta)
124
151
  continue;
125
- if (delta.content)
126
- yield { kind: "text", delta: delta.content, done: false };
152
+ if (delta.content) {
153
+ if (extractor) {
154
+ for (const ch of toChunks(extractor.feed(delta.content)))
155
+ yield ch;
156
+ }
157
+ else
158
+ yield { kind: "text", delta: delta.content, done: false };
159
+ }
127
160
  for (const tc of delta.tool_calls ?? []) {
128
161
  const idx = tc.index ?? 0;
129
162
  if (!startedTools.has(idx) && tc.function?.name) {
@@ -135,6 +168,11 @@ export class CopilotAdapter {
135
168
  }
136
169
  }
137
170
  }
171
+ if (extractor)
172
+ for (const ch of toChunks(extractor.flush()))
173
+ yield ch;
174
+ if (extractedTool && finishReason === "stop")
175
+ finishReason = "tool_use";
138
176
  yield { kind: "done", done: true, finishReason, usage };
139
177
  }
140
178
  }
package/dist/tui/app.js CHANGED
@@ -36,7 +36,7 @@ function ClientBadge({ name, status }) {
36
36
  const cell = (label, on) => (_jsxs(Text, { color: on ? theme.ready : theme.muted, children: [label, ":", on ? "✓" : "○"] }));
37
37
  return (_jsxs(Text, { color: theme.muted, children: [name, " ", cell("u", status.user), " ", cell("p", status.project)] }));
38
38
  }
39
- export function App({ registry, title, workerState = "starting", initialModel = "—", statusSource, readStatus, modelLimits, onChat, loadModels, setup, info, onModelChange, pickModelOnStart, }) {
39
+ export function App({ registry, title, workerState = "starting", initialModel = "—", statusSource, readStatus, modelLimits, onChat, loadModels, setup, info, onModelChange, pickModelOnStart, login, }) {
40
40
  const cmds = registry.list().map((c) => ({ name: c.name, describe: c.describe }));
41
41
  const [entries, setEntries] = useState([
42
42
  { type: "system", text: "Type a message to chat with the assistant, or /help for commands." },
@@ -47,6 +47,7 @@ export function App({ registry, title, workerState = "starting", initialModel =
47
47
  const [screen, setScreen] = useState(pickModelOnStart && loadModels ? { kind: "model" } : null);
48
48
  const [, setNow] = useState(0); // ticks the live loading line while the assistant streams
49
49
  const abortRef = useRef(null); // current turn's interrupt handle
50
+ const loginInFlight = useRef(false); // guards against starting a second device-login flow
50
51
  const add = (e) => setEntries((p) => [...p, e].slice(-100));
51
52
  const refreshStatus = () => { if (readStatus)
52
53
  setStatus(readStatus()); };
@@ -95,6 +96,22 @@ export function App({ registry, title, workerState = "starting", initialModel =
95
96
  setScreen({ kind: "config" });
96
97
  return;
97
98
  }
99
+ if (t === "/login" && login) {
100
+ // Show the verification URL + code right away, then resolve a completion card once the user
101
+ // authorizes. Done as a special case (not a registry command) because the slash registry only
102
+ // renders a command's final return value — it can't surface the code mid-poll. Guarded so a
103
+ // double Enter doesn't start two device-code flows (polling a superseded code 401s).
104
+ if (loginInFlight.current) {
105
+ add({ type: "card", title: "/login", tone: "info", lines: ["already waiting for authorization…"] });
106
+ return;
107
+ }
108
+ loginInFlight.current = true;
109
+ void login((lines) => add({ type: "card", title: "/login", tone: "info", lines }))
110
+ .then((lines) => add({ type: "card", title: "/login", tone: "ok", lines }))
111
+ .catch((e) => add({ type: "card", title: "/login", tone: "error", lines: [`login failed: ${e instanceof Error ? e.message : String(e)}`] }))
112
+ .finally(() => { loginInFlight.current = false; });
113
+ return;
114
+ }
98
115
  if (setup && loadModels && (t === "/setup-claude" || t === "/setup-codex")) {
99
116
  setScreen({ kind: "setup", client: t === "/setup-claude" ? "claude" : "codex" });
100
117
  return;
@@ -1,6 +1,15 @@
1
1
  const DEFAULT_TURN_TIMEOUT_MS = 120_000; // 2 minutes — a turn that hasn't replied by then is given up on
2
- export function makeOnChat(cfg, runner, timeoutMs = DEFAULT_TURN_TIMEOUT_MS) {
2
+ export function makeOnChat(cfg, runner, timeoutMs = DEFAULT_TURN_TIMEOUT_MS, precheck) {
3
3
  return async (text, print, model, abort) => {
4
+ // Gate the turn on auth before firing a doomed request. Without this, a signed-out user's message
5
+ // hangs until the 120s timeout instead of getting an immediate, actionable hint.
6
+ if (precheck) {
7
+ const blocked = await precheck().catch(() => null); // a failed check must never wedge chat
8
+ if (blocked) {
9
+ print(blocked);
10
+ return;
11
+ }
12
+ }
4
13
  const ctrl = abort ?? new AbortController();
5
14
  let timedOut = false;
6
15
  // Race the turn against a hard timeout so a hung SDK/upstream can never block the UI forever.
@@ -0,0 +1,2 @@
1
+ // AUTO-GENERATED by scripts/gen-version.mjs from package.json — do not edit.
2
+ export const APP_VERSION = "0.2.1";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "copilot-reverse",
3
- "version": "0.1.0",
3
+ "version": "0.2.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",
@@ -32,12 +32,14 @@
32
32
  "llm"
33
33
  ],
34
34
  "scripts": {
35
+ "prebuild": "node scripts/gen-version.mjs",
35
36
  "build": "tsc -p tsconfig.json",
36
37
  "test": "vitest run",
37
38
  "test:coverage": "vitest run --coverage",
38
39
  "test:e2e": "vitest run e2e/copilot-reverse.e2e.test.ts tests/e2e",
39
40
  "test:integration": "vitest run --config vitest.integration.config.ts",
40
41
  "dev": "tsx src/cli/index.ts",
42
+ "changeset": "node scripts/changesets.mjs new",
41
43
  "prepublishOnly": "npm run build && npm run test"
42
44
  },
43
45
  "engines": {