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,593 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* oh-my-agent-web ws — 连接 codex app-server WebSocket 的命令行客户端。
|
|
4
|
+
*
|
|
5
|
+
* 参考 /Users/dds/data/ai/work/codex-ws-client.mjs,整理为 oh-my-agent-web 的子命令:
|
|
6
|
+
* oh-my-agent-web ws # 交互模式(Ctrl-C 中断当前 turn / Ctrl-D 退出)
|
|
7
|
+
* oh-my-agent-web ws "帮我看看这个目录" # 单次提问后退出
|
|
8
|
+
* echo "写个 hello world" | oh-my-agent-web ws # 从管道读
|
|
9
|
+
* oh-my-agent-web ws --port 25259 # 指定端口
|
|
10
|
+
* oh-my-agent-web ws --url ws://host:port # 指定完整地址
|
|
11
|
+
*
|
|
12
|
+
* 退出语义:turn 跑在 app-server 上而不在本进程里,所以直接掉线会留下一个没人看的
|
|
13
|
+
* 生成过程(继续烧 token)。因此所有退出路径都走 shutdown():先 `turn/interrupt`,
|
|
14
|
+
* 再关连接。Ctrl-C 第一次 = 中断+关闭,第二次 = 立即退出。
|
|
15
|
+
*
|
|
16
|
+
* 仅用 Node 内置能力(WebSocket 需要 Node >= 22),无额外依赖。
|
|
17
|
+
*/
|
|
18
|
+
import readline from "node:readline";
|
|
19
|
+
import { pathToFileURL } from "node:url";
|
|
20
|
+
|
|
21
|
+
const DEFAULT_HOST = "127.0.0.1";
|
|
22
|
+
const DEFAULT_PORT = 25258;
|
|
23
|
+
|
|
24
|
+
const HELP = `oh-my-agent-web ws — connect to the codex app-server over WebSocket
|
|
25
|
+
|
|
26
|
+
Usage:
|
|
27
|
+
oh-my-agent-web ws [options] ["message" ...]
|
|
28
|
+
|
|
29
|
+
Connection:
|
|
30
|
+
--url <ws://host:port> Full address (also accepts bare host:port). Default ws://${DEFAULT_HOST}:${DEFAULT_PORT}
|
|
31
|
+
--addr <host:port> Address + port (same as --host + --port)
|
|
32
|
+
--host <host> Host, default ${DEFAULT_HOST}
|
|
33
|
+
-p, --port <n> Port, default ${DEFAULT_PORT}
|
|
34
|
+
Env: CODEX_WS_URL / CODEX_WS_HOST / CODEX_WS_PORT
|
|
35
|
+
|
|
36
|
+
Session:
|
|
37
|
+
--cwd <dir> Working directory (default: current dir)
|
|
38
|
+
-t, --thread <id> Resume an existing thread
|
|
39
|
+
--model <name> Model id
|
|
40
|
+
--sandbox <mode> read-only | workspace-write | danger-full-access
|
|
41
|
+
--approval <mode> untrusted | on-request | on-failure | never
|
|
42
|
+
-y, --auto-approve Auto-accept approvals (acceptForSession)
|
|
43
|
+
--decline Auto-decline approvals
|
|
44
|
+
-v, --verbose Print reasoning deltas
|
|
45
|
+
--json Print raw event JSON only
|
|
46
|
+
--timeout <sec> Max wait for a turn, default 1800
|
|
47
|
+
-h, --help Show this help
|
|
48
|
+
`;
|
|
49
|
+
|
|
50
|
+
/** Parse `oh-my-agent-web ws [ ... ]` arguments. */
|
|
51
|
+
export function parseWsArgs(argv) {
|
|
52
|
+
const opts = {
|
|
53
|
+
url: null,
|
|
54
|
+
host: process.env.CODEX_WS_HOST || DEFAULT_HOST,
|
|
55
|
+
port: Number(process.env.CODEX_WS_PORT) || DEFAULT_PORT,
|
|
56
|
+
hostExplicit: false,
|
|
57
|
+
portExplicit: false,
|
|
58
|
+
cwd: process.cwd(),
|
|
59
|
+
thread: null,
|
|
60
|
+
model: null,
|
|
61
|
+
sandbox: null,
|
|
62
|
+
approval: null,
|
|
63
|
+
autoApprove: false,
|
|
64
|
+
autoDecline: false,
|
|
65
|
+
verbose: false,
|
|
66
|
+
json: false,
|
|
67
|
+
timeout: 1800,
|
|
68
|
+
messages: [],
|
|
69
|
+
help: false,
|
|
70
|
+
};
|
|
71
|
+
for (let i = 0; i < argv.length; i++) {
|
|
72
|
+
const a = argv[i];
|
|
73
|
+
const next = () => argv[++i];
|
|
74
|
+
switch (a) {
|
|
75
|
+
case "--url":
|
|
76
|
+
opts.url = next();
|
|
77
|
+
break;
|
|
78
|
+
case "--host":
|
|
79
|
+
case "--addr": {
|
|
80
|
+
const v = String(next() ?? "").trim().replace(/^\[|\]$/g, "");
|
|
81
|
+
const m = v.match(/^(\[?[^\]]+\]?):(\d+)$/);
|
|
82
|
+
if (m) {
|
|
83
|
+
opts.host = m[1].replace(/^\[|\]$/g, "");
|
|
84
|
+
opts.port = Number(m[2]);
|
|
85
|
+
opts.hostExplicit = true;
|
|
86
|
+
opts.portExplicit = true;
|
|
87
|
+
} else if (/^\d+$/.test(v)) {
|
|
88
|
+
opts.port = Number(v);
|
|
89
|
+
opts.portExplicit = true;
|
|
90
|
+
} else {
|
|
91
|
+
opts.host = v;
|
|
92
|
+
opts.hostExplicit = true;
|
|
93
|
+
}
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
case "-p":
|
|
97
|
+
case "--port":
|
|
98
|
+
opts.port = Number(next());
|
|
99
|
+
opts.portExplicit = true;
|
|
100
|
+
break;
|
|
101
|
+
case "--cwd":
|
|
102
|
+
opts.cwd = next();
|
|
103
|
+
break;
|
|
104
|
+
case "-t":
|
|
105
|
+
case "--thread":
|
|
106
|
+
opts.thread = next();
|
|
107
|
+
break;
|
|
108
|
+
case "--model":
|
|
109
|
+
opts.model = next();
|
|
110
|
+
break;
|
|
111
|
+
case "--sandbox":
|
|
112
|
+
opts.sandbox = next();
|
|
113
|
+
break;
|
|
114
|
+
case "--approval":
|
|
115
|
+
opts.approval = next();
|
|
116
|
+
break;
|
|
117
|
+
case "--auto-approve":
|
|
118
|
+
case "-y":
|
|
119
|
+
opts.autoApprove = true;
|
|
120
|
+
break;
|
|
121
|
+
case "--decline":
|
|
122
|
+
opts.autoDecline = true;
|
|
123
|
+
break;
|
|
124
|
+
case "--verbose":
|
|
125
|
+
case "-v":
|
|
126
|
+
opts.verbose = true;
|
|
127
|
+
break;
|
|
128
|
+
case "--json":
|
|
129
|
+
opts.json = true;
|
|
130
|
+
break;
|
|
131
|
+
case "--timeout":
|
|
132
|
+
opts.timeout = Number(next());
|
|
133
|
+
break;
|
|
134
|
+
case "-h":
|
|
135
|
+
case "--help":
|
|
136
|
+
opts.help = true;
|
|
137
|
+
break;
|
|
138
|
+
default:
|
|
139
|
+
opts.messages.push(a);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return opts;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Normalise a raw address into a ws:// URL (accepts ws/wss/http/https/bare host:port). */
|
|
146
|
+
function normalizeWsUrl(raw) {
|
|
147
|
+
if (/^wss?:\/\//i.test(raw)) return raw;
|
|
148
|
+
if (/^https?:\/\//i.test(raw)) return raw.replace(/^http/i, "ws");
|
|
149
|
+
return "ws://" + raw;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Combine --url / --host / --port / env into a single ws:// URL.
|
|
153
|
+
*
|
|
154
|
+
* Priority: --url > --host/--port > env (CODEX_WS_URL / CODEX_WS_HOST / CODEX_WS_PORT) > defaults.
|
|
155
|
+
*/
|
|
156
|
+
export function resolveWsUrl(o) {
|
|
157
|
+
const flagUrl = String(o.url ?? "").trim().replace(/^["']|["']$/g, "");
|
|
158
|
+
if (flagUrl) return normalizeWsUrl(flagUrl);
|
|
159
|
+
|
|
160
|
+
// An explicit --host/--port (or --addr) must beat an env URL.
|
|
161
|
+
const explicitHostPort = Boolean(o.hostExplicit || o.portExplicit);
|
|
162
|
+
if (!explicitHostPort) {
|
|
163
|
+
const envUrl = String(process.env.CODEX_WS_URL ?? "").trim().replace(/^["']|["']$/g, "");
|
|
164
|
+
if (envUrl) return normalizeWsUrl(envUrl);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (!Number.isInteger(o.port) || o.port < 1 || o.port > 65535) {
|
|
168
|
+
throw new Error(`invalid port: ${o.port} (expected 1-65535)`);
|
|
169
|
+
}
|
|
170
|
+
const host = o.host.includes(":") && !o.host.startsWith("[") ? `[${o.host}]` : o.host;
|
|
171
|
+
return `ws://${host}:${o.port}`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
class CodexClient {
|
|
175
|
+
#listeners = new Map();
|
|
176
|
+
|
|
177
|
+
constructor(url) {
|
|
178
|
+
this.url = url;
|
|
179
|
+
this.ws = null;
|
|
180
|
+
this.nextId = 1;
|
|
181
|
+
this.pending = new Map();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
on(name, fn, { once = false } = {}) {
|
|
185
|
+
const list = this.#listeners.get(name) ?? [];
|
|
186
|
+
list.push({ fn, once });
|
|
187
|
+
this.#listeners.set(name, list);
|
|
188
|
+
return () => this.off(name, fn);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
off(name, fn) {
|
|
192
|
+
const list = this.#listeners.get(name) ?? [];
|
|
193
|
+
this.#listeners.set(name, list.filter((l) => l.fn !== fn));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
emit(name, detail) {
|
|
197
|
+
for (const l of [...(this.#listeners.get(name) ?? [])]) {
|
|
198
|
+
if (l.once) this.off(name, l.fn);
|
|
199
|
+
try {
|
|
200
|
+
l.fn({ detail });
|
|
201
|
+
} catch (e) {
|
|
202
|
+
log(`[listener error] ${e.stack}`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
connect() {
|
|
208
|
+
return new Promise((resolve, reject) => {
|
|
209
|
+
const ws = new WebSocket(this.url);
|
|
210
|
+
this.ws = ws;
|
|
211
|
+
ws.onopen = () => {
|
|
212
|
+
log(`[connected] ${this.url}`);
|
|
213
|
+
resolve();
|
|
214
|
+
};
|
|
215
|
+
ws.onerror = (e) => reject(new Error(`WebSocket connect failed: ${e.message ?? e.type}`));
|
|
216
|
+
ws.onclose = (e) => {
|
|
217
|
+
this.emit("closed", { code: e.code, reason: e.reason });
|
|
218
|
+
for (const { reject: rj } of this.pending.values()) rj(new Error("connection closed"));
|
|
219
|
+
this.pending.clear();
|
|
220
|
+
};
|
|
221
|
+
ws.onmessage = (e) => this.#onMessage(String(e.data));
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
#onMessage(raw) {
|
|
226
|
+
let msg;
|
|
227
|
+
try {
|
|
228
|
+
msg = JSON.parse(raw);
|
|
229
|
+
} catch {
|
|
230
|
+
return this.emit("raw", raw);
|
|
231
|
+
}
|
|
232
|
+
// 1) response to one of our requests
|
|
233
|
+
if (msg.id !== undefined && msg.method === undefined) {
|
|
234
|
+
const p = this.pending.get(msg.id);
|
|
235
|
+
if (p) {
|
|
236
|
+
this.pending.delete(msg.id);
|
|
237
|
+
msg.error ? p.reject(new Error(JSON.stringify(msg.error))) : p.resolve(msg.result);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
// 2) server -> client request (needs a reply)
|
|
242
|
+
if (msg.id !== undefined && msg.method) return this.emit("serverRequest", msg);
|
|
243
|
+
// 3) notification
|
|
244
|
+
this.emit("notification", msg);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
send(msg) {
|
|
248
|
+
this.ws.send(JSON.stringify(msg));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
request(method, params) {
|
|
252
|
+
const id = this.nextId++;
|
|
253
|
+
return new Promise((resolve, reject) => {
|
|
254
|
+
this.pending.set(id, { resolve, reject });
|
|
255
|
+
this.send({ method, id, params });
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
respond(id, result) {
|
|
260
|
+
this.send({ id, result });
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
close() {
|
|
264
|
+
try {
|
|
265
|
+
this.ws?.close();
|
|
266
|
+
} catch {
|
|
267
|
+
/* ignore */
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const log = (...a) => process.stderr.write(a.join(" ") + "\n");
|
|
273
|
+
|
|
274
|
+
/** Run the ws client. Resolves when the session ends. */
|
|
275
|
+
export async function runWsClient(argv) {
|
|
276
|
+
const opts = parseWsArgs(argv);
|
|
277
|
+
|
|
278
|
+
if (opts.help) {
|
|
279
|
+
process.stdout.write(HELP);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
let url;
|
|
284
|
+
try {
|
|
285
|
+
url = resolveWsUrl(opts);
|
|
286
|
+
} catch (e) {
|
|
287
|
+
console.error(`[oh-my-agent-web ws] ${e.message}`);
|
|
288
|
+
process.exitCode = 2;
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const client = new CodexClient(url);
|
|
293
|
+
|
|
294
|
+
/* -------- exit / Ctrl-C --------
|
|
295
|
+
* A turn lives on the app-server, not in this process: dropping the socket
|
|
296
|
+
* mid-turn would leave the agent generating (and billing) with nobody
|
|
297
|
+
* watching — and it stays that way, since nothing tells codex to stop. So
|
|
298
|
+
* every exit path funnels through `shutdown()`: interrupt the in-flight turn
|
|
299
|
+
* first, then close the socket. A second Ctrl-C stops waiting for the
|
|
300
|
+
* interrupt round-trip and exits immediately.
|
|
301
|
+
*/
|
|
302
|
+
let activeTurn = null; // { threadId, turnId } while a turn is in flight
|
|
303
|
+
let socketClosed = false;
|
|
304
|
+
let shuttingDown = false;
|
|
305
|
+
let interrupts = 0;
|
|
306
|
+
|
|
307
|
+
const waitForSocketClose = (timeoutMs = 500) =>
|
|
308
|
+
new Promise((resolve) => {
|
|
309
|
+
if (socketClosed) return resolve();
|
|
310
|
+
const done = () => {
|
|
311
|
+
clearTimeout(timer);
|
|
312
|
+
resolve();
|
|
313
|
+
};
|
|
314
|
+
const timer = setTimeout(done, timeoutMs);
|
|
315
|
+
client.on("closed", done, { once: true });
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
client.on("closed", (ev) => {
|
|
319
|
+
socketClosed = true;
|
|
320
|
+
if (shuttingDown) return;
|
|
321
|
+
const { code, reason } = ev.detail ?? {};
|
|
322
|
+
const detail = code ? ` (code=${code})` : "";
|
|
323
|
+
log(`\x1b[31m[closed] connection closed${detail}${reason ? ` ${reason}` : ""}\x1b[0m`);
|
|
324
|
+
void shutdown(1);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
/** Interrupt whatever is running, close the socket, exit. Idempotent. */
|
|
328
|
+
async function shutdown(code, reason) {
|
|
329
|
+
if (shuttingDown) return;
|
|
330
|
+
shuttingDown = true;
|
|
331
|
+
if (reason) log(reason);
|
|
332
|
+
const turn = activeTurn;
|
|
333
|
+
if (turn?.turnId && !socketClosed) {
|
|
334
|
+
try {
|
|
335
|
+
await client.request("turn/interrupt", { threadId: turn.threadId, turnId: turn.turnId });
|
|
336
|
+
log(`\x1b[2m[interrupt] turn ${turn.turnId} interrupted\x1b[0m`);
|
|
337
|
+
} catch (e) {
|
|
338
|
+
log(`\x1b[31m[interrupt] failed: ${e.message}\x1b[0m`);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
activeTurn = null;
|
|
342
|
+
client.close();
|
|
343
|
+
await waitForSocketClose();
|
|
344
|
+
process.exit(code);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function handleInterrupt(signal) {
|
|
348
|
+
interrupts += 1;
|
|
349
|
+
if (interrupts > 1) {
|
|
350
|
+
log(`\n\x1b[31m[${signal}] again — exiting now\x1b[0m`);
|
|
351
|
+
client.close();
|
|
352
|
+
process.exit(130);
|
|
353
|
+
}
|
|
354
|
+
void shutdown(130, `\n\x1b[2m[${signal}] interrupting current turn and closing…\x1b[0m`);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// readline 在 TTY 下会自己接管 Ctrl-C(raw mode 下不会产生进程级 SIGINT),
|
|
358
|
+
// 所以进程级和 rl 级都要挂,靠 shuttingDown / interrupts 去重。
|
|
359
|
+
process.on("SIGINT", () => handleInterrupt("SIGINT"));
|
|
360
|
+
process.on("SIGTERM", () => handleInterrupt("SIGTERM"));
|
|
361
|
+
try {
|
|
362
|
+
await client.connect();
|
|
363
|
+
} catch (e) {
|
|
364
|
+
console.error(`[oh-my-agent-web ws] failed to connect ${url} — ${e.message}`);
|
|
365
|
+
console.error(" make sure the app-server is running, e.g. `oh-my-agent-web` (default ws://127.0.0.1:25258)");
|
|
366
|
+
process.exitCode = 1;
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const init = await client.request("initialize", {
|
|
371
|
+
clientInfo: { name: "oh-my-agent-web-ws", title: "Oh My Agent Web WS Client", version: "1.0.0" },
|
|
372
|
+
capabilities: { experimentalApi: true, requestAttestation: false },
|
|
373
|
+
});
|
|
374
|
+
log(`[server] ${init.userAgent} codexHome=${init.codexHome}`);
|
|
375
|
+
|
|
376
|
+
const threadParams = {
|
|
377
|
+
...(opts.model ? { model: opts.model } : {}),
|
|
378
|
+
...(opts.cwd ? { cwd: opts.cwd } : {}),
|
|
379
|
+
...(opts.approval ? { approvalPolicy: opts.approval } : {}),
|
|
380
|
+
...(opts.sandbox ? { sandbox: opts.sandbox } : {}),
|
|
381
|
+
};
|
|
382
|
+
const threadResp = opts.thread
|
|
383
|
+
? await client.request("thread/resume", { threadId: opts.thread, ...threadParams })
|
|
384
|
+
: await client.request("thread/start", threadParams);
|
|
385
|
+
|
|
386
|
+
const threadId = threadResp.thread.id;
|
|
387
|
+
log(
|
|
388
|
+
`[thread] ${threadId} model=${threadResp.model} cwd=${threadResp.cwd} sandbox=${JSON.stringify(threadResp.sandbox)}`,
|
|
389
|
+
);
|
|
390
|
+
|
|
391
|
+
/* -------- event rendering -------- */
|
|
392
|
+
let inAgentMessage = false;
|
|
393
|
+
let inReasoning = false;
|
|
394
|
+
let pendingApproval = null;
|
|
395
|
+
let rl = null;
|
|
396
|
+
|
|
397
|
+
client.on("notification", (ev) => {
|
|
398
|
+
const { method, params } = ev.detail;
|
|
399
|
+
if (opts.json) return void process.stdout.write(JSON.stringify(ev.detail) + "\n");
|
|
400
|
+
|
|
401
|
+
switch (method) {
|
|
402
|
+
case "item/agentMessage/delta":
|
|
403
|
+
if (!inAgentMessage) {
|
|
404
|
+
process.stdout.write("\n");
|
|
405
|
+
inAgentMessage = true;
|
|
406
|
+
}
|
|
407
|
+
process.stdout.write(params.delta);
|
|
408
|
+
break;
|
|
409
|
+
|
|
410
|
+
case "item/reasoning/summaryTextDelta":
|
|
411
|
+
case "item/reasoning/textDelta":
|
|
412
|
+
if (!opts.verbose) break;
|
|
413
|
+
if (inAgentMessage) {
|
|
414
|
+
process.stdout.write("\n");
|
|
415
|
+
inAgentMessage = false;
|
|
416
|
+
}
|
|
417
|
+
if (!inReasoning) {
|
|
418
|
+
process.stdout.write("\x1b[2m💭 ");
|
|
419
|
+
inReasoning = true;
|
|
420
|
+
}
|
|
421
|
+
process.stdout.write(params.delta);
|
|
422
|
+
break;
|
|
423
|
+
|
|
424
|
+
case "item/started": {
|
|
425
|
+
const it = params.item ?? {};
|
|
426
|
+
if (inAgentMessage) {
|
|
427
|
+
process.stdout.write("\n");
|
|
428
|
+
inAgentMessage = false;
|
|
429
|
+
}
|
|
430
|
+
if (inReasoning) {
|
|
431
|
+
process.stdout.write("\x1b[0m\n");
|
|
432
|
+
inReasoning = false;
|
|
433
|
+
}
|
|
434
|
+
if (it.type === "commandExecution") log(`\x1b[36m▶ exec\x1b[0m ${it.command ?? ""}`);
|
|
435
|
+
else if (it.type === "fileChange") log(`\x1b[33m✎ patch\x1b[0m ${(it.changes ?? []).length} file(s)`);
|
|
436
|
+
else if (it.type === "mcpToolCall" || it.type === "dynamicToolCall") log(`\x1b[35m⚙ tool\x1b[0m ${it.tool ?? it.name ?? ""}`);
|
|
437
|
+
break;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
case "item/completed": {
|
|
441
|
+
const it = params.item ?? {};
|
|
442
|
+
if (inReasoning) {
|
|
443
|
+
process.stdout.write("\x1b[0m");
|
|
444
|
+
inReasoning = false;
|
|
445
|
+
}
|
|
446
|
+
if (it.type === "commandExecution") log(`\x1b[36m◀ exec done\x1b[0m exit=${it.exitCode ?? "?"}`);
|
|
447
|
+
break;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
case "turn/completed": {
|
|
451
|
+
if (inAgentMessage) {
|
|
452
|
+
process.stdout.write("\n");
|
|
453
|
+
inAgentMessage = false;
|
|
454
|
+
}
|
|
455
|
+
if (inReasoning) {
|
|
456
|
+
process.stdout.write("\x1b[0m\n");
|
|
457
|
+
inReasoning = false;
|
|
458
|
+
}
|
|
459
|
+
const t = params.turn ?? {};
|
|
460
|
+
const err = t.error ? ` error=${JSON.stringify(t.error)}` : "";
|
|
461
|
+
log(`\x1b[2m── turn ${t.status ?? "done"}${err} ──\x1b[0m`);
|
|
462
|
+
client.emit("turnDone", t);
|
|
463
|
+
break;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
case "error":
|
|
467
|
+
log(`\x1b[31m[error] ${JSON.stringify(params)}\x1b[0m`);
|
|
468
|
+
break;
|
|
469
|
+
|
|
470
|
+
default:
|
|
471
|
+
break;
|
|
472
|
+
}
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
function decide(id, decision) {
|
|
476
|
+
pendingApproval = null;
|
|
477
|
+
client.respond(id, { decision });
|
|
478
|
+
log(` → ${decision}`);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
client.on("serverRequest", async (ev) => {
|
|
482
|
+
const { id, method, params } = ev.detail;
|
|
483
|
+
if (opts.json) process.stdout.write(JSON.stringify(ev.detail) + "\n");
|
|
484
|
+
|
|
485
|
+
if (opts.autoApprove) return decide(id, "acceptForSession");
|
|
486
|
+
if (opts.autoDecline) return decide(id, "decline");
|
|
487
|
+
|
|
488
|
+
if (method === "item/commandExecution/requestApproval") {
|
|
489
|
+
log(`\x1b[33m⚠ approval needed:\x1b[0m ${params.command ?? ""} (cwd=${params.cwd ?? ""})${params.reason ? " reason: " + params.reason : ""}`);
|
|
490
|
+
} else if (method === "item/fileChange/requestApproval") {
|
|
491
|
+
log(`\x1b[33m⚠ file change approval\x1b[0m${params.reason ? " reason: " + params.reason : ""}`);
|
|
492
|
+
} else {
|
|
493
|
+
log(`\x1b[33m⚠ server request ${method}:\x1b[0m ${JSON.stringify(params).slice(0, 400)}`);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
if (process.stdin.isTTY && rl) {
|
|
497
|
+
pendingApproval = { id, method };
|
|
498
|
+
rl.setPrompt("approve? [y=once / a=session / n=decline] > ");
|
|
499
|
+
rl.prompt();
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
log(" non-interactive, declining by default (use --auto-approve / --decline)");
|
|
503
|
+
decide(id, "decline");
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
async function ask(text) {
|
|
507
|
+
const done = new Promise((resolve) => client.on("turnDone", resolve, { once: true }));
|
|
508
|
+
const started = await client.request("turn/start", {
|
|
509
|
+
threadId,
|
|
510
|
+
input: [{ type: "text", text, text_elements: [] }],
|
|
511
|
+
});
|
|
512
|
+
activeTurn = { threadId, turnId: started?.turn?.id ?? null };
|
|
513
|
+
const timer = setTimeout(() => {
|
|
514
|
+
log(`\x1b[31m[timeout] turn did not finish within ${opts.timeout}s\x1b[0m`);
|
|
515
|
+
client.emit("turnDone", { status: "timeout" });
|
|
516
|
+
}, opts.timeout * 1000);
|
|
517
|
+
await done;
|
|
518
|
+
activeTurn = null;
|
|
519
|
+
clearTimeout(timer);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function setupRepl() {
|
|
523
|
+
rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: process.stdin.isTTY });
|
|
524
|
+
rl.on("line", async (line) => {
|
|
525
|
+
const text = line.trim();
|
|
526
|
+
if (!text) {
|
|
527
|
+
if (process.stdin.isTTY) rl.prompt();
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
if (pendingApproval) {
|
|
531
|
+
if (/^(y|yes)$/i.test(text)) decide(pendingApproval.id, "accept");
|
|
532
|
+
else if (/^(a|all)$/i.test(text)) decide(pendingApproval.id, "acceptForSession");
|
|
533
|
+
else decide(pendingApproval.id, "decline");
|
|
534
|
+
if (process.stdin.isTTY) rl.prompt();
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
rl.pause();
|
|
538
|
+
try {
|
|
539
|
+
await ask(text);
|
|
540
|
+
} catch (e) {
|
|
541
|
+
log(`\x1b[31m[error] ${e.message}\x1b[0m`);
|
|
542
|
+
}
|
|
543
|
+
rl.resume();
|
|
544
|
+
if (process.stdin.isTTY) rl.prompt();
|
|
545
|
+
});
|
|
546
|
+
rl.on("SIGINT", () => handleInterrupt("SIGINT"));
|
|
547
|
+
rl.on("close", () => {
|
|
548
|
+
void shutdown(0);
|
|
549
|
+
});
|
|
550
|
+
if (process.stdin.isTTY) {
|
|
551
|
+
log("\ntype a message and press Enter (Ctrl-C: interrupt & close / Ctrl-D: exit). approvals: y=once / a=session / n=decline\n");
|
|
552
|
+
rl.prompt();
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
if (opts.messages.length) {
|
|
557
|
+
for (const msg of opts.messages) {
|
|
558
|
+
try {
|
|
559
|
+
await ask(msg);
|
|
560
|
+
} catch (e) {
|
|
561
|
+
log(`\x1b[31m[error] ${e.message}\x1b[0m`);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
await shutdown(0);
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
if (process.stdin.isTTY) {
|
|
568
|
+
setupRepl();
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
// piped stdin: the whole input is one message
|
|
572
|
+
let buf = "";
|
|
573
|
+
process.stdin.setEncoding("utf8");
|
|
574
|
+
for await (const chunk of process.stdin) buf += chunk;
|
|
575
|
+
const text = buf.trim();
|
|
576
|
+
if (text) {
|
|
577
|
+
try {
|
|
578
|
+
await ask(text);
|
|
579
|
+
} catch (e) {
|
|
580
|
+
log(`\x1b[31m[error] ${e.message}\x1b[0m`);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
await shutdown(0);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// Allow running this file directly: `node bin/oh-my-agent-web-ws.mjs ...`
|
|
587
|
+
const invoked = process.argv[1] ? pathToFileURL(process.argv[1]).href : "";
|
|
588
|
+
if (invoked === import.meta.url) {
|
|
589
|
+
runWsClient(process.argv.slice(2)).catch((err) => {
|
|
590
|
+
console.error(`[oh-my-agent-web ws] ${err instanceof Error ? err.message : String(err)}`);
|
|
591
|
+
process.exit(1);
|
|
592
|
+
});
|
|
593
|
+
}
|