@kobinflow/canvas-agent 0.6.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/LICENSE +22 -0
- package/README.md +159 -0
- package/agent-instructions.md +13 -0
- package/dist/agent/claude.d.ts +3 -0
- package/dist/agent/claude.js +54 -0
- package/dist/agent/codex-client.d.ts +139 -0
- package/dist/agent/codex-client.js +937 -0
- package/dist/agent/codex-event-history.d.ts +21 -0
- package/dist/agent/codex-event-history.js +163 -0
- package/dist/agent/codex-history.d.ts +46 -0
- package/dist/agent/codex-history.js +822 -0
- package/dist/agent/codex-protocol.d.ts +335 -0
- package/dist/agent/codex-protocol.js +1 -0
- package/dist/agent/codex.d.ts +178 -0
- package/dist/agent/codex.js +509 -0
- package/dist/agent/message-metadata.d.ts +51 -0
- package/dist/agent/message-metadata.js +378 -0
- package/dist/agent/types.d.ts +45 -0
- package/dist/agent/types.js +1 -0
- package/dist/canvas/operations.d.ts +13 -0
- package/dist/canvas/operations.js +167 -0
- package/dist/canvas/schemas.d.ts +989 -0
- package/dist/canvas/schemas.js +155 -0
- package/dist/canvas/session.d.ts +163 -0
- package/dist/canvas/session.js +523 -0
- package/dist/canvas/tools.d.ts +214 -0
- package/dist/canvas/tools.js +27 -0
- package/dist/canvas/types.d.ts +34 -0
- package/dist/canvas/types.js +1 -0
- package/dist/config.d.ts +30 -0
- package/dist/config.js +86 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +7 -0
- package/dist/server/http.d.ts +2 -0
- package/dist/server/http.js +576 -0
- package/dist/server/mcp.d.ts +2 -0
- package/dist/server/mcp.js +27 -0
- package/dist/skills/store.d.ts +54 -0
- package/dist/skills/store.js +426 -0
- package/dist/utils/agent-runtime.d.ts +8 -0
- package/dist/utils/agent-runtime.js +33 -0
- package/dist/utils/date.d.ts +2 -0
- package/dist/utils/date.js +7 -0
- package/dist/utils/logger.d.ts +17 -0
- package/dist/utils/logger.js +81 -0
- package/dist/utils/value.d.ts +5 -0
- package/dist/utils/value.js +8 -0
- package/dist/version-check.d.ts +2 -0
- package/dist/version-check.js +65 -0
- package/package.json +55 -0
|
@@ -0,0 +1,937 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { createAgentLogWriter } from "../utils/agent-runtime.js";
|
|
6
|
+
import { VERSION } from "../config.js";
|
|
7
|
+
import { logger } from "../utils/logger.js";
|
|
8
|
+
import { field } from "../utils/value.js";
|
|
9
|
+
import { codexEventHistory } from "./codex-event-history.js";
|
|
10
|
+
const canvasAgentMcp = canvasAgentMcpCommand();
|
|
11
|
+
const require = createRequire(import.meta.url);
|
|
12
|
+
const STREAM_UPDATE_INTERVAL_MS = 40;
|
|
13
|
+
const supplementalItemTypes = new Set(["agent_message", "reasoning", "plan", "mcp_tool_call", "command_execution", "file_change", "dynamic_tool_call", "collab_tool_call", "web_search", "image_view", "image_generation", "context_compaction"]);
|
|
14
|
+
const SKILL_DRAFT_INSTRUCTIONS = "你只负责根据已提供的对话或画布快照生成可编辑的 Codex Skill 草稿。不要调用任何工具,不要执行命令,不要读取文件,不要访问网络,不要修改任何状态。严格按 outputSchema 返回结果,并排除凭证、密钥、Token、本地路径、临时错误、调试日志和一次性结果。";
|
|
15
|
+
/** 表示错误已经通过 app-server 终态或进程事件通知过网页。 */
|
|
16
|
+
export class CodexReportedError extends Error {
|
|
17
|
+
name = "CodexReportedError";
|
|
18
|
+
}
|
|
19
|
+
/** 封装 Codex app-server 的 JSON-RPC 通信与事件转换。 */
|
|
20
|
+
export class CodexAppClient {
|
|
21
|
+
child;
|
|
22
|
+
emit;
|
|
23
|
+
eventHistory;
|
|
24
|
+
nextId = 1;
|
|
25
|
+
buffer = "";
|
|
26
|
+
currentThreadId = "";
|
|
27
|
+
currentTurnId = "";
|
|
28
|
+
pendingTurnStart;
|
|
29
|
+
startedTurnKeys = new Set();
|
|
30
|
+
textByItem = new Map();
|
|
31
|
+
reasoningTextByItem = new Map();
|
|
32
|
+
lastUsage = null;
|
|
33
|
+
pending = new Map();
|
|
34
|
+
activeTurns = new Map();
|
|
35
|
+
completedTurns = new Map();
|
|
36
|
+
completedTurnResults = new Map();
|
|
37
|
+
pendingDeltas = new Map();
|
|
38
|
+
startedItems = new Map();
|
|
39
|
+
itemSequences = new Map();
|
|
40
|
+
nextItemSequences = new Map();
|
|
41
|
+
plansByTurn = new Map();
|
|
42
|
+
approvalRequests = new Map();
|
|
43
|
+
finalizingTurns = new Map();
|
|
44
|
+
skillReloads = new Map();
|
|
45
|
+
silentThreadIds = new Set();
|
|
46
|
+
structuredOutputByTurn = new Map();
|
|
47
|
+
pendingSilentThreadStarts = new Set();
|
|
48
|
+
pendingThreadStartedNotifications = [];
|
|
49
|
+
pendingPreheatThreadStarts = 0;
|
|
50
|
+
preheatingThreadIds = new Set();
|
|
51
|
+
failing = false;
|
|
52
|
+
failureMessage = "";
|
|
53
|
+
/** 保存 app-server 子进程和事件出口。 */
|
|
54
|
+
constructor(child, emit, eventHistory = codexEventHistory) {
|
|
55
|
+
this.child = child;
|
|
56
|
+
this.emit = emit;
|
|
57
|
+
this.eventHistory = eventHistory;
|
|
58
|
+
}
|
|
59
|
+
/** 启动并初始化 Codex app-server。 */
|
|
60
|
+
static async start(emit, onExit) {
|
|
61
|
+
logger.info("Starting Codex app-server", { executable: process.execPath, codex: codexBin() });
|
|
62
|
+
const child = spawn(process.execPath, [codexBin(), "app-server", "--stdio"], { stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
|
|
63
|
+
const client = new CodexAppClient(child, emit);
|
|
64
|
+
let stopped = false;
|
|
65
|
+
const stop = () => {
|
|
66
|
+
if (stopped)
|
|
67
|
+
return;
|
|
68
|
+
stopped = true;
|
|
69
|
+
onExit();
|
|
70
|
+
};
|
|
71
|
+
child.stdout?.on("data", (chunk) => client.read(chunk.toString()));
|
|
72
|
+
const stderr = createAgentLogWriter((text) => {
|
|
73
|
+
logger.warn("Codex app-server stderr", { text });
|
|
74
|
+
emit("agent_log", { text });
|
|
75
|
+
}, (text) => text.replace(/^\d{4}-\d{2}-\d{2}T[\d:.]+Z\s+/, ""));
|
|
76
|
+
child.stderr?.on("data", (chunk) => {
|
|
77
|
+
if (client.skillDraftActive)
|
|
78
|
+
return stderr.clear();
|
|
79
|
+
stderr.write(chunk.toString());
|
|
80
|
+
});
|
|
81
|
+
child.on("error", (error) => {
|
|
82
|
+
stderr.flush();
|
|
83
|
+
logger.error("Codex app-server process error", error);
|
|
84
|
+
emit("agent_error", { message: error.message });
|
|
85
|
+
client.failAll(error.message, true);
|
|
86
|
+
stop();
|
|
87
|
+
});
|
|
88
|
+
child.on("exit", (code) => {
|
|
89
|
+
stderr.flush();
|
|
90
|
+
logger.warn("Codex app-server exited", { code });
|
|
91
|
+
client.failAll(`Codex app-server exited: ${code ?? 0}`);
|
|
92
|
+
stop();
|
|
93
|
+
emit("agent_log", { text: `Codex app-server exited: ${code ?? 0}` });
|
|
94
|
+
});
|
|
95
|
+
await client.request("initialize", { clientInfo: { name: "canvas-agent", title: "Dianran Canvas Agent", version: VERSION }, capabilities: { experimentalApi: true, requestAttestation: false } });
|
|
96
|
+
client.notify("initialized");
|
|
97
|
+
return client;
|
|
98
|
+
}
|
|
99
|
+
/** 创建新的 Codex 线程。 */
|
|
100
|
+
async startThread(cwd, permissionMode = "request", preheat = false) {
|
|
101
|
+
if (preheat)
|
|
102
|
+
this.pendingPreheatThreadStarts += 1;
|
|
103
|
+
let threadId = "";
|
|
104
|
+
try {
|
|
105
|
+
const { thread } = await this.request("thread/start", { ...threadSettings(permissionMode), ...(cwd ? { cwd } : {}), threadSource: "user" });
|
|
106
|
+
if (!thread.id)
|
|
107
|
+
throw new Error("Codex app-server 没有返回 thread id");
|
|
108
|
+
threadId = thread.id;
|
|
109
|
+
if (preheat) {
|
|
110
|
+
this.preheatingThreadIds.add(threadId);
|
|
111
|
+
await this.completeMcpPreheat(threadId);
|
|
112
|
+
}
|
|
113
|
+
return thread;
|
|
114
|
+
}
|
|
115
|
+
finally {
|
|
116
|
+
if (preheat)
|
|
117
|
+
this.pendingPreheatThreadStarts -= 1;
|
|
118
|
+
if (threadId)
|
|
119
|
+
this.preheatingThreadIds.delete(threadId);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/** 创建不会持久化或向网页广播的草稿线程。 */
|
|
123
|
+
async startSkillDraftThread(cwd) {
|
|
124
|
+
return await this.startSilentThread("thread/start", { ...skillDraftThreadSettings(cwd), threadSource: "user" });
|
|
125
|
+
}
|
|
126
|
+
/** 从指定对话派生不会持久化或向网页广播的草稿线程。 */
|
|
127
|
+
async forkSkillDraftThread(threadId, cwd) {
|
|
128
|
+
return await this.startSilentThread("thread/fork", { ...skillDraftThreadSettings(cwd), threadId, threadSource: "user" });
|
|
129
|
+
}
|
|
130
|
+
/** 恢复已有 Codex 线程。 */
|
|
131
|
+
async resumeThread(threadId, cwd, permissionMode = "request", preheat = false) {
|
|
132
|
+
if (preheat)
|
|
133
|
+
this.pendingPreheatThreadStarts += 1;
|
|
134
|
+
try {
|
|
135
|
+
if (preheat)
|
|
136
|
+
this.preheatingThreadIds.add(threadId);
|
|
137
|
+
const { thread } = await this.request("thread/resume", { threadId, ...threadSettings(permissionMode), ...(cwd ? { cwd } : {}) });
|
|
138
|
+
if (!thread.id)
|
|
139
|
+
throw new Error("Codex app-server 没有返回 thread id");
|
|
140
|
+
if (preheat)
|
|
141
|
+
await this.completeMcpPreheat(thread.id);
|
|
142
|
+
return thread;
|
|
143
|
+
}
|
|
144
|
+
finally {
|
|
145
|
+
if (preheat)
|
|
146
|
+
this.pendingPreheatThreadStarts -= 1;
|
|
147
|
+
if (preheat)
|
|
148
|
+
this.preheatingThreadIds.delete(threadId);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
/** 以 app-server 的权威 MCP 清单响应作为预热完成边界。 */
|
|
152
|
+
async completeMcpPreheat(threadId) {
|
|
153
|
+
const result = await this.request("mcpServerStatus/list", { threadId, limit: 100, detail: "toolsAndAuthOnly" });
|
|
154
|
+
this.emit("agent_bootstrap", { type: "mcp.complete", phase: "preheat", threadId, services: result.data.map(({ name, authStatus }) => ({ name, authStatus })) });
|
|
155
|
+
}
|
|
156
|
+
/** 查询 Codex 线程列表。 */
|
|
157
|
+
listThreads(params) {
|
|
158
|
+
return this.request("thread/list", params);
|
|
159
|
+
}
|
|
160
|
+
/** 读取指定 Codex 线程。 */
|
|
161
|
+
readThread(threadId, includeTurns = true) {
|
|
162
|
+
return this.request("thread/read", { threadId, includeTurns });
|
|
163
|
+
}
|
|
164
|
+
/** 归档指定 Codex 线程。 */
|
|
165
|
+
archiveThread(threadId) {
|
|
166
|
+
return this.request("thread/archive", { threadId });
|
|
167
|
+
}
|
|
168
|
+
/** 释放临时草稿线程的 App Server 订阅和进程内缓存。 */
|
|
169
|
+
async closeSkillDraftThread(threadId) {
|
|
170
|
+
try {
|
|
171
|
+
if (!this.failing)
|
|
172
|
+
await this.request("thread/unsubscribe", { threadId }, true);
|
|
173
|
+
}
|
|
174
|
+
finally {
|
|
175
|
+
this.silentThreadIds.delete(threadId);
|
|
176
|
+
const prefix = `${threadId}\0`;
|
|
177
|
+
[...this.structuredOutputByTurn.keys()].filter((key) => key.startsWith(prefix)).forEach((key) => this.structuredOutputByTurn.delete(key));
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
/** 查询当前账号可用的 Codex 模型。 */
|
|
181
|
+
listModels() {
|
|
182
|
+
return this.request("model/list", { limit: 100, includeHidden: false });
|
|
183
|
+
}
|
|
184
|
+
/** 查询指定工作空间可发现的 Codex Skills。 */
|
|
185
|
+
listSkills(cwd, forceReload = false) {
|
|
186
|
+
if (!forceReload)
|
|
187
|
+
return this.request("skills/list", { cwds: [cwd] });
|
|
188
|
+
const key = process.platform === "win32" ? path.resolve(cwd).toLowerCase() : path.resolve(cwd);
|
|
189
|
+
const current = this.skillReloads.get(key);
|
|
190
|
+
if (current)
|
|
191
|
+
return current;
|
|
192
|
+
const reload = this.request("skills/list", { cwds: [cwd], forceReload: true });
|
|
193
|
+
this.skillReloads.set(key, reload);
|
|
194
|
+
const clear = () => {
|
|
195
|
+
if (this.skillReloads.get(key) === reload)
|
|
196
|
+
this.skillReloads.delete(key);
|
|
197
|
+
};
|
|
198
|
+
void reload.then(clear, clear);
|
|
199
|
+
return reload;
|
|
200
|
+
}
|
|
201
|
+
/** 修改一个已发现 Skill 的启用状态。 */
|
|
202
|
+
setSkillEnabled(path, enabled) {
|
|
203
|
+
return this.request("skills/config/write", { path, enabled });
|
|
204
|
+
}
|
|
205
|
+
/** 返回指定线程在当前进程中收到的最新任务计划。 */
|
|
206
|
+
planUpdates(threadId) {
|
|
207
|
+
return [...this.plansByTurn.values()].filter((item) => item.threadId === threadId);
|
|
208
|
+
}
|
|
209
|
+
/** 清理已归档线程的任务计划缓存。 */
|
|
210
|
+
clearPlanUpdates(threadId) {
|
|
211
|
+
this.plansByTurn.forEach((item, key) => {
|
|
212
|
+
if (item.threadId === threadId)
|
|
213
|
+
this.plansByTurn.delete(key);
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
/** 启动一个 Codex turn 并等待完成通知。 */
|
|
217
|
+
async startTurn(threadId, prompt, images, permissionMode, model, effort, onTurn, skill, messageText, outputSchema) {
|
|
218
|
+
this.preheatingThreadIds.delete(threadId);
|
|
219
|
+
this.currentThreadId = threadId;
|
|
220
|
+
this.currentTurnId = "";
|
|
221
|
+
this.lastUsage = null;
|
|
222
|
+
const pendingStart = { threadId, prompt, messageText, onTurn };
|
|
223
|
+
this.pendingTurnStart = pendingStart;
|
|
224
|
+
try {
|
|
225
|
+
const { turn } = await this.request("turn/start", { threadId, input: codexInput(prompt, images, skill), ...(outputSchema ? skillDraftTurnSettings() : turnSettings(permissionMode)), ...(model ? { model } : {}), ...(effort ? { effort } : {}), ...(outputSchema ? { outputSchema } : {}) }, Boolean(outputSchema));
|
|
226
|
+
const turnId = turn.id;
|
|
227
|
+
if (!turnId)
|
|
228
|
+
throw new Error("Codex app-server 没有返回 turn id");
|
|
229
|
+
pendingStart.turnId = turnId;
|
|
230
|
+
this.currentTurnId = turnId;
|
|
231
|
+
this.notifyTurnStarted(threadId, turnId, pendingStart);
|
|
232
|
+
const turnKey = turnCacheKey(threadId, turnId);
|
|
233
|
+
const completed = this.completedTurns.get(turnKey);
|
|
234
|
+
if (this.completedTurns.has(turnKey)) {
|
|
235
|
+
this.completedTurns.delete(turnKey);
|
|
236
|
+
const result = this.completedTurnResults.get(turnKey);
|
|
237
|
+
this.completedTurnResults.delete(turnKey);
|
|
238
|
+
this.currentThreadId = "";
|
|
239
|
+
this.currentTurnId = "";
|
|
240
|
+
if (completed)
|
|
241
|
+
throw completed;
|
|
242
|
+
return result;
|
|
243
|
+
}
|
|
244
|
+
return await new Promise((resolve, reject) => this.activeTurns.set(turnKey, { resolve, reject, threadId, turnId, prompt, messageText }));
|
|
245
|
+
}
|
|
246
|
+
catch (error) {
|
|
247
|
+
if (!this.currentTurnId)
|
|
248
|
+
this.currentThreadId = "";
|
|
249
|
+
throw error;
|
|
250
|
+
}
|
|
251
|
+
finally {
|
|
252
|
+
if (this.pendingTurnStart === pendingStart)
|
|
253
|
+
this.pendingTurnStart = undefined;
|
|
254
|
+
if (pendingStart.turnId)
|
|
255
|
+
this.startedTurnKeys.delete(turnCacheKey(threadId, pendingStart.turnId));
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
/** 在静默线程中生成结构化输出。 */
|
|
259
|
+
async generateSkillDraft(threadId, prompt, outputSchema, model, effort) {
|
|
260
|
+
this.silentThreadIds.add(threadId);
|
|
261
|
+
const result = await this.startTurn(threadId, prompt, [], "request", model, effort, undefined, undefined, undefined, outputSchema);
|
|
262
|
+
const output = String(field(result, "output") || "").trim();
|
|
263
|
+
if (!output)
|
|
264
|
+
throw new Error("Codex 没有返回 Skill 草稿");
|
|
265
|
+
return output;
|
|
266
|
+
}
|
|
267
|
+
/** 中断当前正在运行且属于指定线程的 Codex turn。 */
|
|
268
|
+
async interruptCurrentTurn(requestedThreadId) {
|
|
269
|
+
const threadId = this.currentThreadId;
|
|
270
|
+
const turnId = this.currentTurnId;
|
|
271
|
+
if (!threadId || !turnId || (requestedThreadId && requestedThreadId !== threadId))
|
|
272
|
+
return false;
|
|
273
|
+
try {
|
|
274
|
+
logger.warn("Interrupting active Codex turn", { threadId, turnId });
|
|
275
|
+
await this.request("turn/interrupt", { threadId, turnId });
|
|
276
|
+
return true;
|
|
277
|
+
}
|
|
278
|
+
catch (error) {
|
|
279
|
+
logger.warn("Failed to interrupt Codex turn", { error, threadId, turnId });
|
|
280
|
+
return false;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
/** 回复网页端已经确认的 Codex 权限请求。 */
|
|
284
|
+
resolveApproval(requestId, decision) {
|
|
285
|
+
const request = this.approvalRequests.get(requestId);
|
|
286
|
+
if (!request)
|
|
287
|
+
return false;
|
|
288
|
+
if (request.decision)
|
|
289
|
+
return true;
|
|
290
|
+
request.decision = decision;
|
|
291
|
+
const permissions = field(request.params, "permissions") || field(request.params, "requestedPermissions");
|
|
292
|
+
const accepted = decision === "accept" || decision === "acceptForSession";
|
|
293
|
+
const result = request.method === "item/permissions/requestApproval"
|
|
294
|
+
? { permissions: accepted ? permissions || {} : {}, scope: decision === "acceptForSession" ? "session" : "turn" }
|
|
295
|
+
: { decision };
|
|
296
|
+
this.write({ id: request.id, result });
|
|
297
|
+
return true;
|
|
298
|
+
}
|
|
299
|
+
/** 标记下一次临时线程创建,使早于请求响应到达的通知也不会外泄。 */
|
|
300
|
+
async startSilentThread(method, params) {
|
|
301
|
+
const pendingStart = Symbol();
|
|
302
|
+
this.pendingSilentThreadStarts.add(pendingStart);
|
|
303
|
+
try {
|
|
304
|
+
const result = await this.request(method, params, true);
|
|
305
|
+
const thread = result.thread;
|
|
306
|
+
if (!thread.id)
|
|
307
|
+
throw new Error("Codex app-server 没有返回 thread id");
|
|
308
|
+
this.silentThreadIds.add(thread.id);
|
|
309
|
+
return thread;
|
|
310
|
+
}
|
|
311
|
+
finally {
|
|
312
|
+
this.pendingSilentThreadStarts.delete(pendingStart);
|
|
313
|
+
if (!this.pendingSilentThreadStarts.size)
|
|
314
|
+
this.flushPendingThreadStartedNotifications();
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
/** 发送 JSON-RPC 请求并保存待处理 Promise。 */
|
|
318
|
+
request(method, params, silent = false) {
|
|
319
|
+
if (this.failing)
|
|
320
|
+
return Promise.reject(new CodexReportedError(this.failureMessage || "Codex app-server 已停止"));
|
|
321
|
+
const id = this.nextId++;
|
|
322
|
+
this.write({ id, method, params }, silent);
|
|
323
|
+
return new Promise((resolve, reject) => this.pending.set(id, { resolve: (result) => resolve(result), reject, silent }));
|
|
324
|
+
}
|
|
325
|
+
/** 发送无需响应的 JSON-RPC 通知。 */
|
|
326
|
+
notify(method, params) {
|
|
327
|
+
this.write(params === undefined ? { method } : { method, params });
|
|
328
|
+
}
|
|
329
|
+
/** 将 JSON-RPC 消息写入 app-server 标准输入。 */
|
|
330
|
+
write(value, silent = false) {
|
|
331
|
+
const method = String(field(value, "method") || "");
|
|
332
|
+
const params = field(value, "params");
|
|
333
|
+
if (method && !silent)
|
|
334
|
+
logger.debug(`Codex ${method}`, { id: field(value, "id"), threadId: field(params, "threadId") });
|
|
335
|
+
this.child.stdin?.write(`${JSON.stringify(value)}\n`);
|
|
336
|
+
}
|
|
337
|
+
/** 按行解析 app-server 标准输出。 */
|
|
338
|
+
read(chunk) {
|
|
339
|
+
this.buffer += chunk;
|
|
340
|
+
const lines = this.buffer.split(/\r?\n/);
|
|
341
|
+
this.buffer = lines.pop() || "";
|
|
342
|
+
lines.filter(Boolean).forEach((line) => {
|
|
343
|
+
try {
|
|
344
|
+
this.handle(JSON.parse(line));
|
|
345
|
+
}
|
|
346
|
+
catch (error) {
|
|
347
|
+
if (!this.skillDraftActive) {
|
|
348
|
+
logger.warn("Invalid Codex app-server output", { error, line });
|
|
349
|
+
this.emit("agent_log", { text: line });
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
/** 分派单条 JSON-RPC 响应、请求或通知。 */
|
|
355
|
+
handle(message) {
|
|
356
|
+
const id = Number(message.id);
|
|
357
|
+
if (message.error && this.pending.has(id)) {
|
|
358
|
+
const error = String(field(message.error, "message") || "Codex request failed");
|
|
359
|
+
if (!this.pending.get(id)?.silent) {
|
|
360
|
+
if (/not materialized yet.*includeTurns/i.test(error))
|
|
361
|
+
logger.debug("Codex thread has no messages yet", { id });
|
|
362
|
+
else
|
|
363
|
+
logger.warn("Codex request failed", { id, error });
|
|
364
|
+
}
|
|
365
|
+
return this.reject(id, error);
|
|
366
|
+
}
|
|
367
|
+
if (this.pending.has(id))
|
|
368
|
+
return this.resolve(id, message.result);
|
|
369
|
+
if (typeof message.method === "string" && "id" in message)
|
|
370
|
+
return this.answerServerRequest(message);
|
|
371
|
+
if (typeof message.method === "string")
|
|
372
|
+
this.handleNotification(message.method, (message.params || {}));
|
|
373
|
+
}
|
|
374
|
+
/** 转换并广播 app-server 通知。 */
|
|
375
|
+
handleNotification(method, params) {
|
|
376
|
+
if (this.handleSilentNotification(method, params))
|
|
377
|
+
return;
|
|
378
|
+
if (method === "skills/changed") {
|
|
379
|
+
this.emit("skills_changed", {});
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
if (method === "serverRequest/resolved") {
|
|
383
|
+
const requestId = String(field(params, "requestId") || "");
|
|
384
|
+
const request = requestId ? this.approvalRequests.get(requestId) : undefined;
|
|
385
|
+
if (request) {
|
|
386
|
+
this.approvalRequests.delete(requestId);
|
|
387
|
+
this.emit("codex_approval_resolved", { ...request.params, ...params, requestId, decision: request.decision });
|
|
388
|
+
}
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
if (method === "mcpServer/startupStatus/updated") {
|
|
392
|
+
const value = params;
|
|
393
|
+
const threadId = value.threadId || this.currentThreadId;
|
|
394
|
+
this.emit("agent_bootstrap", {
|
|
395
|
+
type: "mcp.startup",
|
|
396
|
+
phase: this.pendingPreheatThreadStarts > 0 || this.preheatingThreadIds.has(threadId) ? "preheat" : "runtime",
|
|
397
|
+
threadId,
|
|
398
|
+
name: value.name,
|
|
399
|
+
status: value.status,
|
|
400
|
+
error: value.error,
|
|
401
|
+
failureReason: value.failureReason,
|
|
402
|
+
});
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
const turnEvent = method.startsWith("turn/") || method.startsWith("item/") || method === "thread/tokenUsage/updated" || method === "error";
|
|
406
|
+
if (turnEvent) {
|
|
407
|
+
const threadId = String(field(params, "threadId") || this.currentThreadId);
|
|
408
|
+
const turnId = String(field(params, "turnId") || field(field(params, "turn"), "id") || this.currentTurnId);
|
|
409
|
+
if (method === "turn/started") {
|
|
410
|
+
this.currentThreadId = threadId;
|
|
411
|
+
this.currentTurnId = turnId;
|
|
412
|
+
this.notifyTurnStarted(threadId, turnId);
|
|
413
|
+
}
|
|
414
|
+
params = { ...params, ...(threadId ? { threadId } : {}), ...(turnId ? { turnId } : {}) };
|
|
415
|
+
}
|
|
416
|
+
if (method === "item/agentMessage/delta") {
|
|
417
|
+
const value = params;
|
|
418
|
+
return this.emitDelta("agent_message", value);
|
|
419
|
+
}
|
|
420
|
+
if (method === "item/plan/delta")
|
|
421
|
+
return this.emitDelta("plan", params);
|
|
422
|
+
if (method === "item/reasoning/summaryTextDelta")
|
|
423
|
+
return this.emitDelta("reasoning", params);
|
|
424
|
+
if (method === "item/commandExecution/outputDelta")
|
|
425
|
+
return this.emitDelta("command_execution", params);
|
|
426
|
+
if (method === "turn/plan/updated") {
|
|
427
|
+
const value = params;
|
|
428
|
+
const update = { ...value, threadId: value.threadId || "" };
|
|
429
|
+
if (update.threadId && update.turnId)
|
|
430
|
+
this.plansByTurn.set(turnCacheKey(update.threadId, update.turnId), update);
|
|
431
|
+
params = update;
|
|
432
|
+
}
|
|
433
|
+
if (method === "thread/tokenUsage/updated") {
|
|
434
|
+
this.lastUsage = normalizeUsage(params);
|
|
435
|
+
this.emit("agent_event", { agent: "codex", type: "usage.updated", usage: this.lastUsage, ...codexEventScope(params) });
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
const event = normalizeCodexNotification(method, params);
|
|
439
|
+
if (!event)
|
|
440
|
+
return;
|
|
441
|
+
const eventScope = codexEventScope(params);
|
|
442
|
+
if (event.type === "item.started" || event.type === "item.completed") {
|
|
443
|
+
const item = field(event, "item");
|
|
444
|
+
const id = String(field(item, "id") || "");
|
|
445
|
+
const threadId = String(field(event, "thread_id") || this.currentThreadId);
|
|
446
|
+
const turnId = String(field(event, "turn_id") || this.currentTurnId);
|
|
447
|
+
const key = itemCacheKey({ threadId, turnId, itemId: id });
|
|
448
|
+
if (id && threadId && turnId && event.type === "item.started") {
|
|
449
|
+
this.assignItemSequence(threadId, turnId, id);
|
|
450
|
+
this.startedItems.set(key, item || {});
|
|
451
|
+
}
|
|
452
|
+
if (id && event.type === "item.completed") {
|
|
453
|
+
const started = this.startedItems.get(key);
|
|
454
|
+
if (started)
|
|
455
|
+
event.item = mergeDefinedRecord(started, item || {});
|
|
456
|
+
this.startedItems.delete(key);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
if (event.type === "item.completed") {
|
|
460
|
+
const item = field(event, "item");
|
|
461
|
+
const id = String(field(item, "id") || "");
|
|
462
|
+
const key = itemCacheKey({ threadId: String(field(event, "thread_id") || this.currentThreadId), turnId: String(field(event, "turn_id") || this.currentTurnId), itemId: id });
|
|
463
|
+
this.flushDelta(key);
|
|
464
|
+
const streamedText = this.textByItem.get(key);
|
|
465
|
+
if ((item?.type === "agent_message" || item?.type === "plan") && streamedText && !String(item.text || "").trim())
|
|
466
|
+
item.text = streamedText;
|
|
467
|
+
if (item?.type === "reasoning" && streamedText && !readableCodexText(field(item, "summary")))
|
|
468
|
+
item.summary = streamedText;
|
|
469
|
+
if (item?.type === "command_execution" && streamedText && !String(item.aggregatedOutput || "").trim())
|
|
470
|
+
item.aggregatedOutput = streamedText;
|
|
471
|
+
const threadId = String(field(event, "thread_id") || this.currentThreadId);
|
|
472
|
+
const turnId = String(field(event, "turn_id") || this.currentTurnId);
|
|
473
|
+
if (id && threadId && turnId && item && supplementalItemTypes.has(String(item.type || ""))) {
|
|
474
|
+
const sequence = this.assignItemSequence(threadId, turnId, id);
|
|
475
|
+
void this.eventHistory.record({ threadId, turnId, itemId: id, sequence, item }).catch((error) => logger.warn("Failed to persist Codex event history", { threadId, turnId, itemId: id, error }));
|
|
476
|
+
}
|
|
477
|
+
if (id) {
|
|
478
|
+
this.textByItem.delete(key);
|
|
479
|
+
this.reasoningTextByItem.delete(key);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
let turnPersistence;
|
|
483
|
+
if (event.type === "turn.completed") {
|
|
484
|
+
const turn = field(params, "turn");
|
|
485
|
+
const turnId = String(field(turn, "id") || field(params, "turnId") || "");
|
|
486
|
+
const threadId = String(field(params, "threadId") || field(event, "thread_id") || "");
|
|
487
|
+
const planKey = turnCacheKey(threadId, turnId);
|
|
488
|
+
const plan = this.plansByTurn.get(planKey);
|
|
489
|
+
if (plan)
|
|
490
|
+
this.plansByTurn.set(planKey, { ...plan, turnStatus: String(field(turn, "status") || "completed") });
|
|
491
|
+
if (threadId && turnId) {
|
|
492
|
+
const pendingStart = this.pendingTurnStart?.threadId === threadId && (!this.pendingTurnStart.turnId || this.pendingTurnStart.turnId === turnId) ? this.pendingTurnStart : undefined;
|
|
493
|
+
const turnRecord = {
|
|
494
|
+
...(turn && typeof turn === "object" && !Array.isArray(turn) ? turn : { id: turnId, status: field(turn, "status") || "completed" }),
|
|
495
|
+
...(pendingStart?.prompt ? { input: pendingStart.prompt } : {}),
|
|
496
|
+
...(pendingStart?.messageText ? { messageText: pendingStart.messageText } : {}),
|
|
497
|
+
};
|
|
498
|
+
turnPersistence = this.eventHistory.recordTurn({ threadId, turnId, turn: turnRecord }).catch((error) => logger.warn("Failed to persist Codex turn history", { threadId, turnId, error }));
|
|
499
|
+
this.finalizingTurns.set(planKey, turnPersistence);
|
|
500
|
+
}
|
|
501
|
+
this.finishTurnDeltas(threadId, turnId);
|
|
502
|
+
}
|
|
503
|
+
if (event.type === "turn.completed") {
|
|
504
|
+
event.usage = this.lastUsage;
|
|
505
|
+
const complete = () => this.completeTurn(event, params, eventScope);
|
|
506
|
+
if (turnPersistence)
|
|
507
|
+
void turnPersistence.then(complete);
|
|
508
|
+
else
|
|
509
|
+
complete();
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
this.emit("agent_event", { agent: "codex", ...event });
|
|
513
|
+
}
|
|
514
|
+
/** 草稿线程存续期间隐藏 app-server 自身输出,避免混入网页诊断日志。 */
|
|
515
|
+
get skillDraftActive() {
|
|
516
|
+
return this.pendingSilentThreadStarts.size > 0 || this.silentThreadIds.size > 0;
|
|
517
|
+
}
|
|
518
|
+
/** 消化临时草稿线程事件,不广播、不落补充历史。 */
|
|
519
|
+
handleSilentNotification(method, params) {
|
|
520
|
+
if (method === "thread/started") {
|
|
521
|
+
const thread = field(params, "thread");
|
|
522
|
+
const threadId = String(field(thread, "id") || "");
|
|
523
|
+
if (!threadId)
|
|
524
|
+
return false;
|
|
525
|
+
if (this.silentThreadIds.has(threadId))
|
|
526
|
+
return true;
|
|
527
|
+
if (!this.pendingSilentThreadStarts.size)
|
|
528
|
+
return false;
|
|
529
|
+
this.pendingThreadStartedNotifications.push(params);
|
|
530
|
+
return true;
|
|
531
|
+
}
|
|
532
|
+
if (!(method === "error" || method.startsWith("turn/") || method.startsWith("item/") || method === "thread/tokenUsage/updated"))
|
|
533
|
+
return false;
|
|
534
|
+
const threadId = String(field(params, "threadId") || this.currentThreadId);
|
|
535
|
+
if (!threadId || !this.silentThreadIds.has(threadId))
|
|
536
|
+
return false;
|
|
537
|
+
const turnId = String(field(params, "turnId") || field(field(params, "turn"), "id") || this.currentTurnId);
|
|
538
|
+
if (method === "turn/started") {
|
|
539
|
+
this.currentThreadId = threadId;
|
|
540
|
+
this.currentTurnId = turnId;
|
|
541
|
+
this.notifyTurnStarted(threadId, turnId);
|
|
542
|
+
return true;
|
|
543
|
+
}
|
|
544
|
+
if (method === "item/agentMessage/delta") {
|
|
545
|
+
const itemId = String(field(params, "itemId") || "");
|
|
546
|
+
if (itemId && turnId) {
|
|
547
|
+
const key = itemCacheKey({ threadId, turnId, itemId });
|
|
548
|
+
this.textByItem.set(key, `${this.textByItem.get(key) || ""}${String(field(params, "delta") || "")}`);
|
|
549
|
+
}
|
|
550
|
+
return true;
|
|
551
|
+
}
|
|
552
|
+
if (method === "item/completed") {
|
|
553
|
+
const item = normalizeItem(field(params, "item"));
|
|
554
|
+
const itemId = String(field(item, "id") || "");
|
|
555
|
+
const key = itemCacheKey({ threadId, turnId, itemId });
|
|
556
|
+
if (item.type === "agent_message" && turnId) {
|
|
557
|
+
const text = String(item.text || this.textByItem.get(key) || "").trim();
|
|
558
|
+
if (text)
|
|
559
|
+
this.structuredOutputByTurn.set(turnCacheKey(threadId, turnId), text);
|
|
560
|
+
}
|
|
561
|
+
if (itemId)
|
|
562
|
+
this.textByItem.delete(key);
|
|
563
|
+
return true;
|
|
564
|
+
}
|
|
565
|
+
if (method !== "turn/completed")
|
|
566
|
+
return true;
|
|
567
|
+
const turn = field(params, "turn");
|
|
568
|
+
const completedTurnId = String(field(turn, "id") || turnId);
|
|
569
|
+
const turnKey = turnCacheKey(threadId, completedTurnId);
|
|
570
|
+
const output = this.structuredOutputByTurn.get(turnKey) || "";
|
|
571
|
+
this.structuredOutputByTurn.delete(turnKey);
|
|
572
|
+
this.finishTurnDeltas(threadId, completedTurnId);
|
|
573
|
+
const failure = turn.error ? new CodexReportedError(turn.error.message || "Codex turn failed") : null;
|
|
574
|
+
const pending = this.activeTurns.get(turnKey);
|
|
575
|
+
const result = { output };
|
|
576
|
+
if (pending) {
|
|
577
|
+
this.activeTurns.delete(turnKey);
|
|
578
|
+
failure ? pending.reject(failure) : pending.resolve(result);
|
|
579
|
+
}
|
|
580
|
+
else if (completedTurnId) {
|
|
581
|
+
this.completedTurns.set(turnKey, failure);
|
|
582
|
+
this.completedTurnResults.set(turnKey, result);
|
|
583
|
+
}
|
|
584
|
+
if (completedTurnId === this.currentTurnId) {
|
|
585
|
+
this.currentThreadId = "";
|
|
586
|
+
this.currentTurnId = "";
|
|
587
|
+
}
|
|
588
|
+
return true;
|
|
589
|
+
}
|
|
590
|
+
/** 请求响应确定静默线程 ID 后,重新分流早到的启动通知。 */
|
|
591
|
+
flushPendingThreadStartedNotifications() {
|
|
592
|
+
const notifications = this.pendingThreadStartedNotifications.splice(0);
|
|
593
|
+
notifications.forEach((params) => this.handleNotification("thread/started", params));
|
|
594
|
+
}
|
|
595
|
+
/** 补充历史落盘后再广播 turn 终态,确保界面完成状态可跨 Agent 重启恢复。 */
|
|
596
|
+
completeTurn(event, params, eventScope) {
|
|
597
|
+
const turn = params.turn;
|
|
598
|
+
const turnId = turn.id;
|
|
599
|
+
const turnKey = turnCacheKey(String(field(params, "threadId") || field(event, "thread_id") || ""), turnId);
|
|
600
|
+
this.finalizingTurns.delete(turnKey);
|
|
601
|
+
this.emit("agent_event", { agent: "codex", ...event });
|
|
602
|
+
const pending = this.activeTurns.get(turnKey);
|
|
603
|
+
const error = turn.error;
|
|
604
|
+
const failure = error ? new CodexReportedError(error.message || "Codex turn failed") : null;
|
|
605
|
+
if (pending) {
|
|
606
|
+
this.activeTurns.delete(turnKey);
|
|
607
|
+
failure ? pending.reject(failure) : pending.resolve(event);
|
|
608
|
+
}
|
|
609
|
+
else if (turnId) {
|
|
610
|
+
this.completedTurns.set(turnKey, failure);
|
|
611
|
+
}
|
|
612
|
+
if (turnId === this.currentTurnId) {
|
|
613
|
+
this.currentThreadId = "";
|
|
614
|
+
this.currentTurnId = "";
|
|
615
|
+
}
|
|
616
|
+
this.emit("agent_done", { agent: "codex", usage: event.usage, ...eventScope });
|
|
617
|
+
}
|
|
618
|
+
/** 合并并广播 Agent 文本或执行输出增量。 */
|
|
619
|
+
emitDelta(itemType, params) {
|
|
620
|
+
params = { ...params, threadId: params.threadId || this.currentThreadId, turnId: params.turnId || this.currentTurnId };
|
|
621
|
+
const key = itemCacheKey(params);
|
|
622
|
+
this.textByItem.set(key, itemType === "reasoning" ? this.appendReasoningText(key, params) : `${this.textByItem.get(key) || ""}${params.delta}`);
|
|
623
|
+
const pending = this.pendingDeltas.get(key);
|
|
624
|
+
if (pending) {
|
|
625
|
+
pending.delta += params.delta;
|
|
626
|
+
pending.itemType = itemType;
|
|
627
|
+
pending.params = params;
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
this.pendingDeltas.set(key, {
|
|
631
|
+
delta: params.delta,
|
|
632
|
+
itemType,
|
|
633
|
+
params,
|
|
634
|
+
timer: setTimeout(() => this.flushDelta(key), STREAM_UPDATE_INTERVAL_MS),
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
/** 合并短时间内的文本增量,减少 SSE 传输和前端渲染次数。 */
|
|
638
|
+
flushDelta(key) {
|
|
639
|
+
const pending = this.pendingDeltas.get(key);
|
|
640
|
+
if (!pending)
|
|
641
|
+
return;
|
|
642
|
+
clearTimeout(pending.timer);
|
|
643
|
+
this.pendingDeltas.delete(key);
|
|
644
|
+
if (pending.delta)
|
|
645
|
+
this.emit("agent_event", { agent: "codex", type: "item.updated", item: { id: pending.params.itemId, type: pending.itemType, delta: pending.delta }, ...codexEventScope(pending.params) });
|
|
646
|
+
}
|
|
647
|
+
/** 按 Codex summaryIndex 保存 reasoning 分段,顺序与线程历史一致。 */
|
|
648
|
+
appendReasoningText(key, params) {
|
|
649
|
+
const segments = this.reasoningTextByItem.get(key) || new Map();
|
|
650
|
+
this.reasoningTextByItem.set(key, segments);
|
|
651
|
+
return appendReasoningDelta(segments, params.summaryIndex, params.delta);
|
|
652
|
+
}
|
|
653
|
+
/** turn 结束时发送最后一批增量并清理未收到 item.completed 的缓存。 */
|
|
654
|
+
finishTurnDeltas(threadId, turnId) {
|
|
655
|
+
const prefix = `${turnCacheKey(threadId, turnId)}\0`;
|
|
656
|
+
[...this.pendingDeltas.keys()].filter((key) => key.startsWith(prefix)).forEach((key) => this.flushDelta(key));
|
|
657
|
+
[...this.textByItem.keys()].filter((key) => key.startsWith(prefix)).forEach((key) => this.textByItem.delete(key));
|
|
658
|
+
[...this.reasoningTextByItem.keys()].filter((key) => key.startsWith(prefix)).forEach((key) => this.reasoningTextByItem.delete(key));
|
|
659
|
+
[...this.startedItems.keys()].filter((key) => key.startsWith(prefix)).forEach((key) => this.startedItems.delete(key));
|
|
660
|
+
[...this.itemSequences.keys()].filter((key) => key.startsWith(prefix)).forEach((key) => this.itemSequences.delete(key));
|
|
661
|
+
this.nextItemSequences.delete(turnCacheKey(threadId, turnId));
|
|
662
|
+
}
|
|
663
|
+
/** 为一个 turn 内的 item 固定开始顺序,完成通知只更新内容。 */
|
|
664
|
+
assignItemSequence(threadId, turnId, itemId) {
|
|
665
|
+
const key = itemCacheKey({ threadId, turnId, itemId });
|
|
666
|
+
const existing = this.itemSequences.get(key);
|
|
667
|
+
if (existing !== undefined)
|
|
668
|
+
return existing;
|
|
669
|
+
const turnKey = turnCacheKey(threadId, turnId);
|
|
670
|
+
const sequence = (this.nextItemSequences.get(turnKey) || 0) + 1;
|
|
671
|
+
this.nextItemSequences.set(turnKey, sequence);
|
|
672
|
+
this.itemSequences.set(key, sequence);
|
|
673
|
+
return sequence;
|
|
674
|
+
}
|
|
675
|
+
/** 在通知或 turn/start 响应到达时回调一次 turn 启动状态。 */
|
|
676
|
+
notifyTurnStarted(threadId, turnId, fallback) {
|
|
677
|
+
if (!threadId || !turnId)
|
|
678
|
+
return;
|
|
679
|
+
const pending = this.pendingTurnStart;
|
|
680
|
+
const registration = pending?.threadId === threadId && (!pending.turnId || pending.turnId === turnId) ? pending : fallback;
|
|
681
|
+
if (registration && !registration.turnId)
|
|
682
|
+
registration.turnId = turnId;
|
|
683
|
+
const key = turnCacheKey(threadId, turnId);
|
|
684
|
+
if (this.startedTurnKeys.has(key))
|
|
685
|
+
return;
|
|
686
|
+
if (!registration)
|
|
687
|
+
return;
|
|
688
|
+
this.startedTurnKeys.add(key);
|
|
689
|
+
registration.onTurn?.(turnId);
|
|
690
|
+
}
|
|
691
|
+
/** 自动回复 app-server 发起的授权或交互请求。 */
|
|
692
|
+
answerServerRequest(message) {
|
|
693
|
+
const method = String(message.method);
|
|
694
|
+
const params = field(message, "params") || {};
|
|
695
|
+
const threadId = String(field(params, "threadId") || this.currentThreadId);
|
|
696
|
+
if (this.silentThreadIds.has(threadId)) {
|
|
697
|
+
const result = method === "item/permissions/requestApproval" ? { permissions: {}, scope: "turn" } : { decision: "decline" };
|
|
698
|
+
this.write({ id: message.id, result });
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
if (["item/commandExecution/requestApproval", "item/fileChange/requestApproval", "item/permissions/requestApproval"].includes(method)) {
|
|
702
|
+
const requestId = String(message.id);
|
|
703
|
+
this.approvalRequests.set(requestId, { id: Number(message.id), method, params });
|
|
704
|
+
this.emit("codex_approval", { requestId, method, ...params });
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
const result = method === "mcpServer/elicitation/request" ? { action: "accept", content: {}, _meta: null } : { decision: "decline" };
|
|
708
|
+
this.write({ id: message.id, result });
|
|
709
|
+
this.emit("agent_event", { agent: "codex", type: "server.request", method, params, result });
|
|
710
|
+
}
|
|
711
|
+
/** 完成指定 JSON-RPC 请求。 */
|
|
712
|
+
resolve(id, result) {
|
|
713
|
+
const pending = this.pending.get(id);
|
|
714
|
+
if (pending)
|
|
715
|
+
(this.pending.delete(id), pending.resolve(result));
|
|
716
|
+
}
|
|
717
|
+
/** 拒绝指定 JSON-RPC 请求。 */
|
|
718
|
+
reject(id, message) {
|
|
719
|
+
const pending = this.pending.get(id);
|
|
720
|
+
if (pending)
|
|
721
|
+
(this.pending.delete(id), pending.reject(new Error(message)));
|
|
722
|
+
}
|
|
723
|
+
/** 拒绝进程退出时仍未完成的请求与 turn。 */
|
|
724
|
+
failAll(message, reported = false) {
|
|
725
|
+
if (this.failing)
|
|
726
|
+
return;
|
|
727
|
+
this.failing = true;
|
|
728
|
+
this.failureMessage = message;
|
|
729
|
+
this.approvalRequests.forEach((request, requestId) => this.emit("codex_approval_resolved", { ...request.params, requestId, decision: request.decision || "cancel" }));
|
|
730
|
+
const failedTurns = new Map();
|
|
731
|
+
this.activeTurns.forEach(({ threadId, turnId, prompt, messageText }, key) => {
|
|
732
|
+
if (this.silentThreadIds.has(threadId))
|
|
733
|
+
return;
|
|
734
|
+
if (!this.finalizingTurns.has(key))
|
|
735
|
+
failedTurns.set(key, { threadId, turnId, prompt, messageText });
|
|
736
|
+
});
|
|
737
|
+
const pendingStart = this.pendingTurnStart;
|
|
738
|
+
if (pendingStart?.turnId && !this.silentThreadIds.has(pendingStart.threadId)) {
|
|
739
|
+
const key = turnCacheKey(pendingStart.threadId, pendingStart.turnId);
|
|
740
|
+
if (!this.finalizingTurns.has(key) && !failedTurns.has(key))
|
|
741
|
+
failedTurns.set(key, { threadId: pendingStart.threadId, turnId: pendingStart.turnId, prompt: pendingStart.prompt, messageText: pendingStart.messageText });
|
|
742
|
+
}
|
|
743
|
+
const finalizing = [...this.finalizingTurns.values()];
|
|
744
|
+
const persistence = [...failedTurns.values()].map(({ threadId, turnId, prompt, messageText }) => {
|
|
745
|
+
const turn = { id: turnId, status: "failed", error: { message }, ...(prompt ? { input: prompt } : {}), ...(messageText ? { messageText } : {}) };
|
|
746
|
+
return this.eventHistory.recordTurn({ threadId, turnId, turn }).catch((historyError) => logger.warn("Failed to persist Codex turn failure", { threadId, turnId, error: historyError }));
|
|
747
|
+
});
|
|
748
|
+
const error = reported || failedTurns.size || finalizing.length ? new CodexReportedError(message) : new Error(message);
|
|
749
|
+
this.pendingDeltas.forEach((item) => clearTimeout(item.timer));
|
|
750
|
+
this.pendingDeltas.clear();
|
|
751
|
+
this.textByItem.clear();
|
|
752
|
+
this.reasoningTextByItem.clear();
|
|
753
|
+
this.startedItems.clear();
|
|
754
|
+
this.itemSequences.clear();
|
|
755
|
+
this.nextItemSequences.clear();
|
|
756
|
+
this.plansByTurn.clear();
|
|
757
|
+
this.completedTurns.clear();
|
|
758
|
+
this.completedTurnResults.clear();
|
|
759
|
+
this.approvalRequests.clear();
|
|
760
|
+
this.silentThreadIds.clear();
|
|
761
|
+
this.pendingSilentThreadStarts.clear();
|
|
762
|
+
this.pendingThreadStartedNotifications.length = 0;
|
|
763
|
+
this.structuredOutputByTurn.clear();
|
|
764
|
+
this.pendingTurnStart = undefined;
|
|
765
|
+
this.startedTurnKeys.clear();
|
|
766
|
+
this.lastUsage = null;
|
|
767
|
+
this.currentThreadId = "";
|
|
768
|
+
this.currentTurnId = "";
|
|
769
|
+
void Promise.all([...finalizing, ...persistence]).then(() => {
|
|
770
|
+
failedTurns.forEach(({ threadId, turnId, prompt, messageText }) => {
|
|
771
|
+
const turn = { id: turnId, status: "failed", error: { message }, ...(prompt ? { input: prompt } : {}), ...(messageText ? { messageText } : {}) };
|
|
772
|
+
this.emit("agent_event", { agent: "codex", type: "turn.completed", status: "failed", error: { message }, thread_id: threadId, turn_id: turnId, turn });
|
|
773
|
+
this.emit("agent_done", { agent: "codex", status: "failed", error: { message }, thread_id: threadId, turn_id: turnId });
|
|
774
|
+
});
|
|
775
|
+
this.pending.forEach((item) => item.reject(error));
|
|
776
|
+
this.activeTurns.forEach((item) => item.reject(error));
|
|
777
|
+
this.pending.clear();
|
|
778
|
+
this.activeTurns.clear();
|
|
779
|
+
this.finalizingTurns.clear();
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
/** 将 Codex 的字符串、摘要数组或文本对象转换为展示文本。 */
|
|
784
|
+
function readableCodexText(value) {
|
|
785
|
+
if (typeof value === "string")
|
|
786
|
+
return value.trim();
|
|
787
|
+
if (Array.isArray(value))
|
|
788
|
+
return value.map(readableCodexText).filter(Boolean).join("\n");
|
|
789
|
+
if (!value || typeof value !== "object")
|
|
790
|
+
return "";
|
|
791
|
+
return readableCodexText(field(value, "text"));
|
|
792
|
+
}
|
|
793
|
+
/** 合并单个 reasoning 分段增量并按 summaryIndex 输出。 */
|
|
794
|
+
export function appendReasoningDelta(segments, summaryIndex, delta) {
|
|
795
|
+
const index = Number.isInteger(summaryIndex) ? Number(summaryIndex) : 0;
|
|
796
|
+
segments.set(index, `${segments.get(index) || ""}${delta}`);
|
|
797
|
+
return [...segments.entries()].sort(([left], [right]) => left - right).map(([, text]) => text.trim()).filter(Boolean).join("\n");
|
|
798
|
+
}
|
|
799
|
+
/** 生成仅供进程内缓存使用的完整 turn 与 item 作用域键。 */
|
|
800
|
+
function itemCacheKey(scope) {
|
|
801
|
+
return `${turnCacheKey(scope.threadId, scope.turnId)}\0${scope.itemId}`;
|
|
802
|
+
}
|
|
803
|
+
function turnCacheKey(threadId, turnId) {
|
|
804
|
+
return `${threadId}\0${turnId}`;
|
|
805
|
+
}
|
|
806
|
+
/** 生成 Codex 调用 Canvas Agent MCP 的启动命令。 */
|
|
807
|
+
function canvasAgentMcpCommand() {
|
|
808
|
+
const current = process.argv.find((arg) => /index\.(t|j)s$/.test(arg)) || "";
|
|
809
|
+
const entry = path.resolve(current || fileURLToPath(new URL("../index.js", import.meta.url)));
|
|
810
|
+
const tsx = path.join(path.dirname(entry), "..", "node_modules", "tsx", "dist", "cli.mjs");
|
|
811
|
+
return entry.endsWith(".ts") ? { command: process.execPath, args: [tsx, entry, "mcp"] } : { command: process.execPath, args: [entry, "mcp"] };
|
|
812
|
+
}
|
|
813
|
+
/** 生成 Codex app-server 使用的 MCP 配置。 */
|
|
814
|
+
function codexConfig(permissionMode) {
|
|
815
|
+
return { model_reasoning_summary: "auto", ...(permissionMode === "automatic" ? { approvals_reviewer: "auto_review" } : {}), mcp_servers: { "infinite-canvas": { command: canvasAgentMcp.command, args: canvasAgentMcp.args, default_tools_approval_mode: "approve", startup_timeout_sec: 20, tool_timeout_sec: 90 } } };
|
|
816
|
+
}
|
|
817
|
+
function threadSettings(permissionMode) {
|
|
818
|
+
return { approvalPolicy: permissionMode === "full" ? "never" : "on-request", sandbox: permissionMode === "full" ? "danger-full-access" : "workspace-write", config: codexConfig(permissionMode) };
|
|
819
|
+
}
|
|
820
|
+
function skillDraftThreadSettings(cwd) {
|
|
821
|
+
return {
|
|
822
|
+
approvalPolicy: "never",
|
|
823
|
+
sandbox: "read-only",
|
|
824
|
+
config: { model_reasoning_summary: "auto", mcp_servers: {} },
|
|
825
|
+
cwd,
|
|
826
|
+
developerInstructions: SKILL_DRAFT_INSTRUCTIONS,
|
|
827
|
+
ephemeral: true,
|
|
828
|
+
};
|
|
829
|
+
}
|
|
830
|
+
function turnSettings(permissionMode) {
|
|
831
|
+
return {
|
|
832
|
+
approvalPolicy: permissionMode === "full" ? "never" : "on-request",
|
|
833
|
+
sandboxPolicy: permissionMode === "full" ? { type: "dangerFullAccess" } : { type: "workspaceWrite", networkAccess: false },
|
|
834
|
+
};
|
|
835
|
+
}
|
|
836
|
+
function skillDraftTurnSettings() {
|
|
837
|
+
return { approvalPolicy: "never", sandboxPolicy: { type: "readOnly", networkAccess: false } };
|
|
838
|
+
}
|
|
839
|
+
/** 将文本、本地图片和显式 Skill 转换为 Codex turn 输入。 */
|
|
840
|
+
function codexInput(prompt, images, skill) {
|
|
841
|
+
const text = skill && !mentionsSkill(prompt, skill.name) ? `$${skill.name} ${prompt}` : prompt;
|
|
842
|
+
return [
|
|
843
|
+
{ type: "text", text, text_elements: [] },
|
|
844
|
+
...images.map((file) => ({ type: "localImage", path: file })),
|
|
845
|
+
...(skill ? [{ type: "skill", ...skill }] : []),
|
|
846
|
+
];
|
|
847
|
+
}
|
|
848
|
+
function mentionsSkill(prompt, name) {
|
|
849
|
+
return new RegExp(`\\$${name}(?![A-Za-z0-9_-]|:[A-Za-z0-9_-])`).test(prompt);
|
|
850
|
+
}
|
|
851
|
+
/** 将 app-server 通知转换为前端使用的 Agent 事件。 */
|
|
852
|
+
function normalizeCodexNotification(method, params) {
|
|
853
|
+
const scope = codexEventScope(params);
|
|
854
|
+
if (method === "thread/started")
|
|
855
|
+
return { type: "thread.started", ...scope };
|
|
856
|
+
if (method === "turn/started")
|
|
857
|
+
return { type: "turn.started", ...scope };
|
|
858
|
+
if (method === "turn/completed")
|
|
859
|
+
return { type: "turn.completed", status: field(field(params, "turn"), "status"), error: field(field(params, "turn"), "error"), usage: null, duration_ms: field(field(params, "turn"), "durationMs"), ...scope };
|
|
860
|
+
if (method === "turn/plan/updated")
|
|
861
|
+
return { type: "plan.updated", explanation: field(params, "explanation"), plan: field(params, "plan"), ...scope };
|
|
862
|
+
if (method === "item/started")
|
|
863
|
+
return { type: "item.started", item: normalizeItem(field(params, "item")), ...scope };
|
|
864
|
+
if (method === "item/completed")
|
|
865
|
+
return { type: "item.completed", item: normalizeItem(field(params, "item")), ...scope };
|
|
866
|
+
if (method === "error")
|
|
867
|
+
return { type: "error", message: field(field(params, "error"), "message"), ...scope };
|
|
868
|
+
return null;
|
|
869
|
+
}
|
|
870
|
+
/** 提取 Codex 事件所属的线程和 turn。 */
|
|
871
|
+
function codexEventScope(params) {
|
|
872
|
+
const threadId = String(field(params, "threadId") || field(field(params, "thread"), "id") || "");
|
|
873
|
+
const turnId = String(field(params, "turnId") || field(field(params, "turn"), "id") || "");
|
|
874
|
+
return { ...(threadId ? { thread_id: threadId } : {}), ...(turnId ? { turn_id: turnId } : {}) };
|
|
875
|
+
}
|
|
876
|
+
/** 统一 app-server item 的类型和参数格式。 */
|
|
877
|
+
function normalizeItem(item) {
|
|
878
|
+
const value = item && typeof item === "object" ? { ...item } : {};
|
|
879
|
+
if (value.type === "agentMessage")
|
|
880
|
+
value.type = "agent_message";
|
|
881
|
+
if (value.type === "mcpToolCall")
|
|
882
|
+
value.type = "mcp_tool_call";
|
|
883
|
+
if (value.type === "commandExecution")
|
|
884
|
+
value.type = "command_execution";
|
|
885
|
+
if (value.type === "fileChange")
|
|
886
|
+
value.type = "file_change";
|
|
887
|
+
if (value.type === "dynamicToolCall")
|
|
888
|
+
value.type = "dynamic_tool_call";
|
|
889
|
+
if (value.type === "collabToolCall" || value.type === "collabAgentToolCall")
|
|
890
|
+
value.type = "collab_tool_call";
|
|
891
|
+
if (value.type === "webSearch")
|
|
892
|
+
value.type = "web_search";
|
|
893
|
+
if (value.type === "imageView")
|
|
894
|
+
value.type = "image_view";
|
|
895
|
+
if (value.type === "imageGeneration")
|
|
896
|
+
value.type = "image_generation";
|
|
897
|
+
if (value.type === "contextCompaction")
|
|
898
|
+
value.type = "context_compaction";
|
|
899
|
+
if (value.type === "agent_message" && typeof value.id === "string")
|
|
900
|
+
value.text = String(value.text || "");
|
|
901
|
+
if ("arguments" in value)
|
|
902
|
+
value.arguments = parseMaybeJson(value.arguments);
|
|
903
|
+
return value;
|
|
904
|
+
}
|
|
905
|
+
function mergeDefinedRecord(started, completed) {
|
|
906
|
+
const merged = { ...started };
|
|
907
|
+
Object.entries(completed).forEach(([key, value]) => {
|
|
908
|
+
if (value !== undefined)
|
|
909
|
+
merged[key] = value;
|
|
910
|
+
});
|
|
911
|
+
return merged;
|
|
912
|
+
}
|
|
913
|
+
/** 将 Codex token usage 转换为前端字段。 */
|
|
914
|
+
function normalizeUsage(params) {
|
|
915
|
+
const last = params.tokenUsage.last;
|
|
916
|
+
return {
|
|
917
|
+
input_tokens: last.inputTokens,
|
|
918
|
+
cached_input_tokens: last.cachedInputTokens,
|
|
919
|
+
output_tokens: last.outputTokens,
|
|
920
|
+
reasoning_output_tokens: last.reasoningOutputTokens,
|
|
921
|
+
};
|
|
922
|
+
}
|
|
923
|
+
/** 尝试将字符串解析为 JSON,失败时保留原值。 */
|
|
924
|
+
function parseMaybeJson(value) {
|
|
925
|
+
if (typeof value !== "string")
|
|
926
|
+
return value;
|
|
927
|
+
try {
|
|
928
|
+
return JSON.parse(value);
|
|
929
|
+
}
|
|
930
|
+
catch {
|
|
931
|
+
return value;
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
/** 定位当前依赖中 Codex CLI 的执行文件。 */
|
|
935
|
+
function codexBin() {
|
|
936
|
+
return path.join(path.dirname(require.resolve("@openai/codex/package.json")), "bin", "codex.js");
|
|
937
|
+
}
|