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.
- package/GUIDE.md +142 -0
- package/README.md +60 -0
- package/dist/cli/auth.js +9 -0
- package/dist/cli/index.js +133 -0
- package/dist/core/anthropic-inbound.js +63 -0
- package/dist/core/canonical.js +6 -0
- package/dist/core/fuzzy.js +35 -0
- package/dist/core/openai-inbound.js +83 -0
- package/dist/core/tokens.js +22 -0
- package/dist/daemon/lifecycle.js +32 -0
- package/dist/providers/copilot/adapter.js +146 -0
- package/dist/providers/copilot/auth.js +30 -0
- package/dist/providers/copilot/models.js +49 -0
- package/dist/providers/copilot/token.js +53 -0
- package/dist/providers/types.js +1 -0
- package/dist/shared/client-setup.js +19 -0
- package/dist/shared/config.js +20 -0
- package/dist/shared/control-types.js +1 -0
- package/dist/shared/creds.js +14 -0
- package/dist/shared/format.js +28 -0
- package/dist/shared/ipc.js +1 -0
- package/dist/shared/open-url.js +17 -0
- package/dist/shared/paths.js +11 -0
- package/dist/shared/prefs.js +28 -0
- package/dist/supervisor/api.js +24 -0
- package/dist/supervisor/dashboard.js +110 -0
- package/dist/supervisor/db.js +35 -0
- package/dist/supervisor/events.js +6 -0
- package/dist/supervisor/index.js +66 -0
- package/dist/supervisor/monitor.js +91 -0
- package/dist/tui/app.js +184 -0
- package/dist/tui/assistant/on-chat.js +25 -0
- package/dist/tui/assistant/runtime.js +104 -0
- package/dist/tui/assistant/tools.js +24 -0
- package/dist/tui/components/select.js +30 -0
- package/dist/tui/daemon-client.js +16 -0
- package/dist/tui/panels/metrics-agg.js +22 -0
- package/dist/tui/panels/metrics.js +7 -0
- package/dist/tui/repl.js +45 -0
- package/dist/tui/report.js +35 -0
- package/dist/tui/screens/config.js +13 -0
- package/dist/tui/screens/model.js +16 -0
- package/dist/tui/setup/apply.js +119 -0
- package/dist/tui/setup/clients.js +38 -0
- package/dist/tui/setup/codex-toml.js +47 -0
- package/dist/tui/setup/status.js +35 -0
- package/dist/tui/setup/wizard.js +37 -0
- package/dist/tui/slash/commands.js +68 -0
- package/dist/tui/slash/registry.js +16 -0
- package/dist/tui/theme.js +16 -0
- package/dist/worker/anthropic-server.js +108 -0
- package/dist/worker/errors.js +12 -0
- package/dist/worker/index.js +30 -0
- package/dist/worker/openai-server.js +44 -0
- package/dist/worker/router.js +34 -0
- package/dist/worker/server.js +11 -0
- package/images/dashboard.png +0 -0
- package/package.json +69 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { fileURLToPath } from "node:url";
|
|
2
|
+
import { join, dirname } from "node:path";
|
|
3
|
+
import { mkdirSync } from "node:fs";
|
|
4
|
+
import { openDb, recordRestart, recordRequest } from "./db.js";
|
|
5
|
+
import { WorkerMonitor } from "./monitor.js";
|
|
6
|
+
import { EventBus } from "./events.js";
|
|
7
|
+
import { createControlApp } from "./api.js";
|
|
8
|
+
import { defaultConfig } from "../shared/config.js";
|
|
9
|
+
import { dataDir, dbPath } from "../shared/paths.js";
|
|
10
|
+
import { readGhToken } from "../shared/creds.js";
|
|
11
|
+
import { CopilotTokenStore } from "../providers/copilot/token.js";
|
|
12
|
+
export function startSupervisor() {
|
|
13
|
+
const config = defaultConfig();
|
|
14
|
+
mkdirSync(dataDir(), { recursive: true });
|
|
15
|
+
const db = openDb(dbPath());
|
|
16
|
+
const bus = new EventBus();
|
|
17
|
+
const workerEntry = join(dirname(fileURLToPath(import.meta.url)), "..", "worker", "index.js");
|
|
18
|
+
let state = "starting";
|
|
19
|
+
const monitor = new WorkerMonitor(config, workerEntry, {
|
|
20
|
+
onStateChange: (s) => { state = s; bus.emit("state", { state: s }); },
|
|
21
|
+
onCrash: (d, exitCode, stderrTail) => {
|
|
22
|
+
recordRestart(db, { ts: Date.now(), reason: d.markedUnhealthy ? "unhealthy" : "crash", exitCode, stderrTail, backoffMs: d.backoffMs, markedUnhealthy: d.markedUnhealthy ? 1 : 0 });
|
|
23
|
+
bus.emit("crash", { exitCode, ...d });
|
|
24
|
+
},
|
|
25
|
+
onWorkerMessage: (m) => {
|
|
26
|
+
if (m.type === "request-metric") {
|
|
27
|
+
const sample = { ts: Date.now(), endpoint: m.endpoint, model: m.model, status: m.status, latencyMs: m.latencyMs, error: m.error };
|
|
28
|
+
recordRequest(db, sample);
|
|
29
|
+
bus.emit("metric", sample);
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
const doctor = async () => {
|
|
34
|
+
const gh = readGhToken(dataDir());
|
|
35
|
+
let auth;
|
|
36
|
+
if (!gh) {
|
|
37
|
+
auth = { name: "github-auth", ok: false, detail: "not logged in — restart copilot-reverse to log in" };
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
// Validate the token actually exchanges, not just that it exists on disk.
|
|
41
|
+
try {
|
|
42
|
+
await new CopilotTokenStore(gh).get();
|
|
43
|
+
auth = { name: "github-auth", ok: true, detail: "token valid" };
|
|
44
|
+
}
|
|
45
|
+
catch (e) {
|
|
46
|
+
auth = { name: "github-auth", ok: false, detail: e instanceof Error ? e.message : String(e) };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return [auth, { name: "worker", ok: state === "ready", detail: `worker is ${state}` }];
|
|
50
|
+
};
|
|
51
|
+
const app = createControlApp({
|
|
52
|
+
db, getState: () => state,
|
|
53
|
+
restart: () => monitor.restartManually(),
|
|
54
|
+
stop: () => monitor.stop(),
|
|
55
|
+
start: () => monitor.start(),
|
|
56
|
+
doctor,
|
|
57
|
+
subscribe: (send) => bus.subscribe(send),
|
|
58
|
+
});
|
|
59
|
+
app.listen(config.supervisorPort, config.bindHost, () => monitor.start());
|
|
60
|
+
process.on("SIGINT", () => { monitor.stop(); process.exit(0); });
|
|
61
|
+
process.on("SIGTERM", () => { monitor.stop(); process.exit(0); });
|
|
62
|
+
return { stop: () => monitor.stop() };
|
|
63
|
+
}
|
|
64
|
+
// Allow `node dist/supervisor/index.js` to boot the daemon directly.
|
|
65
|
+
if (process.argv[1] && process.argv[1].endsWith(join("supervisor", "index.js")))
|
|
66
|
+
startSupervisor();
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { fork } from "node:child_process";
|
|
2
|
+
export class RestartController {
|
|
3
|
+
policy;
|
|
4
|
+
now;
|
|
5
|
+
crashTimes = [];
|
|
6
|
+
consecutive = 0;
|
|
7
|
+
constructor(policy, now = () => Date.now()) {
|
|
8
|
+
this.policy = policy;
|
|
9
|
+
this.now = now;
|
|
10
|
+
}
|
|
11
|
+
onCrash() {
|
|
12
|
+
const t = this.now();
|
|
13
|
+
this.crashTimes.push(t);
|
|
14
|
+
this.crashTimes = this.crashTimes.filter((ct) => t - ct < this.policy.windowMs);
|
|
15
|
+
this.consecutive += 1;
|
|
16
|
+
const backoffMs = Math.min(this.policy.baseBackoffMs * 2 ** (this.consecutive - 1), this.policy.maxBackoffMs);
|
|
17
|
+
return { backoffMs, markedUnhealthy: this.crashTimes.length >= this.policy.maxCrashes, crashesInWindow: this.crashTimes.length };
|
|
18
|
+
}
|
|
19
|
+
reset() { this.consecutive = 0; }
|
|
20
|
+
}
|
|
21
|
+
export class WorkerMonitor {
|
|
22
|
+
config;
|
|
23
|
+
workerEntry;
|
|
24
|
+
hooks;
|
|
25
|
+
child;
|
|
26
|
+
controller;
|
|
27
|
+
stderrTail = "";
|
|
28
|
+
state = "starting";
|
|
29
|
+
stopped = false;
|
|
30
|
+
constructor(config, workerEntry, hooks) {
|
|
31
|
+
this.config = config;
|
|
32
|
+
this.workerEntry = workerEntry;
|
|
33
|
+
this.hooks = hooks;
|
|
34
|
+
this.controller = new RestartController(config.restart);
|
|
35
|
+
}
|
|
36
|
+
start() { this.spawn(); }
|
|
37
|
+
currentState() { return this.state; }
|
|
38
|
+
set(s) { this.state = s; this.hooks.onStateChange(s); }
|
|
39
|
+
spawn() {
|
|
40
|
+
this.set("starting");
|
|
41
|
+
const child = fork(this.workerEntry, [], {
|
|
42
|
+
env: { ...process.env, WORKER_PORT: String(this.config.workerPort), BIND_HOST: this.config.bindHost },
|
|
43
|
+
stdio: ["ignore", "ignore", "pipe", "ipc"],
|
|
44
|
+
});
|
|
45
|
+
this.child = child;
|
|
46
|
+
this.stderrTail = "";
|
|
47
|
+
child.stderr?.on("data", (d) => { this.stderrTail = (this.stderrTail + d.toString()).slice(-4000); });
|
|
48
|
+
child.on("message", (m) => {
|
|
49
|
+
if (m.type === "ready") {
|
|
50
|
+
this.controller.reset();
|
|
51
|
+
this.set("ready");
|
|
52
|
+
}
|
|
53
|
+
this.hooks.onWorkerMessage(m);
|
|
54
|
+
});
|
|
55
|
+
child.on("exit", (code) => {
|
|
56
|
+
if (this.stopped)
|
|
57
|
+
return;
|
|
58
|
+
const d = this.controller.onCrash();
|
|
59
|
+
this.hooks.onCrash(d, code, this.stderrTail);
|
|
60
|
+
if (d.markedUnhealthy) {
|
|
61
|
+
this.set("unhealthy");
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
this.set("crashed");
|
|
65
|
+
setTimeout(() => this.spawn(), d.backoffMs);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
restartManually() {
|
|
69
|
+
this.controller.reset();
|
|
70
|
+
this.stopped = false;
|
|
71
|
+
if (this.child && !this.child.killed) {
|
|
72
|
+
this.child.removeAllListeners("exit");
|
|
73
|
+
this.child.kill();
|
|
74
|
+
}
|
|
75
|
+
this.spawn();
|
|
76
|
+
}
|
|
77
|
+
stop() {
|
|
78
|
+
this.stopped = true;
|
|
79
|
+
const child = this.child;
|
|
80
|
+
if (!child || child.killed)
|
|
81
|
+
return;
|
|
82
|
+
// The IPC channel may already be torn down (e.g. right after a manual restart) — sending then
|
|
83
|
+
// throws ERR_IPC_CHANNEL_CLOSED. Guard the graceful shutdown and fall back to a hard kill.
|
|
84
|
+
try {
|
|
85
|
+
if (child.connected)
|
|
86
|
+
child.send({ type: "shutdown" });
|
|
87
|
+
}
|
|
88
|
+
catch { /* channel already closed */ }
|
|
89
|
+
child.kill();
|
|
90
|
+
}
|
|
91
|
+
}
|
package/dist/tui/app.js
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useRef, useState } from "react";
|
|
3
|
+
import { Box, Text, useInput } from "ink";
|
|
4
|
+
import { loadingVerb } from "../shared/format.js";
|
|
5
|
+
import { Repl } from "./repl.js";
|
|
6
|
+
import { SetupWizard } from "./setup/wizard.js";
|
|
7
|
+
import { ModelScreen } from "./screens/model.js";
|
|
8
|
+
import { ConfigScreen } from "./screens/config.js";
|
|
9
|
+
import { theme } from "./theme.js";
|
|
10
|
+
const stateColor = {
|
|
11
|
+
ready: theme.ready, starting: theme.starting, crashed: theme.crashed, unhealthy: theme.unhealthy,
|
|
12
|
+
};
|
|
13
|
+
const EMPTY_STATUS = { claude: { user: false, project: false }, codex: { user: false, project: false } };
|
|
14
|
+
const SPINNER = ["✶", "✸", "✹", "✺", "✹", "✷"];
|
|
15
|
+
const fmtElapsed = (ms) => {
|
|
16
|
+
const s = Math.floor(ms / 1000);
|
|
17
|
+
return s >= 60 ? `${Math.floor(s / 60)}m ${s % 60}s` : `${s}s`;
|
|
18
|
+
};
|
|
19
|
+
const fmtTokens = (n) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`);
|
|
20
|
+
function OutputCard({ title, lines, tone }) {
|
|
21
|
+
const border = tone === "error" ? theme.error : tone === "ok" ? theme.ready : theme.border;
|
|
22
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: border, paddingX: 1, marginBottom: 1, children: [_jsx(Text, { color: theme.accent, bold: true, children: title }), lines.map((l, i) => {
|
|
23
|
+
const m = /^(OK|FAIL)\s+(.*)$/.exec(l);
|
|
24
|
+
if (m) {
|
|
25
|
+
const ok = m[1] === "OK";
|
|
26
|
+
return (_jsxs(Text, { children: [_jsx(Text, { color: ok ? theme.ready : theme.error, children: ok ? "✓ " : "✗ " }), _jsx(Text, { color: theme.output, children: m[2] })] }, i));
|
|
27
|
+
}
|
|
28
|
+
return _jsx(Text, { color: theme.output, children: l }, i);
|
|
29
|
+
})] }));
|
|
30
|
+
}
|
|
31
|
+
function HelpCard({ commands }) {
|
|
32
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.border, paddingX: 1, marginBottom: 1, children: [_jsx(Text, { color: theme.accent, bold: true, children: "Commands" }), commands.map((c) => (_jsxs(Text, { children: [_jsx(Text, { color: theme.prompt, children: c.name.padEnd(16) }), _jsx(Text, { color: theme.muted, children: c.describe })] }, c.name))), _jsx(Text, { color: theme.muted, children: "tip: type / to autocomplete \u00B7 plain text talks to the assistant" })] }));
|
|
33
|
+
}
|
|
34
|
+
// HUD client cell: shows configured scopes read from the real config files.
|
|
35
|
+
function ClientBadge({ name, status }) {
|
|
36
|
+
const cell = (label, on) => (_jsxs(Text, { color: on ? theme.ready : theme.muted, children: [label, ":", on ? "✓" : "○"] }));
|
|
37
|
+
return (_jsxs(Text, { color: theme.muted, children: [name, " ", cell("u", status.user), " ", cell("p", status.project)] }));
|
|
38
|
+
}
|
|
39
|
+
export function App({ registry, title, workerState = "starting", initialModel = "—", statusSource, readStatus, modelLimits, onChat, loadModels, setup, info, onModelChange, pickModelOnStart, }) {
|
|
40
|
+
const cmds = registry.list().map((c) => ({ name: c.name, describe: c.describe }));
|
|
41
|
+
const [entries, setEntries] = useState([
|
|
42
|
+
{ type: "system", text: "Type a message to chat with the assistant, or /help for commands." },
|
|
43
|
+
]);
|
|
44
|
+
const [state, setState] = useState(workerState);
|
|
45
|
+
const [status, setStatus] = useState(() => readStatus?.() ?? EMPTY_STATUS);
|
|
46
|
+
const [model, setModel] = useState(initialModel);
|
|
47
|
+
const [screen, setScreen] = useState(pickModelOnStart && loadModels ? { kind: "model" } : null);
|
|
48
|
+
const [, setNow] = useState(0); // ticks the live loading line while the assistant streams
|
|
49
|
+
const abortRef = useRef(null); // current turn's interrupt handle
|
|
50
|
+
const add = (e) => setEntries((p) => [...p, e].slice(-100));
|
|
51
|
+
const refreshStatus = () => { if (readStatus)
|
|
52
|
+
setStatus(readStatus()); };
|
|
53
|
+
// esc interrupts an in-flight assistant turn (the Repl doesn't use esc, so this is unambiguous).
|
|
54
|
+
useInput((_input, key) => { if (key.escape)
|
|
55
|
+
abortRef.current?.abort(); });
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
if (!statusSource && !readStatus)
|
|
58
|
+
return;
|
|
59
|
+
let alive = true;
|
|
60
|
+
const tick = async () => {
|
|
61
|
+
try {
|
|
62
|
+
const s = await statusSource?.();
|
|
63
|
+
if (alive && s)
|
|
64
|
+
setState(s.workerState);
|
|
65
|
+
}
|
|
66
|
+
catch { /* daemon momentarily down */ }
|
|
67
|
+
if (alive)
|
|
68
|
+
refreshStatus(); // HUD reflects the real config files, even if edited externally
|
|
69
|
+
};
|
|
70
|
+
void tick();
|
|
71
|
+
const id = setInterval(tick, 2000);
|
|
72
|
+
return () => { alive = false; clearInterval(id); };
|
|
73
|
+
}, [statusSource]);
|
|
74
|
+
const streaming = entries.some((e) => e.type === "assistant" && e.streaming);
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
if (!streaming)
|
|
77
|
+
return;
|
|
78
|
+
const id = setInterval(() => setNow((n) => n + 1), 200);
|
|
79
|
+
return () => clearInterval(id);
|
|
80
|
+
}, [streaming]);
|
|
81
|
+
function pickModel(m) {
|
|
82
|
+
setModel(m);
|
|
83
|
+
onModelChange?.(m);
|
|
84
|
+
setScreen(null);
|
|
85
|
+
add({ type: "card", title: "model", tone: "ok", lines: [`✓ chat model set to ${m}`] });
|
|
86
|
+
}
|
|
87
|
+
async function handle(line) {
|
|
88
|
+
add({ type: "user", text: `› ${line}` });
|
|
89
|
+
const t = line.trim();
|
|
90
|
+
if (t === "/model" && loadModels) {
|
|
91
|
+
setScreen({ kind: "model" });
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (t === "/config" && info) {
|
|
95
|
+
setScreen({ kind: "config" });
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (setup && loadModels && (t === "/setup-claude" || t === "/setup-codex")) {
|
|
99
|
+
setScreen({ kind: "setup", client: t === "/setup-claude" ? "claude" : "codex" });
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (line.startsWith("/")) {
|
|
103
|
+
if (t === "/help") {
|
|
104
|
+
add({ type: "help", commands: cmds });
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const out = await registry.run(line);
|
|
108
|
+
const tone = out.some((l) => /fail|error|unknown/i.test(l)) ? "error" : out.some((l) => /^OK /.test(l)) ? "ok" : "info";
|
|
109
|
+
add({ type: "card", title: t, tone, lines: out });
|
|
110
|
+
if (t === "/reset-claude" || t === "/reset-codex")
|
|
111
|
+
refreshStatus(); // HUD follows the files
|
|
112
|
+
}
|
|
113
|
+
else if (onChat) {
|
|
114
|
+
// Open one streaming bubble immediately (shows the live loading line), then append each
|
|
115
|
+
// delta into it in place rather than spawning a new line per chunk.
|
|
116
|
+
add({ type: "assistant", text: "", streaming: true, startedAt: Date.now() });
|
|
117
|
+
const append = (chunk) => setEntries((p) => {
|
|
118
|
+
const copy = [...p];
|
|
119
|
+
for (let i = copy.length - 1; i >= 0; i--) {
|
|
120
|
+
const e = copy[i];
|
|
121
|
+
if (e.type === "assistant" && e.streaming) {
|
|
122
|
+
copy[i] = { ...e, text: e.text + chunk };
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return copy;
|
|
127
|
+
});
|
|
128
|
+
const ctrl = new AbortController();
|
|
129
|
+
abortRef.current = ctrl;
|
|
130
|
+
try {
|
|
131
|
+
await onChat(line, append, model, ctrl);
|
|
132
|
+
}
|
|
133
|
+
finally {
|
|
134
|
+
abortRef.current = null;
|
|
135
|
+
setEntries((p) => p.map((e) => (e.type === "assistant" && e.streaming ? { ...e, streaming: false } : e)));
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
add({ type: "system", text: "(assistant not available — use /help)" });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
const configured = (s) => s.user || s.project;
|
|
143
|
+
let body;
|
|
144
|
+
if (screen?.kind === "model" && loadModels) {
|
|
145
|
+
body = _jsx(ModelScreen, { loadModels: loadModels, limits: modelLimits, current: model, onPick: pickModel, onCancel: () => setScreen(null) });
|
|
146
|
+
}
|
|
147
|
+
else if (screen?.kind === "setup" && setup && loadModels) {
|
|
148
|
+
const client = screen.client;
|
|
149
|
+
body = (_jsx(SetupWizard, { client: client, loadModels: loadModels, limits: modelLimits, apply: (scope, m) => setup.apply(client, scope, m), onDone: (result, m) => {
|
|
150
|
+
refreshStatus();
|
|
151
|
+
setScreen(null);
|
|
152
|
+
add({ type: "card", title: `setup ${client}`, tone: "ok", lines: [`✓ model ${m}`, `wrote ${result.path}`, `keys: ${result.changed.join(", ") || "(no change)"}`] });
|
|
153
|
+
}, onCancel: () => { setScreen(null); add({ type: "system", text: "setup cancelled" }); } }));
|
|
154
|
+
}
|
|
155
|
+
else if (screen?.kind === "config" && info) {
|
|
156
|
+
body = (_jsx(ConfigScreen, { info: info, model: model, clients: { claude: configured(status.claude), codex: configured(status.codex) }, onAction: (a) => {
|
|
157
|
+
if (a === "model")
|
|
158
|
+
setScreen({ kind: "model" });
|
|
159
|
+
else if (a === "setup-claude")
|
|
160
|
+
setScreen({ kind: "setup", client: "claude" });
|
|
161
|
+
else if (a === "setup-codex")
|
|
162
|
+
setScreen({ kind: "setup", client: "codex" });
|
|
163
|
+
else
|
|
164
|
+
setScreen(null);
|
|
165
|
+
} }));
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
body = _jsx(Repl, { onSubmit: handle, commands: cmds });
|
|
169
|
+
}
|
|
170
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { justifyContent: "space-between", paddingX: 1, children: [_jsxs(Text, { color: theme.accent, bold: true, children: ["\u2733 ", title] }), _jsxs(Text, { color: theme.muted, children: ["worker: ", _jsx(Text, { color: stateColor[state], children: state })] })] }), _jsx(Box, { flexDirection: "column", paddingX: 1, marginTop: 1, children: entries.map((e, i) => {
|
|
171
|
+
if (e.type === "card")
|
|
172
|
+
return _jsx(OutputCard, { title: e.title, lines: e.lines, tone: e.tone }, i);
|
|
173
|
+
if (e.type === "help")
|
|
174
|
+
return _jsx(HelpCard, { commands: e.commands }, i);
|
|
175
|
+
const color = e.type === "user" ? theme.user : e.type === "assistant" ? theme.assistant : theme.muted;
|
|
176
|
+
if (e.type === "assistant" && e.streaming) {
|
|
177
|
+
const elapsed = e.startedAt ? Date.now() - e.startedAt : 0;
|
|
178
|
+
const frame = SPINNER[Math.floor(Date.now() / 200) % SPINNER.length];
|
|
179
|
+
const tokens = Math.ceil(e.text.length / 4);
|
|
180
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: theme.accent, children: ["\u273D ", _jsxs(Text, { color: theme.muted, children: [frame, " ", loadingVerb(elapsed), "\u2026 (esc to interrupt \u00B7 ", fmtElapsed(elapsed), " \u00B7 \u2193 ", fmtTokens(tokens), " tokens \u00B7 thinking)"] })] }), e.text ? _jsx(Text, { color: color, children: e.text }) : null] }, i));
|
|
181
|
+
}
|
|
182
|
+
return _jsx(Text, { color: color, children: e.text }, i);
|
|
183
|
+
}) }), body, _jsxs(Box, { paddingX: 1, children: [_jsx(Text, { color: theme.muted, children: "model " }), _jsx(Text, { color: theme.accent, children: model }), _jsx(Text, { color: theme.muted, children: " \u00B7 daemon " }), _jsx(Text, { color: stateColor[state], children: state }), _jsx(Text, { color: theme.muted, children: " \u00B7 " }), _jsx(ClientBadge, { name: "claude", status: status.claude }), _jsx(Text, { color: theme.muted, children: " " }), _jsx(ClientBadge, { name: "codex", status: status.codex }), _jsx(Text, { color: theme.muted, children: " \u00B7 /help" })] })] }));
|
|
184
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
const DEFAULT_TURN_TIMEOUT_MS = 120_000; // 2 minutes — a turn that hasn't replied by then is given up on
|
|
2
|
+
export function makeOnChat(cfg, runner, timeoutMs = DEFAULT_TURN_TIMEOUT_MS) {
|
|
3
|
+
return async (text, print, model, abort) => {
|
|
4
|
+
const ctrl = abort ?? new AbortController();
|
|
5
|
+
let timedOut = false;
|
|
6
|
+
// Race the turn against a hard timeout so a hung SDK/upstream can never block the UI forever.
|
|
7
|
+
// We also abort the controller to try to stop the underlying work.
|
|
8
|
+
const timeout = new Promise((_, reject) => setTimeout(() => { timedOut = true; ctrl.abort(); reject(new Error("turn timeout")); }, timeoutMs));
|
|
9
|
+
try {
|
|
10
|
+
await Promise.race([runner(model ? { ...cfg, model } : cfg, text, print, ctrl), timeout]);
|
|
11
|
+
}
|
|
12
|
+
catch (err) {
|
|
13
|
+
if (timedOut) {
|
|
14
|
+
print(`⎿ no response after ${Math.round(timeoutMs / 1000)}s — gave up (try again or pick a different model)`);
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
if (ctrl.signal.aborted) {
|
|
18
|
+
print("⎿ interrupted");
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
print(`assistant error: ${err instanceof Error ? err.message : String(err)}`);
|
|
22
|
+
print("\n ↳ next: retry · /model to switch model · /doctor to check health · /report to file it");
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { query, tool, createSdkMcpServer } from "@anthropic-ai/claude-agent-sdk";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { buildActions } from "./tools.js";
|
|
4
|
+
import { formatModelList } from "../../shared/format.js";
|
|
5
|
+
const empty = z.object({});
|
|
6
|
+
const setupShape = z.object({
|
|
7
|
+
scope: z.enum(["global", "project"]).optional(),
|
|
8
|
+
model: z.string().optional(),
|
|
9
|
+
}).shape;
|
|
10
|
+
function sdkTools(actions, cfg) {
|
|
11
|
+
const tools = [
|
|
12
|
+
tool("get_status", "Get the proxy worker status and restart history", empty.shape, async () => ({ content: [{ type: "text", text: await actions.get_status({}) }] })),
|
|
13
|
+
tool("restart_worker", "Restart the proxy worker", empty.shape, async () => ({ content: [{ type: "text", text: await actions.restart_worker({}) }] })),
|
|
14
|
+
tool("run_doctor", "Run copilot-reverse health checks", empty.shape, async () => ({ content: [{ type: "text", text: await actions.run_doctor({}) }] })),
|
|
15
|
+
tool("recent_requests", "List recent proxied requests", empty.shape, async () => ({ content: [{ type: "text", text: await actions.recent_requests({}) }] })),
|
|
16
|
+
];
|
|
17
|
+
const listModels = cfg.listModels;
|
|
18
|
+
if (listModels) {
|
|
19
|
+
tools.push(tool("list_models", "List the Copilot models available through the proxy, with their context windows", empty.shape, async () => ({
|
|
20
|
+
content: [{ type: "text", text: formatModelList(await listModels(), cfg.modelLimits) }],
|
|
21
|
+
})));
|
|
22
|
+
}
|
|
23
|
+
const setupClient = cfg.setupClient;
|
|
24
|
+
if (setupClient) {
|
|
25
|
+
for (const client of ["claude", "codex"]) {
|
|
26
|
+
const label = client === "claude" ? "Claude Code" : "Codex";
|
|
27
|
+
tools.push(tool(`setup_${client}`, `Configure ${label} to use the copilot-reverse proxy (writes its config). scope defaults to "global" (all projects); model defaults to the current chat model.`, setupShape, async (args) => {
|
|
28
|
+
const scope = args.scope ?? "global";
|
|
29
|
+
const model = args.model ?? cfg.model;
|
|
30
|
+
const r = await setupClient(client, scope, model);
|
|
31
|
+
return { content: [{ type: "text", text: `configured ${label} (${scope}) with model ${model} — wrote ${r.path}; keys: ${r.changed.join(", ") || "(no change)"}` }] };
|
|
32
|
+
}));
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return tools;
|
|
36
|
+
}
|
|
37
|
+
// Runs one assistant turn, streaming assistant text to `print`. Pass an AbortController to make
|
|
38
|
+
// the turn interruptible (esc) — aborting ends the stream.
|
|
39
|
+
export async function runAssistantTurn(cfg, prompt, print, queryFn = query, abortController) {
|
|
40
|
+
// Dogfood: route the agent SDK through copilot-reverse's own Anthropic endpoint -> Copilot.
|
|
41
|
+
process.env.ANTHROPIC_BASE_URL = cfg.workerBaseUrl;
|
|
42
|
+
process.env.ANTHROPIC_API_KEY = cfg.apiKey;
|
|
43
|
+
// The bundled Claude Code engine assumes a ~200K window and only auto-compacts near it,
|
|
44
|
+
// but the routed Copilot model's real window is often far smaller -> a single turn can
|
|
45
|
+
// overflow and the upstream rejects with context_length_exceeded. Mirror agent-maestro:
|
|
46
|
+
// size auto-compaction to the model's real window, compact early, and drop the billing
|
|
47
|
+
// attribution header that breaks prompt caching on a non-Anthropic gateway.
|
|
48
|
+
const contextWindow = cfg.modelLimits?.[cfg.model] ?? cfg.maxInputTokens;
|
|
49
|
+
if (contextWindow)
|
|
50
|
+
process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW = String(contextWindow);
|
|
51
|
+
// 80% keeps the compaction trigger under the model's input budget even when the window above is
|
|
52
|
+
// the full context window (e.g. 1M ctx vs ~936K prompt budget on the 1M Claude models).
|
|
53
|
+
process.env.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE = process.env.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE ?? "80";
|
|
54
|
+
process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = "1";
|
|
55
|
+
process.env.CLAUDE_CODE_ATTRIBUTION_HEADER = "0";
|
|
56
|
+
const actions = buildActions(cfg.client);
|
|
57
|
+
const mcp = createSdkMcpServer({ name: "copilotReverse", tools: sdkTools(actions, cfg) });
|
|
58
|
+
const response = queryFn({
|
|
59
|
+
prompt,
|
|
60
|
+
options: {
|
|
61
|
+
model: cfg.model,
|
|
62
|
+
mcpServers: { copilotReverse: mcp },
|
|
63
|
+
// Keep the request small so a single turn never overflows a modest Copilot window:
|
|
64
|
+
// - tools: [] -> disable ALL built-in Claude Code tools (Bash/Task/Read/Edit/…), whose huge
|
|
65
|
+
// descriptions otherwise bloat every request and overflow the model -> Copilot 400. The
|
|
66
|
+
// copilot-reverse MCP tools (via mcpServers) remain available. (`allowedTools` only gates
|
|
67
|
+
// permission; `tools` is what actually removes them from the request.)
|
|
68
|
+
// - settingSources: [] -> do NOT load the cwd's CLAUDE.md / project memory / settings.
|
|
69
|
+
tools: [],
|
|
70
|
+
settingSources: [],
|
|
71
|
+
systemPrompt: "You are copilot-reverse's built-in assistant for the local Copilot proxy. Be concise. " +
|
|
72
|
+
"When the user expresses an intent you have a tool for, CALL THE TOOL instead of explaining. " +
|
|
73
|
+
"Tools: get_status, restart_worker, run_doctor, recent_requests, list_models (show available " +
|
|
74
|
+
"models + context windows), setup_claude / setup_codex (configure those clients to use the proxy). " +
|
|
75
|
+
"E.g. 'list models' -> call list_models; 'set up claude' -> call setup_claude.",
|
|
76
|
+
permissionMode: "bypassPermissions",
|
|
77
|
+
includePartialMessages: true,
|
|
78
|
+
...(abortController ? { abortController } : {}),
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
// Stream token-by-token from partial-message events for a live, SSE-like feel. Once any
|
|
82
|
+
// delta has streamed, skip the final complete text block so the answer isn't printed twice.
|
|
83
|
+
// If partial events never arrive (e.g. a stubbed transport), fall back to the full block.
|
|
84
|
+
let streamed = false;
|
|
85
|
+
for await (const message of response) {
|
|
86
|
+
if (message.type === "stream_event") {
|
|
87
|
+
const ev = message.event;
|
|
88
|
+
if (ev.type === "content_block_delta" && ev.delta.type === "text_delta") {
|
|
89
|
+
streamed = true;
|
|
90
|
+
print(ev.delta.text);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
else if (message.type === "assistant") {
|
|
94
|
+
for (const block of message.message.content) {
|
|
95
|
+
if (block.type === "text" && !streamed)
|
|
96
|
+
print(block.text);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
else if (message.type === "result" && message.subtype !== "success") {
|
|
100
|
+
// Don't swallow a failed turn: surface the SDK's terminal error subtype.
|
|
101
|
+
print(`assistant error: ${message.subtype}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Plain action handlers — wrapped as SDK tools in runtime.ts.
|
|
2
|
+
// Each takes a parsed-args object and returns a short text result for the model.
|
|
3
|
+
export function buildActions(client) {
|
|
4
|
+
return {
|
|
5
|
+
async get_status(_args) {
|
|
6
|
+
const s = await client.status();
|
|
7
|
+
return `worker is ${s.workerState}; ${s.restarts.length} restart event(s) recorded`;
|
|
8
|
+
},
|
|
9
|
+
async restart_worker(_args) {
|
|
10
|
+
await client.restart();
|
|
11
|
+
return "restart requested; worker is restarting";
|
|
12
|
+
},
|
|
13
|
+
async run_doctor(_args) {
|
|
14
|
+
const checks = await client.doctor();
|
|
15
|
+
return checks.map((c) => `${c.ok ? "OK" : "FAIL"} ${c.name}: ${c.detail}`).join("; ");
|
|
16
|
+
},
|
|
17
|
+
async recent_requests(_args) {
|
|
18
|
+
const reqs = await client.requests();
|
|
19
|
+
if (!reqs.length)
|
|
20
|
+
return "no requests logged yet";
|
|
21
|
+
return reqs.slice(0, 10).map((r) => `${r.endpoint} ${r.model} ${r.status} ${r.latencyMs}ms`).join("; ");
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { Box, Text, useInput } from "ink";
|
|
4
|
+
import { theme } from "../theme.js";
|
|
5
|
+
// Arrow-navigable single-select list with a bounded, scrolling window so long lists
|
|
6
|
+
// (e.g. the full Copilot model list) never overflow the terminal — the highlight stays
|
|
7
|
+
// visible as you navigate. Only one Select should be mounted at a time.
|
|
8
|
+
export function Select({ items, onSubmit, onCancel, windowSize = 8 }) {
|
|
9
|
+
const [idx, setIdx] = useState(0);
|
|
10
|
+
useInput((_input, key) => {
|
|
11
|
+
if (key.upArrow)
|
|
12
|
+
setIdx((i) => (i - 1 + items.length) % items.length);
|
|
13
|
+
else if (key.downArrow)
|
|
14
|
+
setIdx((i) => (i + 1) % items.length);
|
|
15
|
+
else if (key.return)
|
|
16
|
+
onSubmit(items[idx]);
|
|
17
|
+
else if (key.escape)
|
|
18
|
+
onCancel?.();
|
|
19
|
+
});
|
|
20
|
+
const n = items.length;
|
|
21
|
+
const w = Math.min(windowSize, n);
|
|
22
|
+
// keep the selected row inside the window
|
|
23
|
+
const start = Math.max(0, Math.min(idx - Math.floor(w / 2), n - w));
|
|
24
|
+
const visible = items.slice(start, start + w);
|
|
25
|
+
return (_jsxs(Box, { flexDirection: "column", children: [start > 0 && _jsxs(Text, { color: theme.muted, children: [" \u2191 ", start, " more"] }), visible.map((it, i) => {
|
|
26
|
+
const real = start + i;
|
|
27
|
+
const sel = real === idx;
|
|
28
|
+
return (_jsxs(Text, { color: sel ? theme.accent : theme.output, bold: sel, children: [sel ? "❯ " : " ", it.label] }, it.value));
|
|
29
|
+
}), start + w < n && _jsxs(Text, { color: theme.muted, children: [" \u2193 ", n - start - w, " more"] }), _jsxs(Text, { color: theme.muted, children: ["\u2191\u2193 select \u00B7 enter confirm \u00B7 esc cancel (", idx + 1, "/", n, ")"] })] }));
|
|
30
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export class DaemonClient {
|
|
2
|
+
base;
|
|
3
|
+
fetchFn;
|
|
4
|
+
constructor(base, fetchFn = fetch) {
|
|
5
|
+
this.base = base;
|
|
6
|
+
this.fetchFn = fetchFn;
|
|
7
|
+
}
|
|
8
|
+
async post(path) { await this.fetchFn(`${this.base}${path}`, { method: "POST" }); }
|
|
9
|
+
async status() { return (await (await this.fetchFn(`${this.base}/api/status`)).json()); }
|
|
10
|
+
async restart() { return this.post("/api/restart"); }
|
|
11
|
+
async stop() { return this.post("/api/stop"); }
|
|
12
|
+
async start() { return this.post("/api/start"); }
|
|
13
|
+
async doctor() { return (await (await this.fetchFn(`${this.base}/api/doctor`)).json()).checks; }
|
|
14
|
+
async requests() { return (await (await this.fetchFn(`${this.base}/api/requests`)).json()).requests; }
|
|
15
|
+
eventsUrl() { return `${this.base}/api/events`; }
|
|
16
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export function aggregate(samples) {
|
|
2
|
+
const map = new Map();
|
|
3
|
+
let errors = 0;
|
|
4
|
+
for (const s of samples) {
|
|
5
|
+
if (s.status >= 400)
|
|
6
|
+
errors++;
|
|
7
|
+
const m = map.get(s.model) ?? { count: 0, sum: 0 };
|
|
8
|
+
m.count++;
|
|
9
|
+
m.sum += s.latencyMs;
|
|
10
|
+
map.set(s.model, m);
|
|
11
|
+
}
|
|
12
|
+
return {
|
|
13
|
+
total: samples.length,
|
|
14
|
+
errors,
|
|
15
|
+
byModel: [...map.entries()].map(([model, v]) => ({ model, count: v.count, avgMs: Math.round(v.sum / v.count) })),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
// The failed requests (status >= 400), in the order given (callers pass newest-first), capped at `limit`.
|
|
19
|
+
// This is the actually-useful "log" — what failed and why — as opposed to worker restart events.
|
|
20
|
+
export function recentErrors(samples, limit) {
|
|
21
|
+
return samples.filter((s) => s.status >= 400).slice(0, limit);
|
|
22
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import { aggregate } from "./metrics-agg.js";
|
|
4
|
+
export function MetricsPanel({ samples }) {
|
|
5
|
+
const a = aggregate(samples);
|
|
6
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: ["requests: ", a.total, " errors: ", a.errors] }), a.byModel.map((r) => (_jsxs(Text, { children: [" ", r.model.padEnd(20), " n=", r.count, " avg=", r.avgMs, "ms"] }, r.model)))] }));
|
|
7
|
+
}
|
package/dist/tui/repl.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { Box, Text, useInput } from "ink";
|
|
4
|
+
import { theme } from "./theme.js";
|
|
5
|
+
// Input with a Claude-Code-style slash-command autocomplete dropdown.
|
|
6
|
+
// Editing is end-of-line (append/backspace) for reliability; arrows navigate the
|
|
7
|
+
// suggestion list, Tab completes the highlighted command, Enter submits.
|
|
8
|
+
export function Repl({ onSubmit, commands = [] }) {
|
|
9
|
+
const [value, setValue] = useState("");
|
|
10
|
+
const [sel, setSel] = useState(0);
|
|
11
|
+
const typingCommand = value.startsWith("/") && !value.includes(" ");
|
|
12
|
+
const matches = typingCommand ? commands.filter((c) => c.name.startsWith(value)).slice(0, 8) : [];
|
|
13
|
+
const selIdx = matches.length ? ((sel % matches.length) + matches.length) % matches.length : 0;
|
|
14
|
+
useInput((input, key) => {
|
|
15
|
+
if (key.return) {
|
|
16
|
+
// If the suggestion popup is open, Enter runs the highlighted command (Claude-Code style),
|
|
17
|
+
// not the raw prefix the user typed.
|
|
18
|
+
const line = matches.length ? matches[selIdx].name : value;
|
|
19
|
+
setValue("");
|
|
20
|
+
setSel(0);
|
|
21
|
+
if (line.trim())
|
|
22
|
+
onSubmit(line);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
if (key.tab && matches.length) {
|
|
26
|
+
setValue(matches[selIdx].name + " ");
|
|
27
|
+
setSel(0);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
if (matches.length && (key.upArrow || key.downArrow)) {
|
|
31
|
+
setSel((s) => s + (key.upArrow ? -1 : 1));
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (key.backspace || key.delete) {
|
|
35
|
+
setValue((v) => v.slice(0, -1));
|
|
36
|
+
setSel(0);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (input && !key.ctrl && !key.meta) {
|
|
40
|
+
setValue((v) => v + input);
|
|
41
|
+
setSel(0);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { borderStyle: "round", borderColor: theme.border, paddingX: 1, children: [_jsx(Text, { color: theme.prompt, children: "› " }), _jsx(Text, { children: value }), _jsx(Text, { inverse: true, children: " " }), value.length === 0 && _jsx(Text, { color: theme.muted, children: " type a message \u00B7 / for commands" })] }), matches.length > 0 && (_jsxs(Box, { flexDirection: "column", marginLeft: 2, children: [matches.map((c, i) => (_jsxs(Text, { children: [_jsx(Text, { color: i === selIdx ? theme.accent : theme.muted, children: i === selIdx ? "❯ " : " " }), _jsx(Text, { color: i === selIdx ? theme.accent : theme.output, bold: i === selIdx, children: c.name.padEnd(16) }), _jsx(Text, { color: theme.muted, children: c.describe })] }, c.name))), _jsx(Text, { color: theme.muted, children: " \u2191\u2193 navigate \u00B7 tab complete \u00B7 enter run" })] }))] }));
|
|
45
|
+
}
|