copilot-reverse 0.4.0 → 0.5.0

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.
@@ -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
  }
@@ -41,13 +41,23 @@ export class CopilotTokenStore {
41
41
  return data.token;
42
42
  }
43
43
  }
44
- // True if the stored GitHub token still exchanges for a Copilot token.
44
+ // True if the stored GitHub token still exchanges for a Copilot token. A thin wrapper over
45
+ // probeGithubAuth so the token-exchange logic lives in exactly one place.
45
46
  export async function isCopilotTokenValid(ghToken, fetchFn = fetch) {
47
+ return (await probeGithubAuth(ghToken, fetchFn)).ok;
48
+ }
49
+ export async function probeGithubAuth(ghToken, fetchFn = fetch) {
46
50
  try {
47
51
  await new CopilotTokenStore(ghToken, fetchFn).get();
48
- return true;
52
+ return { ok: true, transient: false, detail: "token valid" };
49
53
  }
50
- catch {
51
- return false;
54
+ catch (e) {
55
+ // CopilotTokenStore throws CopilotAuthError(status) for any non-ok response, and other errors
56
+ // (AbortError on timeout, network failures) for the rest. We treat 401/403 as definitive auth
57
+ // failures; everything else is transient. See the limitations noted above.
58
+ if (e instanceof CopilotAuthError && (e.status === 401 || e.status === 403)) {
59
+ return { ok: false, transient: false, detail: e.message };
60
+ }
61
+ return { ok: false, transient: true, detail: e instanceof Error ? e.message : String(e) };
52
62
  }
53
63
  }
@@ -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 }); });
@@ -0,0 +1,81 @@
1
+ import { probeGithubAuth } from "../providers/copilot/token.js";
2
+ // How often the supervisor re-checks the GitHub token. Token failure is rare (revoke / re-auth) and
3
+ // GitHub rate-limits, so a slow cadence is plenty; an initial short delay populates the status soon
4
+ // after boot without racing worker startup.
5
+ export const GITHUB_HEARTBEAT_INTERVAL_MS = 60_000;
6
+ export const GITHUB_HEARTBEAT_INITIAL_DELAY_MS = 2_000;
7
+ // Shared so /doctor and the heartbeat show the same remediation hint for the signed-out state.
8
+ export const SIGNED_OUT_DETAIL = "not logged in — run /login";
9
+ // Pure reducer: given the prior cached status, whether a token is on disk, and the latest probe
10
+ // result, decide the next cached status. Transient errors are sticky — they keep the prior status —
11
+ // so a brief blip doesn't flip a connected session to "expired". Caveat (see probeGithubAuth): the
12
+ // stickiness is unbounded, and if the FIRST probe is transient (prev still undefined) the status stays
13
+ // undefined / "pending", so /api/status omits `github` and the HUD shows no badge until a non-transient
14
+ // result lands.
15
+ export function nextGithubStatus(prev, hasToken, probe, now) {
16
+ if (!hasToken)
17
+ return { ok: false, hasToken: false, checkedAt: now, detail: SIGNED_OUT_DETAIL };
18
+ if (probe && probe.transient)
19
+ return prev; // keep last-known-good (or stay pending if none yet)
20
+ if (!probe)
21
+ return prev;
22
+ return { ok: probe.ok, hasToken: true, checkedAt: now, detail: probe.detail };
23
+ }
24
+ // Periodically probes the GitHub token in the supervisor process and caches a GithubStatus the control
25
+ // API exposes via /api/status. Dependencies are injected for testing (token reader, probe, clock).
26
+ export class GithubHeartbeat {
27
+ readToken;
28
+ probe;
29
+ now;
30
+ status;
31
+ timer;
32
+ stopped = false;
33
+ inFlight = false;
34
+ intervalMs;
35
+ initialDelayMs;
36
+ constructor(readToken, probe = probeGithubAuth, now = () => Date.now(), opts = {}) {
37
+ this.readToken = readToken;
38
+ this.probe = probe;
39
+ this.now = now;
40
+ this.intervalMs = opts.intervalMs ?? GITHUB_HEARTBEAT_INTERVAL_MS;
41
+ this.initialDelayMs = opts.initialDelayMs ?? GITHUB_HEARTBEAT_INITIAL_DELAY_MS;
42
+ }
43
+ current() { return this.status; }
44
+ // One probe cycle. Reads the token first: no token → signed-out, and the network probe is skipped.
45
+ // Guarded so a slow probe (up to ~8s) can't overlap the next tick.
46
+ async runOnce() {
47
+ if (this.inFlight)
48
+ return;
49
+ this.inFlight = true;
50
+ try {
51
+ const token = this.readToken();
52
+ const probe = token ? await this.probe(token) : null;
53
+ if (this.stopped)
54
+ return; // a late result after stop() must not resurrect the timer/state
55
+ this.status = nextGithubStatus(this.status, Boolean(token), probe, this.now());
56
+ }
57
+ finally {
58
+ this.inFlight = false;
59
+ }
60
+ }
61
+ start() {
62
+ if (this.timer)
63
+ return; // idempotent: don't leak a second timer if start() is called twice
64
+ this.stopped = false;
65
+ const tick = () => { void this.runOnce(); };
66
+ this.timer = setTimeout(() => {
67
+ tick();
68
+ this.timer = setInterval(tick, this.intervalMs);
69
+ }, this.initialDelayMs);
70
+ }
71
+ stop() {
72
+ this.stopped = true;
73
+ // The timer handle is either the initial setTimeout or the later setInterval; clearing both kinds
74
+ // is safe with either function in Node.
75
+ if (this.timer) {
76
+ clearTimeout(this.timer);
77
+ clearInterval(this.timer);
78
+ this.timer = undefined;
79
+ }
80
+ }
81
+ }
@@ -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.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "copilot-reverse",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
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",