copilot-reverse 0.0.1

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.
Files changed (58) hide show
  1. package/GUIDE.md +142 -0
  2. package/README.md +60 -0
  3. package/dist/cli/auth.js +9 -0
  4. package/dist/cli/index.js +133 -0
  5. package/dist/core/anthropic-inbound.js +63 -0
  6. package/dist/core/canonical.js +6 -0
  7. package/dist/core/fuzzy.js +35 -0
  8. package/dist/core/openai-inbound.js +83 -0
  9. package/dist/core/tokens.js +22 -0
  10. package/dist/daemon/lifecycle.js +32 -0
  11. package/dist/providers/copilot/adapter.js +146 -0
  12. package/dist/providers/copilot/auth.js +30 -0
  13. package/dist/providers/copilot/models.js +49 -0
  14. package/dist/providers/copilot/token.js +53 -0
  15. package/dist/providers/types.js +1 -0
  16. package/dist/shared/client-setup.js +19 -0
  17. package/dist/shared/config.js +20 -0
  18. package/dist/shared/control-types.js +1 -0
  19. package/dist/shared/creds.js +14 -0
  20. package/dist/shared/format.js +28 -0
  21. package/dist/shared/ipc.js +1 -0
  22. package/dist/shared/open-url.js +17 -0
  23. package/dist/shared/paths.js +11 -0
  24. package/dist/shared/prefs.js +28 -0
  25. package/dist/supervisor/api.js +24 -0
  26. package/dist/supervisor/dashboard.js +110 -0
  27. package/dist/supervisor/db.js +35 -0
  28. package/dist/supervisor/events.js +6 -0
  29. package/dist/supervisor/index.js +66 -0
  30. package/dist/supervisor/monitor.js +91 -0
  31. package/dist/tui/app.js +184 -0
  32. package/dist/tui/assistant/on-chat.js +25 -0
  33. package/dist/tui/assistant/runtime.js +104 -0
  34. package/dist/tui/assistant/tools.js +24 -0
  35. package/dist/tui/components/select.js +30 -0
  36. package/dist/tui/daemon-client.js +16 -0
  37. package/dist/tui/panels/metrics-agg.js +22 -0
  38. package/dist/tui/panels/metrics.js +7 -0
  39. package/dist/tui/repl.js +45 -0
  40. package/dist/tui/report.js +35 -0
  41. package/dist/tui/screens/config.js +13 -0
  42. package/dist/tui/screens/model.js +16 -0
  43. package/dist/tui/setup/apply.js +119 -0
  44. package/dist/tui/setup/clients.js +38 -0
  45. package/dist/tui/setup/codex-toml.js +47 -0
  46. package/dist/tui/setup/status.js +35 -0
  47. package/dist/tui/setup/wizard.js +37 -0
  48. package/dist/tui/slash/commands.js +68 -0
  49. package/dist/tui/slash/registry.js +16 -0
  50. package/dist/tui/theme.js +16 -0
  51. package/dist/worker/anthropic-server.js +108 -0
  52. package/dist/worker/errors.js +12 -0
  53. package/dist/worker/index.js +30 -0
  54. package/dist/worker/openai-server.js +44 -0
  55. package/dist/worker/router.js +34 -0
  56. package/dist/worker/server.js +11 -0
  57. package/images/dashboard.png +0 -0
  58. package/package.json +69 -0
