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,458 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local Responses API proxy and request/response recorder.
|
|
3
|
+
*
|
|
4
|
+
* Codex app-server talks to this loopback proxy when it is managed by the UI.
|
|
5
|
+
* The proxy forwards the request to the real Codex endpoint while keeping a
|
|
6
|
+
* redacted, thread-aware copy of the exchange for the session log drawer.
|
|
7
|
+
*/
|
|
8
|
+
import { createServer } from "node:http";
|
|
9
|
+
import { chmod, mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { randomUUID } from "node:crypto";
|
|
12
|
+
const DEFAULT_UPSTREAM = "https://chatgpt.com/backend-api/codex";
|
|
13
|
+
const DEFAULT_MAX_BODY = 16 * 1024 * 1024;
|
|
14
|
+
const HISTORY_LIMIT = 400;
|
|
15
|
+
const REQUEST_TIMEOUT_MS = 15 * 60 * 1000;
|
|
16
|
+
function isRecord(value) {
|
|
17
|
+
return typeof value === "object" && value !== null;
|
|
18
|
+
}
|
|
19
|
+
function asString(value) {
|
|
20
|
+
return typeof value === "string" ? value : "";
|
|
21
|
+
}
|
|
22
|
+
function headerValue(headers, name) {
|
|
23
|
+
const value = headers[name.toLowerCase()];
|
|
24
|
+
return Array.isArray(value) ? value.join(", ") : value ?? "";
|
|
25
|
+
}
|
|
26
|
+
function redactedHeaders(headers) {
|
|
27
|
+
const secret = /(authorization|cookie|token|secret|password|api[-_]?key)/i;
|
|
28
|
+
return Object.fromEntries(Object.entries(headers).map(([key, value]) => {
|
|
29
|
+
if (!secret.test(key))
|
|
30
|
+
return [key.toLowerCase(), value];
|
|
31
|
+
const redacted = key.toLowerCase() === "authorization" && value.length > 12 ? `${value.slice(0, 7)}****${value.slice(-4)}` : "[REDACTED]";
|
|
32
|
+
return [key.toLowerCase(), redacted];
|
|
33
|
+
}));
|
|
34
|
+
}
|
|
35
|
+
function incomingHeaders(headers) {
|
|
36
|
+
const result = {};
|
|
37
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
38
|
+
if (value === undefined)
|
|
39
|
+
continue;
|
|
40
|
+
result[key.toLowerCase()] = Array.isArray(value) ? value.join(", ") : value;
|
|
41
|
+
}
|
|
42
|
+
return result;
|
|
43
|
+
}
|
|
44
|
+
function now() {
|
|
45
|
+
return new Date().toISOString();
|
|
46
|
+
}
|
|
47
|
+
function clip(value, max) {
|
|
48
|
+
if (value.length <= max)
|
|
49
|
+
return { value, truncated: false };
|
|
50
|
+
return { value: value.slice(0, max), truncated: true };
|
|
51
|
+
}
|
|
52
|
+
function parseJson(value) {
|
|
53
|
+
try {
|
|
54
|
+
const parsed = JSON.parse(value || "{}");
|
|
55
|
+
return isRecord(parsed) ? parsed : {};
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return {};
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function extractQuestion(body) {
|
|
62
|
+
const obj = parseJson(body);
|
|
63
|
+
const input = obj.input;
|
|
64
|
+
if (typeof input === "string")
|
|
65
|
+
return input.slice(0, 4000);
|
|
66
|
+
if (!Array.isArray(input))
|
|
67
|
+
return "";
|
|
68
|
+
for (let i = input.length - 1; i >= 0; i -= 1) {
|
|
69
|
+
const entry = input[i];
|
|
70
|
+
if (!isRecord(entry) || entry.role !== "user")
|
|
71
|
+
continue;
|
|
72
|
+
const content = entry.content;
|
|
73
|
+
let text = "";
|
|
74
|
+
if (typeof content === "string")
|
|
75
|
+
text = content;
|
|
76
|
+
else if (Array.isArray(content)) {
|
|
77
|
+
text = content
|
|
78
|
+
.filter(isRecord)
|
|
79
|
+
.map((item) => asString(item.text))
|
|
80
|
+
.join("");
|
|
81
|
+
}
|
|
82
|
+
text = text.trim();
|
|
83
|
+
if (text && !text.startsWith("<environment_context"))
|
|
84
|
+
return text.slice(0, 4000);
|
|
85
|
+
}
|
|
86
|
+
return "";
|
|
87
|
+
}
|
|
88
|
+
function extractIds(headers) {
|
|
89
|
+
let meta = {};
|
|
90
|
+
try {
|
|
91
|
+
const parsed = JSON.parse(headers["x-codex-turn-metadata"] ?? "{}");
|
|
92
|
+
if (isRecord(parsed))
|
|
93
|
+
meta = parsed;
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
/* metadata is optional */
|
|
97
|
+
}
|
|
98
|
+
const threadId = asString(meta.thread_id) || headers["thread-id"] || headers["session-id"] || "unknown-session";
|
|
99
|
+
const turnId = asString(meta.turn_id) || threadId;
|
|
100
|
+
const source = asString(meta.thread_source);
|
|
101
|
+
const trigger = asString(meta.turn_trigger);
|
|
102
|
+
return {
|
|
103
|
+
threadId,
|
|
104
|
+
turnId,
|
|
105
|
+
source,
|
|
106
|
+
trigger,
|
|
107
|
+
internal: Boolean(headers["x-openai-memgen-request"]) || source === "automation" || source === "memory_consolidation" || source === "guardian_review" || source === "system" || source === "thread_title" || trigger.startsWith("automation"),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
function sseEvents(body) {
|
|
111
|
+
const events = [];
|
|
112
|
+
for (const block of body.split(/\n\s*\n/)) {
|
|
113
|
+
let data = "";
|
|
114
|
+
for (const line of block.split(/\r?\n/)) {
|
|
115
|
+
if (line.startsWith("data:"))
|
|
116
|
+
data = line.slice(5).trim();
|
|
117
|
+
}
|
|
118
|
+
if (!data || data === "[DONE]")
|
|
119
|
+
continue;
|
|
120
|
+
try {
|
|
121
|
+
const value = JSON.parse(data);
|
|
122
|
+
if (isRecord(value))
|
|
123
|
+
events.push(value);
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
/* An incomplete SSE event is normal while a request is streaming. */
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return events;
|
|
130
|
+
}
|
|
131
|
+
function extractStream(body) {
|
|
132
|
+
const answer = [];
|
|
133
|
+
const reasoning = [];
|
|
134
|
+
const tools = [];
|
|
135
|
+
let usage = null;
|
|
136
|
+
for (const event of sseEvents(body)) {
|
|
137
|
+
const type = asString(event.type);
|
|
138
|
+
if (type === "response.output_text.delta")
|
|
139
|
+
answer.push(asString(event.delta));
|
|
140
|
+
else if (type === "response.reasoning_summary_text.delta")
|
|
141
|
+
reasoning.push(asString(event.delta));
|
|
142
|
+
else if (type === "response.output_item.done" && isRecord(event.item)) {
|
|
143
|
+
const itemType = asString(event.item.type);
|
|
144
|
+
if (["function_call", "custom_tool_call", "local_shell_call", "mcp_call"].includes(itemType)) {
|
|
145
|
+
tools.push(asString(event.item.name) || itemType);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
else if (type === "response.completed" && isRecord(event.response) && isRecord(event.response.usage)) {
|
|
149
|
+
usage = event.response.usage;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return { answer: answer.join(""), reasoning: reasoning.join(""), tools, usage };
|
|
153
|
+
}
|
|
154
|
+
function tokenSummary(usage) {
|
|
155
|
+
const inputDetails = isRecord(usage?.input_tokens_details) ? usage.input_tokens_details : {};
|
|
156
|
+
const outputDetails = isRecord(usage?.output_tokens_details) ? usage.output_tokens_details : {};
|
|
157
|
+
const numberValue = (value) => (typeof value === "number" && Number.isFinite(value) ? value : 0);
|
|
158
|
+
return {
|
|
159
|
+
input: numberValue(usage?.input_tokens),
|
|
160
|
+
cached: numberValue(inputDetails.cached_tokens),
|
|
161
|
+
cacheWrite: numberValue(inputDetails.cache_write_tokens),
|
|
162
|
+
output: numberValue(usage?.output_tokens),
|
|
163
|
+
reasoning: numberValue(outputDetails.reasoning_tokens),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
function modelFromBody(body) {
|
|
167
|
+
const value = parseJson(body).model;
|
|
168
|
+
return typeof value === "string" ? value : null;
|
|
169
|
+
}
|
|
170
|
+
function summarize(item) {
|
|
171
|
+
const stream = extractStream(item.responseBody);
|
|
172
|
+
return {
|
|
173
|
+
id: item.id,
|
|
174
|
+
startedAt: item.startedAt,
|
|
175
|
+
status: item.status,
|
|
176
|
+
model: item.model,
|
|
177
|
+
durationMs: item.durationMs,
|
|
178
|
+
requestBytes: item.requestBytes,
|
|
179
|
+
responseBytes: item.responseBytes,
|
|
180
|
+
error: item.error,
|
|
181
|
+
threadId: item._ids.threadId,
|
|
182
|
+
turnId: item._ids.turnId,
|
|
183
|
+
source: item._ids.source,
|
|
184
|
+
trigger: item._ids.trigger,
|
|
185
|
+
internal: item._ids.internal,
|
|
186
|
+
question: item._question,
|
|
187
|
+
answer: stream.answer.slice(-8000),
|
|
188
|
+
reasoning: stream.reasoning.slice(-4000),
|
|
189
|
+
tools: stream.tools,
|
|
190
|
+
usage: stream.usage,
|
|
191
|
+
tokens: tokenSummary(stream.usage),
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
function detail(item) {
|
|
195
|
+
return {
|
|
196
|
+
...summarize(item),
|
|
197
|
+
requestHeaders: item.requestHeaders,
|
|
198
|
+
responseHeaders: item.responseHeaders,
|
|
199
|
+
requestBody: item.requestBody,
|
|
200
|
+
responseBody: item.responseBody,
|
|
201
|
+
responseTruncated: item.responseTruncated,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
function exchangeFromJson(value) {
|
|
205
|
+
if (!isRecord(value) || typeof value.id !== "string")
|
|
206
|
+
return null;
|
|
207
|
+
const ids = isRecord(value._ids) ? value._ids : {};
|
|
208
|
+
return {
|
|
209
|
+
id: value.id,
|
|
210
|
+
startedAt: asString(value.startedAt) || asString(value.started_at),
|
|
211
|
+
method: asString(value.method) || "POST",
|
|
212
|
+
path: asString(value.path) || "/responses",
|
|
213
|
+
_ids: {
|
|
214
|
+
threadId: asString(ids.threadId) || asString(ids.thread_id) || asString(value.threadId) || asString(value.thread_id) || "unknown-session",
|
|
215
|
+
turnId: asString(ids.turnId) || asString(ids.turn_id) || asString(value.turnId) || asString(value.turn_id) || "",
|
|
216
|
+
source: asString(ids.source) || asString(value.source),
|
|
217
|
+
trigger: asString(ids.trigger) || asString(value.trigger),
|
|
218
|
+
internal: ids.internal === true || value.internal === true,
|
|
219
|
+
},
|
|
220
|
+
_question: asString(value._question) || asString(value.question),
|
|
221
|
+
requestHeaders: isRecord(value.requestHeaders) ? Object.fromEntries(Object.entries(value.requestHeaders).map(([k, v]) => [k, asString(v)])) : isRecord(value.request_headers) ? Object.fromEntries(Object.entries(value.request_headers).map(([k, v]) => [k, asString(v)])) : {},
|
|
222
|
+
requestBody: asString(value.requestBody) || asString(value.request_body),
|
|
223
|
+
requestBytes: typeof value.requestBytes === "number" ? value.requestBytes : typeof value.request_bytes === "number" ? value.request_bytes : 0,
|
|
224
|
+
status: typeof value.status === "number" ? value.status : null,
|
|
225
|
+
responseHeaders: isRecord(value.responseHeaders) ? Object.fromEntries(Object.entries(value.responseHeaders).map(([k, v]) => [k, asString(v)])) : isRecord(value.response_headers) ? Object.fromEntries(Object.entries(value.response_headers).map(([k, v]) => [k, asString(v)])) : {},
|
|
226
|
+
responseBody: asString(value.responseBody) || asString(value.response_body),
|
|
227
|
+
responseBytes: typeof value.responseBytes === "number" ? value.responseBytes : typeof value.response_bytes === "number" ? value.response_bytes : 0,
|
|
228
|
+
responseTruncated: value.responseTruncated === true || value.response_truncated === true,
|
|
229
|
+
durationMs: typeof value.durationMs === "number" ? value.durationMs : typeof value.duration_ms === "number" ? value.duration_ms : null,
|
|
230
|
+
error: typeof value.error === "string" ? value.error : null,
|
|
231
|
+
model: typeof value.model === "string" ? value.model : modelFromBody(asString(value.requestBody) || asString(value.request_body)),
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
async function readRequestBody(req) {
|
|
235
|
+
const chunks = [];
|
|
236
|
+
for await (const chunk of req)
|
|
237
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
238
|
+
return Buffer.concat(chunks);
|
|
239
|
+
}
|
|
240
|
+
function copyableHeaders(headers) {
|
|
241
|
+
return Object.fromEntries(Object.entries(headers).filter(([key]) => !["host", "connection", "content-length", "transfer-encoding"].includes(key.toLowerCase())));
|
|
242
|
+
}
|
|
243
|
+
function jsonResponse(res, status, value) {
|
|
244
|
+
const body = Buffer.from(JSON.stringify(value));
|
|
245
|
+
res.statusCode = status;
|
|
246
|
+
res.setHeader("content-type", "application/json; charset=utf-8");
|
|
247
|
+
res.setHeader("content-length", body.length);
|
|
248
|
+
res.end(body);
|
|
249
|
+
}
|
|
250
|
+
export class RequestInspector {
|
|
251
|
+
upstream;
|
|
252
|
+
dataDir;
|
|
253
|
+
maxBody;
|
|
254
|
+
live = new Map();
|
|
255
|
+
server = null;
|
|
256
|
+
baseUrl = "";
|
|
257
|
+
constructor(options) {
|
|
258
|
+
this.upstream = (options.upstream || process.env.OMAW_CODEX_UPSTREAM || DEFAULT_UPSTREAM).replace(/\/$/, "");
|
|
259
|
+
this.dataDir = options.dataDir;
|
|
260
|
+
this.maxBody = options.maxBody ?? Number(process.env.OMAW_LOG_MAX_BODY ?? DEFAULT_MAX_BODY);
|
|
261
|
+
}
|
|
262
|
+
async start(host = "127.0.0.1") {
|
|
263
|
+
if (this.server)
|
|
264
|
+
return this.baseUrl;
|
|
265
|
+
await mkdir(this.dataDir, { recursive: true, mode: 0o700 });
|
|
266
|
+
this.server = createServer((req, res) => {
|
|
267
|
+
void this.handle(req, res).catch((error) => {
|
|
268
|
+
if (!res.headersSent)
|
|
269
|
+
jsonResponse(res, 502, { error: error instanceof Error ? error.message : String(error) });
|
|
270
|
+
else
|
|
271
|
+
res.destroy();
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
await new Promise((resolve, reject) => {
|
|
275
|
+
this.server?.once("error", reject);
|
|
276
|
+
this.server?.listen(0, host, resolve);
|
|
277
|
+
});
|
|
278
|
+
const address = this.server.address();
|
|
279
|
+
if (!address || typeof address === "string")
|
|
280
|
+
throw new Error("request inspector failed to bind");
|
|
281
|
+
this.baseUrl = `http://${host}:${address.port}`;
|
|
282
|
+
return this.baseUrl;
|
|
283
|
+
}
|
|
284
|
+
async close() {
|
|
285
|
+
const server = this.server;
|
|
286
|
+
this.server = null;
|
|
287
|
+
if (!server)
|
|
288
|
+
return;
|
|
289
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
290
|
+
}
|
|
291
|
+
url() {
|
|
292
|
+
return this.baseUrl;
|
|
293
|
+
}
|
|
294
|
+
async list(threadId, limit = HISTORY_LIMIT) {
|
|
295
|
+
const rows = [];
|
|
296
|
+
for (const item of this.live.values()) {
|
|
297
|
+
if (!threadId || item._ids.threadId === threadId)
|
|
298
|
+
rows.push(item);
|
|
299
|
+
}
|
|
300
|
+
try {
|
|
301
|
+
const days = (await readdir(this.dataDir)).sort().reverse();
|
|
302
|
+
for (const day of days) {
|
|
303
|
+
const folder = join(this.dataDir, day);
|
|
304
|
+
if (!(await stat(folder).catch(() => null))?.isDirectory())
|
|
305
|
+
continue;
|
|
306
|
+
const names = (await readdir(folder)).filter((name) => name.endsWith(".json")).sort().reverse();
|
|
307
|
+
for (const name of names) {
|
|
308
|
+
const parsed = exchangeFromJson(JSON.parse(await readFile(join(folder, name), "utf8")));
|
|
309
|
+
if (parsed && (!threadId || parsed._ids.threadId === threadId))
|
|
310
|
+
rows.push(parsed);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
catch {
|
|
315
|
+
/* The log directory may not exist yet. */
|
|
316
|
+
}
|
|
317
|
+
return rows
|
|
318
|
+
.sort((a, b) => b.startedAt.localeCompare(a.startedAt))
|
|
319
|
+
.slice(0, Math.max(1, Math.min(limit, HISTORY_LIMIT)))
|
|
320
|
+
.map(summarize);
|
|
321
|
+
}
|
|
322
|
+
async get(id) {
|
|
323
|
+
const live = this.live.get(id);
|
|
324
|
+
if (live)
|
|
325
|
+
return detail(live);
|
|
326
|
+
try {
|
|
327
|
+
const days = (await readdir(this.dataDir)).sort().reverse();
|
|
328
|
+
for (const day of days) {
|
|
329
|
+
const file = join(this.dataDir, day, `${id}.json`);
|
|
330
|
+
if (!(await stat(file).catch(() => null))?.isFile())
|
|
331
|
+
continue;
|
|
332
|
+
const parsed = exchangeFromJson(JSON.parse(await readFile(file, "utf8")));
|
|
333
|
+
return parsed ? detail(parsed) : null;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
catch {
|
|
337
|
+
return null;
|
|
338
|
+
}
|
|
339
|
+
return null;
|
|
340
|
+
}
|
|
341
|
+
async handle(req, res) {
|
|
342
|
+
const parsed = new URL(req.url ?? "/", this.baseUrl || "http://127.0.0.1");
|
|
343
|
+
if (req.method === "OPTIONS") {
|
|
344
|
+
res.statusCode = 204;
|
|
345
|
+
res.end();
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
if (req.method !== "GET" && req.method !== "POST") {
|
|
349
|
+
jsonResponse(res, 405, { error: "method not allowed" });
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
let body = Buffer.alloc(0);
|
|
353
|
+
if (req.method === "POST")
|
|
354
|
+
body = await readRequestBody(req);
|
|
355
|
+
const target = `${this.upstream}${parsed.pathname}${parsed.search}`;
|
|
356
|
+
const headers = incomingHeaders(req.headers);
|
|
357
|
+
const requestHeaders = redactedHeaders(headers);
|
|
358
|
+
// GET /models and similar discovery calls are proxied but not recorded as
|
|
359
|
+
// session exchanges; only POST responses contain a user turn.
|
|
360
|
+
if (req.method !== "POST") {
|
|
361
|
+
await this.forward(target, req.method, headers, body, res);
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
const requestBody = body.toString("utf8");
|
|
365
|
+
const clippedRequest = clip(requestBody, this.maxBody);
|
|
366
|
+
const ids = extractIds(headers);
|
|
367
|
+
const id = `${new Date().toISOString().replace(/[-:.TZ]/g, "").slice(0, 12)}-${randomUUID().slice(0, 6)}`;
|
|
368
|
+
const item = {
|
|
369
|
+
id,
|
|
370
|
+
startedAt: now(),
|
|
371
|
+
method: req.method,
|
|
372
|
+
path: parsed.pathname,
|
|
373
|
+
_ids: ids,
|
|
374
|
+
_question: extractQuestion(requestBody),
|
|
375
|
+
requestHeaders,
|
|
376
|
+
requestBody: clippedRequest.value,
|
|
377
|
+
requestBytes: body.length,
|
|
378
|
+
status: null,
|
|
379
|
+
responseHeaders: {},
|
|
380
|
+
responseBody: "",
|
|
381
|
+
responseBytes: 0,
|
|
382
|
+
responseTruncated: false,
|
|
383
|
+
durationMs: null,
|
|
384
|
+
error: null,
|
|
385
|
+
model: modelFromBody(requestBody),
|
|
386
|
+
};
|
|
387
|
+
this.live.set(id, item);
|
|
388
|
+
try {
|
|
389
|
+
await this.forward(target, req.method, headers, body, res, item);
|
|
390
|
+
}
|
|
391
|
+
catch (error) {
|
|
392
|
+
item.status = 502;
|
|
393
|
+
item.error = error instanceof Error ? error.message : String(error);
|
|
394
|
+
if (!res.headersSent)
|
|
395
|
+
jsonResponse(res, 502, { error: item.error });
|
|
396
|
+
}
|
|
397
|
+
finally {
|
|
398
|
+
item.durationMs = Date.now() - Date.parse(item.startedAt);
|
|
399
|
+
this.live.delete(id);
|
|
400
|
+
await this.persist(item);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
async forward(target, method, headers, body, res, item) {
|
|
404
|
+
const response = await fetch(target, {
|
|
405
|
+
method,
|
|
406
|
+
headers: copyableHeaders(headers),
|
|
407
|
+
body: method === "POST" ? new Uint8Array(body) : undefined,
|
|
408
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
409
|
+
});
|
|
410
|
+
if (item) {
|
|
411
|
+
item.status = response.status;
|
|
412
|
+
response.headers.forEach((value, key) => { item.responseHeaders[key] = value; });
|
|
413
|
+
}
|
|
414
|
+
res.statusCode = response.status;
|
|
415
|
+
response.headers.forEach((value, key) => {
|
|
416
|
+
if (!["content-length", "transfer-encoding", "connection", "content-encoding"].includes(key.toLowerCase()))
|
|
417
|
+
res.setHeader(key, value);
|
|
418
|
+
});
|
|
419
|
+
res.flushHeaders();
|
|
420
|
+
if (!response.body) {
|
|
421
|
+
res.end();
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
const reader = response.body.getReader();
|
|
425
|
+
const captured = [];
|
|
426
|
+
let capturedBytes = 0;
|
|
427
|
+
try {
|
|
428
|
+
while (true) {
|
|
429
|
+
const next = await reader.read();
|
|
430
|
+
if (next.done)
|
|
431
|
+
break;
|
|
432
|
+
const chunk = Buffer.from(next.value);
|
|
433
|
+
res.write(chunk);
|
|
434
|
+
if (item) {
|
|
435
|
+
item.responseBytes += chunk.length;
|
|
436
|
+
if (capturedBytes < this.maxBody) {
|
|
437
|
+
const part = chunk.subarray(0, Math.max(0, this.maxBody - capturedBytes));
|
|
438
|
+
captured.push(part);
|
|
439
|
+
capturedBytes += part.length;
|
|
440
|
+
item.responseBody = Buffer.concat(captured).toString("utf8");
|
|
441
|
+
item.responseTruncated = item.responseTruncated || capturedBytes < item.responseBytes;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
finally {
|
|
447
|
+
reader.releaseLock();
|
|
448
|
+
}
|
|
449
|
+
res.end();
|
|
450
|
+
}
|
|
451
|
+
async persist(item) {
|
|
452
|
+
const folder = join(this.dataDir, new Date().toISOString().slice(0, 10));
|
|
453
|
+
await mkdir(folder, { recursive: true, mode: 0o700 });
|
|
454
|
+
const path = join(folder, `${item.id}.json`);
|
|
455
|
+
await writeFile(path, JSON.stringify(item, null, 2), { encoding: "utf8", mode: 0o600 });
|
|
456
|
+
await chmod(path, 0o600).catch(() => undefined);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* oh-my-agent-web — browser <-> server wire protocol.
|
|
3
|
+
*
|
|
4
|
+
* The browser talks to OUR server (not directly to codex). Our server is a thin,
|
|
5
|
+
* transparent proxy in front of `codex app-server --listen ws://127.0.0.1:25258`:
|
|
6
|
+
*
|
|
7
|
+
* browser <--ws /ws--> oh-my-agent-web server <--ws--> codex app-server
|
|
8
|
+
*
|
|
9
|
+
* Because the proxy is transparent, the browser may invoke ANY codex app-server
|
|
10
|
+
* JSON-RPC method by name (`thread/start`, `turn/start`, `model/list`, ...) and
|
|
11
|
+
* receives every codex notification verbatim. The names/types below therefore
|
|
12
|
+
* mirror codex's own protocol; only the *envelope* is ours.
|
|
13
|
+
*
|
|
14
|
+
* Codex types can be regenerated with:
|
|
15
|
+
* codex app-server generate-ts --out shared/codex-ts
|
|
16
|
+
*
|
|
17
|
+
* Envelope rules
|
|
18
|
+
* --------------
|
|
19
|
+
* client -> server:
|
|
20
|
+
* { type: "rpc", requestId, method, params } invoke a codex method (or `omaw/*`)
|
|
21
|
+
* { type: "reply", id, result } answer a serverRequest (approval, user input)
|
|
22
|
+
* { type: "reply", id, error } reject a serverRequest
|
|
23
|
+
* { type: "ping" }
|
|
24
|
+
*
|
|
25
|
+
* server -> client:
|
|
26
|
+
* { type: "welcome", ... } handshake, sent once per connection
|
|
27
|
+
* { type: "status", codex } supervisor state, on change
|
|
28
|
+
* { type: "rpcResult", requestId, ok, ... } answer to our rpc
|
|
29
|
+
* { type: "event", method, params } codex notification (verbatim)
|
|
30
|
+
* { type: "serverRequest", id, method, params }codex -> client request (verbatim)
|
|
31
|
+
* { type: "pong" }
|
|
32
|
+
*/
|
|
33
|
+
export const PROTOCOL_VERSION = 1;
|
|
34
|
+
/* ------------------------------------------------------------------ */
|
|
35
|
+
/* local (`omaw/*`) methods — handled by OUR server, not proxied */
|
|
36
|
+
/* ------------------------------------------------------------------ */
|
|
37
|
+
export const LOCAL_METHODS = {
|
|
38
|
+
/** -> { status: CodexStatus } */
|
|
39
|
+
codexStatus: "omaw/codex/status",
|
|
40
|
+
/** -> { ok: true } — kill + respawn the app-server */
|
|
41
|
+
codexRestart: "omaw/codex/restart",
|
|
42
|
+
/** -> { lines: string[] } — tail of the app-server log */
|
|
43
|
+
codexLog: "omaw/codex/log",
|
|
44
|
+
/** { threadId?: string, limit?: number } -> request/response summaries */
|
|
45
|
+
requestLogsList: "omaw/request-logs/list",
|
|
46
|
+
/** { id: string } -> one full request/response exchange */
|
|
47
|
+
requestLogsDetail: "omaw/request-logs/detail",
|
|
48
|
+
/** { path?: string } -> { path, entries: [{name,path,kind,size,mtime}] } */
|
|
49
|
+
fsList: "omaw/fs/list",
|
|
50
|
+
/** { path: string, maxBytes?: number } -> { path, text, truncated } */
|
|
51
|
+
fsRead: "omaw/fs/read",
|
|
52
|
+
/** -> { cwd, home, codexHome } */
|
|
53
|
+
paths: "omaw/paths",
|
|
54
|
+
};
|
|
55
|
+
/* ------------------------------------------------------------------ */
|
|
56
|
+
/* codex method names we rely on — kept loose on purpose */
|
|
57
|
+
/* ------------------------------------------------------------------ */
|
|
58
|
+
export const CODEX = {
|
|
59
|
+
initialize: "initialize",
|
|
60
|
+
threadStart: "thread/start",
|
|
61
|
+
threadResume: "thread/resume",
|
|
62
|
+
threadRead: "thread/read",
|
|
63
|
+
threadList: "thread/list",
|
|
64
|
+
threadDelete: "thread/delete",
|
|
65
|
+
threadArchive: "thread/archive",
|
|
66
|
+
threadUnarchive: "thread/unarchive",
|
|
67
|
+
threadFork: "thread/fork",
|
|
68
|
+
threadSetName: "thread/name/set",
|
|
69
|
+
threadTurnsList: "thread/turns/list",
|
|
70
|
+
threadItemsList: "thread/items/list",
|
|
71
|
+
turnStart: "turn/start",
|
|
72
|
+
turnSteer: "turn/steer",
|
|
73
|
+
turnInterrupt: "turn/interrupt",
|
|
74
|
+
modelList: "model/list",
|
|
75
|
+
accountRead: "account/read",
|
|
76
|
+
accountRateLimits: "account/rateLimits/read",
|
|
77
|
+
getAuthStatus: "getAuthStatus",
|
|
78
|
+
configRead: "config/read",
|
|
79
|
+
fsReadFile: "fs/readFile",
|
|
80
|
+
fsReadDirectory: "fs/readDirectory",
|
|
81
|
+
fuzzyFileSearch: "fuzzyFileSearch",
|
|
82
|
+
};
|
|
83
|
+
/** codex notification methods that the UI cares about most. */
|
|
84
|
+
export const CODEX_EVENTS = {
|
|
85
|
+
threadStarted: "thread/started",
|
|
86
|
+
threadStatusChanged: "thread/status/changed",
|
|
87
|
+
threadNameUpdated: "thread/name/updated",
|
|
88
|
+
turnStarted: "turn/started",
|
|
89
|
+
turnCompleted: "turn/completed",
|
|
90
|
+
turnDiffUpdated: "turn/diff/updated",
|
|
91
|
+
turnPlanUpdated: "turn/plan/updated",
|
|
92
|
+
itemStarted: "item/started",
|
|
93
|
+
itemCompleted: "item/completed",
|
|
94
|
+
agentMessageDelta: "item/agentMessage/delta",
|
|
95
|
+
reasoningSummaryDelta: "item/reasoning/summaryTextDelta",
|
|
96
|
+
reasoningTextDelta: "item/reasoning/textDelta",
|
|
97
|
+
planDelta: "item/plan/delta",
|
|
98
|
+
commandOutputDelta: "item/commandExecution/outputDelta",
|
|
99
|
+
fileChangeDelta: "item/fileChange/outputDelta",
|
|
100
|
+
mcpProgress: "item/mcpToolCall/progress",
|
|
101
|
+
tokenUsage: "thread/tokenUsage/updated",
|
|
102
|
+
error: "error",
|
|
103
|
+
};
|
|
104
|
+
/** codex server -> client request methods (need a `reply`). */
|
|
105
|
+
export const CODEX_REQUESTS = {
|
|
106
|
+
commandApproval: "item/commandExecution/requestApproval",
|
|
107
|
+
fileChangeApproval: "item/fileChange/requestApproval",
|
|
108
|
+
permissionsApproval: "item/permissions/requestApproval",
|
|
109
|
+
toolInput: "item/tool/requestUserInput",
|
|
110
|
+
elicitation: "mcpServer/elicitation/request",
|
|
111
|
+
dynamicToolCall: "item/tool/call",
|
|
112
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "oh-my-agent-web",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Browser cockpit for the Codex CLI app-server — chat, tool calls, diffs and approvals in one tab",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"author": "duzhenxun",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/duzhenxun/oh-my-agent-web.git"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/duzhenxun/oh-my-agent-web#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/duzhenxun/oh-my-agent-web/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"codex",
|
|
18
|
+
"codex-cli",
|
|
19
|
+
"openai",
|
|
20
|
+
"app-server",
|
|
21
|
+
"web-ui",
|
|
22
|
+
"ai",
|
|
23
|
+
"chat",
|
|
24
|
+
"cli"
|
|
25
|
+
],
|
|
26
|
+
"bin": {
|
|
27
|
+
"oh-my-agent-web": "bin/oh-my-agent-web.mjs",
|
|
28
|
+
"omaw": "bin/oh-my-agent-web.mjs"
|
|
29
|
+
},
|
|
30
|
+
"main": "bin/oh-my-agent-web.mjs",
|
|
31
|
+
"files": [
|
|
32
|
+
"bin/",
|
|
33
|
+
"dist/",
|
|
34
|
+
"web/dist/",
|
|
35
|
+
"scripts/",
|
|
36
|
+
"shared/protocol.ts",
|
|
37
|
+
"README.md"
|
|
38
|
+
],
|
|
39
|
+
"engines": {
|
|
40
|
+
"node": ">=22.19.0"
|
|
41
|
+
},
|
|
42
|
+
"publishConfig": {
|
|
43
|
+
"access": "public"
|
|
44
|
+
},
|
|
45
|
+
"scripts": {
|
|
46
|
+
"dev": "cross-env OMAW_DEV_WEB=1 tsx watch server/index.ts",
|
|
47
|
+
"dev:web": "vite --config web/vite.config.ts",
|
|
48
|
+
"build": "npm run build:web && npm run build:server",
|
|
49
|
+
"build:web": "vite build --config web/vite.config.ts",
|
|
50
|
+
"build:server": "tsc -p tsconfig.server.json",
|
|
51
|
+
"prepack": "npm run build",
|
|
52
|
+
"start": "node bin/oh-my-agent-web.mjs",
|
|
53
|
+
"ws": "node bin/oh-my-agent-web.mjs ws",
|
|
54
|
+
"omaw": "node scripts/omaw.mjs",
|
|
55
|
+
"stop": "node scripts/manage-server.mjs stop",
|
|
56
|
+
"restart": "node scripts/manage-server.mjs restart",
|
|
57
|
+
"smoke": "tsx test/smoke.ts",
|
|
58
|
+
"typecheck": "tsc -p tsconfig.server.json --noEmit && tsc -p web/tsconfig.json --noEmit"
|
|
59
|
+
},
|
|
60
|
+
"dependencies": {
|
|
61
|
+
"express": "^4.21.2",
|
|
62
|
+
"ws": "^8.18.3"
|
|
63
|
+
},
|
|
64
|
+
"devDependencies": {
|
|
65
|
+
"@types/express": "^4.17.23",
|
|
66
|
+
"@types/node": "^22.15.3",
|
|
67
|
+
"@types/react": "^19.1.2",
|
|
68
|
+
"@types/react-dom": "^19.1.2",
|
|
69
|
+
"@types/ws": "^8.18.1",
|
|
70
|
+
"@vitejs/plugin-react": "^4.4.1",
|
|
71
|
+
"cross-env": "^7.0.3",
|
|
72
|
+
"highlight.js": "^11.11.1",
|
|
73
|
+
"react": "^19.1.0",
|
|
74
|
+
"react-dom": "^19.1.0",
|
|
75
|
+
"react-markdown": "^10.1.0",
|
|
76
|
+
"rehype-highlight": "^7.0.2",
|
|
77
|
+
"remark-gfm": "^4.0.1",
|
|
78
|
+
"tsx": "^4.19.4",
|
|
79
|
+
"typescript": "^5.8.3",
|
|
80
|
+
"vite": "^6.3.4"
|
|
81
|
+
}
|
|
82
|
+
}
|