copilot-reverse 0.2.1 → 0.3.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 CHANGED
@@ -11,12 +11,14 @@ import { startSupervisor } from "../supervisor/index.js";
11
11
  import { runAssistantTurn } from "../tui/assistant/runtime.js";
12
12
  import { makeOnChat } from "../tui/assistant/on-chat.js";
13
13
  import { readGhToken, clearGhToken } from "../shared/creds.js";
14
+ import { writeWebIqKey, readWebIqKey } from "../shared/webiq-key.js";
14
15
  import { readClientSetup, writeClientSetup } from "../shared/client-setup.js";
15
16
  import { readChatModel, writeChatModel } from "../shared/prefs.js";
16
17
  import { CopilotTokenStore, isCopilotTokenValid } from "../providers/copilot/token.js";
17
18
  import { fetchCopilotModels, fetchModelLimits } from "../providers/copilot/models.js";
18
19
  import { applyClaude, applyCodex, resetClaude, resetCodex, CLAUDE_ENV_KEYS, CODEX_ENV_KEYS } from "../tui/setup/apply.js";
19
20
  import { readClientStatus } from "../tui/setup/status.js";
21
+ import { summarizeStatus } from "../tui/status-summary.js";
20
22
  import { applyCodexToml } from "../tui/setup/codex-toml.js";
21
23
  import { claudeCopilotReverseEnv } from "../tui/setup/clients.js";
22
24
  import { dataDir } from "../shared/paths.js";
@@ -107,19 +109,21 @@ async function launchTui() {
107
109
  void tokenStore.get().then((t) => fetchModelLimits(t)).then((m) => Object.assign(modelLimits, m)).catch(() => { });
108
110
  // Apply a client's config (shared by the /setup wizard and the assistant's setup_* tools).
109
111
  // For Claude Code we also write the selected model's real context window so the client doesn't
112
+ // For Claude Code we also write the selected model's real context window so the client doesn't
110
113
  // assume the default 200K (which makes a 1M model read "context 100%" far too early). For Codex
111
- // we write BOTH a .env (legacy) and ~/.codex/config.toml (the native Codex config, with the
112
- // model's context window) so either Codex setup style works.
114
+ // the native config is ~/.codex/config.toml (what the standalone CLI actually reads); we also keep
115
+ // a legacy .env for older OpenAI-style tooling, but report the config.toml path since that's the
116
+ // one that matters.
113
117
  const applyClient = (clientKind, scope, model) => {
114
118
  if (clientKind === "claude") {
115
119
  const r = applyClaude(scope, claudeCopilotReverseEnv(anthropicBase, "copilot-reverse-local", model, modelLimits[model]));
116
120
  writeClientSetup(dataDir(), { ...readClientSetup(dataDir()), claude: true });
117
121
  return r;
118
122
  }
119
- const r = applyCodex(scope, { OPENAI_BASE_URL: openaiBase, OPENAI_API_KEY: "copilot-reverse-local", OPENAI_MODEL: model });
120
- applyCodexToml({ baseUrl: openaiBase, model, contextWindow: modelLimits[model] });
123
+ applyCodex(scope, { OPENAI_BASE_URL: openaiBase, OPENAI_API_KEY: "copilot-reverse-local", OPENAI_MODEL: model }); // legacy .env
124
+ const toml = applyCodexToml({ baseUrl: openaiBase, model, contextWindow: modelLimits[model], apiKey: "copilot-reverse-local" });
121
125
  writeClientSetup(dataDir(), { ...readClientSetup(dataDir()), codex: true });
122
- return r;
126
+ return toml; // the native config Codex reads — surface this path in the setup card
123
127
  };
124
128
  const setup = { apply: async (clientKind, scope, model) => applyClient(clientKind, scope, model) };
125
129
  const onChat = makeOnChat({
@@ -144,6 +148,16 @@ async function launchTui() {
144
148
  }
145
149
  });
146
150
  const persistedModel = readChatModel(dataDir());
151
+ // Startup overview. The token was already validated above (re-auth happens before we get here), so
152
+ // GitHub is connected; web search readiness and configured clients are read from disk.
153
+ const clientStatus = readClientStatus();
154
+ const startupStatus = summarizeStatus({
155
+ hasToken: Boolean(readGhToken(dataDir())),
156
+ tokenValid: true,
157
+ webSearchReady: Boolean(readWebIqKey(dataDir())),
158
+ worker: "ready",
159
+ clients: { claude: clientStatus.claude.user || clientStatus.claude.project, codex: clientStatus.codex.user || clientStatus.codex.project },
160
+ });
147
161
  app = render(React.createElement(App, {
148
162
  registry,
149
163
  title: "copilot-reverse",
@@ -164,6 +178,15 @@ async function launchTui() {
164
178
  onModelChange: (m) => writeChatModel(dataDir(), m),
165
179
  pickModelOnStart: !persistedModel,
166
180
  login: doLogin,
181
+ saveWebIqKey: (k) => writeWebIqKey(k, dataDir()),
182
+ webSearchReady: () => Boolean(readWebIqKey(dataDir())),
183
+ startupStatus,
184
+ githubStatus: async () => {
185
+ const token = readGhToken(dataDir());
186
+ if (!token)
187
+ return "signed-out";
188
+ return (await isCopilotTokenValid(token)) ? "connected" : "expired";
189
+ },
167
190
  }));
168
191
  }
169
192
  const program = new Command();
@@ -1,3 +1,4 @@
1
+ import { GATEWAY_TOOL_DEFS, isGatewayTool } from "./server-tools.js";
1
2
  // The Anthropic `system` field may be a plain string or an array of text blocks (the Claude Code
2
3
  // SDK sends blocks with cache_control). Flatten either shape to a string — otherwise it stringifies
3
4
  // to "[object Object]" and the model gets garbage instructions.
@@ -41,15 +42,32 @@ export function anthropicRequestToCanonical(req) {
41
42
  }
42
43
  return {
43
44
  model: req.model, stream: Boolean(req.stream), temperature: req.temperature, maxTokens: req.max_tokens,
44
- // Keep only custom tools with a real JSON-Schema. Anthropic server-side tools (web_search,
45
- // bash, computer, …) arrive with a `type` and no `input_schema`; forwarding them produces an
46
- // invalid tool the model can't fulfil, and the client hangs forever waiting for a tool_result.
47
- tools: req.tools
48
- ?.filter((t) => t.input_schema != null && typeof t.input_schema === "object")
49
- .map((t) => ({ name: t.name, description: t.description, parameters: t.input_schema })),
45
+ tools: mapTools(req.tools),
50
46
  messages,
51
47
  };
52
48
  }
