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,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CodexClient — WebSocket JSON-RPC client to the codex app-server.
|
|
3
|
+
*
|
|
4
|
+
* - 连接后立即 `initialize`(客户端信息 + experimentalApi),记录 userAgent /
|
|
5
|
+
* codexHome / platformOs。
|
|
6
|
+
* - socket 断开 / supervisor 重启后自动重连(指数退避),每次重连都重发 initialize。
|
|
7
|
+
* - `request(method, params)` 用自增数字 id 关联响应;JSON-RPC error 以带 code 的
|
|
8
|
+
* 对象 reject。
|
|
9
|
+
* - server -> client 的通知(无 id)走 `notification` 事件;带 id 的请求(审批 /
|
|
10
|
+
* user input / 动态工具调用)走 `serverRequest` 事件。
|
|
11
|
+
* - 连接状态变化走 `state` 事件(index 借此刷新并广播 `status`)。
|
|
12
|
+
*
|
|
13
|
+
* 注意:codex 的报文可能不带 `jsonrpc` 字段,因此我们收发都兼容有无。
|
|
14
|
+
*/
|
|
15
|
+
import { EventEmitter } from "node:events";
|
|
16
|
+
import { WebSocket } from "ws";
|
|
17
|
+
const RECONNECT_BASE_MS = 500;
|
|
18
|
+
const RECONNECT_MAX_MS = 5_000;
|
|
19
|
+
const INIT_TIMEOUT_MS = 20_000;
|
|
20
|
+
const NOT_CONNECTED = { code: -32001, message: "codex app-server not connected" };
|
|
21
|
+
function isRecord(value) {
|
|
22
|
+
return typeof value === "object" && value !== null;
|
|
23
|
+
}
|
|
24
|
+
export class CodexClient extends EventEmitter {
|
|
25
|
+
url;
|
|
26
|
+
version;
|
|
27
|
+
ws = null;
|
|
28
|
+
connectPromise = null;
|
|
29
|
+
manualClose = false;
|
|
30
|
+
reconnectTimer = null;
|
|
31
|
+
reconnectAttempts = 0;
|
|
32
|
+
nextId = 1;
|
|
33
|
+
pending = new Map();
|
|
34
|
+
handshakeData = null;
|
|
35
|
+
connState = { connected: false, error: null, since: null };
|
|
36
|
+
constructor(url, version) {
|
|
37
|
+
super();
|
|
38
|
+
this.url = url;
|
|
39
|
+
this.version = version;
|
|
40
|
+
}
|
|
41
|
+
state() {
|
|
42
|
+
return { ...this.connState };
|
|
43
|
+
}
|
|
44
|
+
handshake() {
|
|
45
|
+
return this.handshakeData;
|
|
46
|
+
}
|
|
47
|
+
isReady() {
|
|
48
|
+
return this.connState.connected && this.ws !== null && this.ws.readyState === WebSocket.OPEN;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Resolve once the client is ready, or after `timeoutMs` (returns `false`).
|
|
52
|
+
*
|
|
53
|
+
* The browser can attach while the app-server is still booting, and its very
|
|
54
|
+
* first requests (`model/list`, `thread/list`, …) would otherwise fail with
|
|
55
|
+
* `-32001`. Waiting here removes that startup race for every client instead of
|
|
56
|
+
* pushing the problem into each one.
|
|
57
|
+
*/
|
|
58
|
+
async waitUntilReady(timeoutMs) {
|
|
59
|
+
if (this.isReady())
|
|
60
|
+
return true;
|
|
61
|
+
// Nudge a connection along in case nothing else is driving it yet.
|
|
62
|
+
void this.connect();
|
|
63
|
+
return new Promise((resolve) => {
|
|
64
|
+
let settled = false;
|
|
65
|
+
let timer = null;
|
|
66
|
+
const finish = (ok) => {
|
|
67
|
+
if (settled)
|
|
68
|
+
return;
|
|
69
|
+
settled = true;
|
|
70
|
+
if (timer)
|
|
71
|
+
clearTimeout(timer);
|
|
72
|
+
this.off("state", onState);
|
|
73
|
+
resolve(ok);
|
|
74
|
+
};
|
|
75
|
+
const onState = () => {
|
|
76
|
+
if (this.isReady())
|
|
77
|
+
finish(true);
|
|
78
|
+
};
|
|
79
|
+
this.on("state", onState);
|
|
80
|
+
timer = setTimeout(() => finish(this.isReady()), timeoutMs);
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
setState(patch) {
|
|
84
|
+
this.connState = { ...this.connState, ...patch };
|
|
85
|
+
this.emit("state", this.state());
|
|
86
|
+
}
|
|
87
|
+
/** 建立连接;已连接 / 正在连接时幂等。 */
|
|
88
|
+
connect() {
|
|
89
|
+
if (this.isReady())
|
|
90
|
+
return Promise.resolve();
|
|
91
|
+
if (this.connectPromise)
|
|
92
|
+
return this.connectPromise;
|
|
93
|
+
this.manualClose = false;
|
|
94
|
+
this.connectPromise = new Promise((resolve) => {
|
|
95
|
+
const ws = new WebSocket(this.url);
|
|
96
|
+
this.ws = ws;
|
|
97
|
+
ws.on("open", () => {
|
|
98
|
+
this.reconnectAttempts = 0;
|
|
99
|
+
void this.sendInitialize()
|
|
100
|
+
.then(() => resolve())
|
|
101
|
+
.catch(() => {
|
|
102
|
+
// initialize 失败:close 处理器会负责重连,这里只解除等待。
|
|
103
|
+
resolve();
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
ws.on("message", (data) => this.onMessage(data));
|
|
107
|
+
ws.on("error", (err) => {
|
|
108
|
+
this.setState({ connected: false, error: err instanceof Error ? err.message : String(err) });
|
|
109
|
+
});
|
|
110
|
+
ws.on("close", () => {
|
|
111
|
+
const wasConnected = this.connState.connected;
|
|
112
|
+
this.ws = null;
|
|
113
|
+
this.handshakeData = null;
|
|
114
|
+
this.connectPromise = null;
|
|
115
|
+
this.rejectAllPending({ code: -32002, message: "codex app-server connection closed" });
|
|
116
|
+
this.setState({ connected: false, error: wasConnected ? "connection closed" : this.connState.error });
|
|
117
|
+
if (!this.manualClose)
|
|
118
|
+
this.scheduleReconnect();
|
|
119
|
+
resolve();
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
return this.connectPromise;
|
|
123
|
+
}
|
|
124
|
+
scheduleReconnect() {
|
|
125
|
+
if (this.reconnectTimer)
|
|
126
|
+
return;
|
|
127
|
+
const exp = Math.min(RECONNECT_BASE_MS * 2 ** this.reconnectAttempts, RECONNECT_MAX_MS);
|
|
128
|
+
this.reconnectAttempts += 1;
|
|
129
|
+
this.reconnectTimer = setTimeout(() => {
|
|
130
|
+
this.reconnectTimer = null;
|
|
131
|
+
void this.connect();
|
|
132
|
+
}, exp);
|
|
133
|
+
}
|
|
134
|
+
/** 强制断开并重连(supervisor 重启后由 index 调用)。 */
|
|
135
|
+
reconnect() {
|
|
136
|
+
if (this.reconnectTimer) {
|
|
137
|
+
clearTimeout(this.reconnectTimer);
|
|
138
|
+
this.reconnectTimer = null;
|
|
139
|
+
}
|
|
140
|
+
this.reconnectAttempts = 0;
|
|
141
|
+
const ws = this.ws;
|
|
142
|
+
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
|
|
143
|
+
try {
|
|
144
|
+
ws.close();
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
/* ignore */
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
void this.connect();
|
|
152
|
+
}
|
|
153
|
+
// close 事件会触发 scheduleReconnect;这里立即再拉一次以避免等待。
|
|
154
|
+
setTimeout(() => {
|
|
155
|
+
if (!this.isReady())
|
|
156
|
+
void this.connect();
|
|
157
|
+
}, 50);
|
|
158
|
+
}
|
|
159
|
+
close() {
|
|
160
|
+
this.manualClose = true;
|
|
161
|
+
if (this.reconnectTimer) {
|
|
162
|
+
clearTimeout(this.reconnectTimer);
|
|
163
|
+
this.reconnectTimer = null;
|
|
164
|
+
}
|
|
165
|
+
this.rejectAllPending({ code: -32002, message: "codex client closed" });
|
|
166
|
+
const ws = this.ws;
|
|
167
|
+
this.ws = null;
|
|
168
|
+
this.handshakeData = null;
|
|
169
|
+
this.setState({ connected: false, error: null });
|
|
170
|
+
if (ws) {
|
|
171
|
+
try {
|
|
172
|
+
ws.close();
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
/* ignore */
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
async sendInitialize() {
|
|
180
|
+
const params = {
|
|
181
|
+
clientInfo: { name: "oh-my-agent-web", title: "Oh My Agent Web", version: this.version },
|
|
182
|
+
// requestAttestation 在生成类型里是必填布尔;实测 handshake 需要它。
|
|
183
|
+
capabilities: { experimentalApi: true, requestAttestation: false },
|
|
184
|
+
};
|
|
185
|
+
const result = await this.requestInternal("initialize", params, INIT_TIMEOUT_MS);
|
|
186
|
+
if (isRecord(result)) {
|
|
187
|
+
this.handshakeData = {
|
|
188
|
+
userAgent: typeof result.userAgent === "string" ? result.userAgent : "unknown",
|
|
189
|
+
codexHome: typeof result.codexHome === "string" ? result.codexHome : "",
|
|
190
|
+
platformOs: typeof result.platformOs === "string" ? result.platformOs : process.platform,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
this.setState({ connected: true, error: null, since: Date.now() });
|
|
194
|
+
}
|
|
195
|
+
onMessage(data) {
|
|
196
|
+
let msg;
|
|
197
|
+
try {
|
|
198
|
+
const text = typeof data === "string" ? data : Buffer.isBuffer(data) ? data.toString("utf8") : String(data);
|
|
199
|
+
msg = JSON.parse(text);
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
return; // 忽略畸形报文,绝不 crash
|
|
203
|
+
}
|
|
204
|
+
if (!isRecord(msg))
|
|
205
|
+
return;
|
|
206
|
+
const hasId = "id" in msg && msg.id !== null && msg.id !== undefined;
|
|
207
|
+
const method = typeof msg.method === "string" ? msg.method : null;
|
|
208
|
+
const hasResult = "result" in msg;
|
|
209
|
+
const hasError = "error" in msg;
|
|
210
|
+
// 1) 对我们请求的响应。
|
|
211
|
+
if (hasId && method === null && (hasResult || hasError)) {
|
|
212
|
+
const id = msg.id;
|
|
213
|
+
if (typeof id === "number")
|
|
214
|
+
this.settle(id, hasResult ? { ok: true, value: msg.result } : { ok: false, error: msg.error });
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
// 2) server -> client 请求(需要 reply)。
|
|
218
|
+
if (method !== null && hasId) {
|
|
219
|
+
const evt = { id: msg.id, method, params: msg.params };
|
|
220
|
+
this.emit("serverRequest", evt);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
// 3) 通知。
|
|
224
|
+
if (method !== null) {
|
|
225
|
+
const evt = { method, params: msg.params };
|
|
226
|
+
this.emit("notification", evt);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
settle(id, outcome) {
|
|
230
|
+
const entry = this.pending.get(id);
|
|
231
|
+
if (!entry)
|
|
232
|
+
return;
|
|
233
|
+
this.pending.delete(id);
|
|
234
|
+
if (entry.timer)
|
|
235
|
+
clearTimeout(entry.timer);
|
|
236
|
+
if (outcome.ok) {
|
|
237
|
+
entry.resolve(outcome.value);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
entry.reject(normalizeError(outcome.error));
|
|
241
|
+
}
|
|
242
|
+
rejectAllPending(error) {
|
|
243
|
+
for (const [, entry] of this.pending) {
|
|
244
|
+
if (entry.timer)
|
|
245
|
+
clearTimeout(entry.timer);
|
|
246
|
+
entry.reject(error);
|
|
247
|
+
}
|
|
248
|
+
this.pending.clear();
|
|
249
|
+
}
|
|
250
|
+
requestInternal(method, params, timeoutMs) {
|
|
251
|
+
const ws = this.ws;
|
|
252
|
+
if (!ws || ws.readyState !== WebSocket.OPEN)
|
|
253
|
+
return Promise.reject(NOT_CONNECTED);
|
|
254
|
+
const id = this.nextId++;
|
|
255
|
+
return new Promise((resolve, reject) => {
|
|
256
|
+
const timer = timeoutMs > 0
|
|
257
|
+
? setTimeout(() => {
|
|
258
|
+
this.pending.delete(id);
|
|
259
|
+
reject({ code: -32003, message: `${method} timed out after ${timeoutMs}ms` });
|
|
260
|
+
}, timeoutMs)
|
|
261
|
+
: null;
|
|
262
|
+
this.pending.set(id, { method, resolve, reject, timer });
|
|
263
|
+
const payload = { method, id };
|
|
264
|
+
if (params !== undefined)
|
|
265
|
+
payload.params = params;
|
|
266
|
+
try {
|
|
267
|
+
ws.send(JSON.stringify(payload));
|
|
268
|
+
}
|
|
269
|
+
catch (err) {
|
|
270
|
+
this.pending.delete(id);
|
|
271
|
+
if (timer)
|
|
272
|
+
clearTimeout(timer);
|
|
273
|
+
reject({ code: -32002, message: err instanceof Error ? err.message : String(err) });
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
/** 透明代理用:任意 codex 方法。未连接时立即以 -32001 reject。 */
|
|
278
|
+
request(method, params) {
|
|
279
|
+
if (!this.isReady())
|
|
280
|
+
return Promise.reject(NOT_CONNECTED);
|
|
281
|
+
return this.requestInternal(method, params, 0);
|
|
282
|
+
}
|
|
283
|
+
respond(id, result) {
|
|
284
|
+
this.send({ id, result });
|
|
285
|
+
}
|
|
286
|
+
respondError(id, error) {
|
|
287
|
+
this.send({ id, error });
|
|
288
|
+
}
|
|
289
|
+
send(payload) {
|
|
290
|
+
const ws = this.ws;
|
|
291
|
+
if (!ws || ws.readyState !== WebSocket.OPEN)
|
|
292
|
+
return;
|
|
293
|
+
try {
|
|
294
|
+
ws.send(JSON.stringify(payload));
|
|
295
|
+
}
|
|
296
|
+
catch {
|
|
297
|
+
/* 忽略:连接多半已断,重连逻辑会接手 */
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
function normalizeError(raw) {
|
|
302
|
+
if (isRecord(raw)) {
|
|
303
|
+
return {
|
|
304
|
+
code: typeof raw.code === "number" ? raw.code : -32000,
|
|
305
|
+
message: typeof raw.message === "string" ? raw.message : "codex error",
|
|
306
|
+
data: raw.data,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
return { code: -32000, message: typeof raw === "string" ? raw : "codex error" };
|
|
310
|
+
}
|