copilot-reverse 0.2.1 → 0.4.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.
@@ -0,0 +1,161 @@
1
+ import { randomUUID } from "node:crypto";
2
+ // Outbound translation to GitHub Copilot's OpenAI Responses API. Newer Copilot models (e.g. gpt-5.5)
3
+ // are served ONLY on /responses — their `supported_endpoints` omits /chat/completions — so the adapter
4
+ // routes them here instead of the chat path. This is the mirror image of core/responses-inbound.ts
5
+ // (which translates Codex's INBOUND /responses calls); here we SEND /responses to Copilot.
6
+ export const RESPONSES_URL = "https://api.githubcopilot.com/responses";
7
+ function textOf(content) {
8
+ return content.filter((b) => b.type === "text").map((b) => b.text).join("");
9
+ }
10
+ // One canonical message can expand into several Responses items (parallel tool calls / results).
11
+ function messageToItems(m) {
12
+ const items = [];
13
+ const toolResults = m.content.filter((b) => b.type === "tool_result");
14
+ for (const tr of toolResults)
15
+ items.push({ type: "function_call_output", call_id: tr.toolUseId, output: tr.content });
16
+ if (toolResults.length)
17
+ return items; // a tool message carries only results
18
+ const toolUses = m.content.filter((b) => b.type === "tool_use");
19
+ for (const tu of toolUses)
20
+ items.push({ type: "function_call", call_id: tu.id, name: tu.name, arguments: JSON.stringify(tu.input ?? {}) });
21
+ // Assistant text becomes an output_text part; user/system text an input_text part. Images are input_image.
22
+ const text = textOf(m.content);
23
+ const images = m.content.filter((b) => b.type === "image");
24
+ const parts = [];
25
+ const textType = m.role === "assistant" ? "output_text" : "input_text";
26
+ if (text)
27
+ parts.push({ type: textType, text });
28
+ for (const img of images)
29
+ parts.push({ type: "input_image", image_url: img.dataUrl });
30
+ if (parts.length)
31
+ items.push({ type: "message", role: m.role, content: parts });
32
+ return items;
33
+ }
34
+ export function canonicalToResponsesBody(req) {
35
+ const system = req.messages.filter((m) => m.role === "system").map((m) => textOf(m.content)).filter(Boolean).join("\n");
36
+ const input = [];
37
+ for (const m of req.messages) {
38
+ if (m.role === "system")
39
+ continue;
40
+ input.push(...messageToItems(m));
41
+ }
42
+ // Function tools translate to {type:"function",…}; hosted tools (web_search) pass through as {type}.
43
+ const tools = [
44
+ ...(req.tools ?? []).map((t) => ({ type: "function", name: t.name, description: t.description, parameters: t.parameters })),
45
+ ...(req.hostedTools ?? []).map((type) => ({ type })),
46
+ ];
47
+ return {
48
+ model: req.model, input, stream: req.stream,
49
+ ...(system ? { instructions: system } : {}),
50
+ ...(req.temperature !== undefined ? { temperature: req.temperature } : {}),
51
+ ...(req.maxTokens !== undefined ? { max_output_tokens: req.maxTokens } : {}),
52
+ ...(tools.length ? { tools } : {}),
53
+ };
54
+ }
55
+ // ---- non-stream response: Responses object -> canonical -----------------------------------------
56
+ function safeJson(s) { try {
57
+ return s ? JSON.parse(s) : {};
58
+ }
59
+ catch {
60
+ return {};
61
+ } }
62
+ function mapIncomplete(reason) {
63
+ return reason === "max_output_tokens" ? "length" : "stop";
64
+ }
65
+ export function parseResponsesResult(data) {
66
+ const content = [];
67
+ let sawTool = false;
68
+ for (const item of data.output ?? []) {
69
+ if (item.type === "message") {
70
+ const text = (item.content ?? []).filter((p) => p.type === "output_text").map((p) => p.text ?? "").join("");
71
+ if (text)
72
+ content.push({ type: "text", text });
73
+ }
74
+ else if (item.type === "function_call") {
75
+ sawTool = true;
76
+ content.push({ type: "tool_use", id: item.call_id ?? item.id, name: item.name ?? "", input: safeJson(item.arguments) });
77
+ }
78
+ }
79
+ const finishReason = data.status === "incomplete" ? mapIncomplete(data.incomplete_details?.reason) : sawTool ? "tool_use" : "stop";
80
+ return {
81
+ id: data.id ?? `resp-${randomUUID().replace(/-/g, "")}`, model: data.model, content, finishReason,
82
+ usage: { promptTokens: data.usage?.input_tokens ?? 0, completionTokens: data.usage?.output_tokens ?? 0 },
83
+ };
84
+ }
85
+ // ---- streaming: Responses SSE -> canonical chunks ------------------------------------------------
86
+ // Copilot's Responses stream is item-centric: each output item is announced by response.output_item.added
87
+ // (carrying the item's type + identity), then text streams via response.output_text.delta and tool args
88
+ // via response.function_call_arguments.delta. We map item output_index -> a canonical tool index so deltas
89
+ // attach to the right call. The terminal event is response.completed (or response.incomplete on a cap).
90
+ export async function* streamResponses(res) {
91
+ if (!res.body) {
92
+ yield { kind: "done", done: true, finishReason: "stop" };
93
+ return;
94
+ }
95
+ const reader = res.body.getReader();
96
+ const decoder = new TextDecoder();
97
+ let buffer = "";
98
+ let finishReason = "stop";
99
+ let usage;
100
+ const toolByOutputIndex = new Map(); // responses output_index -> canonical tool index
101
+ let nextToolIndex = 0;
102
+ const usageOf = (u) => u ? { promptTokens: u.input_tokens ?? 0, completionTokens: u.output_tokens ?? 0, cachedTokens: u.input_tokens_details?.cached_tokens ?? 0 } : undefined;
103
+ for (;;) {
104
+ const { value, done } = await reader.read();
105
+ if (done)
106
+ break;
107
+ buffer += decoder.decode(value, { stream: true });
108
+ const frames = buffer.split("\n\n");
109
+ buffer = frames.pop() ?? "";
110
+ for (const frame of frames) {
111
+ const line = frame.split("\n").find((l) => l.startsWith("data: "));
112
+ if (!line)
113
+ continue;
114
+ const payload = line.slice(6).trim();
115
+ if (!payload || payload === "[DONE]")
116
+ continue;
117
+ let ev;
118
+ try {
119
+ ev = JSON.parse(payload);
120
+ }
121
+ catch {
122
+ continue;
123
+ }
124
+ switch (ev.type) {
125
+ case "response.output_item.added": {
126
+ const item = ev.item ?? {};
127
+ if (item.type === "function_call") {
128
+ const idx = nextToolIndex++;
129
+ toolByOutputIndex.set(ev.output_index, idx);
130
+ yield { kind: "tool_use_start", index: idx, id: item.call_id ?? item.id ?? `call_${idx}`, name: item.name ?? "", done: false };
131
+ }
132
+ break;
133
+ }
134
+ case "response.output_text.delta":
135
+ if (ev.delta)
136
+ yield { kind: "text", delta: ev.delta, done: false };
137
+ break;
138
+ case "response.function_call_arguments.delta": {
139
+ const idx = toolByOutputIndex.get(ev.output_index);
140
+ if (idx !== undefined && ev.delta)
141
+ yield { kind: "tool_use_delta", index: idx, argsDelta: ev.delta, done: false };
142
+ break;
143
+ }
144
+ case "response.completed":
145
+ if (toolByOutputIndex.size)
146
+ finishReason = "tool_use";
147
+ usage = usageOf(ev.response?.usage) ?? usage;
148
+ break;
149
+ case "response.incomplete":
150
+ finishReason = mapIncomplete(ev.response?.incomplete_details?.reason);
151
+ usage = usageOf(ev.response?.usage) ?? usage;
152
+ break;
153
+ case "response.failed":
154
+ case "error":
155
+ finishReason = "error";
156
+ break;
157
+ }
158
+ }
159
+ }
160
+ yield { kind: "done", done: true, finishReason, usage };
161
+ }
@@ -0,0 +1,66 @@
1
+ // Microsoft Web IQ REST client. Two grounding endpoints used to back Claude Code's server-side
2
+ // web_search / web_fetch tools, which our gateway executes itself (Copilot can't). Every call
3
+ // returns a discriminated result instead of throwing: a failed search must degrade to a message the
4
+ // model can read and answer around, never abort the in-flight turn.
5
+ const SEARCH_URL = "https://api.microsoft.ai/v3/search/web";
6
+ const BROWSE_URL = "https://api.microsoft.ai/v3/browse";
7
+ const DEFAULT_TIMEOUT_MS = 15_000;
8
+ const headers = (key) => ({ host: "api.microsoft.ai", "x-apikey": key, "content-type": "application/json" });
9
+ // Status -> readable, model-facing reason. Kept identical across both endpoints so the model gets a
10
+ // consistent, actionable string it can reason about (e.g. fall back to its own knowledge).
11
+ function statusError(status, kind) {
12
+ if (status === 401 || status === 403)
13
+ return "web search unavailable: WebIQ API key missing or invalid — run /webiq to set it";
14
+ if (status === 429)
15
+ return "web search unavailable: WebIQ rate limit exceeded — try again shortly";
16
+ if (status === 404 && kind === "fetch")
17
+ return "web fetch failed: the page was not found or is not indexed";
18
+ return `web ${kind} failed: WebIQ returned ${status}`;
19
+ }
20
+ async function post(url, key, body, fetchFn, timeoutMs) {
21
+ const ctrl = new AbortController();
22
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
23
+ try {
24
+ return await fetchFn(url, { method: "POST", headers: headers(key), body: JSON.stringify(body), signal: ctrl.signal });
25
+ }
26
+ finally {
27
+ clearTimeout(timer);
28
+ }
29
+ }
30
+ export async function webSearch(key, params, fetchFn = fetch, timeoutMs = DEFAULT_TIMEOUT_MS) {
31
+ if (!key)
32
+ return { ok: false, error: statusError(401, "search") };
33
+ try {
34
+ const res = await post(SEARCH_URL, key, { maxResults: 10, contentFormat: "passage", ...params }, fetchFn, timeoutMs);
35
+ if (!res.ok)
36
+ return { ok: false, error: statusError(res.status, "search") };
37
+ const data = (await res.json());
38
+ return { ok: true, results: data.webResults ?? [] };
39
+ }
40
+ catch {
41
+ return { ok: false, error: "web search failed: could not reach WebIQ" };
42
+ }
43
+ }
44
+ export async function webFetch(key, params, fetchFn = fetch, timeoutMs = DEFAULT_TIMEOUT_MS) {
45
+ if (!key)
46
+ return { ok: false, error: statusError(401, "fetch") };
47
+ try {
48
+ const res = await post(BROWSE_URL, key, { maxLength: 10_000, contentFormat: "markdown", ...params }, fetchFn, timeoutMs);
49
+ if (!res.ok)
50
+ return { ok: false, error: statusError(res.status, "fetch") };
51
+ const data = (await res.json());
52
+ return { ok: true, title: data.title ?? "", url: data.url ?? params.url, content: data.content ?? "" };
53
+ }
54
+ catch {
55
+ return { ok: false, error: "web fetch failed: could not reach WebIQ" };
56
+ }
57
+ }
58
+ // Render results as the tool_result text fed back to the model — compact, citation-friendly.
59
+ export function formatSearchResults(results) {
60
+ if (!results.length)
61
+ return "no results found";
62
+ return results.map((r, i) => `[${i + 1}] ${r.title}\n${r.url}\n${r.content}`.trim()).join("\n\n");
63
+ }
64
+ export function formatFetchResult(r) {
65
+ return `${r.title}\n${r.url}\n\n${r.content}`.trim();
66
+ }
@@ -0,0 +1,59 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ // WebIQ config for the gateway-run web_search / web_fetch tools: the API key plus the active backend
4
+ // MODE. Stored like the GitHub token (plaintext, 0600, in the data dir). The WEBIQ_API_KEY env var
5
+ // takes precedence for the key so CI / headless runs can inject it. Read lazily per request → no
6
+ // worker restart on change.
7
+ //
8
+ // mode "copilot" (DEFAULT) — borrow gpt-5-mini's native web_search; no key needed.
9
+ // mode "webiq" — force ALL models through WebIQ using the stored key.
10
+ const file = (dir) => join(dir, "webiq.json");
11
+ function read(dir) {
12
+ if (!existsSync(file(dir)))
13
+ return {};
14
+ try {
15
+ return JSON.parse(readFileSync(file(dir), "utf8"));
16
+ }
17
+ catch {
18
+ return {};
19
+ }
20
+ }
21
+ function write(dir, data) {
22
+ if (!existsSync(dir))
23
+ mkdirSync(dir, { recursive: true });
24
+ writeFileSync(file(dir), JSON.stringify(data), { mode: 0o600 });
25
+ }
26
+ export function writeWebIqKey(key, dir) {
27
+ write(dir, { ...read(dir), apiKey: key });
28
+ }
29
+ export function readWebIqKey(dir) {
30
+ if (process.env.WEBIQ_API_KEY)
31
+ return process.env.WEBIQ_API_KEY;
32
+ return read(dir).apiKey ?? null;
33
+ }
34
+ // Reset everything — drop the key AND revert to the default copilot backend.
35
+ export function clearWebIqKey(dir) {
36
+ rmSync(file(dir), { force: true });
37
+ }
38
+ export function readWebSearchMode(dir) {
39
+ return read(dir).mode === "webiq" ? "webiq" : "copilot";
40
+ }
41
+ export function writeWebSearchMode(dir, mode) {
42
+ write(dir, { ...read(dir), mode });
43
+ }
44
+ // Master switch for the Copilot "borrow" backend (gpt-5-mini's native web_search). Currently OFF:
45
+ // gpt-5-mini is badly congested on Copilot's /responses (503 "high demand", 20s–7min), while WebIQ is
46
+ // sub-second. So web search routes through WebIQ only; with no key it is unavailable. Flip this to
47
+ // `true` to bring borrow search back (the borrow code path is kept intact). NOTE: this gates only the
48
+ // Claude gateway backend — Codex's native /responses web_search is unaffected (it uses fast gpt-5
49
+ // models directly, not gpt-5-mini).
50
+ export const COPILOT_WEB_SEARCH_ENABLED = false;
51
+ // Resolve which backend a gateway web_search/web_fetch call should use. Pure (no I/O) so both flag
52
+ // states are unit-tested. `enabled` defaults to the live flag; tests pass it explicitly.
53
+ export function resolveWebSearchBackend(mode, hasKey, enabled = COPILOT_WEB_SEARCH_ENABLED) {
54
+ if (!enabled)
55
+ return hasKey ? "webiq" : "unavailable"; // borrow disabled → WebIQ or nothing
56
+ if (mode === "webiq" && hasKey)
57
+ return "webiq";
58
+ return "copilot"; // default borrow (and the webiq-without-key fallback)
59
+ }
package/dist/tui/app.js CHANGED
@@ -6,12 +6,30 @@ import { Repl } from "./repl.js";
6
6
  import { SetupWizard } from "./setup/wizard.js";
