tellrun 1.3.2 → 1.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -11
- package/README.zh-CN.md +1 -0
- package/dist/agents/codex-rpc.js +85 -0
- package/dist/agents/codex-rpc.js.map +1 -0
- package/dist/agents/codex.js +278 -256
- package/dist/agents/codex.js.map +1 -1
- package/dist/core/menus.js +137 -0
- package/dist/core/menus.js.map +1 -0
- package/dist/core/relay.js +33 -4
- package/dist/core/relay.js.map +1 -1
- package/dist/im/telegram.js +58 -56
- package/dist/im/telegram.js.map +1 -1
- package/dist/main.js +1 -1
- package/dist/main.js.map +1 -1
- package/dist/storage/schema.js +48 -9
- package/dist/storage/schema.js.map +1 -1
- package/dist/storage/store.js +45 -66
- package/dist/storage/store.js.map +1 -1
- package/docs/architecture.md +14 -0
- package/docs/prd.md +1 -0
- package/package.json +1 -1
package/dist/agents/codex.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
|
-
import { readFileSync } from "node:fs";
|
|
3
2
|
import { connect } from "node:net";
|
|
4
3
|
import { homedir } from "node:os";
|
|
5
4
|
import { delimiter, dirname, join } from "node:path";
|
|
6
5
|
import { promisify } from "node:util";
|
|
7
|
-
import {
|
|
6
|
+
import { JSONRPCErrorException } from "json-rpc-2.0";
|
|
8
7
|
import WebSocket from "ws";
|
|
9
8
|
import { z } from "zod";
|
|
10
9
|
import { fileChangesSchema, parseCodexInteraction } from "./codex-interaction.js";
|
|
10
|
+
import { createCodexRpc } from "./codex-rpc.js";
|
|
11
11
|
/** 由 Codex 0.165.0 的 generate-ts 核对;只校验实际消费的协议字段。 */
|
|
12
12
|
const turnSchema = z.object({
|
|
13
13
|
id: z.string().min(1),
|
|
@@ -57,47 +57,114 @@ export async function createCodexAgent(command = "codex") {
|
|
|
57
57
|
/** 只绑定当前任务的连接,不能把迟到输入送入下一轮或其他客户端的轮次。 */
|
|
58
58
|
let steer;
|
|
59
59
|
let respond;
|
|
60
|
+
/** 任务与只读查询共用连接流程;各自使用独立 socket,不争用会话。 */
|
|
61
|
+
async function connectServer(cwd, signal) {
|
|
62
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
63
|
+
signal.throwIfAborted();
|
|
64
|
+
const socket = await new Promise((resolve) => {
|
|
65
|
+
const connection = new WebSocket("ws://localhost", {
|
|
66
|
+
createConnection: () => connect(join(env.CODEX_HOME ?? join(homedir(), ".codex"), "app-server-control", "app-server-control.sock")),
|
|
67
|
+
handshakeTimeout: 5_000,
|
|
68
|
+
maxPayload: 8_000_000,
|
|
69
|
+
});
|
|
70
|
+
/** 连接尚未建立时也能响应关停,不等待握手超时。 */
|
|
71
|
+
const abort = () => connection.terminate();
|
|
72
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
73
|
+
connection.once("open", () => {
|
|
74
|
+
signal.removeEventListener("abort", abort);
|
|
75
|
+
resolve(connection);
|
|
76
|
+
});
|
|
77
|
+
connection.once("error", () => {
|
|
78
|
+
signal.removeEventListener("abort", abort);
|
|
79
|
+
connection.terminate();
|
|
80
|
+
resolve(undefined);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
if (socket) {
|
|
84
|
+
if (signal.aborted) {
|
|
85
|
+
socket.terminate();
|
|
86
|
+
signal.throwIfAborted();
|
|
87
|
+
}
|
|
88
|
+
return socket;
|
|
89
|
+
}
|
|
90
|
+
signal.throwIfAborted();
|
|
91
|
+
if (attempt === 0) {
|
|
92
|
+
/** bootstrap 由系统管理独立 daemon,避免随 Tellrun 的进程组退出。 */
|
|
93
|
+
await promisify(execFile)(command, ["app-server", "daemon", "bootstrap"], {
|
|
94
|
+
cwd,
|
|
95
|
+
env,
|
|
96
|
+
timeout: 30_000,
|
|
97
|
+
maxBuffer: 1_048_576,
|
|
98
|
+
signal,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
throw new Error("Codex daemon unavailable.");
|
|
103
|
+
}
|
|
60
104
|
return {
|
|
61
105
|
name: "Codex",
|
|
62
106
|
version: stdout.trim().replace(/^codex-cli /, ""),
|
|
63
107
|
permissionModes: permissionModes.map(({ id, label }) => ({ id, label })),
|
|
108
|
+
/** 分页读取模型目录;不恢复会话、不发起轮次,也不影响任务连接。 */
|
|
109
|
+
async listModels(cwd, signal) {
|
|
110
|
+
signal = AbortSignal.any([signal, AbortSignal.timeout(15_000)]);
|
|
111
|
+
const rpc = createCodexRpc(await connectServer(cwd, signal));
|
|
112
|
+
/** 取消仅关闭目录查询,不能向正在运行的轮次发送中断。 */
|
|
113
|
+
const abort = () => rpc.close("Codex model query cancelled.");
|
|
114
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
115
|
+
try {
|
|
116
|
+
signal.throwIfAborted();
|
|
117
|
+
await rpc.initialize();
|
|
118
|
+
const models = [];
|
|
119
|
+
let cursor = null;
|
|
120
|
+
do {
|
|
121
|
+
signal.throwIfAborted();
|
|
122
|
+
const page = z
|
|
123
|
+
.object({
|
|
124
|
+
data: z.array(z.object({
|
|
125
|
+
model: z.string().min(1),
|
|
126
|
+
displayName: z.string(),
|
|
127
|
+
hidden: z.boolean(),
|
|
128
|
+
supportedReasoningEfforts: z.array(z.object({
|
|
129
|
+
reasoningEffort: z.string().min(1),
|
|
130
|
+
description: z.string(),
|
|
131
|
+
})),
|
|
132
|
+
defaultReasoningEffort: z.string().min(1),
|
|
133
|
+
})),
|
|
134
|
+
nextCursor: z.string().nullable(),
|
|
135
|
+
})
|
|
136
|
+
.parse(await rpc.request("model/list", { limit: 100, includeHidden: false, cursor }));
|
|
137
|
+
for (const model of page.data)
|
|
138
|
+
if (!model.hidden && !models.some((option) => option.id === model.model))
|
|
139
|
+
models.push({
|
|
140
|
+
id: model.model,
|
|
141
|
+
label: model.displayName || model.model,
|
|
142
|
+
reasoningEfforts: model.supportedReasoningEfforts.map((effort) => ({
|
|
143
|
+
id: effort.reasoningEffort,
|
|
144
|
+
label: effort.description,
|
|
145
|
+
})),
|
|
146
|
+
defaultReasoningEffort: model.defaultReasoningEffort,
|
|
147
|
+
});
|
|
148
|
+
cursor = page.nextCursor;
|
|
149
|
+
} while (cursor);
|
|
150
|
+
return models;
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
throw new Error("Codex model query failed. Check the local configuration and connection.");
|
|
154
|
+
}
|
|
155
|
+
finally {
|
|
156
|
+
signal.removeEventListener("abort", abort);
|
|
157
|
+
rpc.close();
|
|
158
|
+
}
|
|
159
|
+
},
|
|
64
160
|
async run(input) {
|
|
65
161
|
if (active)
|
|
66
162
|
throw new Error("Codex is already running a task.");
|
|
67
163
|
const controller = new AbortController();
|
|
68
164
|
active = controller;
|
|
69
|
-
/** 复用后台的 Unix socket,避免把编码进程放进 Tellrun 的服务控制组。 */
|
|
70
|
-
const socketPath = join(env.CODEX_HOME ?? join(homedir(), ".codex"), "app-server-control", "app-server-control.sock");
|
|
71
165
|
let socket;
|
|
72
166
|
try {
|
|
73
|
-
|
|
74
|
-
socket = await new Promise((resolve) => {
|
|
75
|
-
const connection = new WebSocket("ws://localhost", {
|
|
76
|
-
createConnection: () => connect(socketPath),
|
|
77
|
-
handshakeTimeout: 5_000,
|
|
78
|
-
maxPayload: 8_000_000,
|
|
79
|
-
});
|
|
80
|
-
connection.once("open", () => resolve(connection));
|
|
81
|
-
connection.once("error", () => {
|
|
82
|
-
connection.terminate();
|
|
83
|
-
resolve(undefined);
|
|
84
|
-
});
|
|
85
|
-
});
|
|
86
|
-
if (socket)
|
|
87
|
-
break;
|
|
88
|
-
if (attempt === 0) {
|
|
89
|
-
/** bootstrap 交给系统管理独立 daemon;普通 detached 子进程仍会被 systemd 清理。 */
|
|
90
|
-
await promisify(execFile)(command, ["app-server", "daemon", "bootstrap"], {
|
|
91
|
-
cwd: input.cwd,
|
|
92
|
-
env,
|
|
93
|
-
timeout: 30_000,
|
|
94
|
-
maxBuffer: 1_048_576,
|
|
95
|
-
signal: controller.signal,
|
|
96
|
-
});
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
if (!socket)
|
|
100
|
-
throw new Error("Codex daemon unavailable.");
|
|
167
|
+
socket = await connectServer(input.cwd, controller.signal);
|
|
101
168
|
}
|
|
102
169
|
catch {
|
|
103
170
|
active = undefined;
|
|
@@ -116,16 +183,14 @@ export async function createCodexAgent(command = "codex") {
|
|
|
116
183
|
};
|
|
117
184
|
}
|
|
118
185
|
const completed = Promise.withResolvers();
|
|
119
|
-
/** 库管理请求关联和超时;Codex 线上消息省略 jsonrpc 字段。 */
|
|
120
|
-
const client = new JSONRPCClient(({ id, method, params }) => write({ id, method, params }));
|
|
121
186
|
const messages = new Map();
|
|
122
187
|
/** 应用随机 ID 隔离每次连接;已见 RPC 编号保留到任务结束,重复请求不生成新按钮。 */
|
|
123
188
|
const interactions = new Map();
|
|
124
189
|
const seenRequests = new Set();
|
|
125
190
|
const fileChanges = new Map();
|
|
126
|
-
let buffer = "";
|
|
127
191
|
/** 新任务等启动响应,恢复任务等完整快照,再处理当前轮次的早到事件。 */
|
|
128
|
-
|
|
192
|
+
const earlyMessages = [];
|
|
193
|
+
let earlyMessageSize = 0;
|
|
129
194
|
let sessionId = input.sessionId ?? undefined;
|
|
130
195
|
let runId;
|
|
131
196
|
let dispatched = Boolean(input.runId);
|
|
@@ -154,32 +219,15 @@ export async function createCodexAgent(command = "codex") {
|
|
|
154
219
|
if (settled)
|
|
155
220
|
return;
|
|
156
221
|
finish({ status: "interrupted", text });
|
|
157
|
-
|
|
222
|
+
rpc.close(text);
|
|
158
223
|
controller.abort();
|
|
159
224
|
}
|
|
160
|
-
/** 请求、通知和交互应答共用发送入口,一帧对应一条 RPC 消息。 */
|
|
161
|
-
function write(message) {
|
|
162
|
-
try {
|
|
163
|
-
socket?.send(JSON.stringify(message), (error) => {
|
|
164
|
-
if (error)
|
|
165
|
-
fail("Codex connection write failed. Task status unknown.");
|
|
166
|
-
});
|
|
167
|
-
}
|
|
168
|
-
catch {
|
|
169
|
-
fail("Codex connection write failed. Task status unknown.");
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
225
|
/** 超时结束本次跟踪;区分服务端拒绝与连接故障,保留输入的不确定状态。 */
|
|
173
226
|
async function request(method, params) {
|
|
174
227
|
if (controller.signal.aborted && method !== "turn/interrupt")
|
|
175
228
|
throw new Error("Task stopped.");
|
|
176
229
|
try {
|
|
177
|
-
return await
|
|
178
|
-
.timeout(30_000, (id) => {
|
|
179
|
-
fail(`Codex ${method} timed out. Not retried.`);
|
|
180
|
-
return createJSONRPCErrorResponse(id, 0, "Codex request timed out.");
|
|
181
|
-
})
|
|
182
|
-
.request(method, params);
|
|
230
|
+
return await (method === "initialize" ? rpc.initialize() : rpc.request(method, params));
|
|
183
231
|
}
|
|
184
232
|
catch (error) {
|
|
185
233
|
/** 只识别已知占用错误,避免把可能含敏感信息的原始错误转发到 IM。 */
|
|
@@ -214,16 +262,15 @@ export async function createCodexAgent(command = "codex") {
|
|
|
214
262
|
return;
|
|
215
263
|
if (controller.signal.reason === "disconnect") {
|
|
216
264
|
finish();
|
|
217
|
-
|
|
218
|
-
socket?.terminate();
|
|
265
|
+
rpc.close("Tellrun disconnected. Task continues in Codex.");
|
|
219
266
|
return;
|
|
220
267
|
}
|
|
221
268
|
stopTimer = setTimeout(() => fail("Stop unconfirmed; Codex disconnected. Check the project before /resume."), 5_000);
|
|
222
269
|
if (sessionId && runId) {
|
|
223
|
-
void request("turn/interrupt", { threadId: sessionId, turnId: runId }).catch(() =>
|
|
270
|
+
void request("turn/interrupt", { threadId: sessionId, turnId: runId }).catch(() => rpc.close());
|
|
224
271
|
}
|
|
225
272
|
else
|
|
226
|
-
|
|
273
|
+
rpc.close();
|
|
227
274
|
}, { once: true });
|
|
228
275
|
/** 连接关闭不能当作后台任务已停止,更不能向后台补发取消。 */
|
|
229
276
|
function onClose() {
|
|
@@ -232,194 +279,169 @@ export async function createCodexAgent(command = "codex") {
|
|
|
232
279
|
if (!settled)
|
|
233
280
|
fail("Codex disconnected. Task may still be running; check the local session.");
|
|
234
281
|
else
|
|
235
|
-
|
|
282
|
+
rpc.close();
|
|
236
283
|
}
|
|
237
284
|
/** 实时事件与恢复快照共用协议处理,始终只消费当前任务的事件。 */
|
|
238
|
-
function
|
|
285
|
+
function onMessage(message) {
|
|
239
286
|
if (settled)
|
|
240
287
|
return;
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
continue;
|
|
249
|
-
try {
|
|
250
|
-
const message = z
|
|
251
|
-
.object({
|
|
252
|
-
id: z.union([z.number(), z.string()]).optional(),
|
|
253
|
-
method: z.string().optional(),
|
|
254
|
-
params: z.unknown().optional(),
|
|
255
|
-
result: z.unknown().optional(),
|
|
256
|
-
error: z.object({ code: z.number(), message: z.string().optional() }).optional(),
|
|
257
|
-
})
|
|
258
|
-
.parse(JSON.parse(line));
|
|
259
|
-
if (message.method && !runId) {
|
|
260
|
-
if (dispatched) {
|
|
261
|
-
earlyMessages += `${line}\n`;
|
|
262
|
-
if (earlyMessages.length > 8_000_000)
|
|
263
|
-
return fail("Codex event limit exceeded. Task interrupted.");
|
|
264
|
-
}
|
|
265
|
-
continue;
|
|
288
|
+
try {
|
|
289
|
+
if (message.method && !runId) {
|
|
290
|
+
if (dispatched) {
|
|
291
|
+
earlyMessages.push(message);
|
|
292
|
+
earlyMessageSize += JSON.stringify(message).length;
|
|
293
|
+
if (earlyMessageSize > 8_000_000)
|
|
294
|
+
return fail("Codex event limit exceeded. Task interrupted.");
|
|
266
295
|
}
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
if (message.id !== undefined && message.method) {
|
|
299
|
+
/** 共享连接上的交互可能属于其他会话或轮次,不能替其他客户端拒绝。 */
|
|
300
|
+
const context = z
|
|
301
|
+
.object({ threadId: z.string(), turnId: z.string().optional() })
|
|
302
|
+
.safeParse(message.params);
|
|
303
|
+
if (!context.success ||
|
|
304
|
+
context.data.threadId !== sessionId ||
|
|
305
|
+
(context.data.turnId !== undefined && context.data.turnId !== runId))
|
|
306
|
+
return;
|
|
307
|
+
if (controller.signal.aborted || seenRequests.has(message.id))
|
|
308
|
+
return;
|
|
309
|
+
seenRequests.add(message.id);
|
|
310
|
+
const parsed = parseCodexInteraction(message.method, message.params, input.cwd, fileChanges);
|
|
311
|
+
if ("error" in parsed) {
|
|
312
|
+
if (parsed.unsupported)
|
|
313
|
+
rpc.write({
|
|
314
|
+
id: message.id,
|
|
315
|
+
error: { code: -32601, message: "Unsupported interactive request" },
|
|
316
|
+
});
|
|
317
|
+
fail(parsed.error);
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
const { request: interaction } = parsed;
|
|
321
|
+
interactions.set(interaction.id, {
|
|
322
|
+
requestId: message.id,
|
|
323
|
+
...parsed,
|
|
324
|
+
timer: setTimeout(() => {
|
|
325
|
+
const pending = interactions.get(interaction.id);
|
|
326
|
+
if (!pending)
|
|
327
|
+
return;
|
|
328
|
+
if (interaction.blocking) {
|
|
329
|
+
fail("Request timed out. Check /last, then /resume.");
|
|
330
|
+
}
|
|
331
|
+
else {
|
|
332
|
+
interactions.delete(interaction.id);
|
|
333
|
+
/** 非阻塞问题过期只返回空答案,不替用户选择,也不中断正在执行的任务。 */
|
|
334
|
+
try {
|
|
335
|
+
input.onInteractionClosed(interaction.id, "Question expired. Task continues.");
|
|
336
|
+
rpc.write({ id: pending.requestId, result: { answers: {} } });
|
|
299
337
|
}
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
/** 非阻塞问题过期只返回空答案,不替用户选择,也不中断正在执行的任务。 */
|
|
303
|
-
try {
|
|
304
|
-
input.onInteractionClosed(interaction.id, "Question expired. Task continues.");
|
|
305
|
-
write({ id: pending.requestId, result: { answers: {} } });
|
|
306
|
-
}
|
|
307
|
-
catch {
|
|
308
|
-
fail("Cannot save expired question state. Task interrupted.");
|
|
309
|
-
}
|
|
338
|
+
catch {
|
|
339
|
+
fail("Cannot save expired question state. Task interrupted.");
|
|
310
340
|
}
|
|
311
|
-
}
|
|
312
|
-
})
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
if (event.threadId !== sessionId)
|
|
326
|
-
continue;
|
|
327
|
-
seenRequests.add(event.requestId);
|
|
328
|
-
for (const [id, interaction] of interactions) {
|
|
329
|
-
if (interaction.requestId !== event.requestId)
|
|
330
|
-
continue;
|
|
331
|
-
clearTimeout(interaction.timer);
|
|
332
|
-
interactions.delete(id);
|
|
333
|
-
input.onInteractionClosed(id, "Request closed.");
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
else if (message.method === "item/fileChange/patchUpdated") {
|
|
337
|
-
const event = z
|
|
338
|
-
.object({
|
|
339
|
-
threadId: z.string(),
|
|
340
|
-
turnId: z.string(),
|
|
341
|
-
itemId: z.string(),
|
|
342
|
-
changes: fileChangesSchema,
|
|
343
|
-
})
|
|
344
|
-
.parse(message.params);
|
|
345
|
-
if (event.threadId === sessionId && event.turnId === runId)
|
|
346
|
-
fileChanges.set(event.itemId, event.changes);
|
|
347
|
-
}
|
|
348
|
-
else if (message.method === "item/agentMessage/delta") {
|
|
349
|
-
const event = z
|
|
350
|
-
.object({
|
|
351
|
-
threadId: z.string(),
|
|
352
|
-
turnId: z.string(),
|
|
353
|
-
itemId: z.string(),
|
|
354
|
-
delta: z.string(),
|
|
355
|
-
})
|
|
356
|
-
.parse(message.params);
|
|
357
|
-
if (event.threadId !== sessionId || !recordRun(event.turnId))
|
|
341
|
+
}
|
|
342
|
+
}, Math.max(0, interaction.expiresAt - Date.now())),
|
|
343
|
+
});
|
|
344
|
+
input.onInteraction(interaction);
|
|
345
|
+
}
|
|
346
|
+
else if (message.method === "serverRequest/resolved") {
|
|
347
|
+
const event = z
|
|
348
|
+
.object({ threadId: z.string(), requestId: z.union([z.string(), z.number()]) })
|
|
349
|
+
.parse(message.params);
|
|
350
|
+
if (event.threadId !== sessionId)
|
|
351
|
+
return;
|
|
352
|
+
seenRequests.add(event.requestId);
|
|
353
|
+
for (const [id, interaction] of interactions) {
|
|
354
|
+
if (interaction.requestId !== event.requestId)
|
|
358
355
|
continue;
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
publishText(item.text);
|
|
356
|
+
clearTimeout(interaction.timer);
|
|
357
|
+
interactions.delete(id);
|
|
358
|
+
input.onInteractionClosed(id, "Request closed.");
|
|
363
359
|
}
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
360
|
+
}
|
|
361
|
+
else if (message.method === "item/fileChange/patchUpdated") {
|
|
362
|
+
const event = z
|
|
363
|
+
.object({
|
|
364
|
+
threadId: z.string(),
|
|
365
|
+
turnId: z.string(),
|
|
366
|
+
itemId: z.string(),
|
|
367
|
+
changes: fileChangesSchema,
|
|
368
|
+
})
|
|
369
|
+
.parse(message.params);
|
|
370
|
+
if (event.threadId === sessionId && event.turnId === runId)
|
|
371
|
+
fileChanges.set(event.itemId, event.changes);
|
|
372
|
+
}
|
|
373
|
+
else if (message.method === "item/agentMessage/delta") {
|
|
374
|
+
const event = z
|
|
375
|
+
.object({
|
|
376
|
+
threadId: z.string(),
|
|
377
|
+
turnId: z.string(),
|
|
378
|
+
itemId: z.string(),
|
|
379
|
+
delta: z.string(),
|
|
380
|
+
})
|
|
381
|
+
.parse(message.params);
|
|
382
|
+
if (event.threadId !== sessionId || !recordRun(event.turnId))
|
|
383
|
+
return;
|
|
384
|
+
const item = messages.get(event.itemId) ?? { text: "" };
|
|
385
|
+
item.text += event.delta;
|
|
386
|
+
messages.set(event.itemId, item);
|
|
387
|
+
publishText(item.text);
|
|
388
|
+
}
|
|
389
|
+
else if (message.method === "item/started" || message.method === "item/completed") {
|
|
390
|
+
const event = z
|
|
391
|
+
.object({
|
|
392
|
+
threadId: z.string(),
|
|
393
|
+
turnId: z.string(),
|
|
394
|
+
item: z.object({
|
|
395
|
+
type: z.string(),
|
|
396
|
+
id: z.string(),
|
|
397
|
+
text: z.string().optional(),
|
|
398
|
+
phase: z.string().nullish(),
|
|
399
|
+
changes: fileChangesSchema.optional(),
|
|
400
|
+
}),
|
|
401
|
+
})
|
|
402
|
+
.parse(message.params);
|
|
403
|
+
if (event.threadId !== sessionId || !recordRun(event.turnId))
|
|
404
|
+
return;
|
|
405
|
+
if (event.item.type === "fileChange" && event.item.changes)
|
|
406
|
+
fileChanges.set(event.item.id, event.item.changes);
|
|
407
|
+
if (event.item.type === "agentMessage" && event.item.text !== undefined) {
|
|
408
|
+
messages.set(event.item.id, { text: event.item.text, phase: event.item.phase });
|
|
409
|
+
publishText(event.item.text);
|
|
386
410
|
}
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
411
|
+
}
|
|
412
|
+
else if (message.method === "turn/started" || message.method === "turn/completed") {
|
|
413
|
+
const event = z
|
|
414
|
+
.object({ threadId: z.string(), turn: turnSchema })
|
|
415
|
+
.parse(message.params);
|
|
416
|
+
if (event.threadId !== sessionId || !recordRun(event.turn.id))
|
|
417
|
+
return;
|
|
418
|
+
if (message.method === "turn/completed") {
|
|
419
|
+
const final = [...messages.values()].filter((item) => item.phase === "final_answer");
|
|
420
|
+
finish({
|
|
421
|
+
status: event.turn.status === "completed"
|
|
422
|
+
? "completed"
|
|
423
|
+
: event.turn.status === "failed"
|
|
424
|
+
? "failed"
|
|
425
|
+
: controller.signal.aborted
|
|
426
|
+
? "cancelled"
|
|
427
|
+
: "interrupted",
|
|
428
|
+
text: (final.length ? final : [...messages.values()])
|
|
429
|
+
.map((item) => item.text)
|
|
430
|
+
.join("\n\n") ||
|
|
431
|
+
(event.turn.status === "completed"
|
|
432
|
+
? "Codex finished with no text output."
|
|
398
433
|
: event.turn.status === "failed"
|
|
399
|
-
? "failed"
|
|
400
|
-
:
|
|
401
|
-
|
|
402
|
-
: "interrupted",
|
|
403
|
-
text: (final.length ? final : [...messages.values()])
|
|
404
|
-
.map((item) => item.text)
|
|
405
|
-
.join("\n\n") ||
|
|
406
|
-
(event.turn.status === "completed"
|
|
407
|
-
? "Codex finished with no text output."
|
|
408
|
-
: event.turn.status === "failed"
|
|
409
|
-
? "Codex task failed. Check sign-in, model access, and network."
|
|
410
|
-
: "Task interrupted. File changes were not reverted."),
|
|
411
|
-
});
|
|
412
|
-
}
|
|
434
|
+
? "Codex task failed. Check sign-in, model access, and network."
|
|
435
|
+
: "Task interrupted. File changes were not reverted."),
|
|
436
|
+
});
|
|
413
437
|
}
|
|
414
438
|
}
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
439
|
+
}
|
|
440
|
+
catch {
|
|
441
|
+
fail("Codex response or state save failed. Task interrupted; check CLI version and storage.");
|
|
418
442
|
}
|
|
419
443
|
}
|
|
420
|
-
|
|
421
|
-
socket?.on("close", onClose);
|
|
422
|
-
socket?.on("message", (data) => onData(`${data.toString()}\n`));
|
|
444
|
+
const rpc = createCodexRpc(socket, { onMessage, onFailure: fail, onClose });
|
|
423
445
|
steer = async (prompt) => {
|
|
424
446
|
if (!sessionId || !runId || settled || controller.signal.aborted)
|
|
425
447
|
throw new Error("Codex turn is not running. Input not sent; try again later.");
|
|
@@ -464,7 +486,7 @@ export async function createCodexAgent(command = "codex") {
|
|
|
464
486
|
/** 在写入前移除映射,失败也不重发批准;任务终态负责说明不确定结果。 */
|
|
465
487
|
interactions.delete(id);
|
|
466
488
|
clearTimeout(interaction.timer);
|
|
467
|
-
write({
|
|
489
|
+
rpc.write({
|
|
468
490
|
id: interaction.requestId,
|
|
469
491
|
result: interaction.permissions
|
|
470
492
|
? {
|
|
@@ -490,16 +512,7 @@ export async function createCodexAgent(command = "codex") {
|
|
|
490
512
|
const permissionMode = permissionModes.find((mode) => mode.id === (input.permissionMode ?? "default"));
|
|
491
513
|
if (!permissionMode)
|
|
492
514
|
throw new Error("Codex permission mode unavailable. Choose another mode with /permissions.");
|
|
493
|
-
await request("initialize"
|
|
494
|
-
clientInfo: {
|
|
495
|
-
name: "tellrun",
|
|
496
|
-
title: "Tellrun",
|
|
497
|
-
/** 源码和编译产物都读取包版本,与 CLI 的版本显示保持一致。 */
|
|
498
|
-
version: JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")).version,
|
|
499
|
-
},
|
|
500
|
-
capabilities: { experimentalApi: true, requestAttestation: false },
|
|
501
|
-
});
|
|
502
|
-
write({ method: "initialized" });
|
|
515
|
+
await request("initialize");
|
|
503
516
|
if (input.runId) {
|
|
504
517
|
if (!sessionId)
|
|
505
518
|
throw new Error("Codex task session missing. Task not resubmitted.");
|
|
@@ -531,14 +544,17 @@ export async function createCodexAgent(command = "codex") {
|
|
|
531
544
|
recordRun(turn.id);
|
|
532
545
|
/** 完整快照补回离线输出;缓冲增量已包含在快照中,不再次拼接。 */
|
|
533
546
|
for (const item of turn.items)
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
547
|
+
onMessage({
|
|
548
|
+
method: "item/completed",
|
|
549
|
+
params: { threadId: sessionId, turnId: turn.id, item },
|
|
550
|
+
});
|
|
551
|
+
for (const message of earlyMessages)
|
|
552
|
+
if (message.method !== "item/agentMessage/delta")
|
|
553
|
+
onMessage(message);
|
|
554
|
+
earlyMessages.length = 0;
|
|
555
|
+
earlyMessageSize = 0;
|
|
540
556
|
if (turn.status !== "inProgress")
|
|
541
|
-
|
|
557
|
+
onMessage({ method: "turn/completed", params: { threadId: sessionId, turn } });
|
|
542
558
|
return await completed.promise;
|
|
543
559
|
}
|
|
544
560
|
cursor = page.nextCursor;
|
|
@@ -562,14 +578,19 @@ export async function createCodexAgent(command = "codex") {
|
|
|
562
578
|
approvalPolicy: z.unknown(),
|
|
563
579
|
approvalsReviewer: z.string(),
|
|
564
580
|
sandbox: z.object({ type: z.string() }),
|
|
581
|
+
model: z.string().optional(),
|
|
565
582
|
})
|
|
566
583
|
.parse(await request(input.sessionId ? "thread/resume" : "thread/start", {
|
|
567
584
|
...(input.sessionId ? { threadId: input.sessionId, excludeTurns: true } : {}),
|
|
568
585
|
cwd: input.cwd,
|
|
569
586
|
...permissionMode.config,
|
|
587
|
+
...(input.model ? { model: input.model } : {}),
|
|
570
588
|
}));
|
|
571
589
|
if (response.thread.status?.type !== "idle")
|
|
572
590
|
throw new Error("Codex session busy. Message not sent; wait until it is idle.");
|
|
591
|
+
/** 模型被服务端替换时停止派发,避免悄悄使用其他模型。 */
|
|
592
|
+
if (input.model && response.model !== input.model)
|
|
593
|
+
throw new Error("Codex did not apply the selected model. Open /model and choose again.");
|
|
573
594
|
/** 使用服务端实际返回值核实预设,不能在权限被改写时仍启动任务。 */
|
|
574
595
|
if (response.approvalPolicy !== permissionMode.config.approvalPolicy ||
|
|
575
596
|
response.approvalsReviewer !== permissionMode.config.approvalsReviewer ||
|
|
@@ -587,15 +608,17 @@ export async function createCodexAgent(command = "codex") {
|
|
|
587
608
|
request("turn/start", {
|
|
588
609
|
threadId: sessionId,
|
|
589
610
|
input: [{ type: "text", text: input.prompt, text_elements: [] }],
|
|
611
|
+
...(input.model ? { model: input.model } : {}),
|
|
612
|
+
...(input.reasoningEffort ? { effort: input.reasoningEffort } : {}),
|
|
590
613
|
}),
|
|
591
614
|
completed.promise.then(() => null),
|
|
592
615
|
]);
|
|
593
616
|
if (started !== null && !settled) {
|
|
594
617
|
recordRun(z.object({ turn: turnSchema }).parse(started).turn.id);
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
618
|
+
for (const message of earlyMessages)
|
|
619
|
+
onMessage(message);
|
|
620
|
+
earlyMessages.length = 0;
|
|
621
|
+
earlyMessageSize = 0;
|
|
599
622
|
}
|
|
600
623
|
return await completed.promise;
|
|
601
624
|
}
|
|
@@ -616,8 +639,7 @@ export async function createCodexAgent(command = "codex") {
|
|
|
616
639
|
clearTimeout(interaction.timer);
|
|
617
640
|
interactions.clear();
|
|
618
641
|
clearTimeout(stopTimer);
|
|
619
|
-
|
|
620
|
-
socket?.terminate();
|
|
642
|
+
rpc.close();
|
|
621
643
|
active = undefined;
|
|
622
644
|
}
|
|
623
645
|
},
|