49
+ // Custom tools (with a real JSON-Schema) pass through. Anthropic server-side tools arrive with a
50
+ // dated `type` and no input_schema: web_search / web_fetch are converted to gateway function tools
51
+ // (the gateway runs them itself against WebIQ), and every OTHER server tool (bash, computer, …) is
52
+ // dropped — forwarding an unfulfillable tool makes the client hang forever waiting for a result.
53
+ function mapTools(tools) {
54
+ if (!tools)
55
+ return undefined;
56
+ const out = [];
57
+ let injectedGateway = false;
58
+ for (const t of tools) {
59
+ if (t.input_schema != null && typeof t.input_schema === "object") {
60
+ out.push({ name: t.name, description: t.description, parameters: t.input_schema });
61
+ }
62
+ else if (isGatewayTool(t.name) && !injectedGateway) {
63
+ // Replace the schema-less server tool with our gateway defs. Inject the whole set once so the
64
+ // model can use both web_search and web_fetch whenever it asks for either.
65
+ out.push(...GATEWAY_TOOL_DEFS);
66
+ injectedGateway = true;
67
+ }
68
+ }
69
+ return out;
70
+ }
53
71
  export function canonicalToAnthropicResponse(r) {
54
72
  const content = r.content.map((b) => b.type === "text" ? { type: "text", text: b.text } :
55
73
  b.type === "tool_use" ? { type: "tool_use", id: b.id, name: b.name, input: b.input } :
@@ -0,0 +1,140 @@
1
+ import { joinText } from "./canonical.js";
2
+ function partsText(content) {
3
+ if (content == null)
4
+ return "";
5
+ if (typeof content === "string")
6
+ return content;
7
+ return content.map((p) => (typeof p === "string" ? p : p?.text ?? "")).join("");
8
+ }
9
+ function partsImages(content) {
10
+ if (!Array.isArray(content))
11
+ return [];
12
+ const urlOf = (p) => typeof p.image_url === "string" ? p.image_url : p.image_url?.url;
13
+ return content.filter((p) => p?.type === "input_image" && urlOf(p)).map((p) => ({ type: "image", dataUrl: urlOf(p) }));
14
+ }
15
+ function safeJson(s) { try {
16
+ return s ? JSON.parse(s) : {};
17
+ }
18
+ catch {
19
+ return {};
20
+ } }
21
+ function itemToMessage(it) {
22
+ if (it.type === "function_call" && it.call_id) {
23
+ return { role: "assistant", content: [{ type: "tool_use", id: it.call_id, name: it.name ?? "", input: safeJson(it.arguments) }] };
24
+ }
25
+ if (it.type === "function_call_output" && it.call_id) {
26
+ return { role: "tool", content: [{ type: "tool_result", toolUseId: it.call_id, content: it.output ?? "" }] };
27
+ }
28
+ // default: a message item
29
+ const role = (["system", "user", "assistant"].includes(it.role ?? "") ? it.role : "user");
30
+ const content = [];
31
+ const text = partsText(it.content);
32
+ if (text)
33
+ content.push({ type: "text", text });
34
+ content.push(...partsImages(it.content));
35
+ return content.length ? { role, content } : null;
36
+ }
37
+ export function responsesRequestToCanonical(req) {
38
+ const messages = [];
39
+ if (req.instructions)
40
+ messages.push({ role: "system", content: [{ type: "text", text: req.instructions }] });
41
+ if (typeof req.input === "string") {
42
+ messages.push({ role: "user", content: [{ type: "text", text: req.input }] });
43
+ }
44
+ else {
45
+ for (const it of req.input) {
46
+ const m = itemToMessage(it);
47
+ if (m)
48
+ messages.push(m);
49
+ }
50
+ }
51
+ return {
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
+ messages,
55
+ };
56
+ }
57
+ // Build the non-stream Responses object: text -> an output_text message item, tool_use -> function_call items.
58
+ export function canonicalToResponsesResponse(r) {
59
+ const output = [];
60
+ const text = joinText(r.content);
61
+ if (text)
62
+ output.push({ type: "message", id: `msg_${r.id}`, role: "assistant", status: "completed", content: [{ type: "output_text", text, annotations: [] }] });
63
+ for (const b of r.content) {
64
+ if (b.type === "tool_use")
65
+ output.push({ type: "function_call", id: `fc_${b.id}`, call_id: b.id, name: b.name, arguments: JSON.stringify(b.input ?? {}), status: "completed" });
66
+ }
67
+ return {
68
+ id: r.id, object: "response", status: "completed", model: r.model,
69
+ output, output_text: text,
70
+ usage: { input_tokens: r.usage.promptTokens, output_tokens: r.usage.completionTokens, total_tokens: r.usage.promptTokens + r.usage.completionTokens },
71
+ };
72
+ }
73
+ // Stateful SSE emitter for the Responses stream. Each event carries a monotonically increasing
74
+ // sequence_number (Codex/agent-maestro require it). Text streams as one output_text message item;
75
+ // each tool call is its own function_call output item. Indices are allocated sequentially.
76
+ const frame = (event) => `data: ${JSON.stringify(event)}\n\n`;
77
+ export class ResponsesSSE {
78
+ responseId;
79
+ model;
80
+ seq = 0;
81
+ nextIndex = 0;
82
+ textIndex;
83
+ textItemId;
84
+ toolIndex = new Map();
85
+ constructor(responseId, model) {
86
+ this.responseId = responseId;
87
+ this.model = model;
88
+ }
89
+ ev(type, extra) {
90
+ return frame({ type, sequence_number: this.seq++, ...extra });
91
+ }
92
+ envelope(status) {
93
+ return { id: this.responseId, object: "response", status, model: this.model };
94
+ }
95
+ start() {
96
+ return this.ev("response.created", { response: { ...this.envelope("in_progress"), output: [] } });
97
+ }
98
+ text(delta) {
99
+ const out = [];
100
+ if (this.textIndex === undefined) {
101
+ this.textIndex = this.nextIndex++;
102
+ this.textItemId = `msg_${this.responseId}`;
103
+ out.push(this.ev("response.output_item.added", { output_index: this.textIndex, item: { type: "message", id: this.textItemId, role: "assistant", status: "in_progress", content: [] } }));
104
+ 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: [] } }));
105
+ }
106
+ out.push(this.ev("response.output_text.delta", { item_id: this.textItemId, output_index: this.textIndex, content_index: 0, delta }));
107
+ return out;
108
+ }
109
+ toolStart(copilotIdx, callId, name) {
110
+ if (this.toolIndex.has(copilotIdx))
111
+ return [];
112
+ const outputIndex = this.nextIndex++;
113
+ const itemId = `fc_${callId}`;
114
+ this.toolIndex.set(copilotIdx, { outputIndex, itemId });
115
+ return [this.ev("response.output_item.added", { output_index: outputIndex, item: { type: "function_call", id: itemId, call_id: callId, name, arguments: "", status: "in_progress" } })];
116
+ }
117
+ toolArgs(copilotIdx, deltaArgs) {
118
+ const t = this.toolIndex.get(copilotIdx);
119
+ if (!t)
120
+ return [];
121
+ return [this.ev("response.function_call_arguments.delta", { item_id: t.itemId, output_index: t.outputIndex, delta: deltaArgs })];
122
+ }
123
+ // Close all open items and complete the response. `argsByIdx` supplies final accumulated tool args.
124
+ finish(usage, _finishReason, argsByIdx) {
125
+ const out = [];
126
+ if (this.textIndex !== undefined) {
127
+ out.push(this.ev("response.output_text.done", { item_id: this.textItemId, output_index: this.textIndex, content_index: 0, text: "" }));
128
+ 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: [] } }));
129
+ out.push(this.ev("response.output_item.done", { output_index: this.textIndex, item: { type: "message", id: this.textItemId, role: "assistant", status: "completed", content: [] } }));
130
+ }
131
+ for (const [copilotIdx, t] of this.toolIndex) {
132
+ const args = argsByIdx?.get(copilotIdx) ?? "";
133
+ out.push(this.ev("response.function_call_arguments.done", { item_id: t.itemId, output_index: t.outputIndex, arguments: args }));
134
+ out.push(this.ev("response.output_item.done", { output_index: t.outputIndex, item: { type: "function_call", id: t.itemId, status: "completed" } }));
135
+ }
136
+ const u = usage ? { input_tokens: usage.promptTokens, output_tokens: usage.completionTokens, total_tokens: usage.promptTokens + usage.completionTokens } : undefined;
137
+ out.push(this.ev("response.completed", { response: { ...this.envelope("completed"), ...(u ? { usage: u } : {}) } }));
138
+ return out;
139
+ }
140
+ }
@@ -0,0 +1,43 @@
1
+ import { webSearch, webFetch, formatSearchResults, formatFetchResult } from "../providers/webiq/client.js";
2
+ // Tools the GATEWAY executes itself (against WebIQ), rather than forwarding to the model's client.
3
+ // These mirror Claude Code's server-side web_search / web_fetch, which a Copilot-backed gateway must
4
+ // fulfil internally — the model calls them like normal function tools and we run them in-process.
5
+ export const GATEWAY_TOOL_DEFS = [
6
+ {
7
+ name: "web_search",
8
+ description: "Search the web for current information. Returns ranked results with titles, URLs, and content snippets.",
9
+ parameters: { type: "object", properties: { query: { type: "string", description: "The search query." } }, required: ["query"] },
10
+ },
11
+ {
12
+ name: "web_fetch",
13
+ description: "Fetch and read the content of a specific web page by URL.",
14
+ parameters: { type: "object", properties: { url: { type: "string", description: "The URL of the page to fetch." } }, required: ["url"] },
15
+ },
16
+ ];
17
+ const GATEWAY_TOOL_NAMES = new Set(GATEWAY_TOOL_DEFS.map((t) => t.name));
18
+ export function isGatewayTool(name) { return GATEWAY_TOOL_NAMES.has(name); }
19
+ const DEFAULT_CLIENT = { search: webSearch, fetchPage: webFetch };
20
+ const NO_KEY = "web search is not configured — run /web-search-support to add a WebIQ API key";
21
+ export function makeGatewayRunner(getKey, client = DEFAULT_CLIENT) {
22
+ return async (name, input) => {
23
+ const key = getKey();
24
+ if (!key)
25
+ return NO_KEY;
26
+ const arg = (input ?? {});
27
+ if (name === "web_search") {
28
+ const query = typeof arg.query === "string" ? arg.query : "";
29
+ if (!query)
30
+ return "web_search error: missing 'query'";
31
+ const out = await client.search(key, { query });
32
+ return out.ok ? formatSearchResults(out.results) : out.error;
33
+ }
34
+ if (name === "web_fetch") {
35
+ const url = typeof arg.url === "string" ? arg.url : "";
36
+ if (!url)
37
+ return "web_fetch error: missing 'url'";
38
+ const out = await client.fetchPage(key, { url });
39
+ return out.ok ? formatFetchResult(out) : out.error;
40
+ }
41
+ return `unknown gateway tool: ${name}`;
42
+ };
43
+ }
@@ -32,6 +32,20 @@ export async function fetchCopilotModels(token, fetchFn = fetch, timeoutMs = DEF
32
32
  const ids = [...new Set(data.map((m) => m.id).filter((x) => Boolean(x)))];
33
33
  return ids.length ? ids : FALLBACK_MODELS;
34
34
  }