7
7
  import { ModelScreen } from "./screens/model.js";
8
8
  import { ConfigScreen } from "./screens/config.js";
9
+ import { WebIqKeyScreen } from "./screens/webiq-key.js";
10
+ import { summarizeStatus } from "./status-summary.js";
9
11
  import { theme } from "./theme.js";
10
12
  const stateColor = {
11
13
  ready: theme.ready, starting: theme.starting, crashed: theme.crashed, unhealthy: theme.unhealthy,
12
14
  };
13
15
  const EMPTY_STATUS = { claude: { user: false, project: false }, codex: { user: false, project: false } };
14
16
  const SPINNER = ["✶", "✸", "✹", "✺", "✹", "✷"];
17
+ // Startup overview card. GitHub shows a login STATE (no real token expiry exists). Web search shows
18
+ // the resolved backend: "via WebIQ", "via Copilot (native)", or "unavailable — run /webiq".
19
+ // `extra` appends detail lines (e.g. worker restart history for /status).
20
+ function statusCard(s, extra = []) {
21
+ const gh = s.github === "connected" ? "✓ connected" : s.github === "expired" ? "✗ expired — run /login" : "✗ signed out — run /login";
22
+ const web = s.webSearch === "webiq" ? "✓ via WebIQ" : s.webSearch === "copilot" ? "✓ via Copilot (native)" : "✗ unavailable — run /webiq";
23
+ const clients = `claude ${s.clients.claude ? "✓" : "○"} codex ${s.clients.codex ? "✓" : "○"}`;
24
+ const tone = s.github === "connected" ? "ok" : "error";
25
+ return { type: "card", title: "status", tone, lines: [
26
+ `GitHub login ${gh}`,
27
+ `web search ${web}`,
28
+ `worker ${s.worker}`,
29
+ `clients ${clients}`,
30
+ ...extra,
31
+ ] };
32
+ }
15
33
  const fmtElapsed = (ms) => {
16
34
  const s = Math.floor(ms / 1000);
17
35
  return s >= 60 ? `${Math.floor(s / 60)}m ${s % 60}s` : `${s}s`;
@@ -36,13 +54,15 @@ function ClientBadge({ name, status }) {
36
54
  const cell = (label, on) => (_jsxs(Text, { color: on ? theme.ready : theme.muted, children: [label, ":", on ? "✓" : "○"] }));
37
55
  return (_jsxs(Text, { color: theme.muted, children: [name, " ", cell("u", status.user), " ", cell("p", status.project)] }));
38
56
  }
39
- export function App({ registry, title, workerState = "starting", initialModel = "—", statusSource, readStatus, modelLimits, onChat, loadModels, setup, info, onModelChange, pickModelOnStart, login, }) {
57
+ export function App({ registry, title, workerState = "starting", initialModel = "—", statusSource, readStatus, modelLimits, onChat, loadModels, setup, info, onModelChange, pickModelOnStart, login, enableWebiq, disableWebiq, webSearchBackend, startupStatus, githubStatus, }) {
40
58
  const cmds = registry.list().map((c) => ({ name: c.name, describe: c.describe }));
41
- const [entries, setEntries] = useState([
59
+ const [entries, setEntries] = useState(() => [
60
+ ...(startupStatus ? [statusCard(startupStatus)] : []),
42
61
  { type: "system", text: "Type a message to chat with the assistant, or /help for commands." },
43
62
  ]);
44
63
  const [state, setState] = useState(workerState);
45
64
  const [status, setStatus] = useState(() => readStatus?.() ?? EMPTY_STATUS);
65
+ const [webBackend, setWebBackend] = useState(() => webSearchBackend?.() ?? "unavailable");
46
66
  const [model, setModel] = useState(initialModel);
47
67
  const [screen, setScreen] = useState(pickModelOnStart && loadModels ? { kind: "model" } : null);
48
68
  const [, setNow] = useState(0); // ticks the live loading line while the assistant streams
@@ -50,7 +70,8 @@ export function App({ registry, title, workerState = "starting", initialModel =
50
70
  const loginInFlight = useRef(false); // guards against starting a second device-login flow
51
71
  const add = (e) => setEntries((p) => [...p, e].slice(-100));
52
72
  const refreshStatus = () => { if (readStatus)
53
- setStatus(readStatus()); };
73
+ setStatus(readStatus()); if (webSearchBackend)
74
+ setWebBackend(webSearchBackend()); };
54
75
  // esc interrupts an in-flight assistant turn (the Repl doesn't use esc, so this is unambiguous).
55
76
  useInput((_input, key) => { if (key.escape)
56
77
  abortRef.current?.abort(); });
@@ -92,6 +113,38 @@ export function App({ registry, title, workerState = "starting", initialModel =
92
113
  setScreen({ kind: "model" });
93
114
  return;
94
115
  }
116
+ // Web-search backend controls. "/webiq clean" clears the key; "/webiq" opens the key screen and
117
+ // switches to the WebIQ backend on submit. After either, re-read the resolved backend for the HUD.
118
+ if (t === "/webiq clean" && disableWebiq) {
119
+ disableWebiq();
120
+ setWebBackend(webSearchBackend?.() ?? "unavailable");
121
+ add({ type: "card", title: "/webiq", tone: "ok", lines: ["✓ WebIQ key cleared"] });
122
+ return;
123
+ }
124
+ if (t === "/webiq" && enableWebiq) {
125
+ setScreen({ kind: "webiq-key" });
126
+ return;
127
+ }
128
+ if (t === "/status" && (startupStatus || githubStatus || webSearchBackend)) {
129
+ // Render the live status overview (same card as startup), then the worker restart history.
130
+ const github = githubStatus ? await githubStatus() : (startupStatus?.github ?? "signed-out");
131
+ let worker = state, restarts = [];
132
+ try {
133
+ const s = await statusSource?.();
134
+ if (s) {
135
+ worker = s.workerState;
136
+ restarts = s.restarts.slice(0, 5).map((r) => ` ${r.reason} exit=${r.exitCode ?? "-"} ${r.stderrTail.slice(0, 60)}`);
137
+ }
138
+ }
139
+ catch { /* daemon momentarily down — show what we have */ }
140
+ const summary = summarizeStatus({
141
+ hasToken: github !== "signed-out", tokenValid: github === "connected",
142
+ webSearch: webSearchBackend?.() ?? webBackend, worker,
143
+ clients: { claude: status.claude.user || status.claude.project, codex: status.codex.user || status.codex.project },
144
+ });
145
+ add(statusCard(summary, restarts.length ? ["", "recent restarts:", ...restarts] : []));
146
+ return;
147
+ }
95
148
  if (t === "/config" && info) {
96
149
  setScreen({ kind: "config" });
97
150
  return;
@@ -181,6 +234,9 @@ export function App({ registry, title, workerState = "starting", initialModel =
181
234
  setScreen(null);
182
235
  } }));
183
236
  }
237
+ else if (screen?.kind === "webiq-key" && enableWebiq) {
238
+ body = (_jsx(WebIqKeyScreen, { onSubmit: (k) => { enableWebiq(k); setWebBackend(webSearchBackend?.() ?? "webiq"); setScreen(null); add({ type: "card", title: "/webiq", tone: "ok", lines: ["✓ WebIQ enabled — all web search now routes through Microsoft Web IQ"] }); }, onCancel: () => { setScreen(null); add({ type: "system", text: "webiq cancelled" }); } }));
239
+ }
184
240
  else {
185
241
  body = _jsx(Repl, { onSubmit: handle, commands: cmds });
186
242
  }
@@ -197,5 +253,5 @@ export function App({ registry, title, workerState = "starting", initialModel =
197
253
  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));
198
254
  }
199
255
  return _jsx(Text, { color: color, children: e.text }, i);
200
- }) }), body, _jsxs(Box, { paddingX: 1, 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 " }), _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" })] })] }));
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" })] })] })] }));
201
257
  }
@@ -0,0 +1,30 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from "react";
3
+ import { Box, Text, useInput } from "ink";
4
+ import { theme } from "../theme.js";
5
+ // Masked single-line input for the WebIQ API key. Mirrors the Repl's end-of-line editing (append /
6
+ // backspace), but renders bullets instead of the secret. Enter submits a non-empty key; Esc cancels.
7
+ export function WebIqKeyScreen({ onSubmit, onCancel }) {
8
+ const [value, setValue] = useState("");
9
+ useInput((input, key) => {
10
+ if (key.escape) {
11
+ onCancel();
12
+ return;
13
+ }
14
+ if (key.return) {
15
+ const k = value.trim();
16
+ if (k)
17
+ onSubmit(k);
18
+ else
19
+ onCancel();
20
+ return;
21
+ }
22
+ if (key.backspace || key.delete) {
23
+ setValue((v) => v.slice(0, -1));
24
+ return;
25
+ }
26
+ if (input && !key.ctrl && !key.meta)
27
+ setValue((v) => v + input);
28
+ });
29
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.accent, paddingX: 1, marginBottom: 1, children: [_jsx(Text, { color: theme.accent, bold: true, children: "web search support \u2014 paste your WebIQ API key" }), _jsx(Text, { color: theme.muted, children: "enables web_search / web_fetch for connected clients \u00B7 enter to save \u00B7 esc to cancel" }), _jsxs(Box, { children: [_jsx(Text, { color: theme.prompt, children: "key › " }), _jsx(Text, { children: "•".repeat(value.length) }), _jsx(Text, { inverse: true, children: " " })] })] }));
30
+ }
@@ -3,7 +3,9 @@ import { homedir } from "node:os";
3
3
  import { join, dirname } from "node:path";
4
4
  // Codex reads ~/.codex/config.toml. copilot-reverse writes a managed provider block there (model,
5
5
  // provider, context window) while preserving the user's other top-level keys. Mirrors
6
- // agent-maestro's `configureCodex`, but uses wire_api="chat" since our proxy is chat/completions.
6
+ // agent-maestro's `configureCodex`. Codex removed wire_api="chat" (codex#7782), so we write
7
+ // "responses" and serve the OpenAI Responses API at /openai/responses (Codex appends /responses to
8
+ // base_url verbatim — no /v1 auto-added).
7
9
  export const PROVIDER_ID = "copilot-reverse";
8
10
  export function codexTomlPath(home = homedir()) {
9
11
  return join(home, ".codex", "config.toml");
@@ -14,34 +16,57 @@ export function applyCodexToml(opts) {
14
16
  const path = codexTomlPath(opts.home);
15
17
  if (!existsSync(dirname(path)))
16
18
  mkdirSync(dirname(path), { recursive: true });
17
- // Read existing top-level lines, dropping our managed keys and any prior managed provider table,
18
- // but keeping everything else (approval_policy, other providers, etc.) verbatim.
19
+ // Parse existing content into top-level (pre-table) bare keys vs. table blocks, dropping our
20
+ // managed keys and any prior managed provider table. We MUST keep top-level keys and tables
21
+ // separate: in TOML a bare `key = value` after a `[table]` header belongs to that table, so
22
+ // appending our `model_provider` at the end (after the user's [windows]/[marketplaces] tables)
23
+ // silently nested it under the last table — Codex then couldn't see it and fell back to "openai".
19
24
  const existing = existsSync(path) ? readFileSync(path, "utf8") : "";
20
- const kept = [];
21
- let inOurTable = false;
25
+ const keptTopKeys = []; // bare key=value lines before any table
26
+ const keptTables = []; // everything from the first [table] onward (preserved verbatim)
27
+ let inTable = false; // have we passed the first table header?
28
+ let inOurTable = false; // are we inside our own [model_providers.copilot-reverse] block?
22
29
  for (const line of existing.split(/\r?\n/)) {
23
- const tableMatch = /^\s*\[/.test(line);
24
- if (tableMatch)
30
+ if (/^\s*\[/.test(line)) {
31
+ inTable = true;
25
32
  inOurTable = line.trim() === `[model_providers.${PROVIDER_ID}]`;
33
+ }
26
34
  if (inOurTable)
27
- continue; // skip our previously-written provider table
35
+ continue; // skip our previously-written provider table entirely
28
36
  const keyMatch = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(line);
37
+ // Drop our managed top keys wherever they appear. They belong at the top level, but a previous
38
+ // buggy version wrote them AFTER tables (where TOML nests them) — so filter them in the table
39
+ // region too, otherwise the rewrite would duplicate them.
29
40
  if (keyMatch && MANAGED_TOP_KEYS.includes(keyMatch[1]))
30
- continue; // skip our managed top keys
31
- kept.push(line);
41
+ continue;
42
+ (inTable ? keptTables : keptTopKeys).push(line);
32
43
  }
33
- const head = kept.join("\n").replace(/\n{3,}/g, "\n\n").trim();
34
- const managed = [
44
+ // Reassemble in valid TOML order: ALL top-level keys (ours + the user's) first, then all table
45
+ // blocks (the user's preserved tables, then our managed provider table last).
46
+ const topKeys = [
35
47
  `model = "${opts.model}"`,
36
48
  `model_provider = "${PROVIDER_ID}"`,
37
49
  ...(opts.contextWindow ? [`model_context_window = ${opts.contextWindow}`] : []),
38
- "",
50
+ ...keptTopKeys.filter((l) => l.trim()), // the user's other top-level keys (approval_policy, etc.)
51
+ ];
52
+ const ourTable = [
39
53
  `[model_providers.${PROVIDER_ID}]`,
40
54
  `name = "copilot-reverse"`,
41
55
  `base_url = "${opts.baseUrl}"`,
42
- `wire_api = "chat"`,
43
- ].join("\n");
44
- const body = (head ? `${head}\n\n` : "") + managed + "\n";
56
+ `wire_api = "responses"`,
57
+ // Auth: inline a static bearer token so Codex talks to our local proxy instead of falling back
58
+ // to the OpenAI login flow. env_key is unreliable here (a standalone Codex CLI won't see our
59
+ // .env), so we embed the placeholder directly — the worker ignores the key value anyway.
60
+ `requires_openai_auth = false`,
61
+ `experimental_bearer_token = "${opts.apiKey ?? "copilot-reverse-local"}"`,
62
+ ];
63
+ const userTables = keptTables.join("\n").replace(/\n{3,}/g, "\n\n").trim();
64
+ const managed = [
65
+ topKeys.join("\n"),
66
+ ...(userTables ? [userTables] : []),
67
+ ourTable.join("\n"),
68
+ ].join("\n\n");
69
+ const body = managed + "\n";
45
70
  writeFileSync(path, body);
46
71
  return { path, changed: MANAGED_TOP_KEYS };
47
72
  }
@@ -45,6 +45,10 @@ export function buildRegistry(ctx, endpoint, opts = {}) {
45
45
  reg.add({ name: "/login", describe: "sign in to GitHub (device-code)", run: async () => opts.login ? opts.login() : ["login not available"] });
46
46
  reg.add({ name: "/logout", describe: "sign out — remove the stored GitHub token", run: async () => opts.logout ? opts.logout() : ["logout not available"] });
47
47
  reg.add({ name: "/model", describe: "switch the chat model", run: async () => ["opening model picker…"] });
48
+ // Web search works out of the box via Copilot; /webiq opts into Microsoft Web IQ, /webiq clean
49
+ // reverts. Handled in the App (opens the key screen / toggles), so this is a no-op stub that exists
50
+ // only so the command is recognized and not reported as unknown.
51
+ reg.add({ name: "/webiq", describe: "use Microsoft Web IQ for web search (/webiq clean to revert)", run: async () => ["opening webiq…"] });
48
52
  reg.add({ name: "/config", describe: "view & change configuration", run: async () => ["opening config panel…"] });
49
53
  reg.add({ name: "/dashboard", describe: "open the web dashboard in your browser", run: async () => {
50
54
  if (!opts.dashboardUrl)
@@ -0,0 +1,13 @@
1
+ export function githubLoginState(hasToken, tokenValid) {
2
+ if (!hasToken)
3
+ return "signed-out";
4
+ return tokenValid ? "connected" : "expired";
5
+ }
6
+ export function summarizeStatus(i) {
7
+ return {
8
+ github: githubLoginState(i.hasToken, i.tokenValid),
9
+ webSearch: i.webSearch,
10
+ worker: i.worker,
11
+ clients: i.clients,
12
+ };
13
+ }
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.2.1";
2
+ export const APP_VERSION = "0.4.0";