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.
@@ -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 { createJSONRPCErrorResponse, createJSONRPCSuccessResponse, JSONRPCClient, JSONRPCErrorException, } from "json-rpc-2.0";
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
- for (let attempt = 0; attempt < 2 && !controller.signal.aborted; attempt++) {
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
- let earlyMessages = "";
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
- client.rejectAllPendingRequests(text);
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 client
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
- client.rejectAllPendingRequests("Tellrun disconnected. Task continues in Codex.");
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(() => socket?.terminate());
270
+ void request("turn/interrupt", { threadId: sessionId, turnId: runId }).catch(() => rpc.close());
224
271
  }
225
272
  else
226
- socket?.terminate();
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
- client.rejectAllPendingRequests("Codex connection closed.");
282
+ rpc.close();
236
283
  }
237
284
  /** 实时事件与恢复快照共用协议处理,始终只消费当前任务的事件。 */
238
- function onData(chunk) {
285
+ function onMessage(message) {
239
286
  if (settled)
240
287
  return;
241
- buffer += chunk;
242
- if (buffer.length > 8_000_000)
243
- return fail("Codex buffer limit exceeded. Task interrupted.");
244
- const lines = buffer.split("\n");
245
- buffer = lines.pop() ?? "";
246
- for (const line of lines) {
247
- if (!line.trim() || settled)
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
- if (message.id !== undefined && message.method) {
268
- /** 共享连接上的交互可能属于其他会话或轮次,不能替其他客户端拒绝。 */
269
- const context = z
270
- .object({ threadId: z.string(), turnId: z.string().optional() })
271
- .safeParse(message.params);
272
- if (!context.success ||
273
- context.data.threadId !== sessionId ||
274
- (context.data.turnId !== undefined && context.data.turnId !== runId))
275
- continue;
276
- if (controller.signal.aborted || seenRequests.has(message.id))
277
- continue;
278
- seenRequests.add(message.id);
279
- const parsed = parseCodexInteraction(message.method, message.params, input.cwd, fileChanges);
280
- if ("error" in parsed) {
281
- if (parsed.unsupported)
282
- write({
283
- id: message.id,
284
- error: { code: -32601, message: "Unsupported interactive request" },
285
- });
286
- fail(parsed.error);
287
- continue;
288
- }
289
- const { request: interaction } = parsed;
290
- interactions.set(interaction.id, {
291
- requestId: message.id,
292
- ...parsed,
293
- timer: setTimeout(() => {
294
- const pending = interactions.get(interaction.id);
295
- if (!pending)
296
- return;
297
- if (interaction.blocking) {
298
- fail("Request timed out. Check /last, then /resume.");
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
- else {
301
- interactions.delete(interaction.id);
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
- }, Math.max(0, interaction.expiresAt - Date.now())),
312
- });
313
- input.onInteraction(interaction);
314
- }
315
- else if (typeof message.id === "number") {
316
- /** 补齐库需要的协议字段;错误来源标记仅用于本地处理,不信任服务端 data。 */
317
- client.receive(message.error
318
- ? createJSONRPCErrorResponse(message.id, message.error.code, message.error.message ?? "", "codex")
319
- : createJSONRPCSuccessResponse(message.id, message.result));
320
- }
321
- else if (message.method === "serverRequest/resolved") {
322
- const event = z
323
- .object({ threadId: z.string(), requestId: z.union([z.string(), z.number()]) })
324
- .parse(message.params);
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
- const item = messages.get(event.itemId) ?? { text: "" };
360
- item.text += event.delta;
361
- messages.set(event.itemId, item);
362
- publishText(item.text);
356
+ clearTimeout(interaction.timer);
357
+ interactions.delete(id);
358
+ input.onInteractionClosed(id, "Request closed.");
363
359
  }
364
- else if (message.method === "item/started" || message.method === "item/completed") {
365
- const event = z
366
- .object({
367
- threadId: z.string(),
368
- turnId: z.string(),
369
- item: z.object({
370
- type: z.string(),
371
- id: z.string(),
372
- text: z.string().optional(),
373
- phase: z.string().nullish(),
374
- changes: fileChangesSchema.optional(),
375
- }),
376
- })
377
- .parse(message.params);
378
- if (event.threadId !== sessionId || !recordRun(event.turnId))
379
- continue;
380
- if (event.item.type === "fileChange" && event.item.changes)
381
- fileChanges.set(event.item.id, event.item.changes);
382
- if (event.item.type === "agentMessage" && event.item.text !== undefined) {
383
- messages.set(event.item.id, { text: event.item.text, phase: event.item.phase });
384
- publishText(event.item.text);
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
- else if (message.method === "turn/started" || message.method === "turn/completed") {
388
- const event = z
389
- .object({ threadId: z.string(), turn: turnSchema })
390
- .parse(message.params);
391
- if (event.threadId !== sessionId || !recordRun(event.turn.id))
392
- continue;
393
- if (message.method === "turn/completed") {
394
- const final = [...messages.values()].filter((item) => item.phase === "final_answer");
395
- finish({
396
- status: event.turn.status === "completed"
397
- ? "completed"
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
- : controller.signal.aborted
401
- ? "cancelled"
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
- catch {
416
- fail("Codex response or state save failed. Task interrupted; check CLI version and storage.");
417
- }
439
+ }
440
+ catch {
441
+ fail("Codex response or state save failed. Task interrupted; check CLI version and storage.");
418
442
  }
419
443
  }
420
- socket?.on("error", () => fail("Codex connection failed. Task status unknown."));
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
- onData(`${JSON.stringify({ method: "item/completed", params: { threadId: sessionId, turnId: turn.id, item } })}\n`);
535
- onData(`${earlyMessages
536
- .split("\n")
537
- .filter((line) => line && JSON.parse(line).method !== "item/agentMessage/delta")
538
- .join("\n")}\n`);
539
- earlyMessages = "";
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
- onData(`${JSON.stringify({ method: "turn/completed", params: { threadId: sessionId, turn } })}\n`);
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
- earlyMessages += buffer;
596
- buffer = "";
597
- onData(earlyMessages);
598
- earlyMessages = "";
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
- client.rejectAllPendingRequests("Codex connection closed.");
620
- socket?.terminate();
642
+ rpc.close();
621
643
  active = undefined;
622
644
  }
623
645
  },