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,35 @@
1
+ // Sentinel for an unconfigured report target. /report refuses to open until this is changed.
2
+ export const PLACEHOLDER_REPO = "OWNER/REPO";
3
+ // A diagnostics-only report. It contains metrics, doctor output, and worker restart reasons —
4
+ // never request/response bodies — so there is no user prompt content to leak.
5
+ export function buildIssueBody(i) {
6
+ const lines = [
7
+ "## copilot-reverse diagnostics",
8
+ "",
9
+ `- version: ${i.version}`,
10
+ `- platform: ${i.platform}`,
11
+ `- worker state: ${i.status.workerState}`,
12
+ "",
13
+ "### Health checks",
14
+ ...i.doctor.map((c) => `- ${c.ok ? "✓" : "✗"} ${c.name}: ${c.detail}`),
15
+ "",
16
+ "### Recent request errors",
17
+ ...(i.errors.length
18
+ ? i.errors.map((e) => `- \`${e.status}\` ${e.endpoint} ${e.model} — ${e.error ?? "(no message)"}`)
19
+ : ["- (none)"]),
20
+ ];
21
+ if (i.status.restarts.length) {
22
+ lines.push("", "### Recent worker restarts", ...i.status.restarts.slice(0, 5).map((r) => `- ${new Date(r.ts).toISOString()} ${r.reason} exit=${r.exitCode ?? "-"} ${r.stderrTail.slice(0, 120)}`));
23
+ }
24
+ lines.push("", "### What happened", "<!-- describe what you were doing when this occurred -->", "");
25
+ // Keep well under GitHub's ~8KB URL cap once encoded.
26
+ return lines.join("\n").slice(0, 5500);
27
+ }
28
+ export function buildIssueTitle(i) {
29
+ const first = i.errors[0]?.error;
30
+ return `copilot-reverse report: ${first ? first.slice(0, 70) : i.status.workerState}`;
31
+ }
32
+ export function buildIssueUrl(i) {
33
+ const q = `title=${encodeURIComponent(buildIssueTitle(i))}&body=${encodeURIComponent(buildIssueBody(i))}`;
34
+ return `https://github.com/${i.repo}/issues/new?${q}`;
35
+ }
@@ -0,0 +1,13 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ import { Select } from "../components/select.js";
4
+ import { theme } from "../theme.js";
5
+ export function ConfigScreen({ info, model, clients, onAction }) {
6
+ const row = (k, v) => (_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: k.padEnd(12) }), _jsx(Text, { color: theme.output, children: v })] }));
7
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.border, paddingX: 1, marginBottom: 1, children: [_jsx(Text, { color: theme.accent, bold: true, children: "configuration" }), row("chat model", model), row("OpenAI", info.openai), row("Anthropic", info.anthropic), row("ports", `supervisor ${info.supervisorPort} · worker ${info.workerPort}`), row("clients", `claude ${clients.claude ? "✓" : "○"} codex ${clients.codex ? "✓" : "○"}`), row("data dir", info.dataDir), _jsx(Text, { children: " " }), _jsx(Select, { items: [
8
+ { label: "change chat model", value: "model" },
9
+ { label: "configure Claude Code", value: "setup-claude" },
10
+ { label: "configure Codex", value: "setup-codex" },
11
+ { label: "back", value: "back" },
12
+ ], onSubmit: (v) => onAction(v.value), onCancel: () => onAction("back") })] }));
13
+ }
@@ -0,0 +1,16 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useState } from "react";
3
+ import { Box, Text } from "ink";
4
+ import { Select } from "../components/select.js";
5
+ import { theme } from "../theme.js";
6
+ import { formatContextWindow } from "../../shared/format.js";
7
+ // Label a model with its context window (e.g. "claude-opus-4-8 · 200K") and a (current) marker.
8
+ export function modelLabel(m, current, limits) {
9
+ const win = formatContextWindow(limits?.[m]);
10
+ return `${m}${win ? ` · ${win}` : ""}${m === current ? " (current)" : ""}`;
11
+ }
12
+ export function ModelScreen({ loadModels, limits, current, onPick, onCancel }) {
13
+ const [models, setModels] = useState(null);
14
+ useEffect(() => { loadModels().then(setModels).catch(() => setModels([])); }, []);
15
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.accent, paddingX: 1, marginBottom: 1, children: [_jsx(Text, { color: theme.accent, bold: true, children: "select chat model" }), !models ? (_jsx(Text, { color: theme.muted, children: "loading models from Copilot\u2026" })) : (_jsx(Select, { items: models.map((m) => ({ label: modelLabel(m, current, limits), value: m })), onSubmit: (v) => onPick(v.value), onCancel: onCancel }))] }));
16
+ }
@@ -0,0 +1,119 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join, dirname } from "node:path";
4
+ // The env keys copilot-reverse writes for each client — so reset knows exactly what to remove.
5
+ export const CLAUDE_ENV_KEYS = [
6
+ "ANTHROPIC_BASE_URL", "ANTHROPIC_API_KEY", "ANTHROPIC_MODEL",
7
+ "CLAUDE_CODE_AUTO_COMPACT_WINDOW", "CLAUDE_AUTOCOMPACT_PCT_OVERRIDE",
8
+ "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "CLAUDE_CODE_ATTRIBUTION_HEADER",
9
+ ];
10
+ export const CODEX_ENV_KEYS = ["OPENAI_BASE_URL", "OPENAI_API_KEY", "OPENAI_MODEL"];
11
+ // --- Claude Code: merge into settings.json `env` (non-destructive) ---
12
+ export function claudePath(scope, o) {
13
+ const home = o.home ?? homedir();
14
+ const cwd = o.cwd ?? process.cwd();
15
+ return scope === "global" ? join(home, ".claude", "settings.json") : join(cwd, ".claude", "settings.json");
16
+ }
17
+ export function applyClaude(scope, env, o = {}) {
18
+ const path = claudePath(scope, o);
19
+ if (!existsSync(dirname(path)))
20
+ mkdirSync(dirname(path), { recursive: true });
21
+ let settings = {};
22
+ if (existsSync(path)) {
23
+ try {
24
+ settings = JSON.parse(readFileSync(path, "utf8"));
25
+ }
26
+ catch {
27
+ settings = {};
28
+ }
29
+ }
30
+ const envObj = (settings.env && typeof settings.env === "object" ? settings.env : {});
31
+ const changed = [];
32
+ for (const [k, v] of Object.entries(env)) {
33
+ if (envObj[k] !== v) {
34
+ envObj[k] = v;
35
+ changed.push(k);
36
+ }
37
+ }
38
+ settings.env = envObj;
39
+ writeFileSync(path, JSON.stringify(settings, null, 2) + "\n");
40
+ return { path, changed };
41
+ }
42
+ // Inverse of applyClaude: drop copilot-reverse's keys from settings.json `env`, keep everything else.
43
+ export function resetClaude(scope, keys, o = {}) {
44
+ const path = claudePath(scope, o);
45
+ if (!existsSync(path))
46
+ return { path, changed: [] };
47
+ let settings;
48
+ try {
49
+ settings = JSON.parse(readFileSync(path, "utf8"));
50
+ }
51
+ catch {
52
+ return { path, changed: [] };
53
+ }
54
+ const envObj = (settings.env && typeof settings.env === "object" ? settings.env : {});
55
+ const changed = [];
56
+ for (const k of keys) {
57
+ if (k in envObj) {
58
+ delete envObj[k];
59
+ changed.push(k);
60
+ }
61
+ }
62
+ if (Object.keys(envObj).length)
63
+ settings.env = envObj;
64
+ else
65
+ delete settings.env;
66
+ writeFileSync(path, JSON.stringify(settings, null, 2) + "\n");
67
+ return { path, changed };
68
+ }
69
+ // --- Codex / OpenAI clients: merge into a .env file (non-destructive, line-wise) ---
70
+ export function codexPath(scope, o) {
71
+ const home = o.home ?? homedir();
72
+ const cwd = o.cwd ?? process.cwd();
73
+ return scope === "global" ? join(home, ".copilot-reverse", "codex.env") : join(cwd, ".env");
74
+ }
75
+ export function applyCodex(scope, env, o = {}) {
76
+ const path = codexPath(scope, o);
77
+ if (!existsSync(dirname(path)))
78
+ mkdirSync(dirname(path), { recursive: true });
79
+ const lines = existsSync(path) ? readFileSync(path, "utf8").split(/\r?\n/) : [];
80
+ const changed = [];
81
+ const seen = new Set();
82
+ const out = lines.map((line) => {
83
+ const m = /^([A-Za-z_][A-Za-z0-9_]*)=/.exec(line);
84
+ if (m && env[m[1]] !== undefined) {
85
+ seen.add(m[1]);
86
+ const nv = `${m[1]}=${env[m[1]]}`;
87
+ if (line !== nv)
88
+ changed.push(m[1]);
89
+ return nv;
90
+ }
91
+ return line;
92
+ });
93
+ for (const [k, v] of Object.entries(env)) {
94
+ if (!seen.has(k)) {
95
+ out.push(`${k}=${v}`);
96
+ changed.push(k);
97
+ }
98
+ }
99
+ writeFileSync(path, out.join("\n").replace(/\n*$/, "\n"));
100
+ return { path, changed };
101
+ }
102
+ // Inverse of applyCodex: drop copilot-reverse's KEY=value lines, keep every other line.
103
+ export function resetCodex(scope, keys, o = {}) {
104
+ const path = codexPath(scope, o);
105
+ if (!existsSync(path))
106
+ return { path, changed: [] };
107
+ const set = new Set(keys);
108
+ const changed = [];
109
+ const out = readFileSync(path, "utf8").split(/\r?\n/).filter((line) => {
110
+ const m = /^([A-Za-z_][A-Za-z0-9_]*)=/.exec(line);
111
+ if (m && set.has(m[1])) {
112
+ changed.push(m[1]);
113
+ return false;
114
+ }
115
+ return true;
116
+ });
117
+ writeFileSync(path, out.join("\n").replace(/\n*$/, "\n"));
118
+ return { path, changed };
119
+ }
@@ -0,0 +1,38 @@
1
+ export function claudeCodeConfig(e) {
2
+ const base = `http://${e.host}:${e.port}`;
3
+ return {
4
+ env: { ANTHROPIC_BASE_URL: base, ANTHROPIC_API_KEY: e.apiKey },
5
+ instructions: `Set these env vars for Claude Code:\n ANTHROPIC_BASE_URL=${base}\n ANTHROPIC_API_KEY=${e.apiKey}`,
6
+ };
7
+ }
8
+ export const ONE_M_SUFFIX = "[1m]";
9
+ // Claude Code switches to its 1M context window only when ANTHROPIC_MODEL ends with `[1m]` — that
10
+ // suffix is its built-in signal for a 1M model. Mirror agent-maestro: append it for models whose
11
+ // window is in the ~1M band (800K..1.5M). Without it Claude Code assumes 200K -> "context 100%"
12
+ // and /compact fails. The proxy strips the suffix again before forwarding to Copilot.
13
+ export function withClaude1mSuffix(model, contextWindow) {
14
+ return contextWindow && contextWindow > 800_000 && contextWindow < 1_500_000 && !model.endsWith(ONE_M_SUFFIX)
15
+ ? `${model}${ONE_M_SUFFIX}`
16
+ : model;
17
+ }
18
+ // The full env copilot-reverse writes into Claude Code's settings.json. Beyond the endpoint, it tells
19
+ // Claude Code the selected model's real context window (via the [1m] model suffix and
20
+ // CLAUDE_CODE_AUTO_COMPACT_WINDOW) so the client stops assuming the default 200K. Mirrors agent-maestro.
21
+ export function claudeCopilotReverseEnv(base, apiKey, model, contextWindow) {
22
+ return {
23
+ ANTHROPIC_BASE_URL: base,
24
+ ANTHROPIC_API_KEY: apiKey,
25
+ ANTHROPIC_MODEL: withClaude1mSuffix(model, contextWindow),
26
+ ...(contextWindow ? { CLAUDE_CODE_AUTO_COMPACT_WINDOW: String(contextWindow) } : {}),
27
+ CLAUDE_AUTOCOMPACT_PCT_OVERRIDE: "80",
28
+ CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1",
29
+ CLAUDE_CODE_ATTRIBUTION_HEADER: "0", // keep prompt caching working on a non-Anthropic gateway
30
+ };
31
+ }
32
+ export function codexConfig(e) {
33
+ const base = `http://${e.host}:${e.port}/v1`;
34
+ return {
35
+ env: { OPENAI_BASE_URL: base, OPENAI_API_KEY: e.apiKey },
36
+ instructions: `Set these env vars for Codex / OpenAI clients:\n OPENAI_BASE_URL=${base}\n OPENAI_API_KEY=${e.apiKey}`,
37
+ };
38
+ }
@@ -0,0 +1,47 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join, dirname } from "node:path";
4
+ // Codex reads ~/.codex/config.toml. copilot-reverse writes a managed provider block there (model,
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.
7
+ export const PROVIDER_ID = "copilot-reverse";
8
+ export function codexTomlPath(home = homedir()) {
9
+ return join(home, ".codex", "config.toml");
10
+ }
11
+ // The top-level keys we own (so re-applying replaces them instead of duplicating).
12
+ const MANAGED_TOP_KEYS = ["model", "model_provider", "model_context_window"];
13
+ export function applyCodexToml(opts) {
14
+ const path = codexTomlPath(opts.home);
15
+ if (!existsSync(dirname(path)))
16
+ 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
+ const existing = existsSync(path) ? readFileSync(path, "utf8") : "";
20
+ const kept = [];
21
+ let inOurTable = false;
22
+ for (const line of existing.split(/\r?\n/)) {
23
+ const tableMatch = /^\s*\[/.test(line);
24
+ if (tableMatch)
25
+ inOurTable = line.trim() === `[model_providers.${PROVIDER_ID}]`;
26
+ if (inOurTable)
27
+ continue; // skip our previously-written provider table
28
+ const keyMatch = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(line);
29
+ if (keyMatch && MANAGED_TOP_KEYS.includes(keyMatch[1]))
30
+ continue; // skip our managed top keys
31
+ kept.push(line);
32
+ }
33
+ const head = kept.join("\n").replace(/\n{3,}/g, "\n\n").trim();
34
+ const managed = [
35
+ `model = "${opts.model}"`,
36
+ `model_provider = "${PROVIDER_ID}"`,
37
+ ...(opts.contextWindow ? [`model_context_window = ${opts.contextWindow}`] : []),
38
+ "",
39
+ `[model_providers.${PROVIDER_ID}]`,
40
+ `name = "copilot-reverse"`,
41
+ `base_url = "${opts.baseUrl}"`,
42
+ `wire_api = "chat"`,
43
+ ].join("\n");
44
+ const body = (head ? `${head}\n\n` : "") + managed + "\n";
45
+ writeFileSync(path, body);
46
+ return { path, changed: MANAGED_TOP_KEYS };
47
+ }
@@ -0,0 +1,35 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { claudePath, codexPath } from "./apply.js";
3
+ // A copilot-reverse-written endpoint always points at the local loopback proxy — this lets us tell our
4
+ // own config apart from a user's pre-existing ANTHROPIC_BASE_URL / OPENAI_BASE_URL.
5
+ const isCopilotReverse = (v) => typeof v === "string" && /127\.0\.0\.1|localhost/.test(v);
6
+ function claudeConfigured(scope, o) {
7
+ const p = claudePath(scope, o);
8
+ if (!existsSync(p))
9
+ return false;
10
+ try {
11
+ const s = JSON.parse(readFileSync(p, "utf8"));
12
+ return isCopilotReverse(s.env?.ANTHROPIC_BASE_URL);
13
+ }
14
+ catch {
15
+ return false;
16
+ }
17
+ }
18
+ function codexConfigured(scope, o) {
19
+ const p = codexPath(scope, o);
20
+ if (!existsSync(p))
21
+ return false;
22
+ try {
23
+ const m = /^OPENAI_BASE_URL=(.*)$/m.exec(readFileSync(p, "utf8"));
24
+ return !!m && isCopilotReverse(m[1]);
25
+ }
26
+ catch {
27
+ return false;
28
+ }
29
+ }
30
+ export function readClientStatus(o = {}) {
31
+ return {
32
+ claude: { user: claudeConfigured("global", o), project: claudeConfigured("project", o) },
33
+ codex: { user: codexConfigured("global", o), project: codexConfigured("project", o) },
34
+ };
35
+ }
@@ -0,0 +1,37 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useState } from "react";
3
+ import { Box, Text, useInput } from "ink";
4
+ import { Select } from "../components/select.js";
5
+ import { theme } from "../theme.js";
6
+ import { modelLabel } from "../screens/model.js";
7
+ function Dismiss({ onDismiss }) {
8
+ useInput(() => onDismiss());
9
+ return _jsx(Text, { color: theme.muted, children: "press any key to continue" });
10
+ }
11
+ export function SetupWizard({ client, loadModels, limits, apply, onDone, onCancel }) {
12
+ const [step, setStep] = useState("loading");
13
+ const [models, setModels] = useState([]);
14
+ const [model, setModel] = useState("");
15
+ const [result, setResult] = useState(null);
16
+ const [err, setErr] = useState("");
17
+ useEffect(() => {
18
+ loadModels().then((m) => { setModels(m); setStep("model"); }).catch((e) => { setErr(String(e)); setStep("error"); });
19
+ }, []);
20
+ async function doApply(scope) {
21
+ setStep("applying");
22
+ try {
23
+ const r = await apply(scope, model);
24
+ setResult(r);
25
+ setStep("done");
26
+ }
27
+ catch (e) {
28
+ setErr(e instanceof Error ? e.message : String(e));
29
+ setStep("error");
30
+ }
31
+ }
32
+ const heading = step === "model" ? "choose a model" : step === "scope" ? "choose scope" : "";
33
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.accent, paddingX: 1, marginBottom: 1, children: [_jsxs(Text, { color: theme.accent, bold: true, children: ["setup ", client, heading ? ` · ${heading}` : ""] }), step === "loading" && _jsx(Text, { color: theme.muted, children: "loading models from Copilot\u2026" }), step === "model" && (_jsx(Select, { items: models.map((m) => ({ label: modelLabel(m, "", limits), value: m })), onSubmit: (v) => { setModel(v.value); setStep("scope"); }, onCancel: onCancel })), step === "scope" && (_jsx(Select, { items: [
34
+ { label: "global — this machine, all projects", value: "global" },
35
+ { label: "project — current directory only", value: "project" },
36
+ ], onSubmit: (v) => void doApply(v.value), onCancel: onCancel })), step === "applying" && _jsx(Text, { color: theme.muted, children: "applying\u2026" }), step === "done" && result && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: theme.ready, children: ["\u2713 configured \u00B7 model ", model] }), _jsxs(Text, { color: theme.output, children: ["wrote ", result.path] }), _jsxs(Text, { color: theme.muted, children: ["keys: ", result.changed.join(", ") || "(no change)"] }), _jsx(Dismiss, { onDismiss: () => onDone(result, model) })] })), step === "error" && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: theme.error, children: ["failed: ", err] }), _jsx(Dismiss, { onDismiss: onCancel })] }))] }));
37
+ }
@@ -0,0 +1,68 @@
1
+ import { Registry } from "./registry.js";
2
+ import { claudeCodeConfig, codexConfig } from "../setup/clients.js";
3
+ import { aggregate, recentErrors } from "../panels/metrics-agg.js";
4
+ import { openUrl as defaultOpenUrl } from "../../shared/open-url.js";
5
+ import { buildIssueUrl, PLACEHOLDER_REPO } from "../report.js";
6
+ export function buildRegistry(ctx, endpoint, opts = {}) {
7
+ const reg = new Registry(ctx);
8
+ const openUrl = opts.openUrl ?? defaultOpenUrl;
9
+ reg.add({ name: "/status", describe: "show worker status + restart history", run: async (_a, c) => {
10
+ const s = await c.client.status();
11
+ const lines = [`worker: ${s.workerState}`];
12
+ for (const r of s.restarts.slice(0, 5))
13
+ lines.push(` ${r.reason} exit=${r.exitCode ?? "-"} ${r.stderrTail.slice(0, 60)}`);
14
+ return lines;
15
+ } });
16
+ reg.add({ name: "/doctor", describe: "run health checks", run: async (_a, c) => (await c.client.doctor()).map((x) => `${x.ok ? "OK " : "FAIL"} ${x.name}: ${x.detail}`) });
17
+ reg.add({ name: "/restart", describe: "restart the worker", run: async (_a, c) => { await c.client.restart(); return ["restart requested"]; } });
18
+ reg.add({ name: "/stop", describe: "stop the worker", run: async (_a, c) => { await c.client.stop(); return ["worker stopped"]; } });
19
+ reg.add({ name: "/start", describe: "start the worker", run: async (_a, c) => { await c.client.start(); return ["worker started"]; } });
20
+ reg.add({ name: "/logs", describe: "recent request errors (what failed & why)", run: async (_a, c) => {
21
+ const errs = recentErrors(await c.client.requests(), 20);
22
+ if (!errs.length)
23
+ return ["no request errors logged — everything's green ✓"];
24
+ return errs.map((e) => `${new Date(e.ts).toISOString()} ${e.status} ${e.endpoint} ${e.model} — ${e.error ?? "(no message)"}`);
25
+ } });
26
+ reg.add({ name: "/metrics", describe: "request metrics + recent errors", run: async (_a, c) => {
27
+ const reqs = await c.client.requests();
28
+ const a = aggregate(reqs);
29
+ if (!a.total)
30
+ return ["no requests yet"];
31
+ const lines = [`requests: ${a.total} errors: ${a.errors}`, ...a.byModel.map((r) => ` ${r.model.padEnd(20)} n=${r.count} avg=${r.avgMs}ms`)];
32
+ const errs = recentErrors(reqs, 5);
33
+ if (errs.length) {
34
+ lines.push("recent errors:");
35
+ for (const e of errs)
36
+ lines.push(` ${e.status} ${e.model} — ${(e.error ?? "(no message)").slice(0, 80)}`);
37
+ }
38
+ return lines;
39
+ } });
40
+ reg.add({ name: "/setup-claude", describe: "print Claude Code config", run: async () => claudeCodeConfig(endpoint).instructions.split("\n") });
41
+ reg.add({ name: "/setup-codex", describe: "print Codex/OpenAI config", run: async () => codexConfig(endpoint).instructions.split("\n") });
42
+ reg.add({ name: "/setup-status", describe: "show configured endpoints", run: async () => [`OpenAI: http://${endpoint.host}:${endpoint.port}/v1`, `Anthropic: http://${endpoint.host}:${endpoint.port}`] });
43
+ reg.add({ name: "/reset-claude", describe: "restore Claude Code config (remove copilot-reverse's keys)", run: async () => opts.resetClient ? opts.resetClient("claude") : ["reset not available"] });
44
+ reg.add({ name: "/reset-codex", describe: "restore Codex/OpenAI config (remove copilot-reverse's keys)", run: async () => opts.resetClient ? opts.resetClient("codex") : ["reset not available"] });
45
+ reg.add({ name: "/model", describe: "switch the chat model", run: async () => ["opening model picker…"] });
46
+ reg.add({ name: "/config", describe: "view & change configuration", run: async () => ["opening config panel…"] });
47
+ reg.add({ name: "/dashboard", describe: "open the web dashboard in your browser", run: async () => {
48
+ if (!opts.dashboardUrl)
49
+ return ["dashboard URL not available"];
50
+ openUrl(opts.dashboardUrl);
51
+ return [`opening dashboard: ${opts.dashboardUrl}`];
52
+ } });
53
+ reg.add({ name: "/report", describe: "open a pre-filled GitHub issue with diagnostics", run: async (_a, c) => {
54
+ const repo = opts.reportRepo;
55
+ if (!repo || repo === PLACEHOLDER_REPO)
56
+ return ["set reportRepo (owner/repo) in config to enable /report"];
57
+ const [status, doctor, reqs] = await Promise.all([c.client.status(), c.client.doctor(), c.client.requests()]);
58
+ const url = buildIssueUrl({
59
+ repo, version: opts.appVersion ?? "0.0.0", platform: opts.platform ?? process.platform,
60
+ status, doctor, errors: recentErrors(reqs, 10),
61
+ });
62
+ openUrl(url);
63
+ return [`opening a pre-filled GitHub issue for ${repo} in your browser…`];
64
+ } });
65
+ reg.add({ name: "/quit", describe: "exit copilot-reverse", run: async (_a, c) => { c.quit(); return ["bye"]; } });
66
+ reg.add({ name: "/help", describe: "list commands", run: async () => reg.list().map((c) => `${c.name.padEnd(14)} ${c.describe}`) });
67
+ return reg;
68
+ }
@@ -0,0 +1,16 @@
1
+ export class Registry {
2
+ ctx;
3
+ cmds = new Map();
4
+ constructor(ctx) {
5
+ this.ctx = ctx;
6
+ }
7
+ add(cmd) { this.cmds.set(cmd.name, cmd); return this; }
8
+ list() { return [...this.cmds.values()]; }
9
+ async run(line) {
10
+ const [name, ...args] = line.trim().split(/\s+/);
11
+ const cmd = this.cmds.get(name);
12
+ if (!cmd)
13
+ return [`unknown command: ${name} (try /help)`];
14
+ return cmd.run(args, this.ctx);
15
+ }
16
+ }
@@ -0,0 +1,16 @@
1
+ // Claude-Code-inspired palette. Warm coral accent, muted secondary text, clear state colors.
2
+ // Named colors keep broad terminal compatibility; the accent uses hex (truecolor terminals).
3
+ export const theme = {
4
+ accent: "#cc785c", // Claude clay/coral
5
+ prompt: "#cc785c",
6
+ user: "white",
7
+ assistant: "white",
8
+ output: "gray",
9
+ muted: "gray",
10
+ border: "gray",
11
+ ready: "green",
12
+ starting: "yellow",
13
+ crashed: "redBright",
14
+ unhealthy: "red",
15
+ error: "red",
16
+ };
@@ -0,0 +1,108 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { anthropicRequestToCanonical, canonicalToAnthropicResponse } from "../core/anthropic-inbound.js";
3
+ import { estimateTokens } from "../core/tokens.js";
4
+ import { errorHint } from "./errors.js";
5
+ import { CopilotAuthError } from "../providers/copilot/token.js";
6
+ const frame = (event, data) => `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
7
+ export function mountAnthropic(app, router, onMetric) {
8
+ // Anthropic clients (Claude Code) call this to size the prompt and decide when to auto-compact.
9
+ app.post("/v1/messages/count_tokens", (req, res) => {
10
+ res.json({ input_tokens: estimateTokens(anthropicRequestToCanonical(req.body)) });
11
+ });
12
+ app.post("/v1/messages", async (req, res) => {
13
+ const start = Date.now();
14
+ const canon = anthropicRequestToCanonical(req.body);
15
+ canon.model = router.resolveModel(canon.model);
16
+ const provider = router.pick(canon.model);
17
+ const metric = (status, error) => onMetric({ endpoint: "/v1/messages", model: canon.model, status, latencyMs: Date.now() - start, error });
18
+ try {
19
+ if (canon.stream) {
20
+ res.setHeader("content-type", "text/event-stream");
21
+ res.setHeader("cache-control", "no-cache");
22
+ // MUST be unique per message — Anthropic ids are unique and clients (Claude Code) key
23
+ // their message store on it. A constant id made every answer overwrite/dedupe to the
24
+ // first one, so different questions appeared to return the same content.
25
+ const id = `msg_${randomUUID().replace(/-/g, "")}`;
26
+ // Claude Code reads input_tokens from message_start to size the context bar, but the real
27
+ // usage only arrives in the final frame. Seed message_start with an ESTIMATE so the bar
28
+ // isn't stuck at 0%; the terminal message_delta then reports the exact count.
29
+ const estInput = estimateTokens(canon);
30
+ 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 } } }));
31
+ // D3 (interface-freeze §5.4) + mixed text+tool fix (architect, 2026-06-17): the endpoint owns
32
+ // open/stop bookkeeping with DYNAMIC SEQUENTIAL allocation. We do NOT pre-open an index-0 text block,
33
+ // and we do NOT map the Copilot tool index straight to the Anthropic block index (that collides with a
34
+ // text preamble on a mixed turn). Instead, whichever block opens FIRST claims Anthropic index 0, the
35
+ // next claims 1, etc. This keeps indices contiguous-from-0 in all three cases: pure-text (text@0),
36
+ // pure-tool (tool@0), and mixed preamble+tool (text@0, tool@1).
37
+ let next = 0;
38
+ let textIndex; // Anthropic index of the (single) text block, once opened
39
+ const toolIndex = new Map(); // Copilot tool index -> Anthropic block index
40
+ const openedOrder = []; // Anthropic indices in allocation order
41
+ let stopReason = "stop";
42
+ let usage;
43
+ for await (const chunk of provider.stream(canon)) {
44
+ if (chunk.done) {
45
+ stopReason = chunk.finishReason ?? "stop";
46
+ usage = chunk.usage;
47
+ break;
48
+ }
49
+ if (chunk.kind === "text") {
50
+ if (textIndex === undefined) {
51
+ textIndex = next++;
52
+ openedOrder.push(textIndex);
53
+ res.write(frame("content_block_start", { type: "content_block_start", index: textIndex, content_block: { type: "text", text: "" } }));
54
+ }
55
+ res.write(frame("content_block_delta", { type: "content_block_delta", index: textIndex, delta: { type: "text_delta", text: chunk.delta } }));
56
+ }
57
+ else if (chunk.kind === "tool_use_start") {
58
+ if (!toolIndex.has(chunk.index)) {
59
+ const index = next++;
60
+ toolIndex.set(chunk.index, index);
61
+ openedOrder.push(index);
62
+ res.write(frame("content_block_start", { type: "content_block_start", index, content_block: { type: "tool_use", id: chunk.id, name: chunk.name, input: {} } }));
63
+ }
64
+ }
65
+ else if (chunk.kind === "tool_use_delta") {
66
+ const index = toolIndex.get(chunk.index);
67
+ if (index !== undefined)
68
+ res.write(frame("content_block_delta", { type: "content_block_delta", index, delta: { type: "input_json_delta", partial_json: chunk.argsDelta } }));
69
+ }
70
+ }
71
+ // Close every opened block (ascending Anthropic index) before the terminal frames.
72
+ for (const index of [...openedOrder].sort((a, b) => a - b))
73
+ res.write(frame("content_block_stop", { type: "content_block_stop", index }));
74
+ // Report real usage (agent-maestro shape): split cached tokens out of input so Claude Code's
75
+ // context bar is accurate. Falls back to zeros if Copilot didn't return usage.
76
+ const cached = usage?.cachedTokens ?? 0;
77
+ const inputTokens = Math.max(0, (usage?.promptTokens ?? estInput) - cached); // fall back to the estimate
78
+ const deltaUsage = { input_tokens: inputTokens, output_tokens: usage?.completionTokens ?? 0, cache_read_input_tokens: cached };
79
+ res.write(frame("message_delta", { type: "message_delta", delta: { stop_reason: stopReason === "tool_use" ? "tool_use" : stopReason === "length" ? "max_tokens" : "end_turn" }, usage: deltaUsage }));
80
+ res.write(frame("message_stop", { type: "message_stop" }));
81
+ res.end();
82
+ metric(200);
83
+ }
84
+ else {
85
+ res.json(canonicalToAnthropicResponse(await provider.complete(canon)));
86
+ metric(200);
87
+ }
88
+ }
89
+ catch (err) {
90
+ const raw = err instanceof Error ? err.message : String(err);
91
+ const hint = errorHint(raw);
92
+ const message = hint ? `${raw}\n${hint}` : raw;
93
+ const status = err instanceof CopilotAuthError ? 401 : 502;
94
+ const errorType = status === 401 ? "authentication_error" : "api_error";
95
+ if (!res.headersSent) {
96
+ res.status(status).json({ type: "error", error: { type: errorType, message } });
97
+ }
98
+ else {
99
+ // The stream already opened (message_start sent), so we can't set a status code.
100
+ // Emit an Anthropic `error` SSE event before closing so the client renders the
101
+ // failure instead of seeing a silently truncated response.
102
+ res.write(frame("error", { type: "error", error: { type: errorType, message } }));
103
+ res.end();
104
+ }
105
+ metric(status, message);
106
+ }
107
+ });
108
+ }
@@ -0,0 +1,12 @@
1
+ // Turn a raw upstream error message into an actionable hint (agent-maestro v2.8.1/v2.6.0:
2
+ // structured context-window-exceeded + model_not_supported guidance instead of a bare 400).
3
+ export function errorHint(message) {
4
+ const m = message.toLowerCase();
5
+ if (/context_length_exceeded|prompt is too long|maximum context|too many tokens|context window/.test(m)) {
6
+ return "context window exceeded — the conversation is too long; /compact or switch to a larger-context model";
7
+ }
8
+ if (/not supported|unknown model|invalid model|model_not_found|does not support/.test(m)) {
9
+ return "model not supported — run /model to pick an available one";
10
+ }
11
+ return "";
12
+ }
@@ -0,0 +1,30 @@
1
+ import { createWorkerApp } from "./server.js";
2
+ import { Router } from "./router.js";
3
+ import { CopilotAdapter } from "../providers/copilot/adapter.js";
4
+ import { CopilotTokenStore } from "../providers/copilot/token.js";
5
+ import { fetchCopilotModels } from "../providers/copilot/models.js";
6
+ import { readGhToken } from "../shared/creds.js";
7
+ import { dataDir } from "../shared/paths.js";
8
+ import { defaultConfig } from "../shared/config.js";
9
+ function send(msg) { if (process.send)
10
+ process.send(msg); }
11
+ const cfg = defaultConfig();
12
+ const port = Number(process.env.WORKER_PORT ?? cfg.workerPort);
13
+ const host = process.env.BIND_HOST ?? cfg.bindHost;
14
+ const gh = readGhToken(dataDir());
15
+ if (!gh) {
16
+ send({ type: "error", message: "no GitHub token; run `copilot-reverse` and /login first" });
17
+ process.exit(1);
18
+ }
19
+ const tokenStore = new CopilotTokenStore(gh);
20
+ const router = new Router([new CopilotAdapter(tokenStore)], cfg.modelMap);
21
+ // Load the live model list so the router can fuzzy-match near-miss ids (e.g. dated Anthropic ids).
22
+ void tokenStore.get().then((t) => fetchCopilotModels(t)).then((ids) => router.setAvailableModels(ids)).catch(() => { });
23
+ const app = createWorkerApp(router, (m) => send({ type: "request-metric", ...m }));
24
+ const server = app.listen(port, host, () => send({ type: "ready", port }));
25
+ const hb = setInterval(() => send({ type: "heartbeat", ts: Date.now() }), 5_000);
26
+ process.on("message", (m) => { if (m?.type === "shutdown") {
27
+ clearInterval(hb);
28
+ server.close(() => process.exit(0));
29
+ } });
30
+ process.on("uncaughtException", (e) => { send({ type: "error", message: e.message, stack: e.stack }); process.exit(1); });