@hermit-org/cli 0.0.1-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +73 -0
- package/README.zh-CN.md +72 -0
- package/package.json +22 -0
- package/src/commands/index.ts +54 -0
- package/src/commands/pair.ts +95 -0
- package/src/commands/start/index.ts +247 -0
- package/src/index.ts +17 -0
- package/src/lib/config.ts +142 -0
- package/src/lib/gateway.test.ts +143 -0
- package/src/lib/gateway.ts +404 -0
- package/src/lib/pairing.ts +106 -0
- package/src/lib/qr.ts +44 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { readFile, writeFile, access, mkdir } from "node:fs/promises";
|
|
2
|
+
import { join, dirname } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Configuration schema for `hermit.config.json`.
|
|
7
|
+
*/
|
|
8
|
+
export interface HermitConfig {
|
|
9
|
+
/** Command that runs the local ACP agent. */
|
|
10
|
+
agent?: {
|
|
11
|
+
command: string;
|
|
12
|
+
args?: string[];
|
|
13
|
+
};
|
|
14
|
+
/** HTTP gateway settings. */
|
|
15
|
+
gateway?: {
|
|
16
|
+
port?: number;
|
|
17
|
+
hostname?: string;
|
|
18
|
+
endpoint?: string;
|
|
19
|
+
heartbeatInterval?: number;
|
|
20
|
+
cors?: boolean;
|
|
21
|
+
timeout?: number;
|
|
22
|
+
};
|
|
23
|
+
/** Pre-authorized bearer tokens for mobile clients. */
|
|
24
|
+
authorizedTokens?: string[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const DEFAULT_CONFIG: Required<HermitConfig> = {
|
|
28
|
+
agent: {
|
|
29
|
+
command: "npx",
|
|
30
|
+
args: ["codex", "--acp"],
|
|
31
|
+
},
|
|
32
|
+
gateway: {
|
|
33
|
+
port: 8787,
|
|
34
|
+
hostname: "0.0.0.0",
|
|
35
|
+
endpoint: "/",
|
|
36
|
+
heartbeatInterval: 30000,
|
|
37
|
+
cors: true,
|
|
38
|
+
timeout: 0,
|
|
39
|
+
},
|
|
40
|
+
authorizedTokens: [],
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const CONFIG_FILE_NAME = "hermit.config.json";
|
|
44
|
+
|
|
45
|
+
function mergeConfig(base: HermitConfig, override: HermitConfig): HermitConfig {
|
|
46
|
+
return {
|
|
47
|
+
agent:
|
|
48
|
+
override.agent
|
|
49
|
+
? {
|
|
50
|
+
command: override.agent.command ?? base.agent!.command,
|
|
51
|
+
args: override.agent.args ?? base.agent!.args,
|
|
52
|
+
}
|
|
53
|
+
: base.agent,
|
|
54
|
+
gateway:
|
|
55
|
+
override.gateway
|
|
56
|
+
? {
|
|
57
|
+
port: override.gateway.port ?? base.gateway!.port,
|
|
58
|
+
hostname: override.gateway.hostname ?? base.gateway!.hostname,
|
|
59
|
+
endpoint: override.gateway.endpoint ?? base.gateway!.endpoint,
|
|
60
|
+
heartbeatInterval:
|
|
61
|
+
override.gateway.heartbeatInterval ?? base.gateway!.heartbeatInterval,
|
|
62
|
+
cors: override.gateway.cors ?? base.gateway!.cors,
|
|
63
|
+
timeout: override.gateway.timeout ?? base.gateway!.timeout,
|
|
64
|
+
}
|
|
65
|
+
: base.gateway,
|
|
66
|
+
authorizedTokens: override.authorizedTokens ?? base.authorizedTokens,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Load the Hermit configuration from the current working directory, falling
|
|
72
|
+
* back to defaults when the file is absent or invalid.
|
|
73
|
+
*/
|
|
74
|
+
export async function loadConfig(
|
|
75
|
+
cwd: string = process.cwd(),
|
|
76
|
+
): Promise<HermitConfig> {
|
|
77
|
+
const path = join(cwd, CONFIG_FILE_NAME);
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
await access(path);
|
|
81
|
+
const text = await readFile(path, "utf-8");
|
|
82
|
+
const parsed = JSON.parse(text) as HermitConfig;
|
|
83
|
+
return mergeConfig(DEFAULT_CONFIG, parsed);
|
|
84
|
+
} catch (error) {
|
|
85
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
|
86
|
+
return DEFAULT_CONFIG;
|
|
87
|
+
}
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Write the configuration back to `hermit.config.json` in the given directory.
|
|
94
|
+
*/
|
|
95
|
+
export async function saveConfig(
|
|
96
|
+
config: HermitConfig,
|
|
97
|
+
cwd: string = process.cwd(),
|
|
98
|
+
): Promise<void> {
|
|
99
|
+
const path = join(cwd, CONFIG_FILE_NAME);
|
|
100
|
+
await writeFile(path, `${JSON.stringify(config, null, 2)}\n`, "utf-8");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Path to the Hermit data directory inside the user's home folder.
|
|
105
|
+
*/
|
|
106
|
+
export function getHermitDataDir(): string {
|
|
107
|
+
return join(homedir(), ".hermit");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Ensure the Hermit data directory exists.
|
|
112
|
+
*/
|
|
113
|
+
export async function ensureHermitDataDir(): Promise<void> {
|
|
114
|
+
const dir = getHermitDataDir();
|
|
115
|
+
await mkdir(dir, { recursive: true });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Read a JSON file from the Hermit data directory, returning `null` if absent.
|
|
120
|
+
*/
|
|
121
|
+
export async function readHermitJson<T>(name: string): Promise<T | null> {
|
|
122
|
+
const path = join(getHermitDataDir(), name);
|
|
123
|
+
try {
|
|
124
|
+
const text = await readFile(path, "utf-8");
|
|
125
|
+
return JSON.parse(text) as T;
|
|
126
|
+
} catch (error) {
|
|
127
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
throw error;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Write a JSON file to the Hermit data directory.
|
|
136
|
+
*/
|
|
137
|
+
export async function writeHermitJson<T>(name: string, data: T): Promise<void> {
|
|
138
|
+
const path = join(getHermitDataDir(), name);
|
|
139
|
+
await ensureHermitDataDir();
|
|
140
|
+
await mkdir(dirname(path), { recursive: true });
|
|
141
|
+
await writeFile(path, `${JSON.stringify(data, null, 2)}\n`, "utf-8");
|
|
142
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { request } from "node:http";
|
|
3
|
+
import { AcpGatewayServer } from "./gateway";
|
|
4
|
+
|
|
5
|
+
async function getFreePort(): Promise<number> {
|
|
6
|
+
return 10000 + Math.floor(Math.random() * 30000);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
async function post(url: string, body: string): Promise<Response> {
|
|
10
|
+
return fetch(url, {
|
|
11
|
+
method: "POST",
|
|
12
|
+
headers: { "Content-Type": "text/plain" },
|
|
13
|
+
body,
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function readSseUntil(
|
|
18
|
+
url: string,
|
|
19
|
+
predicate: (data: string) => boolean,
|
|
20
|
+
): Promise<string[]> {
|
|
21
|
+
return new Promise((resolve, reject) => {
|
|
22
|
+
const parsed = new URL(url);
|
|
23
|
+
const results: string[] = [];
|
|
24
|
+
|
|
25
|
+
const req = request(
|
|
26
|
+
{
|
|
27
|
+
hostname: parsed.hostname,
|
|
28
|
+
port: parsed.port,
|
|
29
|
+
path: parsed.pathname,
|
|
30
|
+
method: "POST",
|
|
31
|
+
},
|
|
32
|
+
(res) => {
|
|
33
|
+
let buffer = "";
|
|
34
|
+
res.setEncoding("utf-8");
|
|
35
|
+
res.on("data", (chunk: string) => {
|
|
36
|
+
buffer += chunk;
|
|
37
|
+
const parts = buffer.split("\n\n");
|
|
38
|
+
buffer = parts.pop() ?? "";
|
|
39
|
+
for (const part of parts) {
|
|
40
|
+
const match = part.match(/^data: (.*)$/m);
|
|
41
|
+
if (match) {
|
|
42
|
+
results.push(match[1]);
|
|
43
|
+
if (predicate(match[1])) {
|
|
44
|
+
req.destroy();
|
|
45
|
+
resolve(results);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
res.once("error", reject);
|
|
51
|
+
},
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
req.once("error", reject);
|
|
55
|
+
req.end();
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
describe("AcpGatewayServer", () => {
|
|
60
|
+
test("streams agent stdout emitted immediately after spawn", async () => {
|
|
61
|
+
const port = await getFreePort();
|
|
62
|
+
const server = new AcpGatewayServer({
|
|
63
|
+
command: "node",
|
|
64
|
+
args: ["-e", "console.log('startup'); setInterval(() => {}, 1000);"],
|
|
65
|
+
port,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const { url, stop } = await server.start();
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
const results = await readSseUntil(url, (data) => data === "startup");
|
|
72
|
+
expect(results).toContain("startup");
|
|
73
|
+
} finally {
|
|
74
|
+
await stop();
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("streams agent stdout over SSE and accepts stdin via /send", async () => {
|
|
79
|
+
const port = await getFreePort();
|
|
80
|
+
const server = new AcpGatewayServer({
|
|
81
|
+
command: "node",
|
|
82
|
+
args: [
|
|
83
|
+
"-e",
|
|
84
|
+
"process.stdin.on('data', d => process.stdout.write(d)); process.stdin.resume(); setInterval(() => {}, 1000);",
|
|
85
|
+
],
|
|
86
|
+
port,
|
|
87
|
+
sendEndpoint: "/send",
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const { url, stop } = await server.start();
|
|
91
|
+
const sendUrl = `${url.replace(/\/$/, "")}/send`;
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
const consumePromise = readSseUntil(url, (data) => data === "hello");
|
|
95
|
+
|
|
96
|
+
// Give the SSE connection a moment to establish.
|
|
97
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
98
|
+
|
|
99
|
+
const response = await post(sendUrl, "hello\n");
|
|
100
|
+
expect(response.status).toBe(200);
|
|
101
|
+
|
|
102
|
+
const results = await consumePromise;
|
|
103
|
+
expect(results).toContain("hello");
|
|
104
|
+
} finally {
|
|
105
|
+
await stop();
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("returns 404 for unmatched paths", async () => {
|
|
110
|
+
const port = await getFreePort();
|
|
111
|
+
const server = new AcpGatewayServer({
|
|
112
|
+
command: "node",
|
|
113
|
+
args: ["-e", "process.stdin.resume();"],
|
|
114
|
+
port,
|
|
115
|
+
});
|
|
116
|
+
const { url, stop } = await server.start();
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
const response = await fetch(`${url}/not-found`, { method: "POST" });
|
|
120
|
+
expect(response.status).toBe(404);
|
|
121
|
+
} finally {
|
|
122
|
+
await stop();
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("responds to CORS preflight requests", async () => {
|
|
127
|
+
const port = await getFreePort();
|
|
128
|
+
const server = new AcpGatewayServer({
|
|
129
|
+
command: "node",
|
|
130
|
+
args: ["-e", "process.stdin.resume();"],
|
|
131
|
+
port,
|
|
132
|
+
});
|
|
133
|
+
const { url, stop } = await server.start();
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
const response = await fetch(url, { method: "OPTIONS" });
|
|
137
|
+
expect(response.status).toBe(204);
|
|
138
|
+
expect(response.headers.get("access-control-allow-origin")).toBe("*");
|
|
139
|
+
} finally {
|
|
140
|
+
await stop();
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
});
|
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
import { createServer, type Server, type IncomingMessage, type ServerResponse } from "node:http";
|
|
2
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
3
|
+
import { createInterface } from "node:readline";
|
|
4
|
+
import { encodeSse, encodeSseKeepAlive } from "@hermit-org/stdio-to-sse";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Configuration for `AcpGatewayServer`.
|
|
8
|
+
*
|
|
9
|
+
* The gateway spawns a single long-lived child process and exposes two HTTP
|
|
10
|
+
* endpoints:
|
|
11
|
+
* - `GET/POST /` : Server-Sent Events stream of the process stdout.
|
|
12
|
+
* - `POST /send` : Writes the request body to the process stdin.
|
|
13
|
+
*
|
|
14
|
+
* This is protocol-agnostic at the transport level: it bridges bytes between
|
|
15
|
+
* stdio and SSE. The CLI adds ACP/MCP-specific routing, pairing, and auth.
|
|
16
|
+
*/
|
|
17
|
+
export interface ConnectionPayload {
|
|
18
|
+
url: string;
|
|
19
|
+
sendUrl: string;
|
|
20
|
+
token: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface AcpGatewayServerOptions {
|
|
24
|
+
command: string;
|
|
25
|
+
args?: string[];
|
|
26
|
+
port?: number;
|
|
27
|
+
hostname?: string;
|
|
28
|
+
endpoint?: string;
|
|
29
|
+
sendEndpoint?: string;
|
|
30
|
+
qrEndpoint?: string;
|
|
31
|
+
cors?: boolean;
|
|
32
|
+
heartbeatInterval?: number;
|
|
33
|
+
onRequest?: (
|
|
34
|
+
req: IncomingMessage,
|
|
35
|
+
res: ServerResponse,
|
|
36
|
+
) => boolean | Promise<boolean>;
|
|
37
|
+
getQrPayload?: () => ConnectionPayload | null | Promise<ConnectionPayload | null>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface AcpGatewayServerState {
|
|
41
|
+
url: string;
|
|
42
|
+
stop: () => Promise<void>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface ActiveConnection {
|
|
46
|
+
res: ServerResponse;
|
|
47
|
+
heartbeatId: ReturnType<typeof setInterval>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Gateway that exposes one local stdio process as a persistent SSE endpoint.
|
|
52
|
+
*
|
|
53
|
+
* Transport-layer only: no knowledge of ACP, MCP, JSON-RPC, or any specific
|
|
54
|
+
* protocol. The consumer is expected to send/receive line-delimited messages.
|
|
55
|
+
*/
|
|
56
|
+
export class AcpGatewayServer {
|
|
57
|
+
private server?: Server;
|
|
58
|
+
private proc?: ChildProcess;
|
|
59
|
+
private connections: Set<ActiveConnection> = new Set();
|
|
60
|
+
private stdinWriteQueue: Array<{
|
|
61
|
+
chunk: Buffer;
|
|
62
|
+
resolve: () => void;
|
|
63
|
+
reject: (error: Error) => void;
|
|
64
|
+
}> = [];
|
|
65
|
+
private stdinWriting = false;
|
|
66
|
+
private started = false;
|
|
67
|
+
|
|
68
|
+
constructor(private readonly options: AcpGatewayServerOptions) {}
|
|
69
|
+
|
|
70
|
+
async start(): Promise<AcpGatewayServerState> {
|
|
71
|
+
if (this.started) {
|
|
72
|
+
throw new Error("AcpGatewayServer is already started");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const {
|
|
76
|
+
command,
|
|
77
|
+
args = [],
|
|
78
|
+
port = 8080,
|
|
79
|
+
hostname = "0.0.0.0",
|
|
80
|
+
endpoint = "/",
|
|
81
|
+
sendEndpoint = "/send",
|
|
82
|
+
qrEndpoint = "/qr",
|
|
83
|
+
cors = true,
|
|
84
|
+
heartbeatInterval = 30000,
|
|
85
|
+
} = this.options;
|
|
86
|
+
|
|
87
|
+
const normalizedEndpoint = endpoint === "/" ? "/" : endpoint.replace(/\/$/, "");
|
|
88
|
+
const normalizedSendEndpoint = sendEndpoint.replace(/\/$/, "");
|
|
89
|
+
const normalizedQrEndpoint = qrEndpoint.replace(/\/$/, "");
|
|
90
|
+
|
|
91
|
+
this.started = true;
|
|
92
|
+
|
|
93
|
+
return new Promise((resolve, reject) => {
|
|
94
|
+
this.server = createServer(async (req, res) => {
|
|
95
|
+
if (this.options.onRequest) {
|
|
96
|
+
try {
|
|
97
|
+
const handled = await this.options.onRequest(req, res);
|
|
98
|
+
if (handled) return;
|
|
99
|
+
} catch (error) {
|
|
100
|
+
if (!res.headersSent) {
|
|
101
|
+
res.writeHead(500);
|
|
102
|
+
res.end(error instanceof Error ? error.message : "Internal Server Error");
|
|
103
|
+
}
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
if (cors && req.method === "OPTIONS") {
|
|
110
|
+
res.writeHead(204, {
|
|
111
|
+
"Access-Control-Allow-Origin": "*",
|
|
112
|
+
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
|
113
|
+
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
|
114
|
+
});
|
|
115
|
+
res.end();
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if ((req.method === "GET" || req.method === "POST") && req.url === normalizedEndpoint) {
|
|
120
|
+
this.handleSseRequest(req, res, cors, heartbeatInterval);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (req.method === "POST" && req.url === normalizedSendEndpoint) {
|
|
125
|
+
await this.handleSendRequest(req, res, cors);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (req.method === "GET" && req.url === normalizedQrEndpoint) {
|
|
130
|
+
await this.handleQrRequest(req, res, cors);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
res.writeHead(404);
|
|
135
|
+
res.end("Not Found");
|
|
136
|
+
} catch (error) {
|
|
137
|
+
if (!res.headersSent) {
|
|
138
|
+
res.writeHead(500);
|
|
139
|
+
res.end(error instanceof Error ? error.message : "Internal Server Error");
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
this.server.once("error", reject);
|
|
145
|
+
|
|
146
|
+
this.server.listen(port, hostname, () => {
|
|
147
|
+
this.server!.removeListener("error", reject);
|
|
148
|
+
this.spawnAgent(command, args);
|
|
149
|
+
|
|
150
|
+
const displayEndpoint = normalizedEndpoint === "/" ? "" : normalizedEndpoint;
|
|
151
|
+
const host = hostname === "0.0.0.0" ? "localhost" : hostname;
|
|
152
|
+
const url = `http://${host}:${port}${displayEndpoint}`;
|
|
153
|
+
|
|
154
|
+
resolve({
|
|
155
|
+
url,
|
|
156
|
+
stop: () => this.stop(),
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private spawnAgent(command: string, args: string[]): void {
|
|
163
|
+
this.proc = spawn(command, args, {
|
|
164
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
this.proc.once("error", (error) => {
|
|
168
|
+
this.broadcastError(error.message);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
this.proc.once("exit", (code, signal) => {
|
|
172
|
+
this.broadcastError(
|
|
173
|
+
signal
|
|
174
|
+
? `Agent process exited with signal ${signal}`
|
|
175
|
+
: `Agent process exited with code ${code ?? "unknown"}`,
|
|
176
|
+
);
|
|
177
|
+
this.closeAllConnections();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
const rl = createInterface({
|
|
181
|
+
input: this.proc.stdout!,
|
|
182
|
+
crlfDelay: Infinity,
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
rl.on("line", (line) => {
|
|
186
|
+
this.broadcast(line);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
rl.once("close", () => {
|
|
190
|
+
this.closeAllConnections();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
this.proc.stderr!.on("data", (chunk: Buffer) => {
|
|
194
|
+
const text = chunk.toString("utf-8").trimEnd();
|
|
195
|
+
if (text) {
|
|
196
|
+
for (const line of text.split("\n")) {
|
|
197
|
+
if (line.trim()) {
|
|
198
|
+
this.broadcastError(line);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private handleSseRequest(
|
|
206
|
+
_req: IncomingMessage,
|
|
207
|
+
res: ServerResponse,
|
|
208
|
+
cors: boolean,
|
|
209
|
+
heartbeatInterval: number,
|
|
210
|
+
): void {
|
|
211
|
+
const headers: Record<string, string | string[]> = {
|
|
212
|
+
"Content-Type": "text/event-stream",
|
|
213
|
+
"Cache-Control": "no-cache",
|
|
214
|
+
Connection: "keep-alive",
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
if (cors) {
|
|
218
|
+
headers["Access-Control-Allow-Origin"] = "*";
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
res.writeHead(200, headers);
|
|
222
|
+
|
|
223
|
+
const heartbeatId = setInterval(() => {
|
|
224
|
+
if (!res.writableEnded) {
|
|
225
|
+
res.write(encodeSseKeepAlive());
|
|
226
|
+
}
|
|
227
|
+
}, heartbeatInterval);
|
|
228
|
+
|
|
229
|
+
const connection: ActiveConnection = { res, heartbeatId };
|
|
230
|
+
this.connections.add(connection);
|
|
231
|
+
|
|
232
|
+
res.once("close", () => {
|
|
233
|
+
this.removeConnection(connection);
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
private async handleSendRequest(
|
|
238
|
+
req: IncomingMessage,
|
|
239
|
+
res: ServerResponse,
|
|
240
|
+
cors: boolean,
|
|
241
|
+
): Promise<void> {
|
|
242
|
+
if (!this.proc || this.proc.killed || !this.proc.stdin || this.proc.stdin.destroyed) {
|
|
243
|
+
this.sendJson(res, 503, { ok: false, error: "Agent process is not running" }, cors);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const body = await readRequestBody(req);
|
|
248
|
+
await this.writeToStdin(body);
|
|
249
|
+
|
|
250
|
+
this.sendJson(res, 200, { ok: true }, cors);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
private async handleQrRequest(
|
|
254
|
+
_req: IncomingMessage,
|
|
255
|
+
res: ServerResponse,
|
|
256
|
+
cors: boolean,
|
|
257
|
+
): Promise<void> {
|
|
258
|
+
if (!this.options.getQrPayload) {
|
|
259
|
+
this.sendJson(res, 404, { ok: false, error: "QR payload not configured" }, cors);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const payload = await Promise.resolve(this.options.getQrPayload());
|
|
264
|
+
if (!payload) {
|
|
265
|
+
this.sendJson(res, 404, { ok: false, error: "QR payload not available" }, cors);
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const { generateQrBuffer } = await import("./qr");
|
|
270
|
+
const buffer = await generateQrBuffer(payload);
|
|
271
|
+
|
|
272
|
+
const headers: Record<string, string> = {
|
|
273
|
+
"Content-Type": "image/png",
|
|
274
|
+
"Content-Length": String(buffer.length),
|
|
275
|
+
"Cache-Control": "no-store",
|
|
276
|
+
};
|
|
277
|
+
if (cors) {
|
|
278
|
+
headers["Access-Control-Allow-Origin"] = "*";
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
res.writeHead(200, headers);
|
|
282
|
+
res.end(buffer);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
private writeToStdin(chunk: Buffer): Promise<void> {
|
|
286
|
+
return new Promise((resolve, reject) => {
|
|
287
|
+
this.stdinWriteQueue.push({ chunk, resolve, reject });
|
|
288
|
+
this.flushStdinQueue().catch(reject);
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
private async flushStdinQueue(): Promise<void> {
|
|
293
|
+
if (this.stdinWriting || this.stdinWriteQueue.length === 0) return;
|
|
294
|
+
|
|
295
|
+
this.stdinWriting = true;
|
|
296
|
+
|
|
297
|
+
while (this.stdinWriteQueue.length > 0) {
|
|
298
|
+
const { chunk, resolve, reject } = this.stdinWriteQueue.shift()!;
|
|
299
|
+
|
|
300
|
+
try {
|
|
301
|
+
await new Promise<void>((writeResolve, writeReject) => {
|
|
302
|
+
if (!this.proc || !this.proc.stdin || this.proc.stdin.destroyed) {
|
|
303
|
+
writeReject(new Error("Agent stdin is not available"));
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const canContinue = this.proc.stdin.write(chunk, (error) => {
|
|
308
|
+
if (error) writeReject(error);
|
|
309
|
+
else writeResolve();
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
if (!canContinue) {
|
|
313
|
+
this.proc.stdin.once("drain", writeResolve);
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
resolve();
|
|
317
|
+
} catch (error) {
|
|
318
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
this.stdinWriting = false;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
private broadcast(payload: string): void {
|
|
326
|
+
const frame = encodeSse(payload);
|
|
327
|
+
for (const connection of this.connections) {
|
|
328
|
+
if (!connection.res.writableEnded) {
|
|
329
|
+
connection.res.write(frame);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
private broadcastError(message: string): void {
|
|
335
|
+
const frame = encodeSse(message, { event: "error" });
|
|
336
|
+
for (const connection of this.connections) {
|
|
337
|
+
if (!connection.res.writableEnded) {
|
|
338
|
+
connection.res.write(frame);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
private removeConnection(connection: ActiveConnection): void {
|
|
344
|
+
this.connections.delete(connection);
|
|
345
|
+
clearInterval(connection.heartbeatId);
|
|
346
|
+
if (!connection.res.writableEnded) {
|
|
347
|
+
connection.res.end();
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
private closeAllConnections(): void {
|
|
352
|
+
for (const connection of this.connections) {
|
|
353
|
+
this.removeConnection(connection);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
private sendJson(
|
|
358
|
+
res: ServerResponse,
|
|
359
|
+
status: number,
|
|
360
|
+
payload: unknown,
|
|
361
|
+
cors: boolean,
|
|
362
|
+
): void {
|
|
363
|
+
const headers: Record<string, string> = {
|
|
364
|
+
"Content-Type": "application/json",
|
|
365
|
+
};
|
|
366
|
+
if (cors) {
|
|
367
|
+
headers["Access-Control-Allow-Origin"] = "*";
|
|
368
|
+
}
|
|
369
|
+
res.writeHead(status, headers);
|
|
370
|
+
res.end(JSON.stringify(payload));
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
private async stop(): Promise<void> {
|
|
374
|
+
this.closeAllConnections();
|
|
375
|
+
|
|
376
|
+
if (this.proc && !this.proc.killed) {
|
|
377
|
+
this.proc.kill();
|
|
378
|
+
await new Promise<void>((resolve) => {
|
|
379
|
+
const timeout = setTimeout(() => {
|
|
380
|
+
this.proc?.kill("SIGKILL");
|
|
381
|
+
resolve();
|
|
382
|
+
}, 5000);
|
|
383
|
+
this.proc?.once("exit", () => {
|
|
384
|
+
clearTimeout(timeout);
|
|
385
|
+
resolve();
|
|
386
|
+
});
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
return new Promise((resolve) => {
|
|
391
|
+
this.server?.close(() => resolve());
|
|
392
|
+
this.server?.closeAllConnections?.();
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function readRequestBody(req: IncomingMessage): Promise<Buffer> {
|
|
398
|
+
return new Promise((resolve, reject) => {
|
|
399
|
+
const chunks: Buffer[] = [];
|
|
400
|
+
req.on("data", (chunk: Buffer) => chunks.push(chunk));
|
|
401
|
+
req.on("end", () => resolve(Buffer.concat(chunks)));
|
|
402
|
+
req.on("error", reject);
|
|
403
|
+
});
|
|
404
|
+
}
|