copilot-reverse 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/index.js +5 -4
- package/dist/core/responses-inbound.js +26 -5
- package/dist/core/server-tools.js +32 -15
- 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/responses-upstream.js +161 -0
- package/dist/providers/copilot/token.js +14 -4
- package/dist/providers/webiq/client.js +1 -1
- package/dist/shared/webiq-key.js +46 -8
- package/dist/supervisor/api.js +1 -1
- package/dist/supervisor/github-heartbeat.js +81 -0
- package/dist/supervisor/index.js +15 -13
- package/dist/tui/app.js +34 -19
- package/dist/tui/slash/commands.js +4 -1
- package/dist/tui/status-summary.js +1 -1
- package/dist/version.js +1 -1
- package/dist/worker/index.js +23 -8
- package/package.json +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -11,7 +11,7 @@ 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
|
+
import { writeWebIqKey, readWebIqKey, clearWebIqKey, readWebSearchMode, writeWebSearchMode, resolveWebSearchBackend } from "../shared/webiq-key.js";
|
|
15
15
|
import { readClientSetup, writeClientSetup } from "../shared/client-setup.js";
|
|
16
16
|
import { readChatModel, writeChatModel } from "../shared/prefs.js";
|
|
17
17
|
import { CopilotTokenStore, isCopilotTokenValid } from "../providers/copilot/token.js";
|
|
@@ -154,7 +154,7 @@ async function launchTui() {
|
|
|
154
154
|
const startupStatus = summarizeStatus({
|
|
155
155
|
hasToken: Boolean(readGhToken(dataDir())),
|
|
156
156
|
tokenValid: true,
|
|
157
|
-
|
|
157
|
+
webSearch: resolveWebSearchBackend(readWebSearchMode(dataDir()), Boolean(readWebIqKey(dataDir()))),
|
|
158
158
|
worker: "ready",
|
|
159
159
|
clients: { claude: clientStatus.claude.user || clientStatus.claude.project, codex: clientStatus.codex.user || clientStatus.codex.project },
|
|
160
160
|
});
|
|
@@ -178,8 +178,9 @@ async function launchTui() {
|
|
|
178
178
|
onModelChange: (m) => writeChatModel(dataDir(), m),
|
|
179
179
|
pickModelOnStart: !persistedModel,
|
|
180
180
|
login: doLogin,
|
|
181
|
-
|
|
182
|
-
|
|
181
|
+
enableWebiq: (k) => { writeWebIqKey(k, dataDir()); writeWebSearchMode(dataDir(), "webiq"); },
|
|
182
|
+
disableWebiq: () => { clearWebIqKey(dataDir()); },
|
|
183
|
+
webSearchBackend: () => resolveWebSearchBackend(readWebSearchMode(dataDir()), Boolean(readWebIqKey(dataDir()))),
|
|
183
184
|
startupStatus,
|
|
184
185
|
githubStatus: async () => {
|
|
185
186
|
const token = readGhToken(dataDir());
|
|
@@ -50,10 +50,21 @@ export function responsesRequestToCanonical(req) {
|
|
|
50
50
|
}
|
|
51
51
|
return {
|
|
52
52
|
model: req.model, stream: Boolean(req.stream), temperature: req.temperature, maxTokens: req.max_output_tokens,
|
|
53
|
-
|
|
53
|
+
// Function tools and `custom` tools (e.g. Codex's apply_patch) both carry a name — keep them as
|
|
54
|
+
// named tools so Copilot doesn't reject a nameless tool. Only the KNOWN nameless server-side tools
|
|
55
|
+
// pass through as hostedTools; an unrecognized nameless tool is dropped rather than forwarded as a
|
|
56
|
+
// bare {type} (which makes Copilot 400 "Missing required parameter: tools[N].name" and kills the
|
|
57
|
+
// whole stream — surfaced to the Codex CLI as "stream closed before response.completed").
|
|
58
|
+
tools: req.tools?.filter((t) => (t.type === "function" || t.type === "custom") && t.name).map((t) => ({ name: t.name, description: t.description, parameters: t.parameters ?? {} })),
|
|
59
|
+
hostedTools: req.tools?.filter((t) => HOSTED_TOOL_TYPES.has(t.type ?? "")).map((t) => t.type),
|
|
54
60
|
messages,
|
|
55
61
|
};
|
|
56
62
|
}
|
|
63
|
+
// Copilot's /responses accepts these as standalone nameless hosted tools. NOTE: `tool_search` is
|
|
64
|
+
// deliberately excluded — Copilot rejects it unless the request also defines "deferred" tools
|
|
65
|
+
// ("tools.tool_search requires at least one deferred tool"), which we can't satisfy, so forwarding it
|
|
66
|
+
// 400s the whole request. web_search is the one Codex hosted tool we can pass straight through.
|
|
67
|
+
const HOSTED_TOOL_TYPES = new Set(["web_search", "web_search_preview"]);
|
|
57
68
|
// Build the non-stream Responses object: text -> an output_text message item, tool_use -> function_call items.
|
|
58
69
|
export function canonicalToResponsesResponse(r) {
|
|
59
70
|
const output = [];
|
|
@@ -81,6 +92,7 @@ export class ResponsesSSE {
|
|
|
81
92
|
nextIndex = 0;
|
|
82
93
|
textIndex;
|
|
83
94
|
textItemId;
|
|
95
|
+
accumulatedText = ""; // the full assistant text, replayed in the terminal done events
|
|
84
96
|
toolIndex = new Map();
|
|
85
97
|
constructor(responseId, model) {
|
|
86
98
|
this.responseId = responseId;
|
|
@@ -104,6 +116,7 @@ export class ResponsesSSE {
|
|
|
104
116
|
out.push(this.ev("response.content_part.added", { item_id: this.textItemId, output_index: this.textIndex, content_index: 0, part: { type: "output_text", text: "", annotations: [] } }));
|
|
105
117
|
}
|
|
106
118
|
out.push(this.ev("response.output_text.delta", { item_id: this.textItemId, output_index: this.textIndex, content_index: 0, delta }));
|
|
119
|
+
this.accumulatedText += delta;
|
|
107
120
|
return out;
|
|
108
121
|
}
|
|
109
122
|
toolStart(copilotIdx, callId, name) {
|
|
@@ -124,9 +137,10 @@ export class ResponsesSSE {
|
|
|
124
137
|
finish(usage, _finishReason, argsByIdx) {
|
|
125
138
|
const out = [];
|
|
126
139
|
if (this.textIndex !== undefined) {
|
|
127
|
-
|
|
128
|
-
out.push(this.ev("response.
|
|
129
|
-
out.push(this.ev("response.
|
|
140
|
+
const text = this.accumulatedText;
|
|
141
|
+
out.push(this.ev("response.output_text.done", { item_id: this.textItemId, output_index: this.textIndex, content_index: 0, text }));
|
|
142
|
+
out.push(this.ev("response.content_part.done", { item_id: this.textItemId, output_index: this.textIndex, content_index: 0, part: { type: "output_text", text, annotations: [] } }));
|
|
143
|
+
out.push(this.ev("response.output_item.done", { output_index: this.textIndex, item: { type: "message", id: this.textItemId, role: "assistant", status: "completed", content: [{ type: "output_text", text, annotations: [] }] } }));
|
|
130
144
|
}
|
|
131
145
|
for (const [copilotIdx, t] of this.toolIndex) {
|
|
132
146
|
const args = argsByIdx?.get(copilotIdx) ?? "";
|
|
@@ -134,7 +148,14 @@ export class ResponsesSSE {
|
|
|
134
148
|
out.push(this.ev("response.output_item.done", { output_index: t.outputIndex, item: { type: "function_call", id: t.itemId, status: "completed" } }));
|
|
135
149
|
}
|
|
136
150
|
const u = usage ? { input_tokens: usage.promptTokens, output_tokens: usage.completionTokens, total_tokens: usage.promptTokens + usage.completionTokens } : undefined;
|
|
137
|
-
|
|
151
|
+
// Spec-correct clients reconstruct the final response from response.completed.response.output, so
|
|
152
|
+
// include the finished items (the text message + any function calls), not just an empty envelope.
|
|
153
|
+
const output = [];
|
|
154
|
+
if (this.textIndex !== undefined)
|
|
155
|
+
output.push({ type: "message", id: this.textItemId, role: "assistant", status: "completed", content: [{ type: "output_text", text: this.accumulatedText, annotations: [] }] });
|
|
156
|
+
for (const [copilotIdx, t] of this.toolIndex)
|
|
157
|
+
output.push({ type: "function_call", id: t.itemId, call_id: t.itemId.replace(/^fc_/, ""), arguments: argsByIdx?.get(copilotIdx) ?? "", status: "completed" });
|
|
158
|
+
out.push(this.ev("response.completed", { response: { ...this.envelope("completed"), output, ...(u ? { usage: u } : {}) } }));
|
|
138
159
|
return out;
|
|
139
160
|
}
|
|
140
161
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { webSearch, webFetch, formatSearchResults, formatFetchResult } from "../providers/webiq/client.js";
|
|
2
|
-
|
|
3
|
-
//
|
|
4
|
-
//
|
|
2
|
+
import { formatBorrowSources } from "../providers/copilot/borrow-search.js";
|
|
3
|
+
// Tools the GATEWAY executes itself, rather than forwarding to the model's client. These mirror Claude
|
|
4
|
+
// Code's server-side web_search / web_fetch, which a Copilot-backed gateway must fulfil internally —
|
|
5
|
+
// the model calls them like normal function tools and we run them in-process.
|
|
5
6
|
export const GATEWAY_TOOL_DEFS = [
|
|
6
7
|
{
|
|
7
8
|
name: "web_search",
|
|
@@ -16,27 +17,43 @@ export const GATEWAY_TOOL_DEFS = [
|
|
|
16
17
|
];
|
|
17
18
|
const GATEWAY_TOOL_NAMES = new Set(GATEWAY_TOOL_DEFS.map((t) => t.name));
|
|
18
19
|
export function isGatewayTool(name) { return GATEWAY_TOOL_NAMES.has(name); }
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
const DEFAULT_WEBIQ = { search: webSearch, fetchPage: webFetch };
|
|
21
|
+
// Shown when web search is unavailable (Copilot borrow disabled and no WebIQ key configured).
|
|
22
|
+
const UNAVAILABLE = "web search/fetch not available, please run /webiq to use the key, to get the key please go to https://webiq.microsoft.ai/profiles/";
|
|
23
|
+
export function makeGatewayRunner(cfg) {
|
|
24
|
+
const webiq = cfg.webiq ?? DEFAULT_WEBIQ;
|
|
22
25
|
return async (name, input) => {
|
|
23
|
-
const key = getKey();
|
|
24
|
-
if (!key)
|
|
25
|
-
return NO_KEY;
|
|
26
26
|
const arg = (input ?? {});
|
|
27
|
+
const backend = cfg.backend();
|
|
28
|
+
const key = cfg.webiqKey();
|
|
27
29
|
if (name === "web_search") {
|
|
28
|
-
const query = typeof arg.query === "string" ? arg.query : "";
|
|
30
|
+
const query = typeof arg.query === "string" ? arg.query.trim() : "";
|
|
29
31
|
if (!query)
|
|
30
32
|
return "web_search error: missing 'query'";
|
|
31
|
-
|
|
32
|
-
|
|
33
|
+
if (backend === "unavailable")
|
|
34
|
+
return UNAVAILABLE;
|
|
35
|
+
if (backend === "webiq") {
|
|
36
|
+
const out = await webiq.search(key, { query });
|
|
37
|
+
return out.ok ? formatSearchResults(out.results) : out.error;
|
|
38
|
+
}
|
|
39
|
+
const out = await cfg.borrow.run(query);
|
|
40
|
+
return out.ok ? formatBorrowSources(out.sources) : out.error;
|
|
33
41
|
}
|
|
34
42
|
if (name === "web_fetch") {
|
|
35
|
-
const url = typeof arg.url === "string" ? arg.url : "";
|
|
43
|
+
const url = typeof arg.url === "string" ? arg.url.trim() : "";
|
|
36
44
|
if (!url)
|
|
37
45
|
return "web_fetch error: missing 'url'";
|
|
38
|
-
|
|
39
|
-
|
|
46
|
+
if (backend === "unavailable")
|
|
47
|
+
return UNAVAILABLE;
|
|
48
|
+
if (backend === "webiq") {
|
|
49
|
+
const out = await webiq.fetchPage(key, { url });
|
|
50
|
+
return out.ok ? formatFetchResult(out) : out.error;
|
|
51
|
+
}
|
|
52
|
+
// Copilot's web_search tool also fetches: "Open {url}…" makes gpt-5-mini open that exact page.
|
|
53
|
+
const out = await cfg.borrow.run(`Open ${url} and extract its main content.`);
|
|
54
|
+
if (!out.ok)
|
|
55
|
+
return out.error;
|
|
56
|
+
return out.text || formatBorrowSources(out.sources);
|
|
40
57
|
}
|
|
41
58
|
return `unknown gateway tool: ${name}`;
|
|
42
59
|
};
|
package/dist/core/tool-xml.js
CHANGED
|
@@ -4,7 +4,15 @@ import { randomUUID } from "node:crypto";
|
|
|
4
4
|
const TRIGGER_RE = /<(?:antml:)?(?:function_calls>|invoke\b)/;
|
|
5
5
|
// Longest suffix of `s` that is a proper prefix of a trigger token — text we must hold back because
|
|
6
6
|
// it might be the front of a sentinel split across chunk boundaries (e.g. "…<inv" then "oke name=").
|
|
7
|
-
|
|
7
|
+
// MUST list both the bare and the `antml:`-namespaced sentinels: Copilot streams Claude's tool call
|
|
8
|
+
// token by token, so an opening `<invoke` is routinely split (e.g. "…<a" then "ntml:invoke");
|
|
9
|
+
// if the namespaced forms are missing, that "<a" tail isn't recognized as a partial sentinel, leaks
|
|
10
|
+
// as text, and the remainder no longer matches the trigger — the whole call renders literally.
|
|
11
|
+
// Bare sentinel bodies, plus their namespaced variants built by inserting the prefix after "<" (the
|
|
12
|
+
// literal is assembled here rather than written inline so the namespace can't be stripped from source).
|
|
13
|
+
const NS = "antml" + ":";
|
|
14
|
+
const BARE_TOKENS = ["<function_calls>", "<invoke"];
|
|
15
|
+
const PREFIX_TOKENS = [...BARE_TOKENS, ...BARE_TOKENS.map((t) => "<" + NS + t.slice(1))];
|
|
8
16
|
function heldBackLen(s) {
|
|
9
17
|
let max = 0;
|
|
10
18
|
for (const t of PREFIX_TOKENS) {
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { ToolCallExtractor } from "../../core/tool-xml.js";
|
|
3
|
+
import { canonicalToResponsesBody, parseResponsesResult, streamResponses, RESPONSES_URL } from "./responses-upstream.js";
|
|
3
4
|
const CHAT_URL = "https://api.githubcopilot.com/chat/completions";
|
|
5
|
+
// A /chat 400 whose body names one of these means "this model is responses-only" — retry on /responses
|
|
6
|
+
// once. Matches agent-maestro's safety net for models that drop /chat/completions from their endpoints.
|
|
7
|
+
const RESPONSES_HINT_RE = /unsupported_api_for_model|invalid_request_body|does not support|use the responses|model_not_supported/i;
|
|
4
8
|
// Canonical messages -> OpenAI wire messages (Copilot is OpenAI-shaped).
|
|
5
9
|
function toWireMessages(messages) {
|
|
6
10
|
const out = [];
|
|
@@ -54,16 +58,31 @@ async function errorDetail(res) {
|
|
|
54
58
|
export class CopilotAdapter {
|
|
55
59
|
tokenStore;
|
|
56
60
|
fetchFn;
|
|
61
|
+
endpointsFor;
|
|
57
62
|
name = "copilot";
|
|
58
|
-
|
|
63
|
+
// endpointsFor(model) -> the model's supported_endpoints (e.g. ["/responses"]). When known and it
|
|
64
|
+
// omits /chat/completions, route to /responses; unknown ([]) keeps the chat path (with a 400 net).
|
|
65
|
+
constructor(tokenStore, fetchFn = fetch, endpointsFor) {
|
|
59
66
|
this.tokenStore = tokenStore;
|
|
60
67
|
this.fetchFn = fetchFn;
|
|
68
|
+
this.endpointsFor = endpointsFor;
|
|
69
|
+
}
|
|
70
|
+
usesResponses(model) {
|
|
71
|
+
const eps = this.endpointsFor?.(model);
|
|
72
|
+
return !!eps && eps.length > 0 && !eps.includes("/chat/completions");
|
|
61
73
|
}
|
|
62
74
|
async complete(req) {
|
|
75
|
+
if (this.usesResponses(req.model))
|
|
76
|
+
return this.completeResponses(req);
|
|
63
77
|
const token = await this.tokenStore.get();
|
|
64
78
|
const res = await this.fetchFn(CHAT_URL, { method: "POST", headers: headers(token), body: JSON.stringify(buildBody({ ...req, stream: false })) });
|
|
65
|
-
if (!res.ok)
|
|
66
|
-
|
|
79
|
+
if (!res.ok) {
|
|
80
|
+
const detail = await errorDetail(res);
|
|
81
|
+
// Safety net: a responses-only model rejected on /chat — retry once on /responses.
|
|
82
|
+
if (res.status === 400 && RESPONSES_HINT_RE.test(detail))
|
|
83
|
+
return this.completeResponses(req);
|
|
84
|
+
throw new Error(`copilot completion failed: ${res.status}${detail}`);
|
|
85
|
+
}
|
|
67
86
|
const data = (await res.json());
|
|
68
87
|
const choice = data.choices[0];
|
|
69
88
|
const content = [];
|
|
@@ -77,11 +96,36 @@ export class CopilotAdapter {
|
|
|
77
96
|
usage: { promptTokens: data.usage?.prompt_tokens ?? 0, completionTokens: data.usage?.completion_tokens ?? 0 },
|
|
78
97
|
};
|
|
79
98
|
}
|
|
99
|
+
// /responses variants — used for responses-only models and as the /chat 400 safety-net target.
|
|
100
|
+
async completeResponses(req) {
|
|
101
|
+
const token = await this.tokenStore.get();
|
|
102
|
+
const res = await this.fetchFn(RESPONSES_URL, { method: "POST", headers: headers(token), body: JSON.stringify(canonicalToResponsesBody({ ...req, stream: false })) });
|
|
103
|
+
if (!res.ok)
|
|
104
|
+
throw new Error(`copilot responses failed: ${res.status}${await errorDetail(res)}`);
|
|
105
|
+
return { ...parseResponsesResult(await res.json()), model: req.model };
|
|
106
|
+
}
|
|
107
|
+
async *streamResponsesReq(req) {
|
|
108
|
+
const token = await this.tokenStore.get();
|
|
109
|
+
const res = await this.fetchFn(RESPONSES_URL, { method: "POST", headers: headers(token), body: JSON.stringify(canonicalToResponsesBody({ ...req, stream: true })) });
|
|
110
|
+
if (!res.ok || !res.body)
|
|
111
|
+
throw new Error(`copilot responses stream failed: ${res.status}${await errorDetail(res)}`);
|
|
112
|
+
yield* streamResponses(res);
|
|
113
|
+
}
|
|
80
114
|
async *stream(req) {
|
|
115
|
+
if (this.usesResponses(req.model)) {
|
|
116
|
+
yield* this.streamResponsesReq(req);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
81
119
|
const token = await this.tokenStore.get();
|
|
82
120
|
const res = await this.fetchFn(CHAT_URL, { method: "POST", headers: headers(token), body: JSON.stringify(buildBody({ ...req, stream: true })) });
|
|
83
|
-
if (!res.ok || !res.body)
|
|
84
|
-
|
|
121
|
+
if (!res.ok || !res.body) {
|
|
122
|
+
const detail = await errorDetail(res);
|
|
123
|
+
if (res.status === 400 && RESPONSES_HINT_RE.test(detail)) {
|
|
124
|
+
yield* this.streamResponsesReq(req);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
throw new Error(`copilot stream failed: ${res.status}${detail}`);
|
|
128
|
+
}
|
|
85
129
|
const reader = res.body.getReader();
|
|
86
130
|
const decoder = new TextDecoder();
|
|
87
131
|
const startedTools = new Set();
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { RESPONSES_URL } from "./responses-upstream.js";
|
|
2
|
+
// Same identity headers as the chat adapter, plus openai-intent (the /responses host expects it).
|
|
3
|
+
function headers(token) {
|
|
4
|
+
return {
|
|
5
|
+
authorization: `Bearer ${token}`, "content-type": "application/json",
|
|
6
|
+
"editor-version": "vscode/1.95.0", "copilot-integration-id": "vscode-chat", "openai-intent": "conversation-edits",
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
// Pull {title,url} from every url_citation annotation across message output_text parts, de-duped by url.
|
|
10
|
+
export function extractCitations(output) {
|
|
11
|
+
const seen = new Set();
|
|
12
|
+
const sources = [];
|
|
13
|
+
for (const item of output ?? []) {
|
|
14
|
+
if (item?.type !== "message")
|
|
15
|
+
continue;
|
|
16
|
+
for (const part of item.content ?? []) {
|
|
17
|
+
for (const ann of part?.annotations ?? []) {
|
|
18
|
+
if (ann?.type !== "url_citation" || !ann.url || seen.has(ann.url))
|
|
19
|
+
continue;
|
|
20
|
+
seen.add(ann.url);
|
|
21
|
+
sources.push({ title: ann.title || ann.url, url: ann.url });
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return sources;
|
|
26
|
+
}
|
|
27
|
+
// gpt-5's own prose answer (concatenated output_text). We feed Claude the SOURCES, not this — but it
|
|
28
|
+
// is handy for web_fetch ("open this URL and extract…") where the extracted content is the payload.
|
|
29
|
+
export function extractText(output) {
|
|
30
|
+
let text = "";
|
|
31
|
+
for (const item of output ?? []) {
|
|
32
|
+
if (item?.type !== "message")
|
|
33
|
+
continue;
|
|
34
|
+
for (const part of item.content ?? [])
|
|
35
|
+
if (part?.type === "output_text" && part.text)
|
|
36
|
+
text += part.text;
|
|
37
|
+
}
|
|
38
|
+
return text;
|
|
39
|
+
}
|
|
40
|
+
// Run one internal gpt-5-mini web_search. `input` is the full instruction (a query for web_search, or
|
|
41
|
+
// "Open {url} and extract its content" for web_fetch). Never throws — failures become an error string
|
|
42
|
+
// so the gateway tool loop can degrade gracefully. Bounded by a timeout so a congested upstream (gpt-5-
|
|
43
|
+
// mini is prone to "high demand" stalls) fails fast instead of hanging the whole turn for minutes.
|
|
44
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
45
|
+
export async function borrowSearch(tokenStore, input, fetchFn = fetch, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
46
|
+
if (!input.trim())
|
|
47
|
+
return { ok: false, error: "borrow search error: empty query" };
|
|
48
|
+
let token;
|
|
49
|
+
try {
|
|
50
|
+
token = await tokenStore.get();
|
|
51
|
+
}
|
|
52
|
+
catch (e) {
|
|
53
|
+
return { ok: false, error: `borrow search unavailable: ${e instanceof Error ? e.message : String(e)}` };
|
|
54
|
+
}
|
|
55
|
+
const ctrl = new AbortController();
|
|
56
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
57
|
+
try {
|
|
58
|
+
const res = await fetchFn(RESPONSES_URL, {
|
|
59
|
+
method: "POST", headers: headers(token), signal: ctrl.signal,
|
|
60
|
+
// reasoning.effort "low" is a ~5-6x speedup (≈30s→≈5s, and far less variance) vs the default:
|
|
61
|
+
// we discard gpt-5's prose and keep only the citations, so the heavy reasoning it would otherwise
|
|
62
|
+
// do before/after the search is wasted. ("minimal" is rejected by the API alongside web_search.)
|
|
63
|
+
body: JSON.stringify({ model: "gpt-5-mini", input, stream: false, tools: [{ type: "web_search" }], reasoning: { effort: "low" } }),
|
|
64
|
+
});
|
|
65
|
+
if (!res.ok) {
|
|
66
|
+
const detail = await res.text().catch(() => "");
|
|
67
|
+
return { ok: false, error: `borrow search failed: ${res.status}${detail ? ` — ${detail.slice(0, 200)}` : ""}` };
|
|
68
|
+
}
|
|
69
|
+
const data = (await res.json());
|
|
70
|
+
return { ok: true, sources: extractCitations(data.output ?? []), text: extractText(data.output ?? []) };
|
|
71
|
+
}
|
|
72
|
+
catch (e) {
|
|
73
|
+
const timedOut = e instanceof Error && e.name === "AbortError";
|
|
74
|
+
return { ok: false, error: timedOut ? `borrow search timed out after ${timeoutMs}ms` : "borrow search failed: could not reach Copilot" };
|
|
75
|
+
}
|
|
76
|
+
finally {
|
|
77
|
+
clearTimeout(timer);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// Render the borrowed sources as the tool_result text fed back to the model — numbered title+url so
|
|
81
|
+
// the model can cite them. (We deliberately hand back sources, not gpt-5's prose, for web_search.)
|
|
82
|
+
export function formatBorrowSources(sources) {
|
|
83
|
+
if (!sources.length)
|
|
84
|
+
return "no results found";
|
|
85
|
+
return sources.map((s, i) => `[${i + 1}] ${s.title}\n${s.url}`).join("\n\n");
|
|
86
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -41,13 +41,23 @@ export class CopilotTokenStore {
|
|
|
41
41
|
return data.token;
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
|
-
// True if the stored GitHub token still exchanges for a Copilot token.
|
|
44
|
+
// True if the stored GitHub token still exchanges for a Copilot token. A thin wrapper over
|
|
45
|
+
// probeGithubAuth so the token-exchange logic lives in exactly one place.
|
|
45
46
|
export async function isCopilotTokenValid(ghToken, fetchFn = fetch) {
|
|
47
|
+
return (await probeGithubAuth(ghToken, fetchFn)).ok;
|
|
48
|
+
}
|
|
49
|
+
export async function probeGithubAuth(ghToken, fetchFn = fetch) {
|
|
46
50
|
try {
|
|
47
51
|
await new CopilotTokenStore(ghToken, fetchFn).get();
|
|
48
|
-
return true;
|
|
52
|
+
return { ok: true, transient: false, detail: "token valid" };
|
|
49
53
|
}
|
|
50
|
-
catch {
|
|
51
|
-
|
|
54
|
+
catch (e) {
|
|
55
|
+
// CopilotTokenStore throws CopilotAuthError(status) for any non-ok response, and other errors
|
|
56
|
+
// (AbortError on timeout, network failures) for the rest. We treat 401/403 as definitive auth
|
|
57
|
+
// failures; everything else is transient. See the limitations noted above.
|
|
58
|
+
if (e instanceof CopilotAuthError && (e.status === 401 || e.status === 403)) {
|
|
59
|
+
return { ok: false, transient: false, detail: e.message };
|
|
60
|
+
}
|
|
61
|
+
return { ok: false, transient: true, detail: e instanceof Error ? e.message : String(e) };
|
|
52
62
|
}
|
|
53
63
|
}
|
|
@@ -10,7 +10,7 @@ const headers = (key) => ({ host: "api.microsoft.ai", "x-apikey": key, "content-
|
|
|
10
10
|
// consistent, actionable string it can reason about (e.g. fall back to its own knowledge).
|
|
11
11
|
function statusError(status, kind) {
|
|
12
12
|
if (status === 401 || status === 403)
|
|
13
|
-
return "web search unavailable: WebIQ API key missing or invalid — run /
|
|
13
|
+
return "web search unavailable: WebIQ API key missing or invalid — run /webiq to set it";
|
|
14
14
|
if (status === 429)
|
|
15
15
|
return "web search unavailable: WebIQ rate limit exceeded — try again shortly";
|
|
16
16
|
if (status === 404 && kind === "fetch")
|
package/dist/shared/webiq-key.js
CHANGED
|
@@ -1,21 +1,59 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
// WebIQ
|
|
4
|
-
// (plaintext, 0600, in the data dir). The WEBIQ_API_KEY env var
|
|
5
|
-
// runs can inject it
|
|
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.
|
|
6
10
|
const file = (dir) => join(dir, "webiq.json");
|
|
7
|
-
|
|
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) {
|
|
8
22
|
if (!existsSync(dir))
|
|
9
23
|
mkdirSync(dir, { recursive: true });
|
|
10
|
-
writeFileSync(file(dir), JSON.stringify(
|
|
24
|
+
writeFileSync(file(dir), JSON.stringify(data), { mode: 0o600 });
|
|
25
|
+
}
|
|
26
|
+
export function writeWebIqKey(key, dir) {
|
|
27
|
+
write(dir, { ...read(dir), apiKey: key });
|
|
11
28
|
}
|
|
12
29
|
export function readWebIqKey(dir) {
|
|
13
30
|
if (process.env.WEBIQ_API_KEY)
|
|
14
31
|
return process.env.WEBIQ_API_KEY;
|
|
15
|
-
|
|
16
|
-
return null;
|
|
17
|
-
return JSON.parse(readFileSync(file(dir), "utf8")).apiKey ?? null;
|
|
32
|
+
return read(dir).apiKey ?? null;
|
|
18
33
|
}
|
|
34
|
+
// Reset everything — drop the key AND revert to the default copilot backend.
|
|
19
35
|
export function clearWebIqKey(dir) {
|
|
20
36
|
rmSync(file(dir), { force: true });
|
|
21
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/supervisor/api.js
CHANGED
|
@@ -5,7 +5,7 @@ export function createControlApp(deps) {
|
|
|
5
5
|
const app = express();
|
|
6
6
|
app.use(express.json());
|
|
7
7
|
app.get("/", (_req, res) => res.type("html").send(dashboardHtml()));
|
|
8
|
-
app.get("/api/status", (_req, res) => res.json({ workerState: deps.getState(), restarts: listRestarts(deps.db, 50) }));
|
|
8
|
+
app.get("/api/status", (_req, res) => res.json({ workerState: deps.getState(), restarts: listRestarts(deps.db, 50), github: deps.github() }));
|
|
9
9
|
app.post("/api/restart", (_req, res) => { deps.restart(); res.json({ ok: true }); });
|
|
10
10
|
app.post("/api/stop", (_req, res) => { deps.stop(); res.json({ ok: true }); });
|
|
11
11
|
app.post("/api/start", (_req, res) => { deps.start(); res.json({ ok: true }); });
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { probeGithubAuth } from "../providers/copilot/token.js";
|
|
2
|
+
// How often the supervisor re-checks the GitHub token. Token failure is rare (revoke / re-auth) and
|
|
3
|
+
// GitHub rate-limits, so a slow cadence is plenty; an initial short delay populates the status soon
|
|
4
|
+
// after boot without racing worker startup.
|
|
5
|
+
export const GITHUB_HEARTBEAT_INTERVAL_MS = 60_000;
|
|
6
|
+
export const GITHUB_HEARTBEAT_INITIAL_DELAY_MS = 2_000;
|
|
7
|
+
// Shared so /doctor and the heartbeat show the same remediation hint for the signed-out state.
|
|
8
|
+
export const SIGNED_OUT_DETAIL = "not logged in — run /login";
|
|
9
|
+
// Pure reducer: given the prior cached status, whether a token is on disk, and the latest probe
|
|
10
|
+
// result, decide the next cached status. Transient errors are sticky — they keep the prior status —
|
|
11
|
+
// so a brief blip doesn't flip a connected session to "expired". Caveat (see probeGithubAuth): the
|
|
12
|
+
// stickiness is unbounded, and if the FIRST probe is transient (prev still undefined) the status stays
|
|
13
|
+
// undefined / "pending", so /api/status omits `github` and the HUD shows no badge until a non-transient
|
|
14
|
+
// result lands.
|
|
15
|
+
export function nextGithubStatus(prev, hasToken, probe, now) {
|
|
16
|
+
if (!hasToken)
|
|
17
|
+
return { ok: false, hasToken: false, checkedAt: now, detail: SIGNED_OUT_DETAIL };
|
|
18
|
+
if (probe && probe.transient)
|
|
19
|
+
return prev; // keep last-known-good (or stay pending if none yet)
|
|
20
|
+
if (!probe)
|
|
21
|
+
return prev;
|
|
22
|
+
return { ok: probe.ok, hasToken: true, checkedAt: now, detail: probe.detail };
|
|
23
|
+
}
|
|
24
|
+
// Periodically probes the GitHub token in the supervisor process and caches a GithubStatus the control
|
|
25
|
+
// API exposes via /api/status. Dependencies are injected for testing (token reader, probe, clock).
|
|
26
|
+
export class GithubHeartbeat {
|
|
27
|
+
readToken;
|
|
28
|
+
probe;
|
|
29
|
+
now;
|
|
30
|
+
status;
|
|
31
|
+
timer;
|
|
32
|
+
stopped = false;
|
|
33
|
+
inFlight = false;
|
|
34
|
+
intervalMs;
|
|
35
|
+
initialDelayMs;
|
|
36
|
+
constructor(readToken, probe = probeGithubAuth, now = () => Date.now(), opts = {}) {
|
|
37
|
+
this.readToken = readToken;
|
|
38
|
+
this.probe = probe;
|
|
39
|
+
this.now = now;
|
|
40
|
+
this.intervalMs = opts.intervalMs ?? GITHUB_HEARTBEAT_INTERVAL_MS;
|
|
41
|
+
this.initialDelayMs = opts.initialDelayMs ?? GITHUB_HEARTBEAT_INITIAL_DELAY_MS;
|
|
42
|
+
}
|
|
43
|
+
current() { return this.status; }
|
|
44
|
+
// One probe cycle. Reads the token first: no token → signed-out, and the network probe is skipped.
|
|
45
|
+
// Guarded so a slow probe (up to ~8s) can't overlap the next tick.
|
|
46
|
+
async runOnce() {
|
|
47
|
+
if (this.inFlight)
|
|
48
|
+
return;
|
|
49
|
+
this.inFlight = true;
|
|
50
|
+
try {
|
|
51
|
+
const token = this.readToken();
|
|
52
|
+
const probe = token ? await this.probe(token) : null;
|
|
53
|
+
if (this.stopped)
|
|
54
|
+
return; // a late result after stop() must not resurrect the timer/state
|
|
55
|
+
this.status = nextGithubStatus(this.status, Boolean(token), probe, this.now());
|
|
56
|
+
}
|
|
57
|
+
finally {
|
|
58
|
+
this.inFlight = false;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
start() {
|
|
62
|
+
if (this.timer)
|
|
63
|
+
return; // idempotent: don't leak a second timer if start() is called twice
|
|
64
|
+
this.stopped = false;
|
|
65
|
+
const tick = () => { void this.runOnce(); };
|
|
66
|
+
this.timer = setTimeout(() => {
|
|
67
|
+
tick();
|
|
68
|
+
this.timer = setInterval(tick, this.intervalMs);
|
|
69
|
+
}, this.initialDelayMs);
|
|
70
|
+
}
|
|
71
|
+
stop() {
|
|
72
|
+
this.stopped = true;
|
|
73
|
+
// The timer handle is either the initial setTimeout or the later setInterval; clearing both kinds
|
|
74
|
+
// is safe with either function in Node.
|
|
75
|
+
if (this.timer) {
|
|
76
|
+
clearTimeout(this.timer);
|
|
77
|
+
clearInterval(this.timer);
|
|
78
|
+
this.timer = undefined;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
package/dist/supervisor/index.js
CHANGED
|
@@ -8,7 +8,8 @@ import { createControlApp } from "./api.js";
|
|
|
8
8
|
import { defaultConfig } from "../shared/config.js";
|
|
9
9
|
import { dataDir, dbPath } from "../shared/paths.js";
|
|
10
10
|
import { readGhToken } from "../shared/creds.js";
|
|
11
|
-
import {
|
|
11
|
+
import { probeGithubAuth } from "../providers/copilot/token.js";
|
|
12
|
+
import { GithubHeartbeat, SIGNED_OUT_DETAIL } from "./github-heartbeat.js";
|
|
12
13
|
export function startSupervisor() {
|
|
13
14
|
const config = defaultConfig();
|
|
14
15
|
mkdirSync(dataDir(), { recursive: true });
|
|
@@ -34,32 +35,33 @@ export function startSupervisor() {
|
|
|
34
35
|
const gh = readGhToken(dataDir());
|
|
35
36
|
let auth;
|
|
36
37
|
if (!gh) {
|
|
37
|
-
auth = { name: "github-auth", ok: false, detail:
|
|
38
|
+
auth = { name: "github-auth", ok: false, detail: SIGNED_OUT_DETAIL };
|
|
38
39
|
}
|
|
39
40
|
else {
|
|
40
|
-
// Validate the token actually exchanges, not just that it exists on disk.
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
}
|
|
45
|
-
catch (e) {
|
|
46
|
-
auth = { name: "github-auth", ok: false, detail: e instanceof Error ? e.message : String(e) };
|
|
47
|
-
}
|
|
41
|
+
// Validate the token actually exchanges, not just that it exists on disk. Shares the heartbeat's
|
|
42
|
+
// classifier so on-demand /doctor and the periodic probe agree.
|
|
43
|
+
const probe = await probeGithubAuth(gh);
|
|
44
|
+
auth = { name: "github-auth", ok: probe.ok, detail: probe.detail };
|
|
48
45
|
}
|
|
49
46
|
return [auth, { name: "worker", ok: state === "ready", detail: `worker is ${state}` }];
|
|
50
47
|
};
|
|
48
|
+
// Periodically re-check the GitHub token so the UI reflects an expired/revoked login within ~60s,
|
|
49
|
+
// instead of only on the next failed request or a manual /status.
|
|
50
|
+
const heartbeat = new GithubHeartbeat(() => readGhToken(dataDir()));
|
|
51
51
|
const app = createControlApp({
|
|
52
52
|
db, getState: () => state,
|
|
53
53
|
restart: () => monitor.restartManually(),
|
|
54
54
|
stop: () => monitor.stop(),
|
|
55
55
|
start: () => monitor.start(),
|
|
56
56
|
doctor,
|
|
57
|
+
github: () => heartbeat.current(),
|
|
57
58
|
subscribe: (send) => bus.subscribe(send),
|
|
58
59
|
});
|
|
59
60
|
app.listen(config.supervisorPort, config.bindHost, () => monitor.start());
|
|
60
|
-
|
|
61
|
-
process.on("
|
|
62
|
-
|
|
61
|
+
heartbeat.start();
|
|
62
|
+
process.on("SIGINT", () => { heartbeat.stop(); monitor.stop(); process.exit(0); });
|
|
63
|
+
process.on("SIGTERM", () => { heartbeat.stop(); monitor.stop(); process.exit(0); });
|
|
64
|
+
return { stop: () => { heartbeat.stop(); monitor.stop(); } };
|
|
63
65
|
}
|
|
64
66
|
// Allow `node dist/supervisor/index.js` to boot the daemon directly.
|
|
65
67
|
if (process.argv[1] && process.argv[1].endsWith(join("supervisor", "index.js")))
|
package/dist/tui/app.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import { useEffect, useRef, useState } from "react";
|
|
3
3
|
import { Box, Text, useInput } from "ink";
|
|
4
4
|
import { loadingVerb } from "../shared/format.js";
|
|
@@ -7,19 +7,19 @@ import { SetupWizard } from "./setup/wizard.js";
|
|
|
7
7
|
import { ModelScreen } from "./screens/model.js";
|
|
8
8
|
import { ConfigScreen } from "./screens/config.js";
|
|
9
9
|
import { WebIqKeyScreen } from "./screens/webiq-key.js";
|
|
10
|
-
import { summarizeStatus } from "./status-summary.js";
|
|
10
|
+
import { summarizeStatus, githubLoginState } from "./status-summary.js";
|
|
11
11
|
import { theme } from "./theme.js";
|
|
12
12
|
const stateColor = {
|
|
13
13
|
ready: theme.ready, starting: theme.starting, crashed: theme.crashed, unhealthy: theme.unhealthy,
|
|
14
14
|
};
|
|
15
15
|
const EMPTY_STATUS = { claude: { user: false, project: false }, codex: { user: false, project: false } };
|
|
16
16
|
const SPINNER = ["✶", "✸", "✹", "✺", "✹", "✷"];
|
|
17
|
-
// Startup overview card. GitHub shows a login STATE (no real token expiry exists)
|
|
18
|
-
//
|
|
19
|
-
// lines (e.g. worker restart history for /status).
|
|
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
20
|
function statusCard(s, extra = []) {
|
|
21
21
|
const gh = s.github === "connected" ? "✓ connected" : s.github === "expired" ? "✗ expired — run /login" : "✗ signed out — run /login";
|
|
22
|
-
const web = s.webSearch === "
|
|
22
|
+
const web = s.webSearch === "webiq" ? "✓ via WebIQ" : s.webSearch === "copilot" ? "✓ via Copilot (native)" : "✗ unavailable — run /webiq";
|
|
23
23
|
const clients = `claude ${s.clients.claude ? "✓" : "○"} codex ${s.clients.codex ? "✓" : "○"}`;
|
|
24
24
|
const tone = s.github === "connected" ? "ok" : "error";
|
|
25
25
|
return { type: "card", title: "status", tone, lines: [
|
|
@@ -54,7 +54,7 @@ function ClientBadge({ name, status }) {
|
|
|
54
54
|
const cell = (label, on) => (_jsxs(Text, { color: on ? theme.ready : theme.muted, children: [label, ":", on ? "✓" : "○"] }));
|
|
55
55
|
return (_jsxs(Text, { color: theme.muted, children: [name, " ", cell("u", status.user), " ", cell("p", status.project)] }));
|
|
56
56
|
}
|
|
57
|
-
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, }) {
|
|
58
58
|
const cmds = registry.list().map((c) => ({ name: c.name, describe: c.describe }));
|
|
59
59
|
const [entries, setEntries] = useState(() => [
|
|
60
60
|
...(startupStatus ? [statusCard(startupStatus)] : []),
|
|
@@ -62,7 +62,9 @@ export function App({ registry, title, workerState = "starting", initialModel =
|
|
|
62
62
|
]);
|
|
63
63
|
const [state, setState] = useState(workerState);
|
|
64
64
|
const [status, setStatus] = useState(() => readStatus?.() ?? EMPTY_STATUS);
|
|
65
|
-
const [
|
|
65
|
+
const [webBackend, setWebBackend] = useState(() => webSearchBackend?.() ?? "unavailable");
|
|
66
|
+
// GitHub login state, kept fresh by the supervisor heartbeat surfaced through the 2s status poll.
|
|
67
|
+
const [github, setGithub] = useState(startupStatus?.github);
|
|
66
68
|
const [model, setModel] = useState(initialModel);
|
|
67
69
|
const [screen, setScreen] = useState(pickModelOnStart && loadModels ? { kind: "model" } : null);
|
|
68
70
|
const [, setNow] = useState(0); // ticks the live loading line while the assistant streams
|
|
@@ -70,8 +72,8 @@ export function App({ registry, title, workerState = "starting", initialModel =
|
|
|
70
72
|
const loginInFlight = useRef(false); // guards against starting a second device-login flow
|
|
71
73
|
const add = (e) => setEntries((p) => [...p, e].slice(-100));
|
|
72
74
|
const refreshStatus = () => { if (readStatus)
|
|
73
|
-
setStatus(readStatus()); if (
|
|
74
|
-
|
|
75
|
+
setStatus(readStatus()); if (webSearchBackend)
|
|
76
|
+
setWebBackend(webSearchBackend()); };
|
|
75
77
|
// esc interrupts an in-flight assistant turn (the Repl doesn't use esc, so this is unambiguous).
|
|
76
78
|
useInput((_input, key) => { if (key.escape)
|
|
77
79
|
abortRef.current?.abort(); });
|
|
@@ -82,8 +84,11 @@ export function App({ registry, title, workerState = "starting", initialModel =
|
|
|
82
84
|
const tick = async () => {
|
|
83
85
|
try {
|
|
84
86
|
const s = await statusSource?.();
|
|
85
|
-
if (alive && s)
|
|
87
|
+
if (alive && s) {
|
|
86
88
|
setState(s.workerState);
|
|
89
|
+
if (s.github)
|
|
90
|
+
setGithub(githubLoginState(s.github.hasToken, s.github.ok)); // live login badge
|
|
91
|
+
}
|
|
87
92
|
}
|
|
88
93
|
catch { /* daemon momentarily down */ }
|
|
89
94
|
if (alive)
|
|
@@ -113,13 +118,23 @@ export function App({ registry, title, workerState = "starting", initialModel =
|
|
|
113
118
|
setScreen({ kind: "model" });
|
|
114
119
|
return;
|
|
115
120
|
}
|
|
116
|
-
|
|
121
|
+
// Web-search backend controls. "/webiq clean" clears the key; "/webiq" opens the key screen and
|
|
122
|
+
// switches to the WebIQ backend on submit. After either, re-read the resolved backend for the HUD.
|
|
123
|
+
if (t === "/webiq clean" && disableWebiq) {
|
|
124
|
+
disableWebiq();
|
|
125
|
+
setWebBackend(webSearchBackend?.() ?? "unavailable");
|
|
126
|
+
add({ type: "card", title: "/webiq", tone: "ok", lines: ["✓ WebIQ key cleared"] });
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (t === "/webiq" && enableWebiq) {
|
|
117
130
|
setScreen({ kind: "webiq-key" });
|
|
118
131
|
return;
|
|
119
132
|
}
|
|
120
|
-
if (t === "/status" && (startupStatus || githubStatus ||
|
|
133
|
+
if (t === "/status" && (startupStatus || githubStatus || webSearchBackend)) {
|
|
121
134
|
// Render the live status overview (same card as startup), then the worker restart history.
|
|
122
|
-
|
|
135
|
+
// /status is an explicit "is my login OK right now?" — do the live check when wired (the cached
|
|
136
|
+
// heartbeat can be up to ~60s stale), falling back to the cached/seed value only if it isn't.
|
|
137
|
+
const ghState = githubStatus ? await githubStatus() : (github ?? startupStatus?.github ?? "signed-out");
|
|
123
138
|
let worker = state, restarts = [];
|
|
124
139
|
try {
|
|
125
140
|
const s = await statusSource?.();
|
|
@@ -130,8 +145,8 @@ export function App({ registry, title, workerState = "starting", initialModel =
|
|
|
130
145
|
}
|
|
131
146
|
catch { /* daemon momentarily down — show what we have */ }
|
|
132
147
|
const summary = summarizeStatus({
|
|
133
|
-
hasToken:
|
|
134
|
-
|
|
148
|
+
hasToken: ghState !== "signed-out", tokenValid: ghState === "connected",
|
|
149
|
+
webSearch: webSearchBackend?.() ?? webBackend, worker,
|
|
135
150
|
clients: { claude: status.claude.user || status.claude.project, codex: status.codex.user || status.codex.project },
|
|
136
151
|
});
|
|
137
152
|
add(statusCard(summary, restarts.length ? ["", "recent restarts:", ...restarts] : []));
|
|
@@ -226,8 +241,8 @@ export function App({ registry, title, workerState = "starting", initialModel =
|
|
|
226
241
|
setScreen(null);
|
|
227
242
|
} }));
|
|
228
243
|
}
|
|
229
|
-
else if (screen?.kind === "webiq-key" &&
|
|
230
|
-
body = (_jsx(WebIqKeyScreen, { onSubmit: (k) => {
|
|
244
|
+
else if (screen?.kind === "webiq-key" && enableWebiq) {
|
|
245
|
+
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" }); } }));
|
|
231
246
|
}
|
|
232
247
|
else {
|
|
233
248
|
body = _jsx(Repl, { onSubmit: handle, commands: cmds });
|
|
@@ -245,5 +260,5 @@ export function App({ registry, title, workerState = "starting", initialModel =
|
|
|
245
260
|
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: theme.accent, children: ["\u273D ", _jsxs(Text, { color: theme.muted, children: [frame, " ", loadingVerb(elapsed), "\u2026 (esc to interrupt \u00B7 ", fmtElapsed(elapsed), " \u00B7 \u2193 ", fmtTokens(tokens), " tokens \u00B7 thinking)"] })] }), e.text ? _jsx(Text, { color: color, children: e.text }) : null] }, i));
|
|
246
261
|
}
|
|
247
262
|
return _jsx(Text, { color: color, children: e.text }, i);
|
|
248
|
-
}) }), body, _jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsxs(Box, { children: [_jsx(Text, { color: theme.muted, children: "
|
|
263
|
+
}) }), body, _jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsxs(Box, { children: [github && _jsxs(_Fragment, { children: [_jsx(Text, { color: theme.muted, children: "github " }), _jsx(Text, { color: github === "connected" ? theme.ready : theme.error, children: github === "connected" ? "✓" : "✗ /login" })] }), _jsxs(Text, { color: theme.muted, children: [github ? " · " : "", "daemon "] }), _jsx(Text, { color: stateColor[state], children: state })] }), _jsxs(Box, { children: [_jsx(Text, { color: theme.muted, children: "web " }), _jsx(Text, { color: webBackend === "unavailable" ? theme.muted : theme.ready, children: webBackend === "webiq" ? "✓ webiq" : webBackend === "copilot" ? "✓ copilot" : "✗ /webiq" }), _jsx(Text, { color: theme.muted, children: " \u00B7 " }), _jsx(ClientBadge, { name: "claude", status: status.claude }), _jsx(Text, { color: theme.muted, children: " " }), _jsx(ClientBadge, { name: "codex", status: status.codex }), _jsx(Text, { color: theme.muted, children: " \u00B7 /help" })] })] })] }));
|
|
249
264
|
}
|
|
@@ -45,7 +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
|
-
|
|
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…"] });
|
|
49
52
|
reg.add({ name: "/config", describe: "view & change configuration", run: async () => ["opening config panel…"] });
|
|
50
53
|
reg.add({ name: "/dashboard", describe: "open the web dashboard in your browser", run: async () => {
|
|
51
54
|
if (!opts.dashboardUrl)
|
|
@@ -6,7 +6,7 @@ export function githubLoginState(hasToken, tokenValid) {
|
|
|
6
6
|
export function summarizeStatus(i) {
|
|
7
7
|
return {
|
|
8
8
|
github: githubLoginState(i.hasToken, i.tokenValid),
|
|
9
|
-
webSearch: i.
|
|
9
|
+
webSearch: i.webSearch,
|
|
10
10
|
worker: i.worker,
|
|
11
11
|
clients: i.clients,
|
|
12
12
|
};
|
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.5.0";
|
package/dist/worker/index.js
CHANGED
|
@@ -2,10 +2,11 @@ import { createWorkerApp } from "./server.js";
|
|
|
2
2
|
import { Router } from "./router.js";
|
|
3
3
|
import { CopilotAdapter } from "../providers/copilot/adapter.js";
|
|
4
4
|
import { CopilotTokenStore } from "../providers/copilot/token.js";
|
|
5
|
-
import { fetchCopilotModels } from "../providers/copilot/models.js";
|
|
5
|
+
import { fetchCopilotModels, fetchModelEndpoints } from "../providers/copilot/models.js";
|
|
6
6
|
import { readGhToken } from "../shared/creds.js";
|
|
7
|
-
import { readWebIqKey } from "../shared/webiq-key.js";
|
|
7
|
+
import { readWebIqKey, readWebSearchMode, resolveWebSearchBackend } from "../shared/webiq-key.js";
|
|
8
8
|
import { makeGatewayRunner } from "../core/server-tools.js";
|
|
9
|
+
import { borrowSearch } from "../providers/copilot/borrow-search.js";
|
|
9
10
|
import { dataDir } from "../shared/paths.js";
|
|
10
11
|
import { defaultConfig } from "../shared/config.js";
|
|
11
12
|
function send(msg) { if (process.send)
|
|
@@ -19,12 +20,26 @@ if (!gh) {
|
|
|
19
20
|
process.exit(1);
|
|
20
21
|
}
|
|
21
22
|
const tokenStore = new CopilotTokenStore(gh);
|
|
22
|
-
|
|
23
|
-
//
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
23
|
+
// Per-model supported_endpoints, populated lazily from the live model list (same source as the model
|
|
24
|
+
// ids). The adapter reads through this map so responses-only models (e.g. gpt-5.5) route to /responses
|
|
25
|
+
// as soon as discovery resolves; until then the map is empty and the /chat 400 safety net covers it.
|
|
26
|
+
let modelEndpoints = {};
|
|
27
|
+
const router = new Router([new CopilotAdapter(tokenStore, fetch, (m) => modelEndpoints[m] ?? [])], cfg.modelMap);
|
|
28
|
+
// Load the live model list so the router can fuzzy-match near-miss ids (e.g. dated Anthropic ids),
|
|
29
|
+
// and the endpoint map so the adapter can route per model. One token fetch feeds both.
|
|
30
|
+
void tokenStore.get().then(async (t) => {
|
|
31
|
+
const [ids, endpoints] = await Promise.all([fetchCopilotModels(t), fetchModelEndpoints(t)]);
|
|
32
|
+
router.setAvailableModels(ids);
|
|
33
|
+
modelEndpoints = endpoints;
|
|
34
|
+
}).catch(() => { });
|
|
35
|
+
// Gateway-run web_search / web_fetch. The backend is resolved per call (lazy → /webiq toggles need no
|
|
36
|
+
// restart): currently WebIQ when a key is set, else unavailable (Copilot borrow is disabled — see
|
|
37
|
+
// COPILOT_WEB_SEARCH_ENABLED). resolveWebSearchBackend centralises that policy.
|
|
38
|
+
const gatewayRunner = makeGatewayRunner({
|
|
39
|
+
backend: () => resolveWebSearchBackend(readWebSearchMode(dataDir()), Boolean(readWebIqKey(dataDir()))),
|
|
40
|
+
webiqKey: () => readWebIqKey(dataDir()),
|
|
41
|
+
borrow: { run: (input) => borrowSearch(tokenStore, input) },
|
|
42
|
+
});
|
|
28
43
|
const app = createWorkerApp(router, (m) => send({ type: "request-metric", ...m }), gatewayRunner);
|
|
29
44
|
const server = app.listen(port, host, () => send({ type: "ready", port }));
|
|
30
45
|
const hb = setInterval(() => send({ type: "heartbeat", ts: Date.now() }), 5_000);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "copilot-reverse",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Interactive terminal app that exposes your GitHub Copilot subscription as local OpenAI- and Anthropic-compatible endpoints, with a self-healing daemon and a built-in assistant.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|