@@ -0,0 +1,146 @@
1
+ import { randomUUID } from "node:crypto";
2
+ const CHAT_URL = "https://api.githubcopilot.com/chat/completions";
3
+ // Canonical messages -> OpenAI wire messages (Copilot is OpenAI-shaped).
4
+ function toWireMessages(messages) {
5
+ const out = [];
6
+ for (const m of messages) {
7
+ const toolResults = m.content.filter((b) => b.type === "tool_result");
8
+ // EACH tool_result becomes its own OpenAI `tool` message. Anthropic packs parallel results
9
+ // into one user message; emitting only the first (the old bug) left later tool_use ids without
10
+ // a matching tool_result -> Claude/Copilot 400 "tool_use ids ... without tool_result blocks".
11
+ if (toolResults.length) {
12
+ for (const tr of toolResults)
13
+ out.push({ role: "tool", tool_call_id: tr.toolUseId, content: tr.content });
14
+ continue;
15
+ }
16
+ const text = m.content.filter((b) => b.type === "text").map((b) => b.text).join("");
17
+ const images = m.content.filter((b) => b.type === "image");
18
+ const toolUses = m.content.filter((b) => b.type === "tool_use");
19
+ // With images, content becomes an OpenAI multipart array (text part + image_url parts); otherwise a string.
20
+ let msgContent = text || null;
21
+ if (images.length) {
22
+ msgContent = [...(text ? [{ type: "text", text }] : []), ...images.map((img) => ({ type: "image_url", image_url: { url: img.dataUrl } }))];
23
+ }
24
+ const msg = { role: m.role, content: msgContent };
25
+ if (toolUses.length)
26
+ msg.tool_calls = toolUses.map((t) => ({ id: t.id, type: "function", function: { name: t.name, arguments: JSON.stringify(t.input) } }));
27
+ out.push(msg);
28
+ }
29
+ return out;
30
+ }
31
+ function buildBody(req) {
32
+ const body = { model: req.model, messages: toWireMessages(req.messages), stream: req.stream, temperature: req.temperature, max_tokens: req.maxTokens };
33
+ if (req.stream)
34
+ body.stream_options = { include_usage: true }; // ask Copilot for usage in the final frame
35
+ if (req.tools?.length)
36
+ body.tools = req.tools.map((t) => ({ type: "function", function: { name: t.name, description: t.description, parameters: t.parameters } }));
37
+ return body;
38
+ }
39
+ function headers(token) {
40
+ return { authorization: `Bearer ${token}`, "content-type": "application/json", "editor-version": "vscode/1.95.0", "copilot-integration-id": "vscode-chat" };
41
+ }
42
+ // Copilot puts the real reason (bad model, oversized prompt, unsupported tool, …) in the body —
43
+ // surface it instead of a bare status code so failures are diagnosable.
44
+ async function errorDetail(res) {
45
+ try {
46
+ const t = (await res.text()).trim();
47
+ return t ? ` — ${t.slice(0, 400)}` : "";
48
+ }
49
+ catch {
50
+ return "";
51
+ }
52
+ }
53
+ export class CopilotAdapter {
54
+ tokenStore;
55
+ fetchFn;
56
+ name = "copilot";
57
+ constructor(tokenStore, fetchFn = fetch) {
58
+ this.tokenStore = tokenStore;
59
+ this.fetchFn = fetchFn;
60
+ }
61
+ async complete(req) {
62
+ const token = await this.tokenStore.get();
63
+ const res = await this.fetchFn(CHAT_URL, { method: "POST", headers: headers(token), body: JSON.stringify(buildBody({ ...req, stream: false })) });
64
+ if (!res.ok)
65
+ throw new Error(`copilot completion failed: ${res.status}${await errorDetail(res)}`);
66
+ const data = (await res.json());
67
+ const choice = data.choices[0];
68
+ const content = [];
69
+ if (choice.message.content)
70
+ content.push({ type: "text", text: choice.message.content });
71
+ for (const tc of choice.message.tool_calls ?? [])
72
+ content.push({ type: "tool_use", id: tc.id, name: tc.function.name, input: safeJson(tc.function.arguments) });
73
+ return {
74
+ id: data.id ?? `cmpl-${randomUUID().replace(/-/g, "")}`, model: req.model, content,
75
+ finishReason: choice.finish_reason === "tool_calls" ? "tool_use" : choice.finish_reason === "length" ? "length" : "stop",
76
+ usage: { promptTokens: data.usage?.prompt_tokens ?? 0, completionTokens: data.usage?.completion_tokens ?? 0 },
77
+ };
78
+ }
79
+ async *stream(req) {
80
+ const token = await this.tokenStore.get();
81
+ const res = await this.fetchFn(CHAT_URL, { method: "POST", headers: headers(token), body: JSON.stringify(buildBody({ ...req, stream: true })) });
82
+ if (!res.ok || !res.body)
83
+ throw new Error(`copilot stream failed: ${res.status}${await errorDetail(res)}`);
84
+ const reader = res.body.getReader();
85
+ const decoder = new TextDecoder();
86
+ const startedTools = new Set();
87
+ let buffer = "";
88
+ let finishReason = "stop";
89
+ let usage;
90
+ const mapFinish = (f) => f === "tool_calls" ? "tool_use" : f === "length" ? "length" : "stop";
91
+ for (;;) {
92
+ const { value, done } = await reader.read();
93
+ if (done)
94
+ break;
95
+ buffer += decoder.decode(value, { stream: true });
96
+ const events = buffer.split("\n\n");
97
+ buffer = events.pop() ?? "";
98
+ for (const evt of events) {
99
+ const line = evt.split("\n").find((l) => l.startsWith("data: "));
100
+ if (!line)
101
+ continue;
102
+ const payload = line.slice(6).trim();
103
+ if (payload === "[DONE]") {
104
+ yield { kind: "done", done: true, finishReason, usage };
105
+ return;
106
+ }
107
+ let json;
108
+ try {
109
+ json = JSON.parse(payload);
110
+ }
111
+ catch {
112
+ continue;
113
+ }
114
+ // Copilot sends a final frame with empty choices carrying usage (stream_options.include_usage).
115
+ if (json.usage)
116
+ usage = { promptTokens: json.usage.prompt_tokens ?? 0, completionTokens: json.usage.completion_tokens ?? 0, cachedTokens: json.usage.prompt_tokens_details?.cached_tokens ?? 0 };
117
+ const choice = json.choices?.[0];
118
+ if (!choice)
119
+ continue;
120
+ if (choice.finish_reason)
121
+ finishReason = mapFinish(choice.finish_reason);
122
+ const delta = choice.delta;
123
+ if (!delta)
124
+ continue;
125
+ if (delta.content)
126
+ yield { kind: "text", delta: delta.content, done: false };
127
+ for (const tc of delta.tool_calls ?? []) {
128
+ const idx = tc.index ?? 0;
129
+ if (!startedTools.has(idx) && tc.function?.name) {
130
+ startedTools.add(idx);
131
+ yield { kind: "tool_use_start", index: idx, id: tc.id ?? `call_${idx}`, name: tc.function.name, done: false };
132
+ }
133
+ if (tc.function?.arguments)
134
+ yield { kind: "tool_use_delta", index: idx, argsDelta: tc.function.arguments, done: false };
135
+ }
136
+ }
137
+ }
138
+ yield { kind: "done", done: true, finishReason, usage };
139
+ }
140
+ }
141
+ function safeJson(s) { try {
142
+ return JSON.parse(s);
143
+ }
144
+ catch {
145
+ return {};
146
+ } }
@@ -0,0 +1,30 @@
1
+ // Community-documented GitHub Copilot OAuth (unofficial; may change).
2
+ const CLIENT_ID = "Iv1.b507a08c87ecfe98";
3
+ const DEVICE_CODE_URL = "https://github.com/login/device/code";
4
+ const ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token";
5
+ export async function requestDeviceCode(fetchFn = fetch) {
6
+ const res = await fetchFn(DEVICE_CODE_URL, {
7
+ method: "POST",
8
+ headers: { accept: "application/json", "content-type": "application/json" },
9
+ body: JSON.stringify({ client_id: CLIENT_ID, scope: "read:user" }),
10
+ });
11
+ if (!res.ok)
12
+ throw new Error(`device code request failed: ${res.status}`);
13
+ return (await res.json());
14
+ }
15
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
16
+ export async function pollForToken(deviceCode, intervalMs, fetchFn = fetch) {
17
+ for (;;) {
18
+ const res = await fetchFn(ACCESS_TOKEN_URL, {
19
+ method: "POST",
20
+ headers: { accept: "application/json", "content-type": "application/json" },
21
+ body: JSON.stringify({ client_id: CLIENT_ID, device_code: deviceCode, grant_type: "urn:ietf:params:oauth:grant-type:device_code" }),
22
+ });
23
+ const data = (await res.json());
24
+ if (data.access_token)
25
+ return data.access_token;
26
+ if (data.error && data.error !== "authorization_pending" && data.error !== "slow_down")
27
+ throw new Error(`authorization failed: ${data.error}`);
28
+ await sleep(intervalMs);
29
+ }
30
+ }
@@ -0,0 +1,49 @@
1
+ // Live model list from Copilot. Falls back to a curated list if the endpoint is unavailable.
2
+ const MODELS_URL = "https://api.githubcopilot.com/models";
3
+ const FALLBACK = ["gpt-4o", "gpt-4o-mini", "claude-sonnet-4-6", "claude-opus-4-8", "o3-mini"];
4
+ const HEADERS = (token) => ({
5
+ authorization: `Bearer ${token}`,
6
+ "content-type": "application/json",
7
+ "editor-version": "vscode/1.95.0",
8
+ "copilot-integration-id": "vscode-chat",
9
+ });
10
+ const DEFAULT_TIMEOUT_MS = 8000;
11
+ // A stalled Copilot endpoint must never hang the model picker forever — abort after timeoutMs.
12
+ async function getModels(token, fetchFn, timeoutMs) {
13
+ const ctrl = new AbortController();
14
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
15
+ try {
16
+ const res = await fetchFn(MODELS_URL, { headers: HEADERS(token), signal: ctrl.signal });
17
+ if (!res.ok)
18
+ return null;
19
+ return (await res.json()).data ?? [];
20
+ }
21
+ catch {
22
+ return null;
23
+ }
24
+ finally {
25
+ clearTimeout(timer);
26
+ }
27
+ }
28
+ export async function fetchCopilotModels(token, fetchFn = fetch, timeoutMs = DEFAULT_TIMEOUT_MS) {
29
+ const data = await getModels(token, fetchFn, timeoutMs);
30
+ if (!data)
31
+ return FALLBACK;
32
+ const ids = [...new Set(data.map((m) => m.id).filter((x) => Boolean(x)))];
33
+ return ids.length ? ids : FALLBACK;
34
+ }
35
+ // Map of model id -> its real input/context window, used to size auto-compaction per model and
36
+ // to show the window in the picker. Returns {} on failure/timeout so callers fall back gracefully.
37
+ export async function fetchModelLimits(token, fetchFn = fetch, timeoutMs = DEFAULT_TIMEOUT_MS) {
38
+ const data = await getModels(token, fetchFn, timeoutMs);
39
+ if (!data)
40
+ return {};
41
+ const out = {};
42
+ for (const m of data) {
43
+ // Prefer the headline context window (so a 1M model shows as 1M); fall back to the prompt budget.
44
+ const limit = m.capabilities?.limits?.max_context_window_tokens ?? m.capabilities?.limits?.max_prompt_tokens;
45
+ if (m.id && typeof limit === "number")
46
+ out[m.id] = limit;
47
+ }
48
+ return out;
49
+ }
@@ -0,0 +1,53 @@
1
+ const COPILOT_TOKEN_URL = "https://api.github.com/copilot_internal/v2/token";
2
+ // Thrown when the stored GitHub token can no longer be exchanged for a Copilot token
3
+ // (expired / revoked login). Carries an actionable message.
4
+ export class CopilotAuthError extends Error {
5
+ status;
6
+ constructor(status) {
7
+ super(status === 401 || status === 403
8
+ ? "GitHub login expired — restart copilot-reverse (or run `copilot-reverse login`) to re-authenticate"
9
+ : `copilot token exchange failed: ${status}`);
10
+ this.status = status;
11
+ this.name = "CopilotAuthError";
12
+ }
13
+ }
14
+ export class CopilotTokenStore {
15
+ ghToken;
16
+ fetchFn;
17
+ nowMs;
18
+ cached;
19
+ constructor(ghToken, fetchFn = fetch, nowMs = () => Date.now()) {
20
+ this.ghToken = ghToken;
21
+ this.fetchFn = fetchFn;
22
+ this.nowMs = nowMs;
23
+ }
24
+ async get() {
25
+ const skewMs = 60_000;
26
+ if (this.cached && this.cached.expiresAtMs - skewMs > this.nowMs())
27
+ return this.cached.token;
28
+ const ctrl = new AbortController();
29
+ const timer = setTimeout(() => ctrl.abort(), 8000);
30
+ let res;
31
+ try {
32
+ res = await this.fetchFn(COPILOT_TOKEN_URL, { headers: { authorization: `token ${this.ghToken}`, accept: "application/json" }, signal: ctrl.signal });
33
+ }
34
+ finally {
35
+ clearTimeout(timer);
36
+ }
37
+ if (!res.ok)
38
+ throw new CopilotAuthError(res.status);
39
+ const data = (await res.json());
40
+ this.cached = { token: data.token, expiresAtMs: data.expires_at * 1000 };
41
+ return data.token;
42
+ }
43
+ }
44
+ // True if the stored GitHub token still exchanges for a Copilot token.
45
+ export async function isCopilotTokenValid(ghToken, fetchFn = fetch) {
46
+ try {
47
+ await new CopilotTokenStore(ghToken, fetchFn).get();
48
+ return true;
49
+ }
50
+ catch {
51
+ return false;
52
+ }
53
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,19 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ const file = (dir) => join(dir, "clients.json");
4
+ export function readClientSetup(dir) {
5
+ if (!existsSync(file(dir)))
6
+ return { claude: false, codex: false };
7
+ try {
8
+ const d = JSON.parse(readFileSync(file(dir), "utf8"));
9
+ return { claude: Boolean(d.claude), codex: Boolean(d.codex) };
10
+ }
11
+ catch {
12
+ return { claude: false, codex: false };
13
+ }
14
+ }
15
+ export function writeClientSetup(dir, state) {
16
+ if (!existsSync(dir))
17
+ mkdirSync(dir, { recursive: true });
18
+ writeFileSync(file(dir), JSON.stringify(state));
19
+ }
@@ -0,0 +1,20 @@
1
+ export function defaultConfig() {
2
+ return {
3
+ bindHost: "127.0.0.1",
4
+ supervisorPort: 7890,
5
+ workerPort: 7891,
6
+ restart: { maxCrashes: 5, windowMs: 60_000, baseBackoffMs: 500, maxBackoffMs: 8_000 },
7
+ // Empty = pass the requested model straight through to Copilot. Add entries (or "*") to remap.
8
+ modelMap: {},
9
+ // Set MAESTRO_REPORT_REPO=owner/repo to override where /report files diagnostics issues.
10
+ reportRepo: process.env.MAESTRO_REPORT_REPO ?? "wangcansunking/copilot-reverse",
11
+ };
12
+ }
13
+ export function mergeConfig(base, o) {
14
+ return {
15
+ ...base,
16
+ ...o,
17
+ restart: { ...base.restart, ...(o.restart ?? {}) },
18
+ modelMap: { ...base.modelMap, ...(o.modelMap ?? {}) },
19
+ };
20
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,14 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ // M1: plaintext token in the data dir (0600). Encryption-at-rest is M2.
4
+ const file = (dir) => join(dir, "creds.json");
5
+ export function writeGhToken(token, dir) {
6
+ if (!existsSync(dir))
7
+ mkdirSync(dir, { recursive: true });
8
+ writeFileSync(file(dir), JSON.stringify({ ghToken: token }), { mode: 0o600 });
9
+ }
10
+ export function readGhToken(dir) {
11
+ if (!existsSync(file(dir)))
12
+ return null;
13
+ return JSON.parse(readFileSync(file(dir), "utf8")).ghToken ?? null;
14
+ }
@@ -0,0 +1,28 @@
1
+ // Whimsical loading verbs, à la Claude Code — rotated every ~3s based on elapsed time so the
2
+ // label changes as you wait. Deterministic per time-bucket (no per-render flicker).
3
+ export const LOADING_VERBS = [
4
+ "Orchestrating", "Cogitating", "Pondering", "Noodling", "Conjuring", "Percolating",
5
+ "Ruminating", "Synthesizing", "Marshalling", "Untangling", "Wrangling", "Spelunking",
6
+ ];
7
+ export function loadingVerb(elapsedMs) {
8
+ return LOADING_VERBS[Math.floor(Math.max(0, elapsedMs) / 3000) % LOADING_VERBS.length];
9
+ }
10
+ // Human-readable context window, e.g. 1_000_000 -> "1M", 200_000 -> "200K". Empty when unknown.
11
+ export function formatContextWindow(n) {
12
+ if (!n || n <= 0)
13
+ return "";
14
+ if (n >= 1_000_000)
15
+ return `${+(n / 1_000_000).toFixed(n % 1_000_000 ? 1 : 0)}M`;
16
+ if (n >= 1000)
17
+ return `${Math.round(n / 1000)}K`;
18
+ return String(n);
19
+ }
20
+ // Bulleted model list with context windows — used by the assistant's list_models tool.
21
+ export function formatModelList(ids, limits) {
22
+ if (!ids.length)
23
+ return "(no models found)";
24
+ return ids.map((id) => {
25
+ const w = formatContextWindow(limits?.[id]);
26
+ return `- ${id}${w ? ` (${w})` : ""}`;
27
+ }).join("\n");
28
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,17 @@
1
+ import { spawn } from "node:child_process";
2
+ // The platform's URL-opening command. Pure + exported so it can be asserted without spawning.
3
+ export function openCommandFor(url, platform = process.platform) {
4
+ if (platform === "win32")
5
+ return { command: "cmd", args: ["/c", "start", "", url] };
6
+ if (platform === "darwin")
7
+ return { command: "open", args: [url] };
8
+ return { command: "xdg-open", args: [url] };
9
+ }
10
+ // Best-effort: open the URL in the user's default browser. Never throws — if no opener
11
+ // exists (headless box), the caller's printed URL is the fallback.
12
+ export function openUrl(url, platform = process.platform) {
13
+ const { command, args } = openCommandFor(url, platform);
14
+ const child = spawn(command, args, { stdio: "ignore", detached: true });
15
+ child.on("error", () => { });
16
+ child.unref();
17
+ }
@@ -0,0 +1,11 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+ export function dataDir(home = homedir()) {
4
+ return join(home, ".copilot-reverse");
5
+ }
6
+ export function dbPath(home) {
7
+ return join(dataDir(home), "copilot-reverse.db");
8
+ }
9
+ export function configPath(home) {
10
+ return join(dataDir(home), "config.json");
11
+ }
@@ -0,0 +1,28 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ // Small user-preferences store (e.g. the chosen chat model), persisted across launches.
4
+ const file = (dir) => join(dir, "prefs.json");
5
+ export function readChatModel(dir) {
6
+ if (!existsSync(file(dir)))
7
+ return null;
8
+ try {
9
+ return JSON.parse(readFileSync(file(dir), "utf8")).chatModel ?? null;
10
+ }
11
+ catch {
12
+ return null;
13
+ }
14
+ }
15
+ export function writeChatModel(dir, model) {
16
+ if (!existsSync(dir))
17
+ mkdirSync(dir, { recursive: true });
18
+ let cur = {};
19
+ if (existsSync(file(dir))) {
20
+ try {
21
+ cur = JSON.parse(readFileSync(file(dir), "utf8"));
22
+ }
23
+ catch {
24
+ cur = {};
25
+ }
26
+ }
27
+ writeFileSync(file(dir), JSON.stringify({ ...cur, chatModel: model }));
28
+ }
@@ -0,0 +1,24 @@
1
+ import express from "express";
2
+ import { listRestarts, recentRequests } from "./db.js";
3
+ import { dashboardHtml } from "./dashboard.js";
4
+ export function createControlApp(deps) {
5
+ const app = express();
6
+ app.use(express.json());
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) }));
9
+ app.post("/api/restart", (_req, res) => { deps.restart(); res.json({ ok: true }); });
10
+ app.post("/api/stop", (_req, res) => { deps.stop(); res.json({ ok: true }); });
11
+ app.post("/api/start", (_req, res) => { deps.start(); res.json({ ok: true }); });
12
+ app.get("/api/doctor", async (_req, res) => res.json({ checks: await deps.doctor() }));
13
+ app.get("/api/requests", (_req, res) => res.json({ requests: recentRequests(deps.db, 100) }));
14
+ app.get("/api/events", (req, res) => {
15
+ res.setHeader("content-type", "text/event-stream");
16
+ res.setHeader("cache-control", "no-cache");
17
+ res.flushHeaders?.();
18
+ const send = (event, data) => res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
19
+ send("hello", { state: deps.getState() });
20
+ const off = deps.subscribe(send);
21
+ req.on("close", off);
22
+ });
23
+ return app;
24
+ }
@@ -0,0 +1,110 @@
1
+ // A self-contained, dependency-free dashboard page served at GET /. It polls the existing
2
+ // control API (/api/status, /api/requests, /api/doctor) every 2s and renders worker health,
3
+ // request metrics, and — most usefully — recent request errors with their messages.
4
+ export function dashboardHtml() {
5
+ return `<!doctype html>
6
+ <html lang="en">
7
+ <head>
8
+ <meta charset="utf-8" />
9
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
10
+ <title>copilot-reverse dashboard</title>
11
+ <style>
12
+ :root { color-scheme: dark; }
13
+ body { margin: 0; font: 14px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; background: #0b0e14; color: #c7d0e0; }
14
+ header { display: flex; align-items: baseline; gap: 12px; padding: 16px 20px; border-bottom: 1px solid #1c2230; }
15
+ h1 { font-size: 16px; margin: 0; color: #8ab4f8; }
16
+ .muted { color: #6b7689; }
17
+ main { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; padding: 20px; }
18
+ section { background: #11151f; border: 1px solid #1c2230; border-radius: 8px; padding: 14px 16px; }
19
+ section.wide { grid-column: 1 / -1; }
20
+ h2 { font-size: 13px; margin: 0 0 10px; color: #9aa7bd; text-transform: uppercase; letter-spacing: .04em; }
21
+ table { width: 100%; border-collapse: collapse; }
22
+ th, td { text-align: left; padding: 4px 8px; border-bottom: 1px solid #161b27; vertical-align: top; }
23
+ th { color: #6b7689; font-weight: 600; }
24
+ .badge { padding: 1px 8px; border-radius: 999px; font-size: 12px; }
25
+ .ok { color: #6ee7b7; } .bad { color: #f87171; } .warn { color: #fbbf24; }
26
+ .err { color: #f87171; white-space: pre-wrap; word-break: break-word; }
27
+ .pill-ready { background: #064e3b; color: #6ee7b7; }
28
+ .pill-bad { background: #4c1d24; color: #f87171; }
29
+ .empty { color: #6b7689; font-style: italic; }
30
+ </style>
31
+ </head>
32
+ <body>
33
+ <header>
34
+ <h1>✳ copilot-reverse</h1>
35
+ <span class="muted">worker <span id="state" class="badge">…</span></span>
36
+ <span class="muted" id="updated"></span>
37
+ </header>
38
+ <main>
39
+ <section>
40
+ <h2>Health</h2>
41
+ <div id="doctor"><span class="empty">loading…</span></div>
42
+ </section>
43
+ <section>
44
+ <h2>Requests</h2>
45
+ <div id="metrics"><span class="empty">loading…</span></div>
46
+ </section>
47
+ <section class="wide">
48
+ <h2>Recent errors</h2>
49
+ <div id="errors"><span class="empty">loading…</span></div>
50
+ </section>
51
+ <section class="wide">
52
+ <h2>Recent requests</h2>
53
+ <div id="requests"><span class="empty">loading…</span></div>
54
+ </section>
55
+ </main>
56
+ <script>
57
+ const esc = (s) => String(s ?? "").replace(/[&<>]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" }[c]));
58
+ const fmt = (ts) => new Date(ts).toLocaleTimeString();
59
+ async function getJson(p) { const r = await fetch(p); if (!r.ok) throw new Error(p + " -> " + r.status); return r.json(); }
60
+
61
+ function renderState(s) {
62
+ const el = document.getElementById("state");
63
+ el.textContent = s;
64
+ el.className = "badge " + (s === "ready" ? "pill-ready" : "pill-bad");
65
+ }
66
+ function renderDoctor(checks) {
67
+ const el = document.getElementById("doctor");
68
+ if (!checks.length) { el.innerHTML = '<span class="empty">no checks</span>'; return; }
69
+ el.innerHTML = "<table>" + checks.map((c) =>
70
+ '<tr><td class="' + (c.ok ? "ok" : "bad") + '">' + (c.ok ? "✓" : "✗") + "</td><td>" + esc(c.name) + '</td><td class="muted">' + esc(c.detail) + "</td></tr>"
71
+ ).join("") + "</table>";
72
+ }
73
+ function renderRequests(reqs) {
74
+ const total = reqs.length, errors = reqs.filter((r) => r.status >= 400).length;
75
+ document.getElementById("metrics").innerHTML =
76
+ '<div>total <b>' + total + '</b> &nbsp; errors <b class="' + (errors ? "bad" : "ok") + '">' + errors + "</b></div>";
77
+
78
+ const errs = reqs.filter((r) => r.status >= 400).slice(0, 30);
79
+ document.getElementById("errors").innerHTML = errs.length
80
+ ? "<table><tr><th>time</th><th>status</th><th>endpoint</th><th>model</th><th>error</th></tr>" + errs.map((r) =>
81
+ "<tr><td>" + fmt(r.ts) + '</td><td class="bad">' + r.status + "</td><td>" + esc(r.endpoint) + "</td><td>" + esc(r.model) + '</td><td class="err">' + esc(r.error || "(no message)") + "</td></tr>"
82
+ ).join("") + "</table>"
83
+ : '<span class="empty">no request errors — everything\\'s green ✓</span>';
84
+
85
+ const recent = reqs.slice(0, 30);
86
+ document.getElementById("requests").innerHTML = recent.length
87
+ ? "<table><tr><th>time</th><th>status</th><th>endpoint</th><th>model</th><th>ms</th></tr>" + recent.map((r) =>
88
+ "<tr><td>" + fmt(r.ts) + '</td><td class="' + (r.status >= 400 ? "bad" : "ok") + '">' + r.status + "</td><td>" + esc(r.endpoint) + "</td><td>" + esc(r.model) + "</td><td>" + r.latencyMs + "</td></tr>"
89
+ ).join("") + "</table>"
90
+ : '<span class="empty">no requests yet</span>';
91
+ }
92
+ async function tick() {
93
+ try {
94
+ const [status, reqs, doctor] = await Promise.all([
95
+ getJson("/api/status"), getJson("/api/requests"), getJson("/api/doctor"),
96
+ ]);
97
+ renderState(status.workerState);
98
+ renderDoctor(doctor.checks || []);
99
+ renderRequests(reqs.requests || []);
100
+ document.getElementById("updated").textContent = "updated " + new Date().toLocaleTimeString();
101
+ } catch (e) {
102
+ document.getElementById("updated").textContent = "control API unreachable: " + e.message;
103
+ }
104
+ }
105
+ tick();
106
+ setInterval(tick, 2000);
107
+ </script>
108
+ </body>
109
+ </html>`;
110
+ }
@@ -0,0 +1,35 @@
1
+ import Database from "better-sqlite3";
2
+ export function openDb(file) {
3
+ const db = new Database(file);
4
+ db.pragma("journal_mode = WAL");
5
+ db.exec(`
6
+ CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT NOT NULL);
7
+ CREATE TABLE IF NOT EXISTS restart_events (
8
+ id INTEGER PRIMARY KEY AUTOINCREMENT, ts INTEGER NOT NULL, reason TEXT NOT NULL,
9
+ exit_code INTEGER, stderr_tail TEXT NOT NULL, backoff_ms INTEGER NOT NULL, marked_unhealthy INTEGER NOT NULL DEFAULT 0);
10
+ CREATE TABLE IF NOT EXISTS request_log (
11
+ id INTEGER PRIMARY KEY AUTOINCREMENT, ts INTEGER NOT NULL, endpoint TEXT NOT NULL,
12
+ model TEXT NOT NULL, status INTEGER NOT NULL, latency_ms INTEGER NOT NULL, error TEXT);
13
+ `);
14
+ // Migrate request_log tables created before the error column existed.
15
+ const cols = db.prepare(`PRAGMA table_info(request_log)`).all();
16
+ if (!cols.some((c) => c.name === "error"))
17
+ db.exec(`ALTER TABLE request_log ADD COLUMN error TEXT`);
18
+ return db;
19
+ }
20
+ export function recordRestart(db, e) {
21
+ db.prepare(`INSERT INTO restart_events (ts, reason, exit_code, stderr_tail, backoff_ms, marked_unhealthy)
22
+ VALUES (@ts, @reason, @exitCode, @stderrTail, @backoffMs, @markedUnhealthy)`).run(e);
23
+ }
24
+ export function listRestarts(db, limit) {
25
+ return db.prepare(`SELECT ts, reason, exit_code as exitCode, stderr_tail as stderrTail, marked_unhealthy as markedUnhealthy
26
+ FROM restart_events ORDER BY ts DESC LIMIT ?`).all(limit);
27
+ }
28
+ export function recordRequest(db, m) {
29
+ db.prepare(`INSERT INTO request_log (ts, endpoint, model, status, latency_ms, error) VALUES (@ts, @endpoint, @model, @status, @latencyMs, @error)`)
30
+ .run({ error: null, ...m });
31
+ }
32
+ export function recentRequests(db, limit) {
33
+ return db.prepare(`SELECT ts, endpoint, model, status, latency_ms as latencyMs, error FROM request_log ORDER BY ts DESC LIMIT ?`).all(limit)
34
+ .map(({ error, ...r }) => (error == null ? r : { ...r, error }));
35
+ }
@@ -0,0 +1,6 @@
1
+ export class EventBus {
2
+ listeners = new Set();
3
+ subscribe(fn) { this.listeners.add(fn); return () => this.listeners.delete(fn); }
4
+ emit(event, data) { for (const fn of this.listeners)
5
+ fn(event, data); }
6
+ }