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
package/GUIDE.md ADDED
@@ -0,0 +1,142 @@
1
+ # copilot-reverse — User Guide
2
+
3
+ **Use the Copilot subscription you already pay for as a local Claude Code / Codex backend.**
4
+ No new API keys. No per-token bills. One terminal app.
5
+
6
+ ```
7
+ ┌─────────────┐ ┌──────────────────┐ ┌─────────────┐
8
+ │ Claude Code │ ─────▶ │ copilot-reverse │ ─────▶ │ Copilot │
9
+ │ / Codex │ local │ (your machine) │ proxy │ (your sub) │
10
+ └─────────────┘ └──────────────────┘ └─────────────┘
11
+ ```
12
+
13
+ ---
14
+
15
+ ## 60-second start
16
+
17
+ ```bash
18
+ npx copilot-reverse
19
+ ```
20
+
21
+ 1. It asks you to log in to GitHub (device code — paste a code in your browser). One time only.
22
+ 2. The terminal app launches. You'll see a prompt and a status bar.
23
+ 3. In the app, type:
24
+ ```
25
+ /setup-claude
26
+ ```
27
+ Pick a model (e.g. **claude-opus-4.8 (1M)**), choose **global**, done.
28
+ 4. Open a **new** terminal and run `claude`. It's now talking to Copilot through copilot-reverse. 🎉
29
+
30
+ That's it. Codex users: run `/setup-codex` instead.
31
+
32
+ Here's the app itself — a prompt, a live status bar, and slash-command autocomplete:
33
+
34
+ ```text
35
+ ✳ copilot-reverse worker: ready
36
+
37
+ Type a message to chat with the assistant, or /help for commands.
38
+ ╭─────────────────────────────────────────────────────────────────────────────────────╮
39
+ │ › /setup │
40
+ ╰─────────────────────────────────────────────────────────────────────────────────────╯
41
+ ❯ /setup-claude print Claude Code config
42
+ /setup-codex print Codex/OpenAI config
43
+ /setup-status show configured endpoints
44
+ ↑↓ navigate · tab complete · enter run
45
+ model claude-opus-4.8 · daemon ready · claude u:✓ p:○ codex u:✓ p:○ · /help
46
+ ```
47
+
48
+ ---
49
+
50
+ ## What can I do in the app?
51
+
52
+ Just **talk to it** — it understands plain English and will do the work for you:
53
+
54
+ > *"list models"* → shows every model + its context window
55
+ > *"set up claude"* → configures Claude Code
56
+ > *"is the worker healthy?"* → runs a health check
57
+ > *"why did my last request fail?"* → shows the error
58
+
59
+ Prefer commands? Type `/` to see them all. The essentials:
60
+
61
+ | Command | What it does |
62
+ |---|---|
63
+ | `/setup-claude` · `/setup-codex` | Point Claude Code / Codex at copilot-reverse |
64
+ | `/model` | Switch the chat model (1M-context models marked) |
65
+ | `/status` · `/doctor` | Is everything healthy? |
66
+ | `/logs` · `/metrics` | What ran, what failed, and why |
67
+ | `/dashboard` | Open a live web dashboard in your browser |
68
+ | `/report` | File a pre-filled bug report (diagnostics only — no prompts) |
69
+ | `/reset-claude` · `/reset-codex` | Undo setup, restore original config |
70
+ | `/help` · `/quit` | List commands · exit |
71
+
72
+ ### The live dashboard
73
+
74
+ `/dashboard` opens a self-refreshing web view of everything happening through the proxy — worker
75
+ health, request volume, and (most useful) recent **errors with their real messages**:
76
+
77
+ ![copilot-reverse dashboard](images/dashboard.png)
78
+
79
+ ---
80
+
81
+ ## Connect your own tools
82
+
83
+ Already have something that speaks OpenAI or Anthropic? Point it here:
84
+
85
+ - **OpenAI-compatible:** `http://127.0.0.1:7891/v1`
86
+ - **Anthropic-compatible:** `http://127.0.0.1:7891`
87
+
88
+ Any API key value works locally (it's your machine). Example:
89
+
90
+ ```bash
91
+ export ANTHROPIC_BASE_URL=http://127.0.0.1:7891
92
+ export ANTHROPIC_API_KEY=local
93
+ claude
94
+ ```
95
+
96
+ ---
97
+
98
+ ## The status bar, decoded
99
+
100
+ The bottom line of the app (see the screenshot above) tells you everything at a glance:
101
+
102
+ ```text
103
+ model claude-opus-4.8 · daemon ready · claude u:✓ p:○ codex u:○ p:○ · /help
104
+ ```
105
+
106
+ - **worker / daemon** — green `ready` means the proxy is up and self-healing.
107
+ - **claude u:✓ p:○** — Claude Code is configured at the **u**ser (global) level, not in this **p**roject. Read live from your real config files.
108
+
109
+ ---
110
+
111
+ ## Troubleshooting
112
+
113
+ **"context 100%" or `/compact` fails in Claude Code**
114
+ Re-run `/setup-claude` and pick a **1M** model (e.g. `claude-opus-4.8 (1M)`). copilot-reverse writes
115
+ the right context-window hint so the client stops assuming a small window. Then restart Claude Code.
116
+
117
+ **"GitHub login expired"**
118
+ Your Copilot session lapsed. Restart copilot-reverse — it'll prompt you to log in again.
119
+
120
+ **A request failed and I don't know why**
121
+ Type `/logs` (or ask *"why did that fail?"*). Every failure is captured with its real upstream
122
+ message. Still stuck? `/report` opens a pre-filled GitHub issue with diagnostics — **never** your
123
+ prompt content.
124
+
125
+ **Want to undo everything**
126
+ `/reset-claude` and `/reset-codex` remove exactly the keys copilot-reverse added and leave the rest
127
+ of your config untouched.
128
+
129
+ ---
130
+
131
+ ## Good to know
132
+
133
+ - **Your data stays local.** The app proxies between your editor and Copilot on `127.0.0.1`. Your
134
+ GitHub token lives only in `~/.copilot-reverse/creds.json` on your own disk.
135
+ - **It heals itself.** If the proxy crashes, the supervisor restarts it with backoff and records why.
136
+ - **Unofficial endpoints.** This uses community-documented Copilot endpoints with *your own*
137
+ subscription. It may break if GitHub changes them — that's the trade-off for not needing extra keys.
138
+
139
+ ---
140
+
141
+ Questions or bugs? Use `/report` from inside the app, or open an issue on
142
+ [GitHub](https://github.com/wangcansunking/copilot-reverse). Happy hacking. 🚀
package/README.md ADDED
@@ -0,0 +1,60 @@
1
+ # copilot-reverse
2
+
3
+ Interactive terminal app that turns your GitHub Copilot subscription into local
4
+ OpenAI- and Anthropic-compatible endpoints, with a self-healing daemon and a
5
+ built-in assistant.
6
+
7
+ > **New here? Read the [User Guide](GUIDE.md) — a 60-second start, no jargon.**
8
+
9
+ > **Disclaimer:** The GitHub Copilot integration uses community-documented,
10
+ > unofficial endpoints, for use with your own Copilot subscription only. It may
11
+ > break if GitHub changes these endpoints.
12
+
13
+ ![copilot-reverse dashboard](images/dashboard.png)
14
+
15
+ ## Quick start
16
+
17
+ ```bash
18
+ npx copilot-reverse # device-code login, then the TUI launches
19
+ ```
20
+
21
+ In the TUI: `/help`, `/doctor`, `/setup-claude`, `/setup-codex`, `/metrics`, or
22
+ just talk to the assistant in natural language.
23
+
24
+ Point clients at:
25
+ - OpenAI: `http://127.0.0.1:7891/v1`
26
+ - Anthropic: `http://127.0.0.1:7891`
27
+
28
+ ## Architecture (M1)
29
+
30
+ - **TUI** (Ink) — the `copilot-reverse` process: REPL + slash commands + claude-agent-sdk
31
+ assistant (which dogfoods copilot-reverse's own Anthropic endpoint).
32
+ - **Supervisor** (:7890) — control API + SQLite + self-healing worker supervision.
33
+ - **Worker** (:7891) — OpenAI `/v1/chat/completions` + Anthropic `/v1/messages`
34
+ → Copilot, with tool-use translation both ways.
35
+
36
+ ## Development
37
+
38
+ Requires Node >=20.
39
+
40
+ ```bash
41
+ npm install && npm test && npm run build
42
+ ```
43
+
44
+ ### End-to-end tests
45
+
46
+ The [`e2e/`](e2e/) folder holds cross-module end-to-end scenarios (real worker + supervisor +
47
+ TUI wiring, fake Copilot provider). The case catalog is [`e2e/cases.md`](e2e/cases.md) and the
48
+ latest run is [`e2e/RESULTS.md`](e2e/RESULTS.md).
49
+
50
+ **Every code change must keep the full e2e suite green.** `npm test` runs it (the suite is
51
+ included in the default vitest run); `npm run test:e2e` runs only the e2e cases. After a change,
52
+ re-run and update `e2e/RESULTS.md`.
53
+
54
+ ### Test notes
55
+
56
+ - **TUI input tests** (`tests/tui/app.test.tsx`): the test waits ~30 ms after
57
+ `render()` before writing to `stdin`. This is not flakiness padding — Ink's
58
+ `useInput` subscribes to stdin asynchronously after mount, so writes issued in
59
+ the same tick as `render()` are dropped. The delay lets the subscription
60
+ attach; assertions are otherwise unchanged.
@@ -0,0 +1,9 @@
1
+ import { requestDeviceCode, pollForToken } from "../providers/copilot/auth.js";
2
+ import { writeGhToken } from "../shared/creds.js";
3
+ export async function runDeviceLogin(dir, fetchFn = fetch, log = console.log) {
4
+ const code = await requestDeviceCode(fetchFn);
5
+ log(`\nOpen ${code.verification_uri} and enter code: ${code.user_code}\n`);
6
+ const token = await pollForToken(code.device_code, code.interval * 1000, fetchFn);
7
+ writeGhToken(token, dir);
8
+ log("GitHub authorization complete.");
9
+ }
@@ -0,0 +1,133 @@
1
+ #!/usr/bin/env node
2
+ import React from "react";
3
+ import { render } from "ink";
4
+ import { Command } from "commander";
5
+ import { App } from "../tui/app.js";
6
+ import { buildRegistry } from "../tui/slash/commands.js";
7
+ import { DaemonClient } from "../tui/daemon-client.js";
8
+ import { runDeviceLogin } from "./auth.js";
9
+ import { probeSupervisor } from "../daemon/lifecycle.js";
10
+ import { startSupervisor } from "../supervisor/index.js";
11
+ import { runAssistantTurn } from "../tui/assistant/runtime.js";
12
+ import { makeOnChat } from "../tui/assistant/on-chat.js";
13
+ import { readGhToken } from "../shared/creds.js";
14
+ import { readClientSetup, writeClientSetup } from "../shared/client-setup.js";
15
+ import { readChatModel, writeChatModel } from "../shared/prefs.js";
16
+ import { CopilotTokenStore, isCopilotTokenValid } from "../providers/copilot/token.js";
17
+ import { fetchCopilotModels, fetchModelLimits } from "../providers/copilot/models.js";
18
+ import { applyClaude, applyCodex, resetClaude, resetCodex, CLAUDE_ENV_KEYS, CODEX_ENV_KEYS } from "../tui/setup/apply.js";
19
+ import { readClientStatus } from "../tui/setup/status.js";
20
+ import { applyCodexToml } from "../tui/setup/codex-toml.js";
21
+ import { claudeCopilotReverseEnv } from "../tui/setup/clients.js";
22
+ import { dataDir } from "../shared/paths.js";
23
+ import { defaultConfig } from "../shared/config.js";
24
+ const delay = (ms) => new Promise((r) => setTimeout(r, ms));
25
+ const DEFAULT_MODEL = "gpt-4o"; // a valid Copilot model id; pass-through routing uses it as-is
26
+ // Conservative context budget that drives the assistant's auto-compaction. Sized below the
27
+ // common Copilot prompt window (gpt-4o ≈ 128K) so the engine compacts before the upstream
28
+ // rejects an over-long turn. TODO: read each model's real max_prompt_tokens from /models.
29
+ const DEFAULT_MAX_INPUT_TOKENS = 110_000;
30
+ async function launchTui() {
31
+ const cfg = defaultConfig();
32
+ const existingToken = readGhToken(dataDir());
33
+ if (!existingToken) {
34
+ console.log("No GitHub login found — starting device-code login.");
35
+ await runDeviceLogin(dataDir());
36
+ }
37
+ else if (!(await isCopilotTokenValid(existingToken))) {
38
+ console.log("GitHub login expired — re-authenticating.");
39
+ await runDeviceLogin(dataDir());
40
+ }
41
+ // Run the daemon IN-PROCESS — no separate console window pops up. Reuse one if already running.
42
+ let stopSupervisor;
43
+ if (!(await probeSupervisor())) {
44
+ process.stdout.write("starting copilot-reverse…\n");
45
+ stopSupervisor = startSupervisor().stop;
46
+ for (let i = 0; i < 60 && !(await probeSupervisor()); i++)
47
+ await delay(100);
48
+ }
49
+ const base = `http://${cfg.bindHost}:${cfg.supervisorPort}`;
50
+ const client = new DaemonClient(base);
51
+ const workerBase = `http://${cfg.bindHost}:${cfg.workerPort}`;
52
+ const endpoint = { host: cfg.bindHost, port: cfg.workerPort, apiKey: "copilot-reverse-local" };
53
+ let app;
54
+ const quit = () => { stopSupervisor?.(); app?.unmount(); process.exit(0); };
55
+ // Restore a client's config: strip copilot-reverse's keys from BOTH scopes and clear the HUD flag.
56
+ const resetClient = async (clientKind) => {
57
+ const fn = clientKind === "claude" ? resetClaude : resetCodex;
58
+ const keys = clientKind === "claude" ? CLAUDE_ENV_KEYS : CODEX_ENV_KEYS;
59
+ const results = ["global", "project"].map((scope) => fn(scope, keys));
60
+ writeClientSetup(dataDir(), { ...readClientSetup(dataDir()), [clientKind]: false });
61
+ const lines = results
62
+ .filter((r) => r.changed.length)
63
+ .map((r) => `removed ${r.changed.join(", ")} from ${r.path}`);
64
+ return lines.length ? lines : [`no copilot-reverse ${clientKind} config found to remove`];
65
+ };
66
+ const registry = buildRegistry({ client, quit }, endpoint, {
67
+ dashboardUrl: `http://${cfg.bindHost}:${cfg.supervisorPort}/`,
68
+ reportRepo: cfg.reportRepo,
69
+ appVersion: "0.0.1",
70
+ platform: `${process.platform} node-${process.version}`,
71
+ resetClient,
72
+ });
73
+ // Filled in below once we have a token; the assistant prefers a model's real window over the default.
74
+ const modelLimits = {};
75
+ const tokenStore = new CopilotTokenStore(readGhToken(dataDir()));
76
+ const loadModels = async () => {
77
+ const token = await tokenStore.get();
78
+ const [ids, limits] = await Promise.all([fetchCopilotModels(token), fetchModelLimits(token)]);
79
+ Object.assign(modelLimits, limits); // so the picker shows windows and auto-compaction is sized
80
+ return ids;
81
+ };
82
+ // Pull each model's real context window in the background too, in case the picker never opens.
83
+ void tokenStore.get().then((t) => fetchModelLimits(t)).then((m) => Object.assign(modelLimits, m)).catch(() => { });
84
+ // Apply a client's config (shared by the /setup wizard and the assistant's setup_* tools).
85
+ // For Claude Code we also write the selected model's real context window so the client doesn't
86
+ // assume the default 200K (which makes a 1M model read "context 100%" far too early). For Codex
87
+ // we write BOTH a .env (legacy) and ~/.codex/config.toml (the native Codex config, with the
88
+ // model's context window) so either Codex setup style works.
89
+ const applyClient = (clientKind, scope, model) => {
90
+ if (clientKind === "claude") {
91
+ const r = applyClaude(scope, claudeCopilotReverseEnv(workerBase, "copilot-reverse-local", model, modelLimits[model]));
92
+ writeClientSetup(dataDir(), { ...readClientSetup(dataDir()), claude: true });
93
+ return r;
94
+ }
95
+ const r = applyCodex(scope, { OPENAI_BASE_URL: `${workerBase}/v1`, OPENAI_API_KEY: "copilot-reverse-local", OPENAI_MODEL: model });
96
+ applyCodexToml({ baseUrl: `${workerBase}/v1`, model, contextWindow: modelLimits[model] });
97
+ writeClientSetup(dataDir(), { ...readClientSetup(dataDir()), codex: true });
98
+ return r;
99
+ };
100
+ const setup = { apply: async (clientKind, scope, model) => applyClient(clientKind, scope, model) };
101
+ const onChat = makeOnChat({
102
+ client, workerBaseUrl: workerBase, apiKey: "copilot-reverse-local", model: DEFAULT_MODEL,
103
+ maxInputTokens: DEFAULT_MAX_INPUT_TOKENS, modelLimits,
104
+ listModels: loadModels,
105
+ setupClient: async (c, s, m) => applyClient(c, s, m),
106
+ }, (c, p, print, abort) => runAssistantTurn(c, p, print, undefined, abort));
107
+ const persistedModel = readChatModel(dataDir());
108
+ app = render(React.createElement(App, {
109
+ registry,
110
+ title: "copilot-reverse",
111
+ initialModel: persistedModel ?? DEFAULT_MODEL,
112
+ statusSource: () => client.status(),
113
+ readStatus: () => readClientStatus(),
114
+ modelLimits,
115
+ onChat,
116
+ loadModels,
117
+ setup,
118
+ info: {
119
+ openai: `${workerBase}/v1`,
120
+ anthropic: workerBase,
121
+ supervisorPort: cfg.supervisorPort,
122
+ workerPort: cfg.workerPort,
123
+ dataDir: dataDir(),
124
+ },
125
+ onModelChange: (m) => writeChatModel(dataDir(), m),
126
+ pickModelOnStart: !persistedModel,
127
+ }));
128
+ }
129
+ const program = new Command();
130
+ program.name("copilot-reverse").description("copilot-reverse: interactive Copilot proxy").version("0.0.1");
131
+ program.command("login").description("GitHub device-code login").action(() => runDeviceLogin(dataDir()));
132
+ program.action(() => { void launchTui(); });
133
+ program.parseAsync(process.argv);
@@ -0,0 +1,63 @@
1
+ // The Anthropic `system` field may be a plain string or an array of text blocks (the Claude Code
2
+ // SDK sends blocks with cache_control). Flatten either shape to a string — otherwise it stringifies
3
+ // to "[object Object]" and the model gets garbage instructions.
4
+ function systemText(system) {
5
+ if (!system)
6
+ return "";
7
+ if (typeof system === "string")
8
+ return system;
9
+ return system.filter((b) => b.type === "text" && b.text != null).map((b) => b.text).join("");
10
+ }
11
+ function blocksToCanonical(content) {
12
+ if (typeof content === "string")
13
+ return content ? [{ type: "text", text: content }] : [];
14
+ const out = [];
15
+ for (const b of content) {
16
+ if (b.type === "text" && b.text != null)
17
+ out.push({ type: "text", text: b.text });
18
+ else if (b.type === "image" && b.source) {
19
+ // Anthropic image: base64 (media_type + data) or a url source. Normalize to a data URL.
20
+ const dataUrl = b.source.type === "url" && b.source.url
21
+ ? b.source.url
22
+ : `data:${b.source.media_type ?? "image/png"};base64,${b.source.data ?? ""}`;
23
+ out.push({ type: "image", dataUrl });
24
+ }
25
+ else if (b.type === "tool_use")
26
+ out.push({ type: "tool_use", id: b.id, name: b.name, input: b.input });
27
+ else if (b.type === "tool_result")
28
+ out.push({ type: "tool_result", toolUseId: b.tool_use_id, content: typeof b.content === "string" ? b.content : JSON.stringify(b.content) });
29
+ }
30
+ return out;
31
+ }
32
+ export function anthropicRequestToCanonical(req) {
33
+ const messages = [];
34
+ const sys = systemText(req.system);
35
+ if (sys)
36
+ messages.push({ role: "system", content: [{ type: "text", text: sys }] });
37
+ for (const m of req.messages) {
38
+ const content = blocksToCanonical(m.content);
39
+ const isToolResult = content.some((b) => b.type === "tool_result");
40
+ messages.push({ role: isToolResult ? "tool" : m.role, content });
41
+ }
42
+ return {
43
+ model: req.model, stream: Boolean(req.stream), temperature: req.temperature, maxTokens: req.max_tokens,
44
+ // Keep only custom tools with a real JSON-Schema. Anthropic server-side tools (web_search,
45
+ // bash, computer, …) arrive with a `type` and no `input_schema`; forwarding them produces an
46
+ // invalid tool the model can't fulfil, and the client hangs forever waiting for a tool_result.
47
+ tools: req.tools
48
+ ?.filter((t) => t.input_schema != null && typeof t.input_schema === "object")
49
+ .map((t) => ({ name: t.name, description: t.description, parameters: t.input_schema })),
50
+ messages,
51
+ };
52
+ }
53
+ export function canonicalToAnthropicResponse(r) {
54
+ const content = r.content.map((b) => b.type === "text" ? { type: "text", text: b.text } :
55
+ b.type === "tool_use" ? { type: "tool_use", id: b.id, name: b.name, input: b.input } :
56
+ { type: "text", text: "" });
57
+ const stop = r.finishReason === "tool_use" ? "tool_use" : r.finishReason === "length" ? "max_tokens" : "end_turn";
58
+ return {
59
+ id: r.id, type: "message", role: "assistant", model: r.model,
60
+ content, stop_reason: stop, stop_sequence: null,
61
+ usage: { input_tokens: r.usage.promptTokens, output_tokens: r.usage.completionTokens },
62
+ };
63
+ }
@@ -0,0 +1,6 @@
1
+ export function textContent(s) {
2
+ return [{ type: "text", text: s }];
3
+ }
4
+ export function joinText(blocks) {
5
+ return blocks.filter((b) => b.type === "text").map((b) => b.text).join("");
6
+ }
@@ -0,0 +1,35 @@
1
+ // Fuzzy model matching (agent-maestro v2.6.0): clients send ids like `claude-opus-4-8` or
2
+ // `claude-opus-4-8-20251101`, but Copilot advertises `claude-opus-4.8`. Map the request to the
3
+ // closest available model by Jaccard similarity over normalized tokens, so a near-miss id doesn't
4
+ // pass straight through and 404. Date stamps (6+ digit runs) are dropped before comparing.
5
+ function tokenize(id) {
6
+ return new Set(id.toLowerCase()
7
+ .replace(/\b\d{6,}\b/g, " ") // strip date/version stamps like 20251101
8
+ .replace(/[-_.]+/g, " ")
9
+ .split(/\s+/)
10
+ .filter(Boolean));
11
+ }
12
+ function jaccard(a, b) {
13
+ if (!a.size && !b.size)
14
+ return 0;
15
+ let inter = 0;
16
+ for (const x of a)
17
+ if (b.has(x))
18
+ inter++;
19
+ return inter / (a.size + b.size - inter);
20
+ }
21
+ export function bestModelMatch(requested, available, threshold = 0.6) {
22
+ if (available.includes(requested))
23
+ return requested;
24
+ const rt = tokenize(requested);
25
+ let best = null;
26
+ let bestScore = 0;
27
+ for (const m of available) {
28
+ const s = jaccard(rt, tokenize(m));
29
+ if (s > bestScore) {
30
+ bestScore = s;
31
+ best = m;
32
+ }
33
+ }
34
+ return bestScore >= threshold ? best : null;
35
+ }
@@ -0,0 +1,83 @@
1
+ import { joinText } from "./canonical.js";
2
+ // OpenAI content may be a plain string or an array of text parts (clients that split long
3
+ // system/user prompts do this). Collapse text parts to a single string.
4
+ function textOf(content) {
5
+ if (Array.isArray(content))
6
+ return content.map((p) => (typeof p === "string" ? p : p?.text ?? "")).join("");
7
+ return content ?? "";
8
+ }
9
+ // Extract any image_url parts as canonical image blocks (vision support).
10
+ function imagesOf(content) {
11
+ if (!Array.isArray(content))
12
+ return [];
13
+ return content
14
+ .filter((p) => typeof p !== "string" && p?.type === "image_url" && !!p.image_url?.url)
15
+ .map((p) => ({ type: "image", dataUrl: p.image_url.url }));
16
+ }
17
+ function msgToCanonical(m) {
18
+ const role = (["system", "user", "assistant", "tool"].includes(m.role) ? m.role : "user");
19
+ const content = [];
20
+ if (m.role === "tool" && m.tool_call_id) {
21
+ content.push({ type: "tool_result", toolUseId: m.tool_call_id, content: textOf(m.content) });
22
+ }
23
+ else {
24
+ const text = textOf(m.content);
25
+ if (text)
26
+ content.push({ type: "text", text });
27
+ content.push(...imagesOf(m.content));
28
+ for (const tc of m.tool_calls ?? []) {
29
+ content.push({ type: "tool_use", id: tc.id, name: tc.function.name, input: safeJson(tc.function.arguments) });
30
+ }
31
+ }
32
+ return { role, content };
33
+ }
34
+ function safeJson(s) { try {
35
+ return JSON.parse(s);
36
+ }
37
+ catch {
38
+ return {};
39
+ } }
40
+ export function openaiRequestToCanonical(req) {
41
+ return {
42
+ model: req.model,
43
+ stream: Boolean(req.stream),
44
+ temperature: req.temperature,
45
+ maxTokens: req.max_tokens,
46
+ tools: req.tools?.map((t) => ({ name: t.function.name, description: t.function.description, parameters: t.function.parameters })),
47
+ messages: req.messages.map(msgToCanonical),
48
+ };
49
+ }
50
+ export function canonicalToOpenAIResponse(r) {
51
+ const toolCalls = r.content
52
+ .filter((b) => b.type === "tool_use")
53
+ .map((b, i) => ({ index: i, id: b.id, type: "function", function: { name: b.name, arguments: JSON.stringify(b.input) } }));
54
+ return {
55
+ id: r.id, object: "chat.completion", created: 0, model: r.model,
56
+ choices: [{
57
+ index: 0,
58
+ message: { role: "assistant", content: joinText(r.content) || null, ...(toolCalls.length ? { tool_calls: toolCalls } : {}) },
59
+ finish_reason: r.finishReason === "tool_use" ? "tool_calls" : r.finishReason,
60
+ }],
61
+ usage: { prompt_tokens: r.usage.promptTokens, completion_tokens: r.usage.completionTokens, total_tokens: r.usage.promptTokens + r.usage.completionTokens },
62
+ };
63
+ }
64
+ export function canonicalChunkToOpenAISSE(chunk, id, model) {
65
+ if (chunk.done) {
66
+ // Emit a final usage chunk (OpenAI stream_options.include_usage shape) before [DONE].
67
+ if (chunk.usage) {
68
+ const u = { prompt_tokens: chunk.usage.promptTokens, completion_tokens: chunk.usage.completionTokens, total_tokens: chunk.usage.promptTokens + chunk.usage.completionTokens };
69
+ const usageChunk = { id, object: "chat.completion.chunk", created: 0, model, choices: [], usage: u };
70
+ return `data: ${JSON.stringify(usageChunk)}\n\ndata: [DONE]\n\n`;
71
+ }
72
+ return "data: [DONE]\n\n";
73
+ }
74
+ let delta = {};
75
+ if (chunk.kind === "text")
76
+ delta = { content: chunk.delta };
77
+ else if (chunk.kind === "tool_use_start")
78
+ delta = { tool_calls: [{ index: chunk.index, id: chunk.id, type: "function", function: { name: chunk.name, arguments: "" } }] };
79
+ else if (chunk.kind === "tool_use_delta")
80
+ delta = { tool_calls: [{ index: chunk.index, function: { arguments: chunk.argsDelta } }] };
81
+ const payload = { id, object: "chat.completion.chunk", created: 0, model, choices: [{ index: 0, delta, finish_reason: null }] };
82
+ return `data: ${JSON.stringify(payload)}\n\n`;
83
+ }
@@ -0,0 +1,22 @@
1
+ // A rough char/4 token estimate over a canonical request — message text plus tool schemas.
2
+ // It is not a model-exact tokenizer, but it is positive and monotonic in input size, which is
3
+ // enough to back /v1/messages/count_tokens so clients (Claude Code) can time auto-compaction.
4
+ // Mirrors agent-maestro's pragmatic "estimate then calibrate" approach to token counting.
5
+ export function estimateTokens(req) {
6
+ let chars = 0;
7
+ for (const m of req.messages) {
8
+ chars += m.role.length;
9
+ for (const b of m.content) {
10
+ if (b.type === "text")
11
+ chars += b.text.length;
12
+ else if (b.type === "tool_use")
13
+ chars += b.name.length + JSON.stringify(b.input ?? {}).length;
14
+ else if (b.type === "tool_result")
15
+ chars += b.content.length;
16
+ }
17
+ }
18
+ for (const t of req.tools ?? []) {
19
+ chars += t.name.length + (t.description?.length ?? 0) + JSON.stringify(t.parameters ?? {}).length;
20
+ }
21
+ return Math.max(1, Math.ceil(chars / 4));
22
+ }
@@ -0,0 +1,32 @@
1
+ import { spawn as nodeSpawn } from "node:child_process";
2
+ import { fileURLToPath } from "node:url";
3
+ import { join, dirname } from "node:path";
4
+ import { defaultConfig } from "../shared/config.js";
5
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
6
+ export async function ensureDaemon(opts) {
7
+ if (await opts.probe())
8
+ return "already-running";
9
+ opts.spawn();
10
+ for (let i = 0; i < opts.retries; i++) {
11
+ await sleep(opts.delayMs);
12
+ if (await opts.probe())
13
+ return "started";
14
+ }
15
+ throw new Error("daemon did not become healthy in time");
16
+ }
17
+ // Real implementations wired by the CLI/TUI.
18
+ export function spawnSupervisor() {
19
+ const entry = join(dirname(fileURLToPath(import.meta.url)), "..", "supervisor", "index.js");
20
+ const child = nodeSpawn(process.execPath, [entry], { detached: true, stdio: "ignore" });
21
+ child.unref();
22
+ }
23
+ export async function probeSupervisor(fetchFn = fetch) {
24
+ const cfg = defaultConfig();
25
+ try {
26
+ const res = await fetchFn(`http://${cfg.bindHost}:${cfg.supervisorPort}/api/status`);
27
+ return res.ok;
28
+ }
29
+ catch {
30
+ return false;
31
+ }
32
+ }