35
+ // Map of model id -> the Copilot API endpoints it supports (e.g. ["/responses","ws:/responses"]).
36
+ // Used to route each request to the right upstream: newer gpt-5.x models are /responses-only and
37
+ // reject /chat/completions. Returns {} on failure so the adapter falls back to chat/completions.
38
+ export async function fetchModelEndpoints(token, fetchFn = fetch, timeoutMs = DEFAULT_TIMEOUT_MS) {
39
+ const data = await getModels(token, fetchFn, timeoutMs);
40
+ if (!data)
41
+ return {};
42
+ const out = {};
43
+ for (const m of data) {
44
+ if (m.id && Array.isArray(m.supported_endpoints) && m.supported_endpoints.length)
45
+ out[m.id] = m.supported_endpoints;
46
+ }
47
+ return out;
48
+ }
35
49
  // Map of model id -> its real input/context window, used to size auto-compaction per model and
36
50
  // to show the window in the picker. Returns {} on failure/timeout so callers fall back gracefully.
37
51
  export async function fetchModelLimits(token, fetchFn = fetch, timeoutMs = DEFAULT_TIMEOUT_MS) {
@@ -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 /web-search-support 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,21 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ // WebIQ API key for the gateway-run web_search / web_fetch tools. Stored like the GitHub token
4
+ // (plaintext, 0600, in the data dir). The WEBIQ_API_KEY env var takes precedence so CI / headless
5
+ // runs can inject it without writing a file. Read lazily per request → no worker restart on change.
6
+ const file = (dir) => join(dir, "webiq.json");
7
+ export function writeWebIqKey(key, dir) {
8
+ if (!existsSync(dir))
9
+ mkdirSync(dir, { recursive: true });
10
+ writeFileSync(file(dir), JSON.stringify({ apiKey: key }), { mode: 0o600 });
11
+ }
12
+ export function readWebIqKey(dir) {
13
+ if (process.env.WEBIQ_API_KEY)
14
+ return process.env.WEBIQ_API_KEY;
15
+ if (!existsSync(file(dir)))
16
+ return null;
17
+ return JSON.parse(readFileSync(file(dir), "utf8")).apiKey ?? null;
18
+ }
19
+ export function clearWebIqKey(dir) {
20
+ rmSync(file(dir), { force: true });
21
+ }
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
+ // whether a WebIQ key is configured with the command to fix it when not. `extra` appends detail
19
+ // 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 === "ready" ? "✓ ready" : "✗ not configured — run /web-search-support";
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, saveWebIqKey, webSearchReady, 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 [webReady, setWebReady] = useState(() => webSearchReady?.() ?? false);
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 (webSearchReady)
74
+ setWebReady(webSearchReady()); };
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,30 @@ export function App({ registry, title, workerState = "starting", initialModel =
92
113
  setScreen({ kind: "model" });
93
114
  return;
94
115
  }
116
+ if (t === "/web-search-support" && saveWebIqKey) {
117
+ setScreen({ kind: "webiq-key" });
118
+ return;
119
+ }
120
+ if (t === "/status" && (startupStatus || githubStatus || webSearchReady)) {
121
+ // Render the live status overview (same card as startup), then the worker restart history.
122
+ const github = githubStatus ? await githubStatus() : (startupStatus?.github ?? "signed-out");
123
+ let worker = state, restarts = [];
124
+ try {
125
+ const s = await statusSource?.();
126
+ if (s) {
127
+ worker = s.workerState;
128
+ restarts = s.restarts.slice(0, 5).map((r) => ` ${r.reason} exit=${r.exitCode ?? "-"} ${r.stderrTail.slice(0, 60)}`);
129
+ }
130
+ }
131
+ catch { /* daemon momentarily down — show what we have */ }
132
+ const summary = summarizeStatus({
133
+ hasToken: github !== "signed-out", tokenValid: github === "connected",
134
+ webSearchReady: webSearchReady?.() ?? webReady, worker,
135
+ clients: { claude: status.claude.user || status.claude.project, codex: status.codex.user || status.codex.project },
136
+ });
137
+ add(statusCard(summary, restarts.length ? ["", "recent restarts:", ...restarts] : []));
138
+ return;
139
+ }
95
140
  if (t === "/config" && info) {
96
141
  setScreen({ kind: "config" });
97
142
  return;
@@ -181,6 +226,9 @@ export function App({ registry, title, workerState = "starting", initialModel =
181
226
  setScreen(null);
182
227
  } }));
183
228
  }
229
+ else if (screen?.kind === "webiq-key" && saveWebIqKey) {
230
+ body = (_jsx(WebIqKeyScreen, { onSubmit: (k) => { saveWebIqKey(k); setWebReady(true); setScreen(null); add({ type: "card", title: "/web-search-support", tone: "ok", lines: ["✓ WebIQ key saved — web search is now enabled for connected clients"] }); }, onCancel: () => { setScreen(null); add({ type: "system", text: "web-search-support cancelled" }); } }));
231
+ }
184
232
  else {
185
233
  body = _jsx(Repl, { onSubmit: handle, commands: cmds });
186
234
  }
@@ -197,5 +245,5 @@ export function App({ registry, title, workerState = "starting", initialModel =
197
245
  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
246
  }
199
247
  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" })] })] }));
248
+ }) }), 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: webReady ? theme.ready : theme.muted, children: webReady ? "✓" : "✗ /web-search-support" })] }), _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
249
  }
@@ -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,7 @@ 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
+ reg.add({ name: "/web-search-support", describe: "enable web search/fetch (set WebIQ API key)", run: async () => ["opening web-search-support…"] });
48
49
  reg.add({ name: "/config", describe: "view & change configuration", run: async () => ["opening config panel…"] });
49
50
  reg.add({ name: "/dashboard", describe: "open the web dashboard in your browser", run: async () => {
50
51
  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.webSearchReady ? "ready" : "not-configured",
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.3.0";
@@ -3,8 +3,18 @@ import { anthropicRequestToCanonical, canonicalToAnthropicResponse } from "../co
3
3
  import { estimateTokens } from "../core/tokens.js";
4
4
  import { errorHint } from "./errors.js";
5
5
  import { CopilotAuthError } from "../providers/copilot/token.js";
6
+ import { isGatewayTool } from "../core/server-tools.js";
6
7
  const frame = (event, data) => `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
7
- export function mountAnthropic(app, router, onMetric) {
8
+ const safeJson = (s) => { try {
9
+ return JSON.parse(s);
10
+ }
11
+ catch {
12
+ return {};
13
+ } };
14
+ // Bounds the gateway tool loop so a model that calls web_search every turn (or a runner that always
15
+ // returns "search more") can never spin forever inside one request.
16
+ const MAX_TOOL_ITERS = 5;
17
+ export function mountAnthropic(app, router, onMetric, runner) {
8
18
  // Model discovery — Anthropic list shape. Claude Desktop / Anthropic-protocol clients GET this
9
19
  // before chatting; without it they 404 on the connection test.
10
20
  app.get("/anthropic/v1/models", (_req, res) => {
@@ -33,61 +43,112 @@ export function mountAnthropic(app, router, onMetric) {
33
43
  // isn't stuck at 0%; the terminal message_delta then reports the exact count.
34
44
  const estInput = estimateTokens(canon);
35
45
  res.write(frame("message_start", { type: "message_start", message: { id, type: "message", role: "assistant", model: canon.model, content: [], stop_reason: null, usage: { input_tokens: estInput, output_tokens: 0, cache_read_input_tokens: 0 } } }));
36
- // D3 (interface-freeze §5.4) + mixed text+tool fix (architect, 2026-06-17): the endpoint owns
37
- // open/stop bookkeeping with DYNAMIC SEQUENTIAL allocation. We do NOT pre-open an index-0 text block,
38
- // and we do NOT map the Copilot tool index straight to the Anthropic block index (that collides with a
39
- // text preamble on a mixed turn). Instead, whichever block opens FIRST claims Anthropic index 0, the
40
- // next claims 1, etc. This keeps indices contiguous-from-0 in all three cases: pure-text (text@0),
41
- // pure-tool (tool@0), and mixed preamble+tool (text@0, tool@1).
46
+ // D3 (interface-freeze §5.4) + mixed text+tool fix (architect, 2026-06-17) + gateway tool loop
47
+ // (2026-06): the endpoint owns block open/stop bookkeeping with DYNAMIC SEQUENTIAL allocation,
48
+ // and `next` spans ALL loop iterations so block indices stay contiguous-from-0 across turns.
49
+ // Within a turn, text streams live (transparent progress) but tool calls are BUFFERED: only
50
+ // after the turn ends do we know whether they're gateway tools (run here, then loop) or client
51
+ // tools (forwarded to the client, exactly as before). Whichever block opens first claims index 0.
42
52
  let next = 0;
43
- let textIndex; // Anthropic index of the (single) text block, once opened
44
- const toolIndex = new Map(); // Copilot tool index -> Anthropic block index
45
- const openedOrder = []; // Anthropic indices in allocation order
46
- let stopReason = "stop";
47
- let usage;
48
- for await (const chunk of provider.stream(canon)) {
49
- if (chunk.done) {
50
- stopReason = chunk.finishReason ?? "stop";
51
- usage = chunk.usage;
52
- break;
53
- }
54
- if (chunk.kind === "text") {
55
- if (textIndex === undefined) {
56
- textIndex = next++;
57
- openedOrder.push(textIndex);
58
- res.write(frame("content_block_start", { type: "content_block_start", index: textIndex, content_block: { type: "text", text: "" } }));
53
+ let lastPrompt = estInput, lastCached = 0, sumCompletion = 0;
54
+ let finalStop = "stop";
55
+ for (let iter = 0; iter < MAX_TOOL_ITERS; iter++) {
56
+ let textIndex; // Anthropic index of this turn's text block
57
+ const byCopilotIdx = new Map();
58
+ const buffered = []; // tool calls seen this turn, in order
59
+ let turnStop = "stop";
60
+ for await (const chunk of provider.stream(canon)) {
61
+ if (chunk.done) {
62
+ turnStop = chunk.finishReason ?? "stop";
63
+ if (chunk.usage) {
64
+ lastPrompt = chunk.usage.promptTokens ?? lastPrompt;
65
+ lastCached = chunk.usage.cachedTokens ?? 0;
66
+ sumCompletion += chunk.usage.completionTokens ?? 0;
67
+ }
68
+ break;
59
69
  }
60
- res.write(frame("content_block_delta", { type: "content_block_delta", index: textIndex, delta: { type: "text_delta", text: chunk.delta } }));
61
- }
62
- else if (chunk.kind === "tool_use_start") {
63
- if (!toolIndex.has(chunk.index)) {
64
- const index = next++;
65
- toolIndex.set(chunk.index, index);
66
- openedOrder.push(index);
67
- res.write(frame("content_block_start", { type: "content_block_start", index, content_block: { type: "tool_use", id: chunk.id, name: chunk.name, input: {} } }));
70
+ if (chunk.kind === "text") {
71
+ if (textIndex === undefined) {
72
+ textIndex = next++;
73
+ res.write(frame("content_block_start", { type: "content_block_start", index: textIndex, content_block: { type: "text", text: "" } }));
74
+ }
75
+ res.write(frame("content_block_delta", { type: "content_block_delta", index: textIndex, delta: { type: "text_delta", text: chunk.delta } }));
76
+ }
77
+ else if (chunk.kind === "tool_use_start") {
78
+ if (!byCopilotIdx.has(chunk.index)) {
79
+ const t = { id: chunk.id, name: chunk.name, args: "" };
80
+ byCopilotIdx.set(chunk.index, t);
81
+ buffered.push(t);
82
+ }
68
83
  }
84
+ else if (chunk.kind === "tool_use_delta") {
85
+ const t = byCopilotIdx.get(chunk.index);
86
+ if (t)
87
+ t.args += chunk.argsDelta;
88
+ }
89
+ }
90
+ if (textIndex !== undefined)
91
+ res.write(frame("content_block_stop", { type: "content_block_stop", index: textIndex }));
92
+ const gatewayCalls = buffered.filter((t) => isGatewayTool(t.name));
93
+ // Invariant: a gateway tool (web_search/web_fetch) must NEVER reach the client — the client
94
+ // has no handler for it and would stall. So whenever the model calls gateway tools (and a
95
+ // runner is wired), run them here and loop, feeding results back. Any client tools called in
96
+ // the SAME turn are deliberately NOT forwarded yet: we drop them this turn and let the model
97
+ // re-issue them on the next turn, now informed by the search result. (Forwarding them now
98
+ // would end the turn as tool_use and strand the gateway result with nowhere to go.)
99
+ if (runner && gatewayCalls.length) {
100
+ canon.messages.push({ role: "assistant", content: gatewayCalls.map((t) => ({ type: "tool_use", id: t.id, name: t.name, input: safeJson(t.args) })) });
101
+ const results = [];
102
+ for (const t of gatewayCalls)
103
+ results.push({ type: "tool_result", toolUseId: t.id, content: await runner(t.name, safeJson(t.args)) });
104
+ canon.messages.push({ role: "tool", content: results });
105
+ continue;
69
106
  }
70
- else if (chunk.kind === "tool_use_delta") {
71
- const index = toolIndex.get(chunk.index);
72
- if (index !== undefined)
73
- res.write(frame("content_block_delta", { type: "content_block_delta", index, delta: { type: "input_json_delta", partial_json: chunk.argsDelta } }));
107
+ // Terminal turn (no gateway tools, or no runner): forward any buffered tool calls to the
108
+ // client (open/delta/close each at its own freshly-allocated index), then finish.
109
+ for (const t of buffered) {
110
+ const index = next++;
111
+ res.write(frame("content_block_start", { type: "content_block_start", index, content_block: { type: "tool_use", id: t.id, name: t.name, input: {} } }));
112
+ if (t.args)
113
+ res.write(frame("content_block_delta", { type: "content_block_delta", index, delta: { type: "input_json_delta", partial_json: t.args } }));
114
+ res.write(frame("content_block_stop", { type: "content_block_stop", index }));
74
115
  }
116
+ finalStop = buffered.length ? "tool_use" : turnStop;
117
+ break;
75
118
  }
76
- // Close every opened block (ascending Anthropic index) before the terminal frames.
77
- for (const index of [...openedOrder].sort((a, b) => a - b))
78
- res.write(frame("content_block_stop", { type: "content_block_stop", index }));
79
119
  // Report real usage (agent-maestro shape): split cached tokens out of input so Claude Code's
80
- // context bar is accurate. Falls back to zeros if Copilot didn't return usage.
81
- const cached = usage?.cachedTokens ?? 0;
82
- const inputTokens = Math.max(0, (usage?.promptTokens ?? estInput) - cached); // fall back to the estimate
83
- const deltaUsage = { input_tokens: inputTokens, output_tokens: usage?.completionTokens ?? 0, cache_read_input_tokens: cached };
84
- res.write(frame("message_delta", { type: "message_delta", delta: { stop_reason: stopReason === "tool_use" ? "tool_use" : stopReason === "length" ? "max_tokens" : "end_turn" }, usage: deltaUsage }));
120
+ // context bar is accurate. promptTokens is the last turn's (largest, includes tool results);
121
+ // output is summed across turns.
122
+ const inputTokens = Math.max(0, lastPrompt - lastCached);
123
+ const deltaUsage = { input_tokens: inputTokens, output_tokens: sumCompletion, cache_read_input_tokens: lastCached };
124
+ res.write(frame("message_delta", { type: "message_delta", delta: { stop_reason: finalStop === "tool_use" ? "tool_use" : finalStop === "length" ? "max_tokens" : "end_turn" }, usage: deltaUsage }));
85
125
  res.write(frame("message_stop", { type: "message_stop" }));
86
126
  res.end();
87
127
  metric(200);
88
128
  }
89
129
  else {
90
- res.json(canonicalToAnthropicResponse(await provider.complete(canon)));
130
+ // Non-stream: same gateway loop without SSE — run gateway tools and re-complete until the
131
+ // model answers with text (or a client tool), capped identically.
132
+ let resp = await provider.complete(canon);
133
+ for (let iter = 0; runner && iter < MAX_TOOL_ITERS; iter++) {
134
+ const toolUses = resp.content.filter((b) => b.type === "tool_use");
135
+ const gatewayUses = toolUses.filter((b) => isGatewayTool(b.name));
136
+ if (!gatewayUses.length)
137
+ break; // no gateway work left — client tools / text are terminal
138
+ // Run the gateway tools, feed results back, and continue. Any client tools in the SAME turn
139
+ // ride along in the assistant message and remain in the final resp for the client to handle.
140
+ canon.messages.push({ role: "assistant", content: resp.content });
141
+ const results = [];
142
+ for (const u of gatewayUses)
143
+ results.push({ type: "tool_result", toolUseId: u.id, content: await runner(u.name, u.input) });
144
+ canon.messages.push({ role: "tool", content: results });
145
+ resp = await provider.complete(canon);
146
+ }
147
+ // Invariant: never forward a gateway tool_use to the client (it can't handle it). If the cap
148
+ // was hit with gateway calls still pending, strip them — better a partial answer than a stall.
149
+ if (runner)
150
+ resp = { ...resp, content: resp.content.filter((b) => b.type !== "tool_use" || !isGatewayTool(b.name)) };
151
+ res.json(canonicalToAnthropicResponse(resp));
91
152
  metric(200);
92
153
  }
93
154
  }
@@ -4,6 +4,8 @@ import { CopilotAdapter } from "../providers/copilot/adapter.js";
4
4
  import { CopilotTokenStore } from "../providers/copilot/token.js";
5
5
  import { fetchCopilotModels } from "../providers/copilot/models.js";
6
6
  import { readGhToken } from "../shared/creds.js";
7
+ import { readWebIqKey } from "../shared/webiq-key.js";
8
+ import { makeGatewayRunner } from "../core/server-tools.js";
7
9
  import { dataDir } from "../shared/paths.js";
8
10
  import { defaultConfig } from "../shared/config.js";
9
11
  function send(msg) { if (process.send)
@@ -20,7 +22,10 @@ const tokenStore = new CopilotTokenStore(gh);
20
22
  const router = new Router([new CopilotAdapter(tokenStore)], cfg.modelMap);
21
23
  // Load the live model list so the router can fuzzy-match near-miss ids (e.g. dated Anthropic ids).
22
24
  void tokenStore.get().then((t) => fetchCopilotModels(t)).then((ids) => router.setAvailableModels(ids)).catch(() => { });
23
- const app = createWorkerApp(router, (m) => send({ type: "request-metric", ...m }));
25
+ // Gateway-run web_search / web_fetch: reads the WebIQ key lazily per call (env or data dir), so
26
+ // setting it via /web-search-support takes effect without restarting the worker.
27
+ const gatewayRunner = makeGatewayRunner(() => readWebIqKey(dataDir()));
28
+ const app = createWorkerApp(router, (m) => send({ type: "request-metric", ...m }), gatewayRunner);
24
29
  const server = app.listen(port, host, () => send({ type: "ready", port }));
25
30
  const hb = setInterval(() => send({ type: "heartbeat", ts: Date.now() }), 5_000);
26
31
  process.on("message", (m) => { if (m?.type === "shutdown") {
@@ -1,5 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { openaiRequestToCanonical, canonicalToOpenAIResponse, canonicalChunkToOpenAISSE } from "../core/openai-inbound.js";
3
+ import { responsesRequestToCanonical, canonicalToResponsesResponse, ResponsesSSE } from "../core/responses-inbound.js";
3
4
  import { errorHint } from "./errors.js";
4
5
  import { CopilotAuthError } from "../providers/copilot/token.js";
5
6
  export function mountOpenAI(app, router, onMetric) {
@@ -46,4 +47,65 @@ export function mountOpenAI(app, router, onMetric) {
46
47
  metric(status, message);
47
48
  }
48
49
  });
50
+ // OpenAI Responses API — Codex speaks ONLY this after codex#7782 removed wire_api="chat". Codex
51
+ // POSTs {base_url}/responses, so with base_url …/openai the route is /openai/responses. Same
52
+ // canonical pipeline as chat/completions; the Responses translator handles the item-centric shape.
53
+ app.post("/openai/responses", async (req, res) => {
54
+ const start = Date.now();
55
+ const canon = responsesRequestToCanonical(req.body);
56
+ canon.model = router.resolveModel(canon.model);
57
+ const provider = router.pick(canon.model);
58
+ const metric = (status, error) => onMetric({ endpoint: "/openai/responses", model: canon.model, status, latencyMs: Date.now() - start, error });
59
+ try {
60
+ if (canon.stream) {
61
+ res.setHeader("content-type", "text/event-stream");
62
+ res.setHeader("cache-control", "no-cache");
63
+ const sse = new ResponsesSSE(`resp_${randomUUID().replace(/-/g, "")}`, canon.model);
64
+ res.write(sse.start());
65
+ const argsByIdx = new Map();
66
+ let usage;
67
+ let finish = "stop";
68
+ for await (const chunk of provider.stream(canon)) {
69
+ if (chunk.done) {
70
+ finish = chunk.finishReason ?? "stop";
71
+ usage = chunk.usage;
72
+ break;
73
+ }
74
+ if (chunk.kind === "text")
75
+ for (const f of sse.text(chunk.delta))
76
+ res.write(f);
77
+ else if (chunk.kind === "tool_use_start")
78
+ for (const f of sse.toolStart(chunk.index, chunk.id, chunk.name))
79
+ res.write(f);
80
+ else if (chunk.kind === "tool_use_delta") {
81
+ argsByIdx.set(chunk.index, (argsByIdx.get(chunk.index) ?? "") + chunk.argsDelta);
82
+ for (const f of sse.toolArgs(chunk.index, chunk.argsDelta))
83
+ res.write(f);
84
+ }
85
+ }
86
+ for (const f of sse.finish(usage, finish, argsByIdx))
87
+ res.write(f);
88
+ res.end();
89
+ metric(200);
90
+ }
91
+ else {
92
+ res.json(canonicalToResponsesResponse(await provider.complete(canon)));
93
+ metric(200);
94
+ }
95
+ }
96
+ catch (err) {
97
+ const raw = err instanceof Error ? err.message : String(err);
98
+ const hint = errorHint(raw);
99
+ const message = hint ? `${raw}\n${hint}` : raw;
100
+ const status = err instanceof CopilotAuthError ? 401 : 502;
101
+ if (!res.headersSent) {
102
+ res.status(status).json({ error: { type: "error", message } });
103
+ }
104
+ else {
105
+ res.write(`data: ${JSON.stringify({ type: "error", message })}\n\n`);
106
+ res.end();
107
+ }
108
+ metric(status, message);
109
+ }
110
+ });
49
111
  }
@@ -1,11 +1,11 @@
1
1
  import express from "express";
2
2
  import { mountOpenAI } from "./openai-server.js";
3
3
  import { mountAnthropic } from "./anthropic-server.js";
4
- export function createWorkerApp(router, onMetric) {
4
+ export function createWorkerApp(router, onMetric, gatewayRunner) {
5
5
  const app = express();
6
6
  app.use(express.json({ limit: "20mb" }));
7
7
  app.get("/healthz", (_req, res) => res.json({ ok: true }));
8
8
  mountOpenAI(app, router, onMetric);
9
- mountAnthropic(app, router, onMetric);
9
+ mountAnthropic(app, router, onMetric, gatewayRunner);
10
10
  return app;
11
11
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "copilot-reverse",
3
- "version": "0.2.1",
3
+ "version": "0.3.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",