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,446 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CodexSupervisor — manages the `codex app-server` child process.
|
|
3
|
+
*
|
|
4
|
+
* 职责:
|
|
5
|
+
* - 启动时优先探测端口上是否已有健康的 app-server;有则复用(external attach),
|
|
6
|
+
* 绝不重复 spawn(用户自己起的 / 上一次遗留的 daemon 都能被复用)。
|
|
7
|
+
* - 否则 spawn `codex app-server --listen ws://127.0.0.1:<port>`。
|
|
8
|
+
* - readiness:同时看 stdout 里的 `listening on:` 与轮询 `/readyz`(措辞可能变)。
|
|
9
|
+
* - stdout/stderr 进环形缓冲(最近 500 行),供 `omaw/codex/log` 读取。
|
|
10
|
+
* - 意外退出时指数退避重启(上限 10s),并累加 restarts。主动 stop 不重启。
|
|
11
|
+
* - 只 kill 我们自己 spawn 的子进程。
|
|
12
|
+
*
|
|
13
|
+
* 状态里 `pid === null && phase === "ready"` 表示复用了外部已有的 app-server。
|
|
14
|
+
*/
|
|
15
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
16
|
+
import { EventEmitter } from "node:events";
|
|
17
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
18
|
+
import { get as httpGet } from "node:http";
|
|
19
|
+
import { delimiter, isAbsolute, join } from "node:path";
|
|
20
|
+
import { homedir } from "node:os";
|
|
21
|
+
const LOG_CAP = 500;
|
|
22
|
+
const READY_TIMEOUT_MS = 20_000;
|
|
23
|
+
const PROBE_TIMEOUT_MS = 1_000;
|
|
24
|
+
const PROBE_INTERVAL_MS = 250;
|
|
25
|
+
const RESTART_BASE_MS = 500;
|
|
26
|
+
const RESTART_MAX_MS = 10_000;
|
|
27
|
+
const STOP_GRACE_MS = 5_000;
|
|
28
|
+
function probeReadyz(port, timeoutMs = PROBE_TIMEOUT_MS) {
|
|
29
|
+
return new Promise((resolve) => {
|
|
30
|
+
let settled = false;
|
|
31
|
+
const done = (ok) => {
|
|
32
|
+
if (settled)
|
|
33
|
+
return;
|
|
34
|
+
settled = true;
|
|
35
|
+
resolve(ok);
|
|
36
|
+
};
|
|
37
|
+
const req = httpGet({ host: "127.0.0.1", port, path: "/readyz", timeout: timeoutMs }, (res) => {
|
|
38
|
+
res.resume();
|
|
39
|
+
done(res.statusCode === 200);
|
|
40
|
+
});
|
|
41
|
+
req.on("timeout", () => {
|
|
42
|
+
req.destroy();
|
|
43
|
+
done(false);
|
|
44
|
+
});
|
|
45
|
+
req.on("error", () => done(false));
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
function delay(ms) {
|
|
49
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
50
|
+
}
|
|
51
|
+
function isDir(p) {
|
|
52
|
+
try {
|
|
53
|
+
return statSync(p).isDirectory();
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* 解析 codex 可执行文件。
|
|
61
|
+
*
|
|
62
|
+
* 为什么要这么麻烦:spawn 一个 PATH 里找不到的命令只会抛一个没有任何上下文的
|
|
63
|
+
* `spawn codex ENOENT` —— 从 launchd / 桌面图标启动时 PATH 往往是极简的,
|
|
64
|
+
* 用户会看到“找不到 codex”却不知道原因。所以显式地:
|
|
65
|
+
* 1. 先尊重 `OMAW_CODEX_BIN`(含 `/` 就直接用);
|
|
66
|
+
* 2. 再 `which`;
|
|
67
|
+
* 3. 最后扫常见安装位置(nvm / homebrew / local / bun / volta)。
|
|
68
|
+
* 全部失败时回退到原名,让 spawn 自己报错,但日志里已给出可诊断信息。
|
|
69
|
+
*/
|
|
70
|
+
function resolveBin(bin) {
|
|
71
|
+
if (isAbsolute(bin) || bin.includes("/"))
|
|
72
|
+
return { bin, note: null };
|
|
73
|
+
const which = () => {
|
|
74
|
+
try {
|
|
75
|
+
const out = execFileSync(process.platform === "win32" ? "where" : "which", [bin], {
|
|
76
|
+
encoding: "utf8",
|
|
77
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
78
|
+
});
|
|
79
|
+
const first = out.split(/\r?\n/).find((line) => line.trim());
|
|
80
|
+
return first ? first.trim() : null;
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
const found = which();
|
|
87
|
+
if (found)
|
|
88
|
+
return { bin: found, note: null };
|
|
89
|
+
const home = homedir();
|
|
90
|
+
const candidates = [
|
|
91
|
+
join(home, ".local", "bin", bin),
|
|
92
|
+
"/opt/homebrew/bin/" + bin,
|
|
93
|
+
"/usr/local/bin/" + bin,
|
|
94
|
+
join(home, ".bun", "bin", bin),
|
|
95
|
+
join(home, ".volta", "bin", bin),
|
|
96
|
+
];
|
|
97
|
+
try {
|
|
98
|
+
const nvmDir = join(home, ".nvm", "versions", "node");
|
|
99
|
+
for (const version of readdirSync(nvmDir)) {
|
|
100
|
+
candidates.push(join(nvmDir, version, "bin", bin));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
/* no nvm — fine */
|
|
105
|
+
}
|
|
106
|
+
for (const candidate of candidates) {
|
|
107
|
+
if (existsSync(candidate)) {
|
|
108
|
+
return { bin: candidate, note: `PATH 中未找到 \`${bin}\`,改用 ${candidate}` };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
bin,
|
|
113
|
+
note: `PATH 中未找到 \`${bin}\`(PATH=${(process.env.PATH ?? "").split(delimiter).join(":")});可用 OMAW_CODEX_BIN 指定绝对路径`,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
export class CodexSupervisor extends EventEmitter {
|
|
117
|
+
url;
|
|
118
|
+
port;
|
|
119
|
+
opts;
|
|
120
|
+
child = null;
|
|
121
|
+
/** true 表示 child 是本进程 spawn 的(只有此时才允许 kill)。 */
|
|
122
|
+
managed = false;
|
|
123
|
+
stopping = false;
|
|
124
|
+
restartTimer = null;
|
|
125
|
+
readyDone = false;
|
|
126
|
+
stdoutSawListening = false;
|
|
127
|
+
restarts = 0;
|
|
128
|
+
logs = [];
|
|
129
|
+
_status;
|
|
130
|
+
constructor(opts) {
|
|
131
|
+
super();
|
|
132
|
+
this.opts = opts;
|
|
133
|
+
this.port = opts.port;
|
|
134
|
+
this.url = `ws://127.0.0.1:${opts.port}`;
|
|
135
|
+
this._status = {
|
|
136
|
+
phase: "starting",
|
|
137
|
+
url: this.url,
|
|
138
|
+
pid: null,
|
|
139
|
+
connected: false,
|
|
140
|
+
error: null,
|
|
141
|
+
since: null,
|
|
142
|
+
restarts: 0,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
status() {
|
|
146
|
+
return { ...this._status };
|
|
147
|
+
}
|
|
148
|
+
/** pid 为空但 ready —— 说明复用了外部进程,我们不拥有它。 */
|
|
149
|
+
isExternal() {
|
|
150
|
+
return this.managed === false && this._status.phase === "ready" && this.child === null;
|
|
151
|
+
}
|
|
152
|
+
onStatus(cb) {
|
|
153
|
+
this.on("status", cb);
|
|
154
|
+
return () => this.off("status", cb);
|
|
155
|
+
}
|
|
156
|
+
onLog(cb) {
|
|
157
|
+
this.on("log", cb);
|
|
158
|
+
return () => this.off("log", cb);
|
|
159
|
+
}
|
|
160
|
+
logLines() {
|
|
161
|
+
return [...this.logs];
|
|
162
|
+
}
|
|
163
|
+
setStatus(patch) {
|
|
164
|
+
this._status = { ...this._status, ...patch, restarts: this.restarts };
|
|
165
|
+
this.emit("status", this.status());
|
|
166
|
+
}
|
|
167
|
+
log(line) {
|
|
168
|
+
for (const raw of line.split(/\r?\n/)) {
|
|
169
|
+
const entry = raw.trimEnd();
|
|
170
|
+
if (!entry)
|
|
171
|
+
continue;
|
|
172
|
+
this.logs.push(entry);
|
|
173
|
+
if (this.logs.length > LOG_CAP)
|
|
174
|
+
this.logs.splice(0, this.logs.length - LOG_CAP);
|
|
175
|
+
this.emit("log", entry);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
/** 启动或复用 app-server。重复调用是幂等的。 */
|
|
179
|
+
async start() {
|
|
180
|
+
if (this.stopping)
|
|
181
|
+
return;
|
|
182
|
+
if (this.child)
|
|
183
|
+
return;
|
|
184
|
+
if (this.restartTimer) {
|
|
185
|
+
clearTimeout(this.restartTimer);
|
|
186
|
+
this.restartTimer = null;
|
|
187
|
+
}
|
|
188
|
+
// 先复用:端口已有健康的 app-server 就不 spawn(用户自己起的 / 遗留 daemon)。
|
|
189
|
+
if (await probeReadyz(this.port)) {
|
|
190
|
+
this.managed = false;
|
|
191
|
+
this.log(`检测到已有健康的 app-server,复用外部进程 (external): ${this.url}`);
|
|
192
|
+
this.setStatus({ phase: "ready", pid: null, error: null, since: Date.now() });
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
this.spawnChild();
|
|
196
|
+
}
|
|
197
|
+
spawnChild() {
|
|
198
|
+
if (this.stopping || this.child)
|
|
199
|
+
return;
|
|
200
|
+
this.readyDone = false;
|
|
201
|
+
this.stdoutSawListening = false;
|
|
202
|
+
this.setStatus({ phase: "starting", pid: null, error: null, connected: false });
|
|
203
|
+
const args = ["app-server", "--listen", this.url];
|
|
204
|
+
if (this.opts.codexBaseUrl) {
|
|
205
|
+
// Keep the override process-local: the user's ~/.codex/config.toml is
|
|
206
|
+
// never modified. JSON quoting makes the URL a valid TOML string.
|
|
207
|
+
args.push("-c", `chatgpt_base_url=${JSON.stringify(this.opts.codexBaseUrl)}`);
|
|
208
|
+
// Some installations use a custom Responses provider (the request
|
|
209
|
+
// inspector reference project uses `model_provider = "capture"`).
|
|
210
|
+
// Override that provider too, otherwise its configured :8898 endpoint
|
|
211
|
+
// bypasses this inspector and the session drawer stays empty.
|
|
212
|
+
args.push("-c", `model_providers.capture.base_url=${JSON.stringify(this.opts.codexBaseUrl)}`);
|
|
213
|
+
}
|
|
214
|
+
// cwd 不存在时 Node 的 spawn 会抛一个误导性的 `spawn <bin> ENOENT`,
|
|
215
|
+
// 看起来像“找不到可执行文件”。这里显式校验并回退,日志说明原因。
|
|
216
|
+
let cwd = this.opts.cwd;
|
|
217
|
+
if (!isDir(cwd)) {
|
|
218
|
+
this.log(`⚠ 工作目录不存在: ${cwd},改用 ${process.cwd()}`);
|
|
219
|
+
cwd = process.cwd();
|
|
220
|
+
}
|
|
221
|
+
this.log(`$ ${this.opts.bin} ${args.join(" ")} (cwd=${cwd})`);
|
|
222
|
+
const resolved = resolveBin(this.opts.bin);
|
|
223
|
+
if (resolved.note)
|
|
224
|
+
this.log(`⚠ ${resolved.note}`);
|
|
225
|
+
let child;
|
|
226
|
+
try {
|
|
227
|
+
child = spawn(resolved.bin, args, {
|
|
228
|
+
cwd,
|
|
229
|
+
env: this.opts.env ?? process.env,
|
|
230
|
+
stdio: "pipe",
|
|
231
|
+
// Own process group: `codex app-server` re-execs, so we must be able to
|
|
232
|
+
// signal the whole tree (see `signalGroup`).
|
|
233
|
+
detached: true,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
catch (err) {
|
|
237
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
238
|
+
this.log(`spawn 失败: ${message}`);
|
|
239
|
+
this.setStatus({ phase: "error", error: message });
|
|
240
|
+
this.scheduleRestart();
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
this.child = child;
|
|
244
|
+
this.managed = true;
|
|
245
|
+
this.setStatus({ pid: child.pid ?? null });
|
|
246
|
+
child.stdout.setEncoding("utf8");
|
|
247
|
+
child.stderr.setEncoding("utf8");
|
|
248
|
+
child.stdout.on("data", (chunk) => this.onOutput(chunk));
|
|
249
|
+
child.stderr.on("data", (chunk) => this.onOutput(chunk));
|
|
250
|
+
child.on("error", (err) => {
|
|
251
|
+
this.log(`子进程错误: ${err.message}`);
|
|
252
|
+
this.setStatus({ error: err.message });
|
|
253
|
+
// spawn 本身失败(ENOENT / EACCES)时不会有 exit 事件,必须在这里兜底重启;
|
|
254
|
+
// 运行中管道的偶发错误则保留 child 引用,交给 exit 处理。
|
|
255
|
+
if (child.pid === undefined) {
|
|
256
|
+
this.child = null;
|
|
257
|
+
this.managed = false;
|
|
258
|
+
this.scheduleRestart();
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
child.on("exit", (code, signal) => {
|
|
262
|
+
const detail = signal ? `signal=${signal}` : `code=${code}`;
|
|
263
|
+
this.log(`app-server 退出 (${detail})`);
|
|
264
|
+
// Reap any re-exec'd grandchild in our group. Without this an orphan keeps
|
|
265
|
+
// the port and later starts would attach to it instead of spawning fresh.
|
|
266
|
+
if (this.managed)
|
|
267
|
+
this.signalGroup("SIGKILL");
|
|
268
|
+
this.child = null;
|
|
269
|
+
this.managed = false;
|
|
270
|
+
if (this.stopping) {
|
|
271
|
+
this.setStatus({ phase: "stopped", pid: null, connected: false });
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
this.setStatus({
|
|
275
|
+
phase: "error",
|
|
276
|
+
pid: null,
|
|
277
|
+
connected: false,
|
|
278
|
+
error: `app-server exited (${detail})`,
|
|
279
|
+
});
|
|
280
|
+
this.scheduleRestart();
|
|
281
|
+
});
|
|
282
|
+
void this.waitForReady(child);
|
|
283
|
+
}
|
|
284
|
+
/** stdout 既用于日志,也可能直接告诉我们已经 listening。 */
|
|
285
|
+
onOutput(chunk) {
|
|
286
|
+
for (const line of chunk.split(/\r?\n/)) {
|
|
287
|
+
if (line.includes("listening on:"))
|
|
288
|
+
this.stdoutSawListening = true;
|
|
289
|
+
}
|
|
290
|
+
this.log(chunk);
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Poll `/readyz` 直到 200;stdout 已报 listening 时即使 readyz 一直失败,
|
|
294
|
+
* 也会在超时后进入 ready(不同版本 app-server 探测路径可能不同)。
|
|
295
|
+
*/
|
|
296
|
+
async waitForReady(child) {
|
|
297
|
+
const deadline = Date.now() + READY_TIMEOUT_MS;
|
|
298
|
+
while (!this.stopping && this.child === child && !this.readyDone) {
|
|
299
|
+
if (await probeReadyz(this.port)) {
|
|
300
|
+
if (this.child !== child || this.stopping)
|
|
301
|
+
return;
|
|
302
|
+
this.readyDone = true;
|
|
303
|
+
this.log(`app-server ready: ${this.url}`);
|
|
304
|
+
this.setStatus({ phase: "ready", error: null, since: Date.now() });
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
if (this.stdoutSawListening && Date.now() > deadline) {
|
|
308
|
+
if (this.child !== child || this.stopping)
|
|
309
|
+
return;
|
|
310
|
+
this.readyDone = true;
|
|
311
|
+
this.log(`app-server stdout 报告 listening,但 /readyz 未响应;按 ready 处理`);
|
|
312
|
+
this.setStatus({ phase: "ready", error: null, since: Date.now() });
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
if (Date.now() > deadline) {
|
|
316
|
+
if (this.child !== child || this.stopping)
|
|
317
|
+
return;
|
|
318
|
+
this.log(`等待 app-server ready 超时 (${READY_TIMEOUT_MS}ms)`);
|
|
319
|
+
this.setStatus({ phase: "error", error: "app-server readiness timeout" });
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
await delay(PROBE_INTERVAL_MS);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
scheduleRestart() {
|
|
326
|
+
if (this.stopping || this.restartTimer)
|
|
327
|
+
return;
|
|
328
|
+
this.restarts += 1;
|
|
329
|
+
const exp = Math.min(RESTART_BASE_MS * 2 ** Math.max(0, this.restarts - 1), RESTART_MAX_MS);
|
|
330
|
+
this.log(`将在 ${exp}ms 后重启 app-server (第 ${this.restarts} 次)`);
|
|
331
|
+
this.setStatus({ phase: "starting", pid: null, connected: false });
|
|
332
|
+
this.restartTimer = setTimeout(() => {
|
|
333
|
+
this.restartTimer = null;
|
|
334
|
+
void this.start();
|
|
335
|
+
}, exp);
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* Signal the whole process **group** we created for the child.
|
|
339
|
+
*
|
|
340
|
+
* `codex app-server` re-execs: the process we spawn forks the real listener and
|
|
341
|
+
* exits. Signalling only `child.pid` therefore orphans the listener, which then
|
|
342
|
+
* keeps holding the port — the next start would "attach" to that stale process
|
|
343
|
+
* and `restart()` would silently be a no-op. Because we spawn with
|
|
344
|
+
* `detached: true` the child is a group leader, so `-pid` reaches every member.
|
|
345
|
+
*/
|
|
346
|
+
signalGroup(signal) {
|
|
347
|
+
const pid = this.child?.pid;
|
|
348
|
+
if (!pid)
|
|
349
|
+
return;
|
|
350
|
+
try {
|
|
351
|
+
process.kill(-pid, signal);
|
|
352
|
+
}
|
|
353
|
+
catch {
|
|
354
|
+
/* group already gone, or spawned externally — fall back per-process */
|
|
355
|
+
try {
|
|
356
|
+
this.child?.kill(signal);
|
|
357
|
+
}
|
|
358
|
+
catch {
|
|
359
|
+
/* already gone */
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
/** 优雅 + 强制 kill 当前子进程(整组);仅对 managed child 生效。 */
|
|
364
|
+
async killChild() {
|
|
365
|
+
const child = this.child;
|
|
366
|
+
if (!child || !this.managed)
|
|
367
|
+
return;
|
|
368
|
+
await new Promise((resolve) => {
|
|
369
|
+
let settled = false;
|
|
370
|
+
const done = () => {
|
|
371
|
+
if (settled)
|
|
372
|
+
return;
|
|
373
|
+
settled = true;
|
|
374
|
+
resolve();
|
|
375
|
+
};
|
|
376
|
+
child.once("exit", done);
|
|
377
|
+
try {
|
|
378
|
+
this.signalGroup("SIGTERM");
|
|
379
|
+
}
|
|
380
|
+
catch {
|
|
381
|
+
done();
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
setTimeout(() => {
|
|
385
|
+
if (settled)
|
|
386
|
+
return;
|
|
387
|
+
this.log("SIGTERM 未在宽限期内退出,改用 SIGKILL");
|
|
388
|
+
this.signalGroup("SIGKILL");
|
|
389
|
+
// SIGKILL 后 exit 事件很快到来;再兜底一次。
|
|
390
|
+
setTimeout(done, 500);
|
|
391
|
+
}, STOP_GRACE_MS);
|
|
392
|
+
});
|
|
393
|
+
this.child = null;
|
|
394
|
+
this.managed = false;
|
|
395
|
+
}
|
|
396
|
+
/** 主动重启(`omaw/codex/restart`):杀掉我们的子进程后重新 spawn。 */
|
|
397
|
+
async restart() {
|
|
398
|
+
if (this.stopping)
|
|
399
|
+
return;
|
|
400
|
+
if (this.restartTimer) {
|
|
401
|
+
clearTimeout(this.restartTimer);
|
|
402
|
+
this.restartTimer = null;
|
|
403
|
+
}
|
|
404
|
+
this.log("收到重启请求");
|
|
405
|
+
if (this.isExternal() || (!this.managed && !this.child)) {
|
|
406
|
+
// 外部进程不归我们管;只能重新探测。端口仍健康就继续复用。
|
|
407
|
+
const healthy = await probeReadyz(this.port);
|
|
408
|
+
this.log(healthy ? "外部 app-server 仍然健康,继续复用" : "外部 app-server 已不可用,尝试自行 spawn");
|
|
409
|
+
if (healthy) {
|
|
410
|
+
this.setStatus({ phase: "ready", error: null, since: Date.now() });
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
await this.start();
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
this.restarts += 1;
|
|
417
|
+
this.setStatus({ phase: "starting", connected: false, error: null });
|
|
418
|
+
await this.killChild();
|
|
419
|
+
this.readyDone = false;
|
|
420
|
+
await this.start();
|
|
421
|
+
}
|
|
422
|
+
/** 服务端优雅退出:只 kill 我们 spawn 的子进程。 */
|
|
423
|
+
async stop() {
|
|
424
|
+
if (this.stopping)
|
|
425
|
+
return;
|
|
426
|
+
this.stopping = true;
|
|
427
|
+
if (this.restartTimer) {
|
|
428
|
+
clearTimeout(this.restartTimer);
|
|
429
|
+
this.restartTimer = null;
|
|
430
|
+
}
|
|
431
|
+
this.log("正在停止 app-server 管理…");
|
|
432
|
+
await this.killChild();
|
|
433
|
+
this.stopping = false;
|
|
434
|
+
this._status = { ...this._status, phase: "stopped", pid: null, connected: false };
|
|
435
|
+
this.emit("status", this.status());
|
|
436
|
+
}
|
|
437
|
+
/** 由 codex-client 的连接状态回调;同步进 CodexStatus。 */
|
|
438
|
+
setConnected(connected, error = null) {
|
|
439
|
+
this.setStatus({
|
|
440
|
+
connected,
|
|
441
|
+
// 连接成功后必须清掉上次的 ECONNREFUSED 之类的陈旧错误。
|
|
442
|
+
error: connected ? null : (error ?? this._status.error),
|
|
443
|
+
since: connected ? Date.now() : this._status.since,
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FsService — 实现浏览器侧的 `omaw/fs/list`、`omaw/fs/read`、`omaw/paths`。
|
|
3
|
+
*
|
|
4
|
+
* 这是一个本地开发工具,不做沙箱根限制;但:
|
|
5
|
+
* - 对目录项使用 lstat,symlink 只报告为 symlink,不跟随展开;
|
|
6
|
+
* - 读取时检测 NUL 字节,二进制文件明确拒绝而不是返回乱码;
|
|
7
|
+
* - 所有错误以带 code 的 FsError 抛出,由 index 转成 JSON-RPC error。
|
|
8
|
+
*/
|
|
9
|
+
import { lstat, open, readdir, stat } from "node:fs/promises";
|
|
10
|
+
import { homedir } from "node:os";
|
|
11
|
+
import { isAbsolute, join, resolve, sep } from "node:path";
|
|
12
|
+
const DEFAULT_MAX_BYTES = 256 * 1024;
|
|
13
|
+
const MAX_BYTES_LIMIT = 16 * 1024 * 1024;
|
|
14
|
+
const MAX_ENTRIES = 2_000;
|
|
15
|
+
const BINARY_SNIFF_BYTES = 8_000;
|
|
16
|
+
export class FsError extends Error {
|
|
17
|
+
code;
|
|
18
|
+
constructor(code, message) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.name = "FsError";
|
|
21
|
+
this.code = code;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/** `~` / `~/x` 展开;相对路径基于 cwd。 */
|
|
25
|
+
export function expandHome(input, home = homedir()) {
|
|
26
|
+
if (input === "~")
|
|
27
|
+
return home;
|
|
28
|
+
if (input.startsWith("~/") || input.startsWith("~\\"))
|
|
29
|
+
return join(home, input.slice(2));
|
|
30
|
+
return input;
|
|
31
|
+
}
|
|
32
|
+
export function toAbsolute(input, cwd, home = homedir()) {
|
|
33
|
+
const expanded = expandHome(input, home);
|
|
34
|
+
return isAbsolute(expanded) ? resolve(expanded) : resolve(cwd, expanded);
|
|
35
|
+
}
|
|
36
|
+
export function paths(cwd, codexHome) {
|
|
37
|
+
return { cwd, home: homedir(), codexHome };
|
|
38
|
+
}
|
|
39
|
+
export async function listDirectory(opts) {
|
|
40
|
+
const home = homedir();
|
|
41
|
+
const target = toAbsolute(opts.path?.trim() || ".", opts.cwd, home);
|
|
42
|
+
const maxEntries = Math.max(1, Math.min(opts.maxEntries ?? MAX_ENTRIES, MAX_ENTRIES));
|
|
43
|
+
let dirents;
|
|
44
|
+
try {
|
|
45
|
+
dirents = await readdir(target, { withFileTypes: true });
|
|
46
|
+
}
|
|
47
|
+
catch (err) {
|
|
48
|
+
throw toFsError(err, target);
|
|
49
|
+
}
|
|
50
|
+
// 若用户明确进入 node_modules/.git 内部,就不再跳过它们(否则无法浏览)。
|
|
51
|
+
const segments = target.split(sep);
|
|
52
|
+
const insideSkipped = segments.includes("node_modules") || segments.includes(".git");
|
|
53
|
+
const entries = [];
|
|
54
|
+
for (const dirent of dirents) {
|
|
55
|
+
if (!insideSkipped && (dirent.name === "node_modules" || dirent.name === ".git"))
|
|
56
|
+
continue;
|
|
57
|
+
const full = join(target, dirent.name);
|
|
58
|
+
let size = 0;
|
|
59
|
+
let mtime = 0;
|
|
60
|
+
let kind;
|
|
61
|
+
try {
|
|
62
|
+
// lstat:symlink 本身不被展开,避免指向目录外。
|
|
63
|
+
const st = await lstat(full);
|
|
64
|
+
size = st.size;
|
|
65
|
+
mtime = st.mtimeMs;
|
|
66
|
+
kind = st.isDirectory()
|
|
67
|
+
? "directory"
|
|
68
|
+
: st.isSymbolicLink()
|
|
69
|
+
? "symlink"
|
|
70
|
+
: st.isFile()
|
|
71
|
+
? "file"
|
|
72
|
+
: "other";
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
kind = dirent.isDirectory() ? "directory" : dirent.isSymbolicLink() ? "symlink" : "file";
|
|
76
|
+
}
|
|
77
|
+
entries.push({ name: dirent.name, path: full, kind, size, mtime });
|
|
78
|
+
if (entries.length >= maxEntries)
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
// 目录优先,其余按名字。
|
|
82
|
+
entries.sort((a, b) => {
|
|
83
|
+
const ad = a.kind === "directory" ? 0 : 1;
|
|
84
|
+
const bd = b.kind === "directory" ? 0 : 1;
|
|
85
|
+
if (ad !== bd)
|
|
86
|
+
return ad - bd;
|
|
87
|
+
return a.name.localeCompare(b.name);
|
|
88
|
+
});
|
|
89
|
+
return { path: target, entries };
|
|
90
|
+
}
|
|
91
|
+
export async function readTextFile(opts) {
|
|
92
|
+
if (!opts.path || !opts.path.trim())
|
|
93
|
+
throw new FsError(-32602, "path is required");
|
|
94
|
+
const home = homedir();
|
|
95
|
+
const target = toAbsolute(opts.path, opts.cwd, home);
|
|
96
|
+
const maxBytes = Math.max(1, Math.min(opts.maxBytes ?? DEFAULT_MAX_BYTES, MAX_BYTES_LIMIT));
|
|
97
|
+
try {
|
|
98
|
+
const st = await stat(target);
|
|
99
|
+
if (st.isDirectory())
|
|
100
|
+
throw new FsError(-32000, `is a directory: ${target}`);
|
|
101
|
+
}
|
|
102
|
+
catch (err) {
|
|
103
|
+
if (err instanceof FsError)
|
|
104
|
+
throw err;
|
|
105
|
+
throw toFsError(err, target);
|
|
106
|
+
}
|
|
107
|
+
let handle;
|
|
108
|
+
try {
|
|
109
|
+
handle = await open(target, "r");
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
throw toFsError(err, target);
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
// 多读 1 字节用于判断是否 truncated。
|
|
116
|
+
const buffer = Buffer.alloc(maxBytes + 1);
|
|
117
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
|
118
|
+
const truncated = bytesRead > maxBytes;
|
|
119
|
+
const usable = buffer.subarray(0, Math.min(bytesRead, maxBytes));
|
|
120
|
+
const sniff = usable.subarray(0, Math.min(usable.length, BINARY_SNIFF_BYTES));
|
|
121
|
+
if (sniff.includes(0))
|
|
122
|
+
throw new FsError(-32000, `binary file, refusing to read as text: ${target}`);
|
|
123
|
+
return { path: target, text: usable.toString("utf8"), truncated };
|
|
124
|
+
}
|
|
125
|
+
finally {
|
|
126
|
+
await handle.close().catch(() => undefined);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
function toFsError(err, target) {
|
|
130
|
+
const e = err;
|
|
131
|
+
switch (e?.code) {
|
|
132
|
+
case "ENOENT":
|
|
133
|
+
return new FsError(-32000, `not found: ${target}`);
|
|
134
|
+
case "EACCES":
|
|
135
|
+
case "EPERM":
|
|
136
|
+
return new FsError(-32000, `permission denied: ${target}`);
|
|
137
|
+
case "ENOTDIR":
|
|
138
|
+
return new FsError(-32000, `not a directory: ${target}`);
|
|
139
|
+
case "EISDIR":
|
|
140
|
+
return new FsError(-32000, `is a directory: ${target}`);
|
|
141
|
+
default:
|
|
142
|
+
return new FsError(-32000, e?.message ? `${e.message}: ${target}` : `fs error: ${target}`);
|
|
143
|
+
}
|
|
144
|
+
}
|