@xiaohhhh1/canvas-agent 0.4.74 → 0.4.76

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.
@@ -5,10 +5,40 @@ export type CodexModelSettings = {
5
5
  model?: string;
6
6
  reasoningEffort?: string;
7
7
  };
8
+ export type CodexAppPhase = "spawn" | "initialize" | "idle" | "thread/start" | "turn/start" | "turn/running" | "terminating" | "exited";
9
+ export type CodexAppProcessFailureDetails = {
10
+ phase: CodexAppPhase | string;
11
+ code: number | null;
12
+ signal: NodeJS.Signals | null;
13
+ stderr: string;
14
+ cause?: unknown;
15
+ source?: "spawn" | "exit" | "stdin" | "timeout";
16
+ diagnosticId?: string;
17
+ cleanupConfirmed?: boolean;
18
+ cleanupBarrier?: Promise<void>;
19
+ };
20
+ /** 可安全向网页和本机状态暴露的 app-server 进程故障。 */
21
+ export declare class CodexAppProcessError extends Error {
22
+ readonly phase: string;
23
+ readonly code: number | null;
24
+ readonly signal: NodeJS.Signals | null;
25
+ readonly stderr: string;
26
+ readonly source: "spawn" | "exit" | "stdin" | "timeout";
27
+ readonly diagnosticId: string;
28
+ cleanupConfirmed: boolean;
29
+ cleanupBarrier?: Promise<void>;
30
+ constructor(details: CodexAppProcessFailureDetails);
31
+ }
32
+ export declare function isCodexAppProcessError(error: unknown): error is CodexAppProcessError;
33
+ /** 去掉凭证、图片、用户目录及控制符,只保留有界的 stderr 尾部。 */
34
+ export declare function sanitizeCodexDiagnosticText(value: unknown, maxChars?: number): string;
35
+ /** Append stderr only after redaction so the in-memory tail never holds raw payloads. */
36
+ export declare function appendCodexDiagnosticTail(current: string, chunk: unknown, maxChars?: number): string;
8
37
  /** 封装 Codex app-server 的 JSON-RPC 通信与事件转换。 */
9
38
  export declare class CodexAppClient {
10
39
  private child;
11
40
  private emit;
41
+ private onExit;
12
42
  private nextId;
13
43
  private buffer;
14
44
  private currentThreadId;
@@ -22,10 +52,20 @@ export declare class CodexAppClient {
22
52
  private pendingDeltas;
23
53
  private plansByTurn;
24
54
  private approvalRequests;
55
+ private phase;
56
+ private stderrTail;
57
+ private logicalClosed;
58
+ private osExitSeen;
59
+ private failDelivered;
60
+ private terminationExpected;
61
+ private exitDelivered;
62
+ private terminalError;
63
+ private exitPromise;
64
+ private resolveExit;
25
65
  /** 保存 app-server 子进程和事件出口。 */
26
66
  private constructor();
27
67
  /** 启动并初始化 Codex app-server。 */
28
- static start(emit: AgentEmit, onExit: () => void): Promise<CodexAppClient>;
68
+ static start(emit: AgentEmit, onExit: () => void, initializeTimeoutMs?: number): Promise<CodexAppClient>;
29
69
  /** 创建新的 Codex 线程。 */
30
70
  startThread(cwd?: string, permissionMode?: AgentPermissionMode, modelSettings?: CodexModelSettings): Promise<import("./codex-protocol.js").CodexThread>;
31
71
  /** 恢复已有 Codex 线程。 */
@@ -51,7 +91,12 @@ export declare class CodexAppClient {
51
91
  /** 中断当前正在运行的 Codex turn。 */
52
92
  interruptCurrentTurn(): Promise<boolean>;
53
93
  /** 终止失去响应的 app-server,让后续队列可以在新进程继续。 */
54
- terminate(message?: string): Promise<void>;
94
+ terminate(message?: string, diagnostics?: {
95
+ persistDiagnostic?: boolean;
96
+ phase?: CodexAppPhase | string;
97
+ }): Promise<boolean>;
98
+ /** 仅供 worker 隔离等待真实 OS exit;不包含 stderr 或其它诊断内容。 */
99
+ waitForExit(): Promise<void>;
55
100
  /** 回复网页端已经确认的 Codex 权限请求。 */
56
101
  resolveApproval(requestId: string, decision: string): boolean;
57
102
  /** 发送 JSON-RPC 请求并保存待处理 Promise。 */
@@ -83,6 +128,12 @@ export declare class CodexAppClient {
83
128
  private resolve;
84
129
  /** 拒绝指定 JSON-RPC 请求。 */
85
130
  private reject;
131
+ /** 只在内存保存 stderr 尾部;原文绝不直接发往网页或写入日志。 */
132
+ private recordStderr;
133
+ /** error/exit/stdin 统一且只执行一次的进程收尾。 */
134
+ private handleProcessFailure;
135
+ private noteOsExit;
136
+ private deliverExit;
86
137
  /** 拒绝进程退出时仍未完成的请求与 turn。 */
87
138
  private failAll;
88
139
  }
@@ -1,8 +1,10 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import fs from "node:fs/promises";
2
4
  import { createRequire } from "node:module";
3
5
  import path from "node:path";
4
6
  import { fileURLToPath } from "node:url";
5
- import { VERSION } from "../config.js";
7
+ import { CONFIG_DIR, VERSION } from "../config.js";
6
8
  import { logger } from "../utils/logger.js";
7
9
  import { field } from "../utils/value.js";
8
10
  const canvasAgentMcp = canvasAgentMcpCommand();
@@ -10,10 +12,89 @@ const require = createRequire(import.meta.url);
10
12
  const STREAM_UPDATE_INTERVAL_MS = 40;
11
13
  const TURN_RECONCILE_INTERVAL_MS = 5_000;
12
14
  const APP_SERVER_INITIALIZE_TIMEOUT_MS = 30_000;
15
+ const CODEX_STDERR_TAIL_CHARS = 32 * 1024;
16
+ const CODEX_VISIBLE_DIAGNOSTIC_CHARS = 2_048;
17
+ const CODEX_DIAGNOSTIC_FILE_BYTES = 512 * 1024;
18
+ const CODEX_DIAGNOSTIC_ENTRY_BYTES = 12 * 1024;
19
+ let diagnosticWriteQueue = Promise.resolve();
20
+ /** 可安全向网页和本机状态暴露的 app-server 进程故障。 */
21
+ export class CodexAppProcessError extends Error {
22
+ phase;
23
+ code;
24
+ signal;
25
+ stderr;
26
+ source;
27
+ diagnosticId;
28
+ cleanupConfirmed;
29
+ cleanupBarrier;
30
+ constructor(details) {
31
+ const stderr = sanitizeCodexDiagnosticText(details.stderr, 8_192);
32
+ const source = details.source || "exit";
33
+ const diagnosticId = details.diagnosticId || randomUUID();
34
+ const summary = [
35
+ `Codex app-server process failure (phase=${details.phase}`,
36
+ `code=${details.code ?? "none"}`,
37
+ `signal=${details.signal ?? "none"}`,
38
+ `diagnosticId=${diagnosticId})`,
39
+ ].join(", ");
40
+ super(summary);
41
+ this.name = "CodexAppProcessError";
42
+ this.phase = details.phase;
43
+ this.code = details.code;
44
+ this.signal = details.signal;
45
+ this.stderr = stderr;
46
+ this.source = source;
47
+ this.diagnosticId = diagnosticId;
48
+ this.cleanupConfirmed = details.cleanupConfirmed !== false;
49
+ this.cleanupBarrier = details.cleanupBarrier;
50
+ if (details.cause !== undefined)
51
+ this.cause = details.cause;
52
+ }
53
+ }
54
+ export function isCodexAppProcessError(error) {
55
+ return error instanceof CodexAppProcessError || Boolean(error && typeof error === "object" && error.name === "CodexAppProcessError");
56
+ }
57
+ /** 去掉凭证、图片、用户目录及控制符,只保留有界的 stderr 尾部。 */
58
+ export function sanitizeCodexDiagnosticText(value, maxChars = CODEX_VISIBLE_DIAGNOSTIC_CHARS) {
59
+ const budget = Math.max(0, Math.floor(maxChars));
60
+ if (!budget)
61
+ return "";
62
+ let text = String(value || "")
63
+ .replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, "")
64
+ .replace(/data:image\/[a-z0-9.+-]+;base64,[a-z0-9+/=_-]+/gi, "data:image/[REDACTED]")
65
+ // `trim()` below can remove the only space when "Bearer " and its
66
+ // value arrive in separate stderr chunks, so also redact the joined
67
+ // continuation form before it can enter the retained tail.
68
+ .replace(/\bBearer\s*[^\s"',;]+/gi, "Bearer [REDACTED]")
69
+ .replace(/\bsk-[a-z0-9_-]+/gi, "sk-[REDACTED]")
70
+ // stderr can arrive in arbitrary chunks. If the previous chunk ended
71
+ // inside a redacted value, discard every token-like continuation first
72
+ // so later assignment rules stay idempotent.
73
+ .replace(/["']?\[REDACTED(?:_LONG_TOKEN)?\]["']?[a-z0-9._~+/_=-]+/gi, "[REDACTED]")
74
+ .replace(/(["']?)([a-z][a-z0-9_]*(?:_token|_secret|_password|_api_key))\1(\s*[:=]\s*)(?:\[REDACTED(?:_LONG_TOKEN)?\]|"[^"]*"|'[^']*'|[^\s,;\]}]+)/gi, '$1$2$1$3"[REDACTED]"')
75
+ .replace(/([?&](?:access_token|refresh_token|connect_token|token|api_?key|secret|password)=)[^&\s]+/gi, "$1[REDACTED]")
76
+ .replace(/(["']?(?:authorization|api[-_ ]?key|access[-_ ]?token|refresh[-_ ]?token|connect[-_ ]?token|cookie|session|password|secret)["']?\s*[=:]\s*)["'][^"']*["']/gi, "$1\"[REDACTED]\"")
77
+ .replace(/((?:authorization|api[-_ ]?key|access[-_ ]?token|refresh[-_ ]?token|connect[-_ ]?token|cookie|session|password|secret)\s*[=:]\s*)(?:\[REDACTED(?:_LONG_TOKEN)?\]|[^\s"',;\]}]+)/gi, "$1[REDACTED]")
78
+ // A tail slice can begin in the middle of an image payload or unknown
79
+ // credential and therefore no longer contain a recognisable prefix.
80
+ .replace(/(?<![a-z0-9])[a-z0-9._~+/_=-]{32,}(?![a-z0-9])/gi, "[REDACTED_LONG_TOKEN]")
81
+ .replace(/[a-z]:[\\/]Users[\\/][^\\/]+(?=[\\/])/gi, "%USERPROFILE%")
82
+ .replace(/\/(?:Users|home)\/[^/\s]+/g, "$HOME")
83
+ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, "")
84
+ .trim();
85
+ if (text.length > budget)
86
+ text = text.slice(-budget);
87
+ return text;
88
+ }
89
+ /** Append stderr only after redaction so the in-memory tail never holds raw payloads. */
90
+ export function appendCodexDiagnosticTail(current, chunk, maxChars = CODEX_STDERR_TAIL_CHARS) {
91
+ return sanitizeCodexDiagnosticText(`${current}${String(chunk || "")}`, maxChars);
92
+ }
13
93
  /** 封装 Codex app-server 的 JSON-RPC 通信与事件转换。 */
14
94
  export class CodexAppClient {
15
95
  child;
16
96
  emit;
97
+ onExit;
17
98
  nextId = 1;
18
99
  buffer = "";
19
100
  currentThreadId = "";
@@ -27,55 +108,89 @@ export class CodexAppClient {
27
108
  pendingDeltas = new Map();
28
109
  plansByTurn = new Map();
29
110
  approvalRequests = new Map();
111
+ phase = "spawn";
112
+ stderrTail = "";
113
+ logicalClosed = false;
114
+ osExitSeen = false;
115
+ failDelivered = false;
116
+ terminationExpected = false;
117
+ exitDelivered = false;
118
+ terminalError = null;
119
+ exitPromise;
120
+ resolveExit;
30
121
  /** 保存 app-server 子进程和事件出口。 */
31
- constructor(child, emit) {
122
+ constructor(child, emit, onExit) {
32
123
  this.child = child;
33
124
  this.emit = emit;
125
+ this.onExit = onExit;
126
+ this.exitPromise = new Promise((resolve) => { this.resolveExit = resolve; });
34
127
  }
35
128
  /** 启动并初始化 Codex app-server。 */
36
- static async start(emit, onExit) {
129
+ static async start(emit, onExit, initializeTimeoutMs = APP_SERVER_INITIALIZE_TIMEOUT_MS) {
37
130
  logger.info("Starting Codex app-server", { executable: process.execPath, codex: codexBin() });
38
131
  const child = spawn(process.execPath, [codexBin(), "app-server", "--stdio"], { stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
39
- const client = new CodexAppClient(child, emit);
132
+ const client = new CodexAppClient(child, emit, onExit);
40
133
  child.stdout?.on("data", (chunk) => client.read(chunk.toString()));
41
134
  child.stderr?.on("data", (chunk) => {
42
- const text = chunk.toString();
43
- logger.warn("Codex app-server stderr", { text });
44
- emit("agent_log", { text });
135
+ client.recordStderr(chunk.toString());
136
+ });
137
+ child.once("error", (error) => {
138
+ client.handleProcessFailure(new CodexAppProcessError({ phase: client.phase, code: null, signal: null, stderr: `${client.stderrTail}\n${error.message}`, cause: error, source: "spawn" }));
45
139
  });
46
- child.on("error", (error) => {
47
- logger.error("Codex app-server process error", error);
48
- emit("agent_error", { message: error.message });
140
+ child.once("exit", (code, signal) => {
141
+ client.handleProcessFailure(new CodexAppProcessError({ phase: client.phase, code, signal, stderr: client.stderrTail, source: "exit" }), true);
49
142
  });
50
- child.on("exit", (code) => {
51
- logger.warn("Codex app-server exited", { code });
52
- client.failAll(`Codex app-server exited: ${code ?? 0}`);
53
- onExit();
54
- emit("agent_log", { text: `Codex app-server exited: ${code ?? 0}` });
143
+ // spawn 失败时 Node 可能只有 error + close 而没有 exit;close 表示 stdio
144
+ // 已关闭,可安全解除 worker 的 OS-exit 隔离。正常 exit + close 由幂等保护处理。
145
+ child.once("close", () => {
146
+ client.noteOsExit();
55
147
  });
56
148
  try {
57
- await withTimeout(client.request("initialize", { clientInfo: { name: "canvas-agent", title: "Infinite Canvas Agent", version: VERSION }, capabilities: { experimentalApi: true, requestAttestation: false } }), APP_SERVER_INITIALIZE_TIMEOUT_MS, "Codex app-server initialize timed out");
149
+ client.phase = "initialize";
150
+ await withTimeout(client.request("initialize", { clientInfo: { name: "canvas-agent", title: "Infinite Canvas Agent", version: VERSION }, capabilities: { experimentalApi: true, requestAttestation: false } }), Math.max(1, Math.min(APP_SERVER_INITIALIZE_TIMEOUT_MS, initializeTimeoutMs)), "Codex app-server initialize timed out");
58
151
  client.notify("initialized");
152
+ client.phase = "idle";
59
153
  return client;
60
154
  }
61
155
  catch (error) {
62
- await client.terminate("Codex app-server 初始化超时,已回收失去响应的进程");
63
- throw error;
156
+ const processError = isCodexAppProcessError(error) ? error : new CodexAppProcessError({ phase: "initialize", code: child.exitCode, signal: child.signalCode, stderr: `${client.stderrTail}\n${error instanceof Error ? error.message : String(error)}`, cause: error, source: "timeout" });
157
+ if (!isCodexAppProcessError(error))
158
+ void persistCodexProcessDiagnostic(processError);
159
+ processError.cleanupConfirmed = await client.terminate("Codex app-server 初始化失败,已回收当前进程");
160
+ if (!processError.cleanupConfirmed)
161
+ processError.cleanupBarrier = client.waitForExit();
162
+ throw processError;
64
163
  }
65
164
  }
66
165
  /** 创建新的 Codex 线程。 */
67
166
  async startThread(cwd, permissionMode = "request", modelSettings = {}) {
68
- const { thread } = await this.request("thread/start", { ...threadSettings(permissionMode, modelSettings), ...(cwd ? { cwd } : {}), ...(modelSettings.model ? { model: modelSettings.model } : {}), threadSource: "user" });
69
- if (!thread.id)
70
- throw new Error("Codex app-server 没有返回 thread id");
71
- return thread;
167
+ this.stderrTail = "";
168
+ this.phase = "thread/start";
169
+ try {
170
+ const { thread } = await this.request("thread/start", { ...threadSettings(permissionMode, modelSettings), ...(cwd ? { cwd } : {}), ...(modelSettings.model ? { model: modelSettings.model } : {}), threadSource: "user" });
171
+ if (!thread.id)
172
+ throw new Error("Codex app-server 没有返回 thread id");
173
+ return thread;
174
+ }
175
+ finally {
176
+ if (!this.logicalClosed)
177
+ this.phase = "idle";
178
+ }
72
179
  }
73
180
  /** 恢复已有 Codex 线程。 */
74
181
  async resumeThread(threadId, cwd, permissionMode = "request", modelSettings = {}) {
75
- const { thread } = await this.request("thread/resume", { threadId, ...threadSettings(permissionMode, modelSettings), ...(cwd ? { cwd } : {}), ...(modelSettings.model ? { model: modelSettings.model } : {}) });
76
- if (!thread.id)
77
- throw new Error("Codex app-server 没有返回 thread id");
78
- return thread;
182
+ this.stderrTail = "";
183
+ this.phase = "thread/start";
184
+ try {
185
+ const { thread } = await this.request("thread/resume", { threadId, ...threadSettings(permissionMode, modelSettings), ...(cwd ? { cwd } : {}), ...(modelSettings.model ? { model: modelSettings.model } : {}) });
186
+ if (!thread.id)
187
+ throw new Error("Codex app-server 没有返回 thread id");
188
+ return thread;
189
+ }
190
+ finally {
191
+ if (!this.logicalClosed)
192
+ this.phase = "idle";
193
+ }
79
194
  }
80
195
  /** 查询 Codex 线程列表。 */
81
196
  listThreads(params) {
@@ -103,25 +218,33 @@ export class CodexAppClient {
103
218
  /** 启动一个 Codex turn 并等待完成通知。 */
104
219
  async startTurn(threadId, prompt, images, permissionMode, onTurn, outputSchema, modelSettings = {}) {
105
220
  this.currentThreadId = threadId;
106
- const { turn } = await this.request("turn/start", { threadId, input: codexInput(prompt, images), ...turnSettings(permissionMode), ...(modelSettings.model ? { model: modelSettings.model } : {}), ...(modelSettings.reasoningEffort ? { effort: modelSettings.reasoningEffort } : {}), ...(outputSchema ? { outputSchema } : {}) });
107
- const turnId = turn.id;
108
- if (!turnId)
109
- throw new Error("Codex app-server 没有返回 turn id");
110
- this.currentTurnId = turnId;
111
- onTurn?.(turnId);
112
- const completed = this.completedTurns.get(turnId);
113
- if (this.completedTurns.has(turnId)) {
114
- this.completedTurns.delete(turnId);
115
- this.currentThreadId = "";
116
- this.currentTurnId = "";
117
- if (completed?.error)
118
- throw completed.error;
119
- return completed?.text || "";
221
+ this.phase = "turn/start";
222
+ try {
223
+ const { turn } = await this.request("turn/start", { threadId, input: codexInput(prompt, images), ...turnSettings(permissionMode), ...(modelSettings.model ? { model: modelSettings.model } : {}), ...(modelSettings.reasoningEffort ? { effort: modelSettings.reasoningEffort } : {}), ...(outputSchema ? { outputSchema } : {}) });
224
+ const turnId = turn.id;
225
+ if (!turnId)
226
+ throw new Error("Codex app-server 没有返回 turn id");
227
+ this.currentTurnId = turnId;
228
+ this.phase = "turn/running";
229
+ onTurn?.(turnId);
230
+ const completed = this.completedTurns.get(turnId);
231
+ if (this.completedTurns.has(turnId)) {
232
+ this.completedTurns.delete(turnId);
233
+ this.currentThreadId = "";
234
+ this.currentTurnId = "";
235
+ if (completed?.error)
236
+ throw completed.error;
237
+ return completed?.text || "";
238
+ }
239
+ return await new Promise((resolve, reject) => {
240
+ this.activeTurns.set(turnId, { resolve, reject });
241
+ this.scheduleTurnReconciliation(threadId, turnId);
242
+ });
243
+ }
244
+ finally {
245
+ if (!this.logicalClosed)
246
+ this.phase = "idle";
120
247
  }
121
- return await new Promise((resolve, reject) => {
122
- this.activeTurns.set(turnId, { resolve, reject });
123
- this.scheduleTurnReconciliation(threadId, turnId);
124
- });
125
248
  }
126
249
  /** 中断当前正在运行的 Codex turn。 */
127
250
  async interruptCurrentTurn() {
@@ -140,15 +263,40 @@ export class CodexAppClient {
140
263
  }
141
264
  }
142
265
  /** 终止失去响应的 app-server,让后续队列可以在新进程继续。 */
143
- terminate(message = "Codex app-server was restarted") {
144
- this.failAll(message);
145
- if (this.child.exitCode !== null)
146
- return Promise.resolve();
147
- return new Promise((resolve) => {
148
- const timer = setTimeout(resolve, 2000);
149
- this.child.once("exit", () => { clearTimeout(timer); resolve(); });
266
+ async terminate(message = "Codex app-server was restarted", diagnostics = {}) {
267
+ const failurePhase = diagnostics.phase || this.phase;
268
+ if (!this.logicalClosed) {
269
+ this.terminationExpected = true;
270
+ this.logicalClosed = true;
271
+ this.terminalError = new Error(message);
272
+ this.phase = "terminating";
273
+ this.failAll(this.terminalError);
274
+ this.deliverExit();
275
+ }
276
+ if (diagnostics.persistDiagnostic) {
277
+ void persistCodexProcessDiagnostic(new CodexAppProcessError({ phase: failurePhase, code: this.child.exitCode, signal: this.child.signalCode, stderr: `${this.stderrTail}\n${message}`, source: "timeout" }));
278
+ }
279
+ if (this.osExitSeen || this.child.exitCode !== null) {
280
+ this.noteOsExit();
281
+ return true;
282
+ }
283
+ if (!this.child.killed)
150
284
  this.child.kill();
151
- });
285
+ let timer;
286
+ try {
287
+ return await Promise.race([
288
+ this.exitPromise.then(() => true),
289
+ new Promise((resolve) => { timer = setTimeout(() => resolve(false), 2_000); }),
290
+ ]);
291
+ }
292
+ finally {
293
+ if (timer)
294
+ clearTimeout(timer);
295
+ }
296
+ }
297
+ /** 仅供 worker 隔离等待真实 OS exit;不包含 stderr 或其它诊断内容。 */
298
+ waitForExit() {
299
+ return this.exitPromise;
152
300
  }
153
301
  /** 回复网页端已经确认的 Codex 权限请求。 */
154
302
  resolveApproval(requestId, decision) {
@@ -166,8 +314,18 @@ export class CodexAppClient {
166
314
  /** 发送 JSON-RPC 请求并保存待处理 Promise。 */
167
315
  request(method, params) {
168
316
  const id = this.nextId++;
169
- this.write({ id, method, params });
170
- return new Promise((resolve, reject) => this.pending.set(id, { resolve: (result) => resolve(result), reject }));
317
+ return new Promise((resolve, reject) => {
318
+ if (this.logicalClosed || this.terminalError)
319
+ return reject(this.terminalError || new Error("Codex app-server is closed"));
320
+ this.pending.set(id, { resolve: (result) => resolve(result), reject });
321
+ try {
322
+ this.write({ id, method, params });
323
+ }
324
+ catch (error) {
325
+ this.pending.delete(id);
326
+ reject(error instanceof Error ? error : new Error(String(error)));
327
+ }
328
+ });
171
329
  }
172
330
  /** 发送无需响应的 JSON-RPC 通知。 */
173
331
  notify(method, params) {
@@ -179,7 +337,24 @@ export class CodexAppClient {
179
337
  const params = field(value, "params");
180
338
  if (method)
181
339
  logger.debug(`Codex ${method}`, { id: field(value, "id"), threadId: field(params, "threadId") });
182
- this.child.stdin?.write(`${JSON.stringify(value)}\n`);
340
+ const stdin = this.child.stdin;
341
+ if (!stdin || stdin.destroyed || !stdin.writable) {
342
+ const error = new CodexAppProcessError({ phase: this.phase, code: this.child.exitCode, signal: this.child.signalCode, stderr: this.stderrTail, source: "stdin" });
343
+ this.handleProcessFailure(error);
344
+ throw error;
345
+ }
346
+ try {
347
+ stdin.write(`${JSON.stringify(value)}\n`, (cause) => {
348
+ if (!cause)
349
+ return;
350
+ this.handleProcessFailure(new CodexAppProcessError({ phase: this.phase, code: this.child.exitCode, signal: this.child.signalCode, stderr: `${this.stderrTail}\n${cause.message}`, cause, source: "stdin" }));
351
+ });
352
+ }
353
+ catch (cause) {
354
+ const error = new CodexAppProcessError({ phase: this.phase, code: this.child.exitCode, signal: this.child.signalCode, stderr: `${this.stderrTail}\n${cause instanceof Error ? cause.message : String(cause)}`, cause, source: "stdin" });
355
+ this.handleProcessFailure(error);
356
+ throw error;
357
+ }
183
358
  }
184
359
  /** 按行解析 app-server 标准输出。 */
185
360
  read(chunk) {
@@ -191,8 +366,8 @@ export class CodexAppClient {
191
366
  this.handle(JSON.parse(line));
192
367
  }
193
368
  catch (error) {
194
- logger.warn("Invalid Codex app-server output", { error, line });
195
- this.emit("agent_log", { text: line });
369
+ logger.warn("Invalid Codex app-server output", { error: error instanceof Error ? error.message : String(error), bytes: line.length });
370
+ this.emit("agent_log", { text: "Codex app-server 返回了无法解析的本地响应;为保护提示词和凭证,原始内容仅丢弃不展示" });
196
371
  }
197
372
  });
198
373
  }
@@ -395,14 +570,55 @@ export class CodexAppClient {
395
570
  if (pending)
396
571
  (this.pending.delete(id), pending.reject(new Error(message)));
397
572
  }
573
+ /** 只在内存保存 stderr 尾部;原文绝不直接发往网页或写入日志。 */
574
+ recordStderr(chunk) {
575
+ this.stderrTail = appendCodexDiagnosticTail(this.stderrTail, chunk);
576
+ }
577
+ /** error/exit/stdin 统一且只执行一次的进程收尾。 */
578
+ handleProcessFailure(error, exitSeen = false) {
579
+ if (exitSeen)
580
+ this.noteOsExit();
581
+ if (this.logicalClosed)
582
+ return;
583
+ this.logicalClosed = true;
584
+ this.terminalError = error;
585
+ const expected = this.terminationExpected;
586
+ if (exitSeen)
587
+ this.phase = "exited";
588
+ if (!expected) {
589
+ this.failAll(error);
590
+ void persistCodexProcessDiagnostic(error);
591
+ logger.warn("Codex app-server process failure", { message: error.message });
592
+ this.emit("agent_log", { text: error.message });
593
+ if (this.child.exitCode === null && !this.child.killed)
594
+ this.child.kill();
595
+ }
596
+ this.deliverExit();
597
+ }
598
+ noteOsExit() {
599
+ if (this.osExitSeen)
600
+ return;
601
+ this.osExitSeen = true;
602
+ this.phase = "exited";
603
+ this.resolveExit();
604
+ }
605
+ deliverExit() {
606
+ if (this.exitDelivered)
607
+ return;
608
+ this.exitDelivered = true;
609
+ this.onExit();
610
+ }
398
611
  /** 拒绝进程退出时仍未完成的请求与 turn。 */
399
- failAll(message) {
400
- [...this.pending.values()].forEach((item) => item.reject(new Error(message)));
612
+ failAll(error) {
613
+ if (this.failDelivered)
614
+ return;
615
+ this.failDelivered = true;
616
+ [...this.pending.values()].forEach((item) => item.reject(error));
401
617
  [...this.activeTurns.entries()].forEach(([turnId, item]) => {
402
618
  if (item.reconcileTimer)
403
619
  clearTimeout(item.reconcileTimer);
404
620
  this.activeTurns.delete(turnId);
405
- item.reject(new Error(message));
621
+ item.reject(error);
406
622
  });
407
623
  this.pendingDeltas.forEach((item) => clearTimeout(item.timer));
408
624
  this.pending.clear();
@@ -415,6 +631,29 @@ export class CodexAppClient {
415
631
  this.currentTurnId = "";
416
632
  }
417
633
  }
634
+ /** 将脱敏诊断写入用户目录;单日有界,写入失败不影响脚本恢复。 */
635
+ function persistCodexProcessDiagnostic(error) {
636
+ diagnosticWriteQueue = diagnosticWriteQueue.catch(() => undefined).then(async () => {
637
+ const directory = path.join(CONFIG_DIR, "logs");
638
+ const file = path.join(directory, `codex-app-server-diagnostics-${new Date().toISOString().slice(0, 10)}.jsonl`);
639
+ await fs.mkdir(directory, { recursive: true, mode: 0o700 });
640
+ const currentBytes = await fs.stat(file).then((value) => value.size).catch(() => 0);
641
+ const entry = `${JSON.stringify({
642
+ at: new Date().toISOString(),
643
+ diagnosticId: error.diagnosticId,
644
+ phase: error.phase,
645
+ source: error.source,
646
+ code: error.code,
647
+ signal: error.signal,
648
+ stderr: sanitizeCodexDiagnosticText(error.stderr, 8_192),
649
+ })}\n`;
650
+ const bytes = Buffer.byteLength(entry);
651
+ if (bytes > CODEX_DIAGNOSTIC_ENTRY_BYTES || currentBytes + bytes > CODEX_DIAGNOSTIC_FILE_BYTES)
652
+ return;
653
+ await fs.appendFile(file, entry, { encoding: "utf8", mode: 0o600 });
654
+ }).catch((error) => logger.debug("Failed to persist Codex process diagnostic", { error }));
655
+ return diagnosticWriteQueue;
656
+ }
418
657
  async function withTimeout(promise, timeoutMs, message) {
419
658
  let timer;
420
659
  try {
@@ -1,3 +1,4 @@
1
+ import { type CodexAppProcessError } from "./codex-client.js";
1
2
  import type { AgentAttachment, AgentEmit, AgentPermissionMode } from "./types.js";
2
3
  type CodexRunOptions = {
3
4
  threadId?: string;
@@ -29,19 +30,34 @@ export type CodexWorkflowRunResult = {
29
30
  ok: false;
30
31
  error: string;
31
32
  retryable: boolean;
33
+ failureKind: "contract" | "transport" | "timeout" | "turn";
32
34
  timings: {
33
35
  queueWaitMs: number;
34
36
  threadStartMs: number;
35
37
  modelMs: number;
36
38
  };
37
39
  };
40
+ type CodexWorkflowFailureKind = Extract<CodexWorkflowRunResult, {
41
+ ok: false;
42
+ }>["failureKind"];
38
43
  export declare const FLOW_C_CODEX_MODEL = "gpt-5.6-terra";
39
44
  export declare const FLOW_C_CODEX_REASONING_EFFORT = "medium";
40
45
  export declare const FLOW_C_CODEX_WORKER_CONCURRENCY: number;
46
+ export declare const FLOW_C_CODEX_PROCESS_MAX_ATTEMPTS = 3;
41
47
  export declare function flowCCodexWorkerStatus(): {
42
48
  active: number;
43
49
  limit: number;
44
50
  };
51
+ /**
52
+ * 记录尚未确认 OS exit 的 worker 进程。worker 可以释放给队列,但在对应
53
+ * barrier 完成前只能等待/失败,绝不能启动替代 app-server。
54
+ */
55
+ export declare class WorkflowCodexLaneQuarantine {
56
+ private barriers;
57
+ quarantine(workerIndex: number, barrier: Promise<void>): Promise<undefined>;
58
+ wait(workerIndex: number, timeoutMs: number): Promise<boolean>;
59
+ isQuarantined(workerIndex: number): boolean;
60
+ }
45
61
  export { summarizeCodexThread } from "./codex-history.js";
46
62
  /** 将 Codex turn 加入串行队列并等待执行完成。 */
47
63
  export declare function runCodexTurn(prompt: string, emit: AgentEmit, attachments?: AgentAttachment[], options?: CodexRunOptions): Promise<CodexRunResult>;
@@ -57,6 +73,15 @@ export declare function runCodexWorkflowTurn(prompt: string, emit: AgentEmit, op
57
73
  onWorkerStart?: () => void;
58
74
  onWorkerFinish?: () => void;
59
75
  }): Promise<CodexWorkflowRunResult>;
76
+ /** A deadline exhausted after process recovery is terminal for this handoff wave. */
77
+ export declare function isWorkflowFailureRetryable(failureKind: CodexWorkflowFailureKind, cleanupConfirmed: boolean, processRecoveryObserved: boolean): boolean;
78
+ type CodexProcessRetryOptions = {
79
+ maxAttempts?: number;
80
+ backoffMs?: number | ((failedAttempt: number) => number);
81
+ onRetry?: (error: CodexAppProcessError, failedAttempt: number) => Promise<void> | void;
82
+ };
83
+ /** 只重试明确的 app-server 进程/stdio 故障;结构契约和普通模型失败均立即上抛。 */
84
+ export declare function runCodexProcessRetries<T>(operation: (attempt: number) => Promise<T>, options?: CodexProcessRetryOptions): Promise<T>;
60
85
  /**
61
86
  * 给 Flow C 的完整 app-server 链路设置硬截止时间。超时后不再等待原 Promise
62
87
  * 自行结束;否则初始化或 thread/start 永不返回时会永久占住 worker lane。