oh-my-agent-web 0.2.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 +484 -0
- package/bin/oh-my-agent-web-ps.mjs +203 -0
- package/bin/oh-my-agent-web-ws.mjs +593 -0
- package/bin/oh-my-agent-web.mjs +321 -0
- package/bin/update-check.mjs +142 -0
- package/dist/server/codex-client.js +310 -0
- package/dist/server/codex-supervisor.js +446 -0
- package/dist/server/fs-service.js +144 -0
- package/dist/server/index.js +543 -0
- package/dist/server/request-inspector.js +458 -0
- package/dist/shared/protocol.js +112 -0
- package/package.json +82 -0
- package/scripts/manage-server.mjs +155 -0
- package/scripts/omaw.mjs +74 -0
- package/shared/protocol.ts +228 -0
- package/web/dist/assets/index-B8-VzDf2.css +1 -0
- package/web/dist/assets/index-DeC2eiiq.js +42 -0
- package/web/dist/assets/markdown-CYXe6JZx.js +55 -0
- package/web/dist/favicon.svg +12 -0
- package/web/dist/index.html +35 -0
|
@@ -0,0 +1,543 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* oh-my-agent-web server — express + WebSocket hub in front of the codex app-server.
|
|
3
|
+
*
|
|
4
|
+
* browser <--ws /ws--> [this] <--ws--> codex app-server (ws://127.0.0.1:25258)
|
|
5
|
+
*
|
|
6
|
+
* 我们是「透明 JSON-RPC 代理 + 进程守护」:
|
|
7
|
+
* - 浏览器可以按名字调用任意 codex 方法;所有 codex 通知原样广播。
|
|
8
|
+
* - `omaw/*` 是本地方法(状态 / 日志 / 文件系统 / 路径),由本进程处理。
|
|
9
|
+
* - codex 反向请求(审批 / user input / 动态工具)广播给所有标签页,谁先 reply 谁赢,
|
|
10
|
+
* 随后广播 `serverRequest/resolved` 让其他标签页关掉弹窗。
|
|
11
|
+
* - 同一 `dist/server/index.js` 既能被 CLI `startServer()` 调用,也能直接 `node` 运行。
|
|
12
|
+
*/
|
|
13
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
14
|
+
import { createServer } from "node:http";
|
|
15
|
+
import { homedir } from "node:os";
|
|
16
|
+
import { dirname, join, resolve } from "node:path";
|
|
17
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
18
|
+
import express from "express";
|
|
19
|
+
import { WebSocket, WebSocketServer } from "ws";
|
|
20
|
+
import { CodexClient } from "./codex-client.js";
|
|
21
|
+
import { CodexSupervisor } from "./codex-supervisor.js";
|
|
22
|
+
import { RequestInspector } from "./request-inspector.js";
|
|
23
|
+
import { FsError, listDirectory, paths as resolvePaths, readTextFile } from "./fs-service.js";
|
|
24
|
+
import { LOCAL_METHODS, PROTOCOL_VERSION, } from "../shared/protocol.js";
|
|
25
|
+
/* ------------------------------------------------------------------ */
|
|
26
|
+
/* config */
|
|
27
|
+
/* ------------------------------------------------------------------ */
|
|
28
|
+
const DEFAULT_OMAW_PORT = 25257;
|
|
29
|
+
const DEFAULT_OMAW_HOST = "127.0.0.1";
|
|
30
|
+
const DEFAULT_CODEX_PORT = 25258;
|
|
31
|
+
const DEFAULT_CODEX_BIN = "codex";
|
|
32
|
+
const HEARTBEAT_MS = 30_000;
|
|
33
|
+
const SERVER_REQUEST_HISTORY = 2_000;
|
|
34
|
+
/**
|
|
35
|
+
* How long a proxied `codex/*` call waits for the app-server before giving up
|
|
36
|
+
* with `-32001`. Long enough to cover a cold `codex app-server` boot (and a
|
|
37
|
+
* reconnect after a crash), short enough that a genuinely dead server still
|
|
38
|
+
* reports an error quickly.
|
|
39
|
+
*/
|
|
40
|
+
const CODEX_READY_WAIT_MS = 20_000;
|
|
41
|
+
/**
|
|
42
|
+
* `OMAW_DEV_WEB=1` —— dev 时前端不经 `web/dist`,而是把 Vite **挂在同一个进程里**
|
|
43
|
+
* 当中间件(middleware mode)。
|
|
44
|
+
*
|
|
45
|
+
* 这样对外只有一个源、一个端口:`http://127.0.0.1:25257`。页面由 Vite 现编
|
|
46
|
+
* (源码 + HMR,不用 build),`/ws` 与页面同源,也没有第二个端口要记、要开、
|
|
47
|
+
* 或者要配置跨源白名单。
|
|
48
|
+
*/
|
|
49
|
+
const DEV_SOURCE = process.env.OMAW_DEV_WEB === "1";
|
|
50
|
+
/**
|
|
51
|
+
* Vite 的 HMR 通道挂在同一个 http server 上,得给个专属路径,
|
|
52
|
+
* 否则会和下面 `/ws` 的 upgrade 拦截器撞车(非 /ws 一律 destroy)。
|
|
53
|
+
*/
|
|
54
|
+
const DEV_HMR_PATH = "/omaw-hmr";
|
|
55
|
+
/* ------------------------------------------------------------------ */
|
|
56
|
+
/* package metadata */
|
|
57
|
+
/* ------------------------------------------------------------------ */
|
|
58
|
+
/** 从当前文件向上找 package.json —— 兼容 tsx(src) 与 dist 两种目录深度。 */
|
|
59
|
+
function findPackageRoot(startDir) {
|
|
60
|
+
let dir = startDir;
|
|
61
|
+
for (let i = 0; i < 8; i += 1) {
|
|
62
|
+
if (existsSync(join(dir, "package.json")))
|
|
63
|
+
return dir;
|
|
64
|
+
const parent = dirname(dir);
|
|
65
|
+
if (parent === dir)
|
|
66
|
+
break;
|
|
67
|
+
dir = parent;
|
|
68
|
+
}
|
|
69
|
+
return startDir;
|
|
70
|
+
}
|
|
71
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
72
|
+
const PACKAGE_ROOT = findPackageRoot(__dirname);
|
|
73
|
+
function readOwnVersion() {
|
|
74
|
+
try {
|
|
75
|
+
const pkg = JSON.parse(readFileSync(join(PACKAGE_ROOT, "package.json"), "utf8"));
|
|
76
|
+
return pkg.version ?? "0.0.0";
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return "0.0.0";
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function codexVersionFromHandshake(userAgent) {
|
|
83
|
+
if (!userAgent)
|
|
84
|
+
return null;
|
|
85
|
+
// 形如 `codex-cli 0.156.1` 或 `codex/0.156.1 ...`。
|
|
86
|
+
const match = /(\d+\.\d+\.\d+(?:[-+][\w.]+)?)/.exec(userAgent);
|
|
87
|
+
return match ? match[1] : userAgent;
|
|
88
|
+
}
|
|
89
|
+
/* ------------------------------------------------------------------ */
|
|
90
|
+
/* origin admission */
|
|
91
|
+
/* ------------------------------------------------------------------ */
|
|
92
|
+
function parseAllowOrigins() {
|
|
93
|
+
return (process.env.OMAW_ALLOW_ORIGINS ?? "")
|
|
94
|
+
.split(",")
|
|
95
|
+
.map((s) => s.trim().toLowerCase())
|
|
96
|
+
.filter(Boolean);
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* 浏览器 upgrade 的 Origin 校验:
|
|
100
|
+
* - 无 Origin(curl / 脚本 / ws 测试)放行;
|
|
101
|
+
* - 设置了 OMAW_ALLOW_ORIGINS 就按白名单;
|
|
102
|
+
* - 否则放行同源,以及 localhost / 127.0.0.1 / ::1 的任意端口(覆盖 Vite dev server)。
|
|
103
|
+
*/
|
|
104
|
+
function originAllowed(req) {
|
|
105
|
+
const origin = req.headers.origin;
|
|
106
|
+
if (typeof origin !== "string" || !origin)
|
|
107
|
+
return true;
|
|
108
|
+
const lower = origin.toLowerCase();
|
|
109
|
+
const allow = parseAllowOrigins();
|
|
110
|
+
if (allow.length > 0)
|
|
111
|
+
return allow.includes(lower);
|
|
112
|
+
if (lower === "null")
|
|
113
|
+
return false;
|
|
114
|
+
let url;
|
|
115
|
+
try {
|
|
116
|
+
url = new URL(origin);
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
const hostHeader = req.headers.host;
|
|
122
|
+
if (typeof hostHeader === "string" && hostHeader) {
|
|
123
|
+
try {
|
|
124
|
+
const hostUrl = new URL(`http://${hostHeader}`);
|
|
125
|
+
if (url.hostname === hostUrl.hostname && url.port === hostUrl.port)
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
/* ignore */
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const hn = url.hostname.toLowerCase();
|
|
133
|
+
return hn === "localhost" || hn === "127.0.0.1" || hn === "::1" || hn === "[::1]";
|
|
134
|
+
}
|
|
135
|
+
/* ------------------------------------------------------------------ */
|
|
136
|
+
/* helpers */
|
|
137
|
+
/* ------------------------------------------------------------------ */
|
|
138
|
+
function isRecord(value) {
|
|
139
|
+
return typeof value === "object" && value !== null;
|
|
140
|
+
}
|
|
141
|
+
function asJsonRpcError(err) {
|
|
142
|
+
if (err instanceof FsError)
|
|
143
|
+
return { code: err.code, message: err.message };
|
|
144
|
+
if (isRecord(err)) {
|
|
145
|
+
return {
|
|
146
|
+
code: typeof err.code === "number" ? err.code : -32000,
|
|
147
|
+
message: typeof err.message === "string" ? err.message : "internal error",
|
|
148
|
+
data: err.data,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
return { code: -32000, message: err instanceof Error ? err.message : String(err) };
|
|
152
|
+
}
|
|
153
|
+
/* ------------------------------------------------------------------ */
|
|
154
|
+
/* startServer */
|
|
155
|
+
/* ------------------------------------------------------------------ */
|
|
156
|
+
export async function startServer(options = {}) {
|
|
157
|
+
const version = readOwnVersion();
|
|
158
|
+
const cwd = resolve(options.cwd ?? process.env.OMAW_CWD ?? process.cwd());
|
|
159
|
+
const port = options.port ?? Number(process.env.OMAW_PORT ?? DEFAULT_OMAW_PORT);
|
|
160
|
+
const host = options.host ?? process.env.OMAW_HOST ?? DEFAULT_OMAW_HOST;
|
|
161
|
+
const codexPort = options.codexPort ?? Number(process.env.OMAW_CODEX_PORT ?? DEFAULT_CODEX_PORT);
|
|
162
|
+
const codexBin = options.codexBin ?? process.env.OMAW_CODEX_BIN ?? DEFAULT_CODEX_BIN;
|
|
163
|
+
const log = options.quiet
|
|
164
|
+
? () => undefined
|
|
165
|
+
: (...args) => {
|
|
166
|
+
console.log("[oh-my-agent-web]", ...args);
|
|
167
|
+
};
|
|
168
|
+
/* -------- supervisor + client (capture/inspector off by default) -------- */
|
|
169
|
+
// 抓包(loopback Responses 代理)默认关闭:只有显式设置 OMAW_REQUEST_INSPECTOR=1
|
|
170
|
+
// 才会启动 inspector,也才会给 app-server 注入 chatgpt_base_url /
|
|
171
|
+
// model_providers.capture.base_url 这两个 -c 覆盖。默认一个都不加,codex 走自己的配置。
|
|
172
|
+
const inspector = new RequestInspector({ dataDir: join(PACKAGE_ROOT, "data") });
|
|
173
|
+
let inspectorUrl = "";
|
|
174
|
+
if (process.env.OMAW_REQUEST_INSPECTOR === "1") {
|
|
175
|
+
try {
|
|
176
|
+
inspectorUrl = await inspector.start();
|
|
177
|
+
log(`request inspector: ${inspectorUrl} -> ${process.env.OMAW_CODEX_UPSTREAM ?? "https://chatgpt.com/backend-api/codex"}`);
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
log(`request inspector disabled: ${error instanceof Error ? error.message : String(error)}`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
const supervisor = new CodexSupervisor({ port: codexPort, bin: codexBin, cwd, codexBaseUrl: inspectorUrl || undefined });
|
|
184
|
+
const client = new CodexClient(supervisor.url, version);
|
|
185
|
+
supervisor.onLog((line) => {
|
|
186
|
+
if (!options.quiet)
|
|
187
|
+
console.log("[codex]", line);
|
|
188
|
+
});
|
|
189
|
+
await supervisor.start();
|
|
190
|
+
void client.connect();
|
|
191
|
+
/* -------- HTTP app -------- */
|
|
192
|
+
const app = express();
|
|
193
|
+
app.disable("x-powered-by");
|
|
194
|
+
// 先建 http server:Vite 的 HMR 要挂到它上面(见下面的 middleware mode)。
|
|
195
|
+
// 路由按注册顺序匹配,所以这里提前 createServer 不影响 /api 的优先级。
|
|
196
|
+
const httpServer = createServer(app);
|
|
197
|
+
app.get("/api/health", (_req, res) => {
|
|
198
|
+
const body = { ok: true, codex: supervisor.status(), version };
|
|
199
|
+
res.json(body);
|
|
200
|
+
});
|
|
201
|
+
app.get("/api/version", (_req, res) => {
|
|
202
|
+
const hs = client.handshake();
|
|
203
|
+
res.json({
|
|
204
|
+
name: "oh-my-agent-web",
|
|
205
|
+
version,
|
|
206
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
207
|
+
codexVersion: codexVersionFromHandshake(hs?.userAgent),
|
|
208
|
+
codexUserAgent: hs?.userAgent ?? null,
|
|
209
|
+
codexUrl: supervisor.url,
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
// 前端:dev 把 Vite 当中间件挂进本进程(源码 + HMR);生产发 web/dist。
|
|
213
|
+
// 两种情况下对外都只有 http://127.0.0.1:<port> 一个源,页面与 /ws 同源。
|
|
214
|
+
if (DEV_SOURCE) {
|
|
215
|
+
// devDependency,且只在源码模式下加载。
|
|
216
|
+
const { createServer: createViteServer } = await import("vite");
|
|
217
|
+
const vite = await createViteServer({
|
|
218
|
+
configFile: join(PACKAGE_ROOT, "web", "vite.config.ts"),
|
|
219
|
+
// spa:index.html 的转换 + history fallback 都交给 Vite,
|
|
220
|
+
// 替代生产分支里那段 express 的 SPA fallback。
|
|
221
|
+
appType: "spa",
|
|
222
|
+
server: { middlewareMode: true, hmr: { server: httpServer, path: DEV_HMR_PATH } },
|
|
223
|
+
});
|
|
224
|
+
app.use(vite.middlewares);
|
|
225
|
+
httpServer.once("close", () => void vite.close());
|
|
226
|
+
log(`serving web UI from source via Vite (middleware mode, hmr=${DEV_HMR_PATH}, no build needed)`);
|
|
227
|
+
}
|
|
228
|
+
else {
|
|
229
|
+
const webDist = join(PACKAGE_ROOT, "web", "dist");
|
|
230
|
+
if (existsSync(webDist)) {
|
|
231
|
+
app.use(express.static(webDist));
|
|
232
|
+
app.get(/^\/(?!api\/|ws).*/, (_req, res) => {
|
|
233
|
+
res.sendFile(join(webDist, "index.html"));
|
|
234
|
+
});
|
|
235
|
+
log(`serving web UI from ${webDist}`);
|
|
236
|
+
}
|
|
237
|
+
else {
|
|
238
|
+
log(`web/dist not found — dev 直接跑 \`npm run dev\`(源码模式,无需 build),生产先 \`npm run build\``);
|
|
239
|
+
app.get(/^\/(?!api\/|ws).*/, (_req, res) => {
|
|
240
|
+
res.status(404).type("text/plain").send("web/dist 不存在。dev 请跑 npm run dev(源码模式,无需 build);生产请先 npm run build。");
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
/* -------- WebSocket hub -------- */
|
|
245
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
246
|
+
const browsers = new Set();
|
|
247
|
+
const send = (ws, msg) => {
|
|
248
|
+
if (ws.readyState !== WebSocket.OPEN)
|
|
249
|
+
return;
|
|
250
|
+
try {
|
|
251
|
+
ws.send(JSON.stringify(msg));
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
/* ignore */
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
const broadcast = (msg) => {
|
|
258
|
+
for (const ws of browsers)
|
|
259
|
+
send(ws, msg);
|
|
260
|
+
};
|
|
261
|
+
const broadcastStatus = () => {
|
|
262
|
+
broadcast({ type: "status", codex: supervisor.status() });
|
|
263
|
+
};
|
|
264
|
+
let prevPhase = supervisor.status().phase;
|
|
265
|
+
supervisor.onStatus((status) => {
|
|
266
|
+
broadcastStatus();
|
|
267
|
+
// supervisor 重新就绪后主动拉一次 codex 连接,避免等满 client 自己的退避。
|
|
268
|
+
if (status.phase === "ready" && prevPhase !== "ready" && !client.isReady())
|
|
269
|
+
client.reconnect();
|
|
270
|
+
prevPhase = status.phase;
|
|
271
|
+
});
|
|
272
|
+
client.on("state", (state) => {
|
|
273
|
+
supervisor.setConnected(state.connected, state.error);
|
|
274
|
+
});
|
|
275
|
+
client.on("notification", (evt) => {
|
|
276
|
+
broadcast({ type: "event", method: evt.method, params: evt.params });
|
|
277
|
+
});
|
|
278
|
+
// codex 反向请求:广播给所有浏览器,记录待答复集合用于去重。
|
|
279
|
+
const pendingServerRequests = new Map();
|
|
280
|
+
const answeredServerRequests = new Set();
|
|
281
|
+
client.on("serverRequest", (evt) => {
|
|
282
|
+
const key = String(evt.id);
|
|
283
|
+
const threadId = isRecord(evt.params) && typeof evt.params.threadId === "string" ? evt.params.threadId : null;
|
|
284
|
+
pendingServerRequests.set(key, { method: evt.method, threadId });
|
|
285
|
+
broadcast({ type: "serverRequest", id: evt.id, method: evt.method, params: evt.params });
|
|
286
|
+
});
|
|
287
|
+
client.on("state", (state) => {
|
|
288
|
+
// 断线后 codex 可能复用 request id,清掉去重历史避免误吞新请求。
|
|
289
|
+
if (!state.connected) {
|
|
290
|
+
pendingServerRequests.clear();
|
|
291
|
+
answeredServerRequests.clear();
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
function rememberAnswered(key) {
|
|
295
|
+
answeredServerRequests.add(key);
|
|
296
|
+
if (answeredServerRequests.size > SERVER_REQUEST_HISTORY) {
|
|
297
|
+
const first = answeredServerRequests.values().next().value;
|
|
298
|
+
if (first !== undefined)
|
|
299
|
+
answeredServerRequests.delete(first);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
/* -------- local `omaw/*` methods -------- */
|
|
303
|
+
async function handleLocal(method, params) {
|
|
304
|
+
const p = isRecord(params) ? params : {};
|
|
305
|
+
switch (method) {
|
|
306
|
+
case LOCAL_METHODS.codexStatus:
|
|
307
|
+
return { status: supervisor.status() };
|
|
308
|
+
case LOCAL_METHODS.codexRestart:
|
|
309
|
+
await supervisor.restart();
|
|
310
|
+
client.reconnect();
|
|
311
|
+
return { ok: true };
|
|
312
|
+
case LOCAL_METHODS.codexLog:
|
|
313
|
+
return { lines: supervisor.logLines() };
|
|
314
|
+
case LOCAL_METHODS.requestLogsList:
|
|
315
|
+
return inspector.list(typeof p.threadId === "string" ? p.threadId : undefined, typeof p.limit === "number" ? p.limit : undefined);
|
|
316
|
+
case LOCAL_METHODS.requestLogsDetail: {
|
|
317
|
+
if (typeof p.id !== "string" || !p.id)
|
|
318
|
+
throw new FsError(-32602, "omaw/request-logs/detail requires { id: string }");
|
|
319
|
+
const entry = await inspector.get(p.id);
|
|
320
|
+
if (!entry)
|
|
321
|
+
throw new FsError(-32004, "request log not found");
|
|
322
|
+
return entry;
|
|
323
|
+
}
|
|
324
|
+
case LOCAL_METHODS.fsList:
|
|
325
|
+
return listDirectory({
|
|
326
|
+
path: typeof p.path === "string" ? p.path : undefined,
|
|
327
|
+
cwd,
|
|
328
|
+
maxEntries: typeof p.maxEntries === "number" ? p.maxEntries : undefined,
|
|
329
|
+
});
|
|
330
|
+
case LOCAL_METHODS.fsRead:
|
|
331
|
+
if (typeof p.path !== "string")
|
|
332
|
+
throw new FsError(-32602, "omaw/fs/read requires { path: string }");
|
|
333
|
+
return readTextFile({
|
|
334
|
+
path: p.path,
|
|
335
|
+
cwd,
|
|
336
|
+
maxBytes: typeof p.maxBytes === "number" ? p.maxBytes : undefined,
|
|
337
|
+
});
|
|
338
|
+
case LOCAL_METHODS.paths: {
|
|
339
|
+
const codexHome = client.handshake()?.codexHome || process.env.CODEX_HOME || join(homedir(), ".codex");
|
|
340
|
+
return resolvePaths(cwd, codexHome);
|
|
341
|
+
}
|
|
342
|
+
default:
|
|
343
|
+
throw { code: -32601, message: `unknown local method: ${method}` };
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
/* -------- browser message handling -------- */
|
|
347
|
+
async function handleRpc(ws, requestId, method, params) {
|
|
348
|
+
try {
|
|
349
|
+
let result;
|
|
350
|
+
if (method.startsWith("omaw/")) {
|
|
351
|
+
result = await handleLocal(method, params);
|
|
352
|
+
}
|
|
353
|
+
else {
|
|
354
|
+
// A browser opened during startup races the app-server's boot (and a
|
|
355
|
+
// reconnect after a codex crash). Wait briefly instead of failing the
|
|
356
|
+
// client's first requests; only report -32001 if it never comes up.
|
|
357
|
+
if (!client.isReady() && !(await client.waitUntilReady(CODEX_READY_WAIT_MS))) {
|
|
358
|
+
throw { code: -32001, message: "codex app-server not connected" };
|
|
359
|
+
}
|
|
360
|
+
result = await client.request(method, params);
|
|
361
|
+
}
|
|
362
|
+
send(ws, { type: "rpcResult", requestId, ok: true, result });
|
|
363
|
+
}
|
|
364
|
+
catch (err) {
|
|
365
|
+
send(ws, { type: "rpcResult", requestId, ok: false, error: asJsonRpcError(err) });
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
function handleReply(id, result, error) {
|
|
369
|
+
const key = String(id);
|
|
370
|
+
if (answeredServerRequests.has(key))
|
|
371
|
+
return; // 其他标签页已答复,忽略后来者
|
|
372
|
+
rememberAnswered(key);
|
|
373
|
+
const pending = pendingServerRequests.get(key);
|
|
374
|
+
pendingServerRequests.delete(key);
|
|
375
|
+
if (error)
|
|
376
|
+
client.respondError(id, error);
|
|
377
|
+
else
|
|
378
|
+
client.respond(id, result);
|
|
379
|
+
// 让其他标签页关闭审批弹窗;复用 codex 自己的通知名。
|
|
380
|
+
broadcast({
|
|
381
|
+
type: "event",
|
|
382
|
+
method: "serverRequest/resolved",
|
|
383
|
+
params: { threadId: pending?.threadId ?? null, requestId: id },
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
async function onBrowserMessage(ws, raw) {
|
|
387
|
+
let parsed;
|
|
388
|
+
try {
|
|
389
|
+
parsed = JSON.parse(typeof raw === "string" ? raw : raw.toString("utf8"));
|
|
390
|
+
}
|
|
391
|
+
catch {
|
|
392
|
+
log("忽略非 JSON 的浏览器报文");
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
if (!isRecord(parsed) || typeof parsed.type !== "string")
|
|
396
|
+
return;
|
|
397
|
+
const msg = parsed;
|
|
398
|
+
switch (msg.type) {
|
|
399
|
+
case "rpc": {
|
|
400
|
+
if (typeof msg.requestId !== "string" || typeof msg.method !== "string")
|
|
401
|
+
return;
|
|
402
|
+
await handleRpc(ws, msg.requestId, msg.method, msg.params);
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
case "reply": {
|
|
406
|
+
if (msg.id === undefined || msg.id === null)
|
|
407
|
+
return;
|
|
408
|
+
const hasError = "error" in msg && msg.error !== undefined;
|
|
409
|
+
const error = hasError ? asJsonRpcError(msg.error) : undefined;
|
|
410
|
+
handleReply(msg.id, msg.result, error);
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
case "ping":
|
|
414
|
+
send(ws, { type: "pong" });
|
|
415
|
+
return;
|
|
416
|
+
default:
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
wss.on("connection", (ws) => {
|
|
421
|
+
ws.__alive = true;
|
|
422
|
+
browsers.add(ws);
|
|
423
|
+
ws.on("pong", () => {
|
|
424
|
+
ws.__alive = true;
|
|
425
|
+
});
|
|
426
|
+
ws.on("message", (raw) => {
|
|
427
|
+
void onBrowserMessage(ws, raw).catch((err) => log("browser message handler error:", err));
|
|
428
|
+
});
|
|
429
|
+
ws.on("error", (err) => log("browser socket error:", err.message));
|
|
430
|
+
ws.on("close", () => browsers.delete(ws));
|
|
431
|
+
const info = {
|
|
432
|
+
version,
|
|
433
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
434
|
+
cwd,
|
|
435
|
+
codexUrl: supervisor.url,
|
|
436
|
+
codexVersion: codexVersionFromHandshake(client.handshake()?.userAgent),
|
|
437
|
+
platform: process.platform,
|
|
438
|
+
};
|
|
439
|
+
send(ws, { type: "welcome", info });
|
|
440
|
+
send(ws, { type: "status", codex: supervisor.status() });
|
|
441
|
+
});
|
|
442
|
+
// 升级前做 origin 校验 + 路径校验;非 /ws 直接 destroy(dev 下 Vite 的 HMR 通道除外,
|
|
443
|
+
// 它由 Vite 自己在这个 server 上注册的 upgrade 监听器处理)。
|
|
444
|
+
httpServer.on("upgrade", (req, socket, head) => {
|
|
445
|
+
let pathname = "/";
|
|
446
|
+
try {
|
|
447
|
+
pathname = new URL(req.url ?? "/", "http://localhost").pathname;
|
|
448
|
+
}
|
|
449
|
+
catch {
|
|
450
|
+
/* fall through */
|
|
451
|
+
}
|
|
452
|
+
if (DEV_SOURCE && pathname === DEV_HMR_PATH)
|
|
453
|
+
return;
|
|
454
|
+
if (pathname !== "/ws") {
|
|
455
|
+
socket.destroy();
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
if (!originAllowed(req)) {
|
|
459
|
+
socket.write("HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Length: 0\r\n\r\n");
|
|
460
|
+
socket.destroy();
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
wss.handleUpgrade(req, socket, head, (ws) => wss.emit("connection", ws, req));
|
|
464
|
+
});
|
|
465
|
+
// 心跳:30s 一次 ping,未回 pong 的直接 terminate。
|
|
466
|
+
const heartbeat = setInterval(() => {
|
|
467
|
+
for (const ws of browsers) {
|
|
468
|
+
if (ws.__alive === false) {
|
|
469
|
+
ws.terminate();
|
|
470
|
+
browsers.delete(ws);
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
ws.__alive = false;
|
|
474
|
+
try {
|
|
475
|
+
ws.ping();
|
|
476
|
+
}
|
|
477
|
+
catch {
|
|
478
|
+
/* ignore */
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}, HEARTBEAT_MS);
|
|
482
|
+
heartbeat.unref?.();
|
|
483
|
+
/* -------- listen -------- */
|
|
484
|
+
await new Promise((resolvePromise, rejectPromise) => {
|
|
485
|
+
httpServer.once("error", rejectPromise);
|
|
486
|
+
httpServer.listen(port, host, () => resolvePromise());
|
|
487
|
+
});
|
|
488
|
+
const address = httpServer.address();
|
|
489
|
+
const actualPort = typeof address === "object" && address ? address.port : port;
|
|
490
|
+
const url = `http://${host}:${actualPort}`;
|
|
491
|
+
log(`listening on ${url} (ws://${host}:${actualPort}/ws)`);
|
|
492
|
+
log(`codex app-server: ${supervisor.url} (${supervisor.isExternal() ? "external" : "managed"})`);
|
|
493
|
+
/* -------- close -------- */
|
|
494
|
+
let closed = false;
|
|
495
|
+
async function close() {
|
|
496
|
+
if (closed)
|
|
497
|
+
return;
|
|
498
|
+
closed = true;
|
|
499
|
+
clearInterval(heartbeat);
|
|
500
|
+
for (const ws of browsers) {
|
|
501
|
+
try {
|
|
502
|
+
ws.close(1001, "server shutting down");
|
|
503
|
+
}
|
|
504
|
+
catch {
|
|
505
|
+
/* ignore */
|
|
506
|
+
}
|
|
507
|
+
ws.terminate();
|
|
508
|
+
}
|
|
509
|
+
browsers.clear();
|
|
510
|
+
client.close();
|
|
511
|
+
await supervisor.stop();
|
|
512
|
+
await inspector.close();
|
|
513
|
+
await new Promise((resolvePromise) => {
|
|
514
|
+
wss.close(() => resolvePromise());
|
|
515
|
+
// 没有活跃连接时 wss.close 回调可能不触发;兜底。
|
|
516
|
+
setTimeout(resolvePromise, 500);
|
|
517
|
+
});
|
|
518
|
+
await new Promise((resolvePromise) => {
|
|
519
|
+
httpServer.close(() => resolvePromise());
|
|
520
|
+
setTimeout(resolvePromise, 500);
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
return { port: actualPort, host, url, cwd, version, supervisor, client, close };
|
|
524
|
+
}
|
|
525
|
+
/* ------------------------------------------------------------------ */
|
|
526
|
+
/* direct execution (node dist/server/index.js) */
|
|
527
|
+
/* ------------------------------------------------------------------ */
|
|
528
|
+
const invokedPath = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : "";
|
|
529
|
+
if (invokedPath === import.meta.url) {
|
|
530
|
+
startServer()
|
|
531
|
+
.then((handle) => {
|
|
532
|
+
const shutdown = (signal) => {
|
|
533
|
+
console.log(`\n[oh-my-agent-web] received ${signal}, shutting down…`);
|
|
534
|
+
void handle.close().finally(() => process.exit(0));
|
|
535
|
+
};
|
|
536
|
+
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
537
|
+
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
538
|
+
})
|
|
539
|
+
.catch((err) => {
|
|
540
|
+
console.error("[oh-my-agent-web] failed to start:", err);
|
|
541
|
+
process.exit(1);
|
|
542
|
+
});
|
|
543
|
+
}
|