dsh-remote-web 0.7.9 → 0.7.10-beta.10

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.
@@ -0,0 +1,736 @@
1
+ /**
2
+ * Codex 适配骨架(M2 第 4 周)—— 把 Codex CLI 的 `exec --json` 输出映射进**同一套 5 类事件**。
3
+ *
4
+ * ## 这文件是"第二家 harness"的样板
5
+ *
6
+ * 它只做两件事:
7
+ * ① `mapCodexObject`:Codex 的一行 JSON → 归一化事件(与 Claude 同一套词表);
8
+ * ② `detectCodex`:找 CLI、问版本、探能力位(是否支持 `--json` / `--output-schema`)。
9
+ *
10
+ * **不改驱动、不改事件词表、不改审批桥** —— 这正是第 4 周要证明的事(见 `agent/README.md` 接入清单)。
11
+ *
12
+ * ## 依据来源(全部官方,2026-11 读取)
13
+ *
14
+ * | 依据 | 出处 |
15
+ * |---|---|
16
+ * | `exec --json` 顶层事件与样本 JSONL | <https://learn.chatgpt.com/docs/non-interactive-mode>("Sample JSON stream" 一段,测试夹具逐字取自这里) |
17
+ * | 顶层事件/字段权威定义 | `openai/codex` → `codex-rs/exec/src/exec_events.rs`(`ThreadEvent` / `ThreadItem` / 各 Item 结构体) |
18
+ * | 同款 TS 类型(跨语言对照) | `openai/codex` → `sdk/typescript/src/events.ts`、`sdk/typescript/src/items.ts` |
19
+ * | 本机 CLI 能力位(`--json` / `--output-schema` / `--approve-for-me` 是否存在) | 本机 `codex exec --help`(`codex-cli 0.150.1`,实测) |
20
+ *
21
+ * ## 实测校准(M2 第 5 周,**1 次真实调用**)
22
+ *
23
+ * 命令:`codex exec --json --sandbox read-only --skip-git-repo-check "Reply with exactly: OK"`(本机 0.150.1)。
24
+ * 结果:CLI 退出码 **1**(本机 codex 配置的模型 `gpt-6.1-sol` 与当前 ChatGPT 账号不兼容 → 400 model_not_found),
25
+ * 但 stdout **仍是合法 JSONL**,共 6 行,喂 `createCodexDriver` 后 **unknown=0**。实得顺序:
26
+ *
27
+ * ```
28
+ * thread.started
29
+ * item.completed(error) ← 模型元数据警告
30
+ * item.completed(error) ← skills 上下文预算警告(可能多条,且**早于 turn.started**)
31
+ * turn.started
32
+ * error ← message 是**嵌套 JSON 字符串**({"type":"error","status":400,…})
33
+ * turn.failed ← error.message 同样是嵌套 JSON 字符串
34
+ * ```
35
+ *
36
+ * 与官方文档样本的差异(已按实测修正/记录):
37
+ * ① `item.completed(error)` 可以在 `turn.started` **之前**、可以**多条**(映射器不依赖顺序,天然兼容);
38
+ * ② 顶层 `error` / `turn.failed` 的 message 是**JSON 编码的字符串** → 新增 `normalizeErrorMessage`
39
+ * 解一层(否则手机上会显示 {"type":"error",…} 这种乱码);
40
+ * ③ 正常路径(`agent_message` / `command_execution`)**本轮没拿到**(账号模型限制),
41
+ * 仍以官方文档样本为准,等业主修正 codex 的 model 后再跑一次校准。
42
+ *
43
+ * ## 未核实项(**不要当成事实用**,下一轮必须实跑一次 `codex exec --json` 校准)
44
+ *
45
+ * 1. `item.started` 的 `command_execution` 是否一定带 `aggregated_output`(官方样本里**没有**这个字段)
46
+ * —— 本模块按"缺失=空串"处理。
47
+ * 2. `item.updated` 的增量语义(官方只给了类型、没给样本)—— 本模块**忽略**,避免重复 tool.call。
48
+ * 3. `command_execution.status` 是否会出现 `declined`(Rust 枚举有、TS 类型没有)—— 本模块未知值按 failed 处理。
49
+ * 4. `web_search` / `file_change` / `todo_list` / `collab_tool_call` 的实际线上形状(TS 联合类型未含 collab)
50
+ * —— 字段按官方类型定义解析,缺字段一律降级、不崩。
51
+ * 5. **审批不可回调**:本机 `codex exec --help` 只有 `--approve-for-me`(自动审查)与
52
+ * `--dangerously-bypass-approvals-and-sandbox`,**没有**"把审批交给外部程序"的开关。
53
+ * ⇒ exec 模式下我们只能做**只通知**(`mode:"notify-only"`);要真审批得走 `codex app-server`
54
+ * (experimental,JSON-RPC 带 `approvalId`,见第 1 周报告 §3.2)——**本轮不接**。
55
+ */
56
+
57
+ import fsSync from "node:fs";
58
+ import path from "node:path";
59
+ import { spawnSync as nodeSpawnSync, execFileSync as nodeExecFileSync } from "node:child_process";
60
+
61
+ import {
62
+ DEFAULT_MAX_LINE_BYTES,
63
+ DEFAULT_MAX_PREVIEW_CHARS,
64
+ EVENT_TYPES,
65
+ byteLength,
66
+ createJsonlDriver,
67
+ frameLine,
68
+ parseLineJson,
69
+ previewOf
70
+ } from "./events.mjs";
71
+ import { NPM_ROOT_ENV, probeCandidateVersion, probeNpmRoot, splitPathList } from "./detect.mjs";
72
+ import { formatVersion, parseVersion } from "./version.mjs";
73
+
74
+ /** 显式指定 codex CLI(排障/企业定制)。 */
75
+ export const CODEX_BIN_ENV = "DSH_AGENT_CODEX_BIN";
76
+ /** 显式指定 npm 全局根;不设则回落到通用 `DSH_AGENT_NPM_ROOT` / 自动探测。 */
77
+ export const CODEX_NPM_ROOT_ENV = "DSH_AGENT_CODEX_NPM_ROOT";
78
+
79
+ /** 官方定义的顶层事件类型(`exec_events.rs` 的 `ThreadEvent`)。 */
80
+ export const CODEX_TOP_EVENTS = Object.freeze([
81
+ "thread.started",
82
+ "turn.started",
83
+ "turn.completed",
84
+ "turn.failed",
85
+ "item.started",
86
+ "item.updated",
87
+ "item.completed",
88
+ "error"
89
+ ]);
90
+
91
+ /** 官方定义的 item 类型(`ThreadItemDetails`);`collab_tool_call` 仅 Rust 侧有【未核实】。 */
92
+ export const CODEX_ITEM_TYPES = Object.freeze([
93
+ "agent_message",
94
+ "reasoning",
95
+ "command_execution",
96
+ "file_change",
97
+ "mcp_tool_call",
98
+ "collab_tool_call",
99
+ "web_search",
100
+ "todo_list",
101
+ "error"
102
+ ]);
103
+
104
+ /** Codex 的能力声明(detectCodex 会按本机 help 实测覆盖)。 */
105
+ export const CODEX_CAPABILITIES = Object.freeze({
106
+ jsonOutput: true, // `codex exec --json`(官方文档 + 本机 help 实测)
107
+ structuredOutput: true, // `--output-schema`
108
+ resume: true, // 交互式有 resume;exec 侧续接方式【未核实】
109
+ appServer: true, // experimental app-server(审批可路由,本轮不接)
110
+ externalApprovalHook: false // exec 模式没有"审批回调外部程序"的开关(本机 help 实测)
111
+ });
112
+
113
+ const DEFAULT_CODEX_FS = {
114
+ existsSync: (p) => fsSync.existsSync(p),
115
+ statSync: (p) => fsSync.statSync(p),
116
+ realpathSync: (p) => fsSync.realpathSync(p)
117
+ };
118
+
119
+ /** 工具语义归一(与 ACP 的 kind 词表一致,和 Claude 映射共用一套 UI 词汇)。 */
120
+ function codexToolMeta(type) {
121
+ switch (type) {
122
+ case "command_execution":
123
+ return { name: "shell", kind: "execute" };
124
+ case "file_change":
125
+ return { name: "file_change", kind: "edit" };
126
+ case "web_search":
127
+ return { name: "web_search", kind: "fetch" };
128
+ case "mcp_tool_call":
129
+ return { name: "mcp", kind: "other" };
130
+ case "collab_tool_call":
131
+ return { name: "collab", kind: "other" };
132
+ default:
133
+ return { name: type, kind: "other" };
134
+ }
135
+ }
136
+
137
+ /**
138
+ * 实测(0.150.1):顶层 `error` 与 `turn.failed` 的 message 是**JSON 字符串**(内层还有一层 error 对象)。
139
+ * 直接进 UI 会变成乱码;这里解一层,优先取内层 `.error.message` / `.message`,解析失败原样返回。
140
+ */
141
+ export function normalizeErrorMessage(raw) {
142
+ const s = String(raw ?? "");
143
+ try {
144
+ const obj = JSON.parse(s);
145
+ if (obj && typeof obj === "object") {
146
+ if (typeof obj?.error?.message === "string") return obj.error.message;
147
+ if (typeof obj.message === "string") return obj.message;
148
+ }
149
+ } catch {
150
+ /* 不是 JSON → 原样用 */
151
+ }
152
+ return s;
153
+ }
154
+
155
+ function isErrStatus(status) {
156
+ return String(status || "") === "failed";
157
+ }
158
+
159
+ /** 从 MCP result.content 里取文本(只用于算字节数/截断预览)。 */
160
+ function mcpContentText(result) {
161
+ const content = result?.content;
162
+ if (!Array.isArray(content)) return "";
163
+ return content
164
+ .map((c) => (c && typeof c === "object" && typeof c.text === "string" ? c.text : ""))
165
+ .filter(Boolean)
166
+ .join("\n");
167
+ }
168
+
169
+ /**
170
+ * Codex 一行的**对象**映射(单行纯函数,不跨行记忆)。
171
+ *
172
+ * 注意:单行视角下,`item.completed` 的工类 item 会同时产出 `tool.call` + `tool.result`
173
+ * (因为没有"上一行"可参照)。跑真实流时请用 `createCodexDriver`(内部带去重)。
174
+ *
175
+ * @param {object} obj
176
+ * @param {{now?:()=>number, maxPreviewChars?:number}} [ctx]
177
+ * @returns {{ok:true, events:object[]}|{ok:false, reason:string}}
178
+ */
179
+ export function mapCodexObject(obj, {
180
+ now = () => Date.now(),
181
+ maxPreviewChars = DEFAULT_MAX_PREVIEW_CHARS
182
+ } = {}) {
183
+ if (!obj || typeof obj !== "object") return { ok: false, reason: "not-object" };
184
+ const at = now();
185
+ const base = (type) => ({ v: 1, type, at });
186
+
187
+ switch (obj.type) {
188
+ case "thread.started":
189
+ return {
190
+ ok: true,
191
+ events: [{
192
+ ...base(EVENT_TYPES.STATUS),
193
+ status: "working",
194
+ phase: "thread",
195
+ threadId: String(obj.thread_id || "")
196
+ }]
197
+ };
198
+
199
+ case "turn.started":
200
+ return { ok: true, events: [{ ...base(EVENT_TYPES.STATUS), status: "working", phase: "turn" }] };
201
+
202
+ case "turn.completed": {
203
+ const u = obj.usage || {};
204
+ return {
205
+ ok: true,
206
+ events: [{
207
+ ...base(EVENT_TYPES.TURN_END),
208
+ reason: "completed",
209
+ isError: false,
210
+ usage: {
211
+ inputTokens: Number(u.input_tokens) || 0,
212
+ cachedInputTokens: Number(u.cached_input_tokens) || 0,
213
+ // 官方文档样本没有这个字段 → 缺失按 0(【未核实】线上是否总是给)
214
+ cacheWriteInputTokens: Number(u.cache_write_input_tokens) || 0,
215
+ outputTokens: Number(u.output_tokens) || 0,
216
+ reasoningOutputTokens: Number(u.reasoning_output_tokens) || 0
217
+ }
218
+ }]
219
+ };
220
+ }
221
+
222
+ case "turn.failed": {
223
+ const msg = normalizeErrorMessage(obj?.error?.message);
224
+ return {
225
+ ok: true,
226
+ events: [{
227
+ ...base(EVENT_TYPES.TURN_END),
228
+ reason: "error",
229
+ isError: true,
230
+ errorMessage: previewOf(msg, maxPreviewChars)
231
+ }]
232
+ };
233
+ }
234
+
235
+ case "error":
236
+ // 事件流层面的不可恢复错误(可能伴随 turn.failed,也可能单独出现)
237
+ return {
238
+ ok: true,
239
+ events: [{
240
+ ...base(EVENT_TYPES.STATUS),
241
+ status: "error",
242
+ scope: "stream",
243
+ message: previewOf(normalizeErrorMessage(obj.message), maxPreviewChars)
244
+ }]
245
+ };
246
+
247
+ case "item.started":
248
+ case "item.updated":
249
+ case "item.completed":
250
+ return mapCodexItem(obj, { phase: obj.type, at, maxPreviewChars, seen: null });
251
+
252
+ default:
253
+ return { ok: false, reason: `unknown-type:${String(obj.type || "?")}` };
254
+ }
255
+ }
256
+
257
+ /**
258
+ * item 事件映射(内部)。
259
+ *
260
+ * @param {object} obj 顶层事件对象
261
+ * @param {{phase:string, at:number, maxPreviewChars:number, seen:Set<string>|null}} opts
262
+ * seen 非空时做跨行去重(真实流场景);null = 单行纯函数模式
263
+ */
264
+ function mapCodexItem(obj, { phase, at, maxPreviewChars, seen }) {
265
+ const item = obj.item;
266
+ if (!item || typeof item !== "object") return { ok: false, reason: "item-without-payload" };
267
+ const itemType = String(item.type || "");
268
+ if (!itemType) return { ok: false, reason: "item-without-type" };
269
+
270
+ const base = (type) => ({ v: 1, type, at });
271
+ const completing = phase === "item.completed";
272
+ const starting = phase === "item.started";
273
+ const id = String(item.id || "");
274
+
275
+ // 是否要发 tool.call:started 一定发;completed 只在"还没发过"时补发
276
+ // (有状态=真实流:started 已发过就跳过;无状态=单行纯函数:completed 需要自带 call 才能表达)
277
+ const maybeCall = () => {
278
+ if (phase === "item.updated") return false;
279
+ if (seen) {
280
+ if (seen.has(id)) return false;
281
+ seen.add(id);
282
+ }
283
+ return true;
284
+ };
285
+
286
+ switch (itemType) {
287
+ case "agent_message": {
288
+ // 官方样本:item.completed 携带完整 text;started 的形态【未核实】→ 只在 completed 发
289
+ if (!completing) return { ok: true, events: [] };
290
+ const text = String(item.text ?? "");
291
+ if (!text) return { ok: true, events: [] };
292
+ return { ok: true, events: [{ ...base(EVENT_TYPES.MESSAGE_DELTA), text, final: true }] };
293
+ }
294
+
295
+ case "reasoning": {
296
+ // 推理摘要**内容不落**(可能有命令/文件内容),只给出"在思考"与字节数
297
+ if (!starting && !completing) return { ok: true, events: [] };
298
+ if (seen) {
299
+ const key = `reasoning:${id}`;
300
+ if (seen.has(key)) return { ok: true, events: [] };
301
+ seen.add(key);
302
+ }
303
+ return {
304
+ ok: true,
305
+ events: [{
306
+ ...base(EVENT_TYPES.STATUS),
307
+ status: "thinking",
308
+ textBytes: byteLength(item.text ?? "")
309
+ }]
310
+ };
311
+ }
312
+
313
+ case "command_execution": {
314
+ const status = String(item.status || "");
315
+ if (!starting && !completing) return { ok: true, events: [] }; // item.updated:忽略(增量语义【未核实】)
316
+ const events = [];
317
+ if (maybeCall()) {
318
+ events.push({
319
+ ...base(EVENT_TYPES.TOOL_CALL),
320
+ toolCallId: id,
321
+ name: "shell",
322
+ kind: "execute",
323
+ // 只落**命令字节数**,不落命令原文
324
+ inputBytes: byteLength(item.command ?? "")
325
+ });
326
+ }
327
+ if (completing) {
328
+ const exitCode = Number.isInteger(item.exit_code) ? item.exit_code : null;
329
+ const out = String(item.aggregated_output ?? "");
330
+ // 只有 completed/in_progress 算"非失败";未知值(如 Rust 侧有、TS 没有的 declined)一律按失败
331
+ const statusOk = status === "completed" || status === "in_progress";
332
+ events.push({
333
+ ...base(EVENT_TYPES.TOOL_RESULT),
334
+ toolCallId: id,
335
+ isError: !statusOk || (exitCode !== null && exitCode !== 0),
336
+ exitCode,
337
+ outputBytes: byteLength(out),
338
+ outputPreview: previewOf(out, maxPreviewChars)
339
+ });
340
+ }
341
+ return { ok: true, events };
342
+ }
343
+
344
+ case "file_change": {
345
+ const changes = Array.isArray(item.changes) ? item.changes : [];
346
+ if (!starting && !completing) return { ok: true, events: [] };
347
+ const events = [];
348
+ if (maybeCall()) {
349
+ events.push({
350
+ ...base(EVENT_TYPES.TOOL_CALL),
351
+ toolCallId: id,
352
+ name: "file_change",
353
+ kind: "edit",
354
+ inputBytes: 0,
355
+ fileCount: changes.length
356
+ });
357
+ }
358
+ if (completing) {
359
+ events.push({
360
+ ...base(EVENT_TYPES.TOOL_RESULT),
361
+ toolCallId: id,
362
+ isError: isErrStatus(item.status),
363
+ fileCount: changes.length,
364
+ outputBytes: 0,
365
+ outputPreview: ""
366
+ });
367
+ }
368
+ return { ok: true, events };
369
+ }
370
+
371
+ case "mcp_tool_call": {
372
+ const meta = codexToolMeta(itemType);
373
+ const fullName = `mcp:${String(item.server || "?")}/${String(item.tool || "?")}`;
374
+ if (!starting && !completing) return { ok: true, events: [] };
375
+ const events = [];
376
+ if (maybeCall()) {
377
+ events.push({
378
+ ...base(EVENT_TYPES.TOOL_CALL),
379
+ toolCallId: id,
380
+ name: fullName,
381
+ kind: meta.kind,
382
+ inputBytes: byteLength(JSON.stringify(item.arguments ?? null))
383
+ });
384
+ }
385
+ if (completing) {
386
+ const text = mcpContentText(item.result);
387
+ events.push({
388
+ ...base(EVENT_TYPES.TOOL_RESULT),
389
+ toolCallId: id,
390
+ isError: isErrStatus(item.status) || Boolean(item.error),
391
+ outputBytes: byteLength(text),
392
+ outputPreview: previewOf(String(item?.error?.message || text), maxPreviewChars)
393
+ });
394
+ }
395
+ return { ok: true, events };
396
+ }
397
+
398
+ case "web_search": {
399
+ if (!starting && !completing) return { ok: true, events: [] };
400
+ const events = [];
401
+ if (maybeCall()) {
402
+ events.push({
403
+ ...base(EVENT_TYPES.TOOL_CALL),
404
+ toolCallId: id,
405
+ name: "web_search",
406
+ kind: "fetch",
407
+ inputBytes: byteLength(item.query ?? "")
408
+ });
409
+ }
410
+ if (completing) {
411
+ const results = Array.isArray(item.results) ? item.results : [];
412
+ events.push({
413
+ ...base(EVENT_TYPES.TOOL_RESULT),
414
+ toolCallId: id,
415
+ isError: false,
416
+ resultCount: results.length,
417
+ outputBytes: 0,
418
+ outputPreview: ""
419
+ });
420
+ }
421
+ return { ok: true, events };
422
+ }
423
+
424
+ case "collab_tool_call": {
425
+ // 仅 Rust `ThreadItemDetails` 有;TS 联合类型未含 → 【未核实】
426
+ const meta = codexToolMeta(itemType);
427
+ if (!starting && !completing) return { ok: true, events: [] };
428
+ const events = [];
429
+ if (maybeCall()) {
430
+ events.push({
431
+ ...base(EVENT_TYPES.TOOL_CALL),
432
+ toolCallId: id,
433
+ name: `collab:${String(item.tool || "?")}`,
434
+ kind: meta.kind,
435
+ inputBytes: byteLength(item.prompt ?? "")
436
+ });
437
+ }
438
+ if (completing) {
439
+ events.push({
440
+ ...base(EVENT_TYPES.TOOL_RESULT),
441
+ toolCallId: id,
442
+ isError: isErrStatus(item.status),
443
+ outputBytes: 0,
444
+ outputPreview: ""
445
+ });
446
+ }
447
+ return { ok: true, events };
448
+ }
449
+
450
+ case "todo_list": {
451
+ const items = Array.isArray(item.items) ? item.items : [];
452
+ return {
453
+ ok: true,
454
+ events: [{
455
+ ...base(EVENT_TYPES.STATUS),
456
+ status: "plan",
457
+ planItems: items.length,
458
+ planDone: items.filter((x) => x && x.completed === true).length
459
+ }]
460
+ };
461
+ }
462
+
463
+ case "error":
464
+ return {
465
+ ok: true,
466
+ events: [{
467
+ ...base(EVENT_TYPES.STATUS),
468
+ status: "error",
469
+ scope: "item",
470
+ message: previewOf(normalizeErrorMessage(item.message), maxPreviewChars)
471
+ }]
472
+ };
473
+
474
+ default:
475
+ return { ok: false, reason: `unknown-item-type:${itemType}` };
476
+ }
477
+ }
478
+
479
+ /**
480
+ * Codex 一行(字符串)的映射(单行纯函数)。
481
+ * @param {string} line
482
+ * @param {{now?:()=>number, maxLineBytes?:number, maxPreviewChars?:number}} [opts]
483
+ */
484
+ export function mapCodexLine(line, {
485
+ now = () => Date.now(),
486
+ maxLineBytes = DEFAULT_MAX_LINE_BYTES,
487
+ maxPreviewChars = DEFAULT_MAX_PREVIEW_CHARS
488
+ } = {}) {
489
+ const framed = frameLine(line, { maxLineBytes });
490
+ if (!framed.ok) return framed;
491
+ const parsed = parseLineJson(framed.raw);
492
+ if (!parsed.ok) return parsed;
493
+ return mapCodexObject(parsed.obj, { now, maxPreviewChars });
494
+ }
495
+
496
+ /**
497
+ * 有状态映射器(给真实流用):跨行记住已发过 tool.call 的 item id,避免 started/completed 重复。
498
+ * @param {{now?:()=>number, maxPreviewChars?:number}} [opts]
499
+ */
500
+ export function createCodexMapper({ now = () => Date.now(), maxPreviewChars = DEFAULT_MAX_PREVIEW_CHARS } = {}) {
501
+ const seen = new Set();
502
+ return function mapCodexObjectStateful(obj, ctx = {}) {
503
+ const useNow = typeof ctx.now === "function" ? ctx.now : now;
504
+ const usePreview = Number.isFinite(ctx.maxPreviewChars) ? ctx.maxPreviewChars : maxPreviewChars;
505
+ if (!obj || typeof obj !== "object") return { ok: false, reason: "not-object" };
506
+ const at = useNow();
507
+ if (obj.type === "item.started" || obj.type === "item.updated" || obj.type === "item.completed") {
508
+ return mapCodexItem(obj, { phase: obj.type, at, maxPreviewChars: usePreview, seen });
509
+ }
510
+ return mapCodexObject(obj, { now: useNow, maxPreviewChars: usePreview });
511
+ };
512
+ }
513
+
514
+ /**
515
+ * 现成的 Codex 事件驱动(与 `createEventMapper` 同构,只是换了 mapLine)。
516
+ * @param {{sink?:object, onEvent?:Function, onUnknown?:Function, now?:()=>number,
517
+ * maxLineBytes?:number, maxPreviewChars?:number}} [opts]
518
+ */
519
+ export function createCodexDriver({
520
+ sink = null,
521
+ onEvent,
522
+ onUnknown,
523
+ now = () => Date.now(),
524
+ maxLineBytes = DEFAULT_MAX_LINE_BYTES,
525
+ maxPreviewChars = DEFAULT_MAX_PREVIEW_CHARS
526
+ } = {}) {
527
+ return createJsonlDriver({
528
+ mapLine: createCodexMapper({ now, maxPreviewChars }),
529
+ sink,
530
+ onEvent,
531
+ onUnknown,
532
+ now,
533
+ maxLineBytes,
534
+ maxPreviewChars,
535
+ driverName: "codex"
536
+ });
537
+ }
538
+
539
+ // ─────────────────────────────────────────────────────────────────────────────
540
+ // CLI 探测(找 codex / 问版本 / 探能力位)—— 复刻 Claude 那套"永远不抛错"的风格
541
+ // ─────────────────────────────────────────────────────────────────────────────
542
+
543
+ /**
544
+ * 构造候选(按优先级)。codex 的 npm 包 bin 是 **Node 脚本** `bin/codex.js`
545
+ * (本机实测:`/opt/homebrew/bin/codex` → `@openai/codex/bin/codex.js`),
546
+ * 它在内部自己 `spawn` 平台原生二进制;所以"用 node 跑 codex.js"是跨平台最稳的一条。
547
+ *
548
+ * @returns {Array<{kind:string, command:string, argsPrefix:string[], pathHint?:string}>}
549
+ */
550
+ export function buildCodexCandidates({
551
+ env = process.env,
552
+ platform = process.platform,
553
+ execPath = process.execPath,
554
+ fs = DEFAULT_CODEX_FS,
555
+ npmRoot = "",
556
+ isExecutable,
557
+ realpath
558
+ } = {}) {
559
+ const out = [];
560
+ const seen = new Set();
561
+ const push = (c) => {
562
+ const key = `${c.command}:${(c.argsPrefix || []).join(",")}`;
563
+ if (seen.has(key)) return;
564
+ seen.add(key);
565
+ out.push(c);
566
+ };
567
+ const isExec =
568
+ typeof isExecutable === "function"
569
+ ? isExecutable
570
+ : (p) => {
571
+ try {
572
+ const st = fs.statSync(p);
573
+ if (!st.isFile()) return false;
574
+ if (platform === "win32") return /\.exe$/i.test(p);
575
+ return (Number(st.mode) & 0o111) !== 0;
576
+ } catch {
577
+ return false;
578
+ }
579
+ };
580
+ const real = typeof realpath === "function" ? realpath : (p) => {
581
+ try {
582
+ return fs.realpathSync(p);
583
+ } catch {
584
+ return p;
585
+ }
586
+ };
587
+
588
+ // 1) 显式覆盖:.js/.cjs/.mjs 用 node 跑,其余当可执行直接跑
589
+ const explicit = String(env?.[CODEX_BIN_ENV] ?? "").trim();
590
+ if (explicit) {
591
+ if (/\.(c|m)?js$/i.test(explicit)) {
592
+ push({ kind: "explicit-node", command: execPath, argsPrefix: [explicit], pathHint: explicit });
593
+ } else {
594
+ push({ kind: "explicit", command: explicit, argsPrefix: [], pathHint: explicit });
595
+ }
596
+ }
597
+
598
+ // 2) PATH。posix 解软链;Windows 只认 codex.exe(.cmd 无 shell 跑不了,同 Claude 的理由)
599
+ for (const dir of splitPathList(env?.PATH, platform)) {
600
+ const names = platform === "win32" ? ["codex.exe"] : ["codex"];
601
+ for (const name of names) {
602
+ const p = path.join(dir, name);
603
+ if (!isExec(p)) continue;
604
+ push({ kind: "path", command: platform === "win32" ? p : real(p), argsPrefix: [], pathHint: p });
605
+ break;
606
+ }
607
+ }
608
+
609
+ // 3) npm 全局包里的 Node 启动器(跨平台最稳;它会自己找平台原生二进制)
610
+ if (npmRoot) {
611
+ const js = path.join(npmRoot, "@openai", "codex", "bin", "codex.js");
612
+ try {
613
+ if (fs.statSync(js).isFile()) {
614
+ push({ kind: "npm-node", command: execPath, argsPrefix: [js], pathHint: js });
615
+ }
616
+ } catch {
617
+ /* 不存在就跳过 */
618
+ }
619
+ }
620
+ return out;
621
+ }
622
+
623
+ /**
624
+ * 探能力位:跑一次 `exec --help` 看有哪些开关(**不调用模型**)。
625
+ * @returns {{ok:boolean, caps:object, evidence:object, reason?:string}}
626
+ */
627
+ export function probeCodexCaps(candidate, { spawnSync = nodeSpawnSync, timeoutMs = 10_000 } = {}) {
628
+ const caps = { ...CODEX_CAPABILITIES, jsonOutput: false, structuredOutput: false, externalApprovalHook: false };
629
+ try {
630
+ const r = spawnSync(candidate.command, [...(candidate.argsPrefix || []), "exec", "--help"], {
631
+ encoding: "utf8",
632
+ timeout: timeoutMs,
633
+ windowsHide: true,
634
+ shell: false,
635
+ stdio: ["ignore", "pipe", "pipe"]
636
+ });
637
+ const text = `${r?.stdout ?? ""}${r?.stderr ?? ""}`;
638
+ if (r?.error || (typeof r?.status === "number" && r.status !== 0) || !text.trim()) {
639
+ return { ok: false, caps, evidence: { helpTextLength: text.length }, reason: `exec --help 失败(${r?.error?.code || r?.status || "空输出"})` };
640
+ }
641
+ caps.jsonOutput = /--json\b/.test(text);
642
+ caps.structuredOutput = /--output-schema\b/.test(text);
643
+ caps.approvalsAutoReview = /--approve-for-me\b/.test(text);
644
+ caps.sandboxFlag = /--sandbox\b/.test(text);
645
+ caps.externalApprovalHook = false; // 仅当出现"把审批交给外部程序"的开关才置 true(当前官方没有)
646
+ return {
647
+ ok: true,
648
+ caps,
649
+ evidence: {
650
+ helpTextLength: text.length,
651
+ matched: {
652
+ json: caps.jsonOutput,
653
+ outputSchema: caps.structuredOutput,
654
+ approveForMe: Boolean(caps.approvalsAutoReview),
655
+ sandbox: Boolean(caps.sandboxFlag)
656
+ }
657
+ }
658
+ };
659
+ } catch (err) {
660
+ return { ok: false, caps, evidence: {}, reason: `能力探测异常: ${err?.code || err?.message || err}` };
661
+ }
662
+ }
663
+
664
+ /**
665
+ * 一站式探测:找 CLI → 问版本 → 探 `exec --help` 能力位。**永远不抛错**。
666
+ *
667
+ * 状态:`ok` | `cli-missing` | `version-unknown` | `error`
668
+ * 注意:即使 `ok:true`,`mode` 也是 **`notify-only`** —— exec 模式没有审批回调开关(见文件头 5)。
669
+ */
670
+ export function detectCodex({
671
+ env = process.env,
672
+ platform = process.platform,
673
+ execPath = process.execPath,
674
+ fs = DEFAULT_CODEX_FS,
675
+ spawnSync = nodeSpawnSync,
676
+ execFileSync = nodeExecFileSync,
677
+ npmRoot,
678
+ probeTimeoutMs = 10_000,
679
+ probeCaps = true,
680
+ isExecutable,
681
+ realpath
682
+ } = {}) {
683
+ const warnings = [];
684
+ let root = npmRoot;
685
+ if (root === undefined) {
686
+ // 显式 env > 通用 env > 复用 Claude 那套 npm root 探测(同一份路径启发式)
687
+ root =
688
+ String(env?.[CODEX_NPM_ROOT_ENV] ?? "").trim() ||
689
+ String(env?.[NPM_ROOT_ENV] ?? "").trim() ||
690
+ probeNpmRoot({ execFileSync, platform, env, fs, execPath });
691
+ }
692
+
693
+ let candidates;
694
+ try {
695
+ candidates = buildCodexCandidates({ env, platform, execPath, fs, npmRoot: root, isExecutable, realpath });
696
+ } catch (err) {
697
+ return { ok: false, status: "error", mode: "notify-only", reason: `构建候选异常: ${err?.message || err}`, caps: { ...CODEX_CAPABILITIES } };
698
+ }
699
+
700
+ const attempts = [];
701
+ for (const c of candidates) {
702
+ const r = probeCandidateVersion(c, { spawnSync, timeoutMs: probeTimeoutMs });
703
+ attempts.push({ kind: c.kind, path: c.pathHint || c.command, ok: r.ok, reason: r.ok ? "" : r.reason });
704
+ if (!r.ok) continue;
705
+
706
+ const capsResult = probeCaps
707
+ ? probeCodexCaps(c, { spawnSync, timeoutMs: probeTimeoutMs })
708
+ : { ok: true, caps: { ...CODEX_CAPABILITIES }, evidence: {} };
709
+ if (!capsResult.ok) warnings.push({ code: "caps-probe-failed", message: capsResult.reason });
710
+
711
+ return {
712
+ ok: true,
713
+ status: "ok",
714
+ // exec 模式只能只通知(审批需 app-server,本轮不接)
715
+ mode: "notify-only",
716
+ cli: { kind: c.kind, path: c.pathHint || c.command, command: c.command, argsPrefix: c.argsPrefix || [] },
717
+ version: { ...r.version, text: formatVersion(r.version) },
718
+ caps: capsResult.caps,
719
+ capsEvidence: capsResult.evidence,
720
+ attempts,
721
+ warnings
722
+ };
723
+ }
724
+
725
+ return {
726
+ ok: false,
727
+ status: candidates.length ? "version-unknown" : "cli-missing",
728
+ mode: "notify-only",
729
+ reason: candidates.length
730
+ ? "找到了 codex 候选但都拿不到版本(安装损坏?或 --version 输出形态变了)"
731
+ : "没有找到 Codex CLI(未安装,或不在 PATH / npm 全局目录里)",
732
+ caps: { ...CODEX_CAPABILITIES },
733
+ attempts,
734
+ warnings
735
+ };
736
+ }