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.
- package/dist/cli/index.js +29 -5
- package/dist/core/anthropic-inbound.js +24 -6
- package/dist/core/responses-inbound.js +143 -0
- package/dist/core/server-tools.js +60 -0
- package/dist/core/tool-xml.js +9 -1
- package/dist/providers/copilot/adapter.js +49 -5
- package/dist/providers/copilot/borrow-search.js +86 -0
- package/dist/providers/copilot/models.js +14 -0
- package/dist/providers/copilot/responses-upstream.js +161 -0
- package/dist/providers/webiq/client.js +66 -0
- package/dist/shared/webiq-key.js +59 -0
- package/dist/tui/app.js +60 -4
- package/dist/tui/screens/webiq-key.js +30 -0
- package/dist/tui/setup/codex-toml.js +41 -16
- package/dist/tui/slash/commands.js +4 -0
- package/dist/tui/status-summary.js +13 -0
- package/dist/version.js +1 -1
- package/dist/worker/anthropic-server.js +105 -44
- package/dist/worker/index.js +25 -5
- package/dist/worker/openai-server.js +62 -0
- package/dist/worker/server.js +2 -2
- package/package.json +1 -1
|
@@ -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
|
|
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
|
-
//
|
|
18
|
-
//
|
|
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
|
|
21
|
-
|
|
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
|
-
|
|
24
|
-
|
|
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;
|
|
31
|
-
|
|
41
|
+
continue;
|
|
42
|
+
(inTable ? keptTables : keptTopKeys).push(line);
|
|
32
43
|
}
|
|
33
|
-
|
|
34
|
-
|
|
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 = "
|
|
43
|
-
|
|
44
|
-
|
|
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
|
+
export const APP_VERSION = "0.4.0";
|