@keyou007/agent-bot 0.1.20-alpha.1 → 0.1.21

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.
Files changed (92) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +27 -4
  3. package/README.zh.md +23 -4
  4. package/dist/cli/AgentPrerequisites.js +11 -47
  5. package/dist/cli/AgentPrerequisites.js.map +1 -1
  6. package/dist/cli/DefaultAgentSelection.js +1 -1
  7. package/dist/cli/DefaultAgentSelection.js.map +1 -1
  8. package/dist/cli.js +15 -0
  9. package/dist/cli.js.map +1 -1
  10. package/dist/codex/CodexEventMapper.js +21 -1
  11. package/dist/codex/CodexEventMapper.js.map +1 -1
  12. package/dist/codex/CodexLocalActivityDetector.js +32 -10
  13. package/dist/codex/CodexLocalActivityDetector.js.map +1 -1
  14. package/dist/codex/CodexProcessManager.js +56 -7
  15. package/dist/codex/CodexProcessManager.js.map +1 -1
  16. package/dist/codex/CodexRuntime.js +361 -168
  17. package/dist/codex/CodexRuntime.js.map +1 -1
  18. package/dist/codex/CodexVersion.js +18 -0
  19. package/dist/codex/CodexVersion.js.map +1 -0
  20. package/dist/codex/ProviderModels.js +159 -0
  21. package/dist/codex/ProviderModels.js.map +1 -0
  22. package/dist/codex/ProviderSwitch.js +32 -0
  23. package/dist/codex/ProviderSwitch.js.map +1 -0
  24. package/dist/commands/CommandRouter.js +8 -3
  25. package/dist/commands/CommandRouter.js.map +1 -1
  26. package/dist/console/ConsoleTurnPresenter.js +2 -1
  27. package/dist/console/ConsoleTurnPresenter.js.map +1 -1
  28. package/dist/feishu/CardRenderer.js +82 -27
  29. package/dist/feishu/CardRenderer.js.map +1 -1
  30. package/dist/feishu/FeishuConnector.js +17 -5
  31. package/dist/feishu/FeishuConnector.js.map +1 -1
  32. package/dist/feishu/FeishuTurnPresenter.js +34 -8
  33. package/dist/feishu/FeishuTurnPresenter.js.map +1 -1
  34. package/dist/feishu/InboundText.js +11 -0
  35. package/dist/feishu/InboundText.js.map +1 -1
  36. package/dist/feishu/MergedForwardMessage.js +17 -5
  37. package/dist/feishu/MergedForwardMessage.js.map +1 -1
  38. package/dist/index.js +3 -1
  39. package/dist/index.js.map +1 -1
  40. package/dist/local-files/LocalFileViewerServer.js +164 -19
  41. package/dist/local-files/LocalFileViewerServer.js.map +1 -1
  42. package/dist/local-files/MarkdownFileLinks.js +65 -0
  43. package/dist/local-files/MarkdownFileLinks.js.map +1 -0
  44. package/dist/local-files/TurnPreviewPage.js +725 -0
  45. package/dist/local-files/TurnPreviewPage.js.map +1 -0
  46. package/dist/presentation/OutboundRouter.js +6 -1
  47. package/dist/presentation/OutboundRouter.js.map +1 -1
  48. package/dist/presentation/TurnStateReducer.js +142 -3
  49. package/dist/presentation/TurnStateReducer.js.map +1 -1
  50. package/dist/proxy/ProxySessionController.js +573 -325
  51. package/dist/proxy/ProxySessionController.js.map +1 -1
  52. package/dist/state/StateStore.js +65 -65
  53. package/dist/state/StateStore.js.map +1 -1
  54. package/dist/utils/formatStorageSize.js +15 -0
  55. package/dist/utils/formatStorageSize.js.map +1 -0
  56. package/dist/utils/semanticVersion.js +39 -0
  57. package/dist/utils/semanticVersion.js.map +1 -0
  58. package/docs/technical-reference.md +11 -3
  59. package/docs/technical-reference.zh.md +11 -3
  60. package/npm-shrinkwrap.json +2 -2
  61. package/package.json +1 -1
  62. package/skills/agent-bot/SKILL.md +11 -1
  63. package/src/cli/AgentPrerequisites.ts +14 -40
  64. package/src/cli/DefaultAgentSelection.ts +1 -1
  65. package/src/cli.ts +13 -0
  66. package/src/codex/CodexEventMapper.ts +31 -1
  67. package/src/codex/CodexLocalActivityDetector.ts +41 -9
  68. package/src/codex/CodexProcessManager.ts +48 -7
  69. package/src/codex/CodexRuntime.ts +397 -204
  70. package/src/codex/CodexVersion.ts +18 -0
  71. package/src/codex/ProviderModels.ts +185 -0
  72. package/src/codex/ProviderSwitch.ts +36 -0
  73. package/src/commands/CommandRouter.ts +7 -2
  74. package/src/commands/commandTypes.ts +1 -1
  75. package/src/console/ConsoleTurnPresenter.ts +3 -0
  76. package/src/feishu/CardRenderer.ts +84 -43
  77. package/src/feishu/FeishuConnector.ts +15 -5
  78. package/src/feishu/FeishuTurnPresenter.ts +46 -4
  79. package/src/feishu/InboundText.ts +11 -0
  80. package/src/feishu/MergedForwardMessage.ts +15 -5
  81. package/src/index.ts +3 -1
  82. package/src/local-files/LocalFileViewerServer.ts +179 -20
  83. package/src/local-files/MarkdownFileLinks.ts +64 -0
  84. package/src/local-files/TurnPreviewPage.ts +792 -0
  85. package/src/presentation/OutboundRouter.ts +8 -0
  86. package/src/presentation/TurnStateReducer.ts +175 -2
  87. package/src/presentation/turnViewTypes.ts +13 -0
  88. package/src/proxy/ProxySessionController.ts +547 -297
  89. package/src/runtime/types.ts +33 -3
  90. package/src/state/StateStore.ts +86 -90
  91. package/src/utils/formatStorageSize.ts +13 -0
  92. package/src/utils/semanticVersion.ts +28 -0
@@ -1,3 +1,4 @@
1
+ import { statSync } from "node:fs";
1
2
  import { appendGeneratedImageMarkdown } from "../utils/generatedImageMarkdown.js";
2
3
  import { normalizeTaskTitle } from "../utils/taskTitle.js";
3
4
  import { AppServerRequestError } from "./AppServerConnection.js";
@@ -5,6 +6,8 @@ import { mapCodexNotification } from "./CodexEventMapper.js";
5
6
  import { CodexLocalActivityDetector } from "./CodexLocalActivityDetector.js";
6
7
  import { detectProjectlessWorkspace } from "./ProjectlessWorkspace.js";
7
8
  import { threadWriterLockPath } from "./ThreadWriterProcess.js";
9
+ import { assertProviderSettingsApplied, PROVIDER_SWITCH_BUSY, providerSwitchFailure } from "./ProviderSwitch.js";
10
+ import { fetchProviderModels } from "./ProviderModels.js";
8
11
  const WINDOWS_SCREENSHOT_DEVELOPER_INSTRUCTIONS = [
9
12
  "When capturing any screenshot on Windows, use one fresh process and make it Per-Monitor DPI Aware V2 before loading System.Windows.Forms, System.Drawing, or UI Automation, and before calling any screen, window, or bounds API.",
10
13
  "Call user32!SetProcessDpiAwarenessContext((IntPtr)-4) in that same process and verify that it succeeded or that the process is already PMv2 before continuing.",
@@ -16,10 +19,10 @@ const WINDOWS_SCREENSHOT_DEVELOPER_INSTRUCTIONS = [
16
19
  const SESSION_REQUEST_TIMEOUT_MS = 60_000;
17
20
  const CONTROL_REQUEST_TIMEOUT_MS = 10_000;
18
21
  const SYNC_REQUEST_TIMEOUT_MS = 5_000;
19
- const THREAD_TURN_PAGE_SIZE = 100;
20
22
  const FORK_SOURCE_TURN_PAGE_SIZE = 20;
21
23
  const FORK_SOURCE_REQUEST_TIMEOUT_MS = 15_000;
22
24
  const USER_RESUMABLE_THREAD_SOURCE_KINDS = ["cli", "vscode", "exec", "appServer"];
25
+ const BUILT_IN_CODEX_PROVIDER_ID = "openai";
23
26
  // A timed-out fork keeps running in App Server and can create an orphan thread.
24
27
  // Wait for its response; connection closure still rejects the request.
25
28
  const FORK_REQUEST_TIMEOUT_MS = 0;
@@ -34,9 +37,9 @@ export class CodexRuntime {
34
37
  unsubscribe;
35
38
  unsubscribeDisconnect;
36
39
  sessionSyncs = new Map();
40
+ sessionOperations = new Map();
37
41
  localActivityDetector;
38
42
  codexHome;
39
- threadListSortKey = "recency_at";
40
43
  releaseInFlight;
41
44
  constructor(provider, logger) {
42
45
  this.provider = provider;
@@ -56,6 +59,9 @@ export class CodexRuntime {
56
59
  return this.codexHome ? threadWriterLockPath(this.codexHome, remoteSessionId) : undefined;
57
60
  }
58
61
  async createSession(input) {
62
+ return this.runSessionOperation(input.localSessionId, () => this.createSessionNow(input));
63
+ }
64
+ async createSessionNow(input) {
59
65
  const client = await this.client();
60
66
  const response = await client.request("thread/start", {
61
67
  cwd: input.cwd,
@@ -80,6 +86,9 @@ export class CodexRuntime {
80
86
  return session;
81
87
  }
82
88
  async resumeSession(input) {
89
+ return this.runSessionOperation(input.localSessionId, () => this.resumeSessionNow(input));
90
+ }
91
+ async resumeSessionNow(input) {
83
92
  const client = await this.client();
84
93
  const response = await client.request("thread/resume", {
85
94
  threadId: input.remoteSessionId,
@@ -96,6 +105,9 @@ export class CodexRuntime {
96
105
  return session;
97
106
  }
98
107
  async forkSession(input) {
108
+ return this.runSessionOperation(input.localSessionId, () => this.forkSessionNow(input));
109
+ }
110
+ async forkSessionNow(input) {
99
111
  const client = await this.client();
100
112
  const forkParams = {
101
113
  threadId: input.remoteSessionId,
@@ -107,19 +119,10 @@ export class CodexRuntime {
107
119
  ...threadLifecycleParams(input.cwd),
108
120
  ...permissionParams(input.permissionMode),
109
121
  };
110
- let response;
111
- try {
112
- response = await client.request("thread/fork", {
113
- ...forkParams,
114
- excludeTurns: true,
115
- }, FORK_REQUEST_TIMEOUT_MS);
116
- }
117
- catch (error) {
118
- if (!isUnsupportedExcludeTurnsError(error))
119
- throw error;
120
- this.logger.warn({ error }, "App Server does not support thread/fork excludeTurns; retrying without it.");
121
- response = await client.request("thread/fork", forkParams, FORK_REQUEST_TIMEOUT_MS);
122
- }
122
+ const response = await client.request("thread/fork", {
123
+ ...forkParams,
124
+ excludeTurns: true,
125
+ }, FORK_REQUEST_TIMEOUT_MS);
123
126
  const requestedTitle = normalizeTaskTitle(input.title);
124
127
  if (requestedTitle) {
125
128
  await client.request("thread/name/set", {
@@ -135,9 +138,13 @@ export class CodexRuntime {
135
138
  return session;
136
139
  }
137
140
  async startTurn(sessionId, prompt) {
141
+ return this.runSessionOperation(sessionId, () => this.startTurnNow(sessionId, prompt));
142
+ }
143
+ async startTurnNow(sessionId, prompt) {
138
144
  const session = this.requireSession(sessionId);
139
145
  const client = await this.client();
140
146
  await this.ensureSessionResumed(session, client);
147
+ session.canReplaceEmptyThread = false;
141
148
  const start = () => client.request("turn/start", {
142
149
  threadId: session.remoteSessionId,
143
150
  input: codexUserInput(prompt),
@@ -172,24 +179,14 @@ export class CodexRuntime {
172
179
  }
173
180
  async listRemoteSessions(input = {}) {
174
181
  const client = await this.client();
175
- let response;
176
- try {
177
- response = await this.requestThreadList(client, input, this.threadListSortKey);
178
- }
179
- catch (error) {
180
- if (this.threadListSortKey !== "recency_at" || !isUnsupportedRecencySortError(error))
181
- throw error;
182
- this.threadListSortKey = "updated_at";
183
- this.logger.warn({ error }, "App Server does not support thread/list recency_at sorting; retrying with updated_at.");
184
- response = await this.requestThreadList(client, input, this.threadListSortKey);
185
- }
182
+ const response = await this.requestThreadList(client, input);
186
183
  const sessions = await Promise.all(response.data.map(async (thread) => {
187
184
  const listed = remoteSessionSummary(thread);
188
185
  if (listed.status === "active" || listed.lastTurnStatus === "inProgress")
189
186
  return listed;
190
187
  try {
191
- const detail = await client.request("thread/read", { threadId: thread.id, includeTurns: true }, SYNC_REQUEST_TIMEOUT_MS);
192
- return mergeRemoteSessionSummary(listed, remoteSessionSummary(detail.thread));
188
+ const turns = await this.readLatestThreadTurns(client, thread.id, "summary");
189
+ return mergeRemoteSessionSummary(listed, remoteSessionSummary({ ...thread, turns }));
193
190
  }
194
191
  catch {
195
192
  return listed;
@@ -203,35 +200,84 @@ export class CodexRuntime {
203
200
  nextCursor: response.nextCursor ?? undefined,
204
201
  };
205
202
  }
206
- requestThreadList(client, input, sortKey) {
203
+ requestThreadList(client, input) {
207
204
  return client.request("thread/list", {
208
205
  cursor: input.cursor,
209
206
  limit: input.limit ?? 20,
210
- sortKey,
207
+ sortKey: this.provider.getAgentFamily?.() === "traex" ? "updated_at" : "recency_at",
211
208
  sortDirection: "desc",
209
+ // Omitting this filter only lists the App Server's current Provider.
210
+ modelProviders: [],
212
211
  sourceKinds: USER_RESUMABLE_THREAD_SOURCE_KINDS,
213
212
  archived: false,
214
213
  searchTerm: input.searchTerm,
215
214
  }, CONTROL_REQUEST_TIMEOUT_MS);
216
215
  }
217
- async readRemoteSession(remoteSessionId) {
216
+ async readRemoteSession(remoteSessionId, view = "latest") {
217
+ const client = await this.client();
218
+ const response = await client.request("thread/read", { threadId: remoteSessionId, includeTurns: false }, SYNC_REQUEST_TIMEOUT_MS);
219
+ const turns = view === "metadata" ? []
220
+ : await this.readLatestThreadTurns(client, remoteSessionId, view === "latest-full" ? "full" : "summary");
221
+ return this.decorateRemoteSession(remoteSessionId, remoteSessionSummary({ ...response.thread, turns }));
222
+ }
223
+ async readRemoteSessionMetrics(remoteSessionId) {
218
224
  const client = await this.client();
219
- const response = await this.readThreadWithTurns(client, remoteSessionId);
220
- return this.decorateRemoteSession(remoteSessionId, remoteSessionSummary(response.thread));
225
+ const metadata = await client.request("thread/read", { threadId: remoteSessionId, includeTurns: false }, SYNC_REQUEST_TIMEOUT_MS);
226
+ const { turnCount, turnIds } = await this.countRemoteTurns(client, remoteSessionId);
227
+ const rolloutPath = stringValue(metadata.thread.path)?.trim() || undefined;
228
+ const loaded = [...this.sessions.values()].filter((session) => session.remoteSessionId === remoteSessionId);
229
+ for (const session of loaded) {
230
+ session.rolloutPath = rolloutPath ?? session.rolloutPath;
231
+ const knownTurnIds = new Set(turnIds);
232
+ if (session.activeTurnId && !knownTurnIds.has(session.activeTurnId))
233
+ knownTurnIds.add(session.activeTurnId);
234
+ session.knownTurnIds = knownTurnIds;
235
+ session.turnCount = knownTurnIds.size;
236
+ }
237
+ const effectivePath = rolloutPath ?? loaded[0]?.rolloutPath;
238
+ return {
239
+ turnCount: loaded[0]?.turnCount ?? turnCount,
240
+ storageBytes: rolloutStorageBytes(effectivePath),
241
+ };
242
+ }
243
+ async countRemoteTurns(client, remoteSessionId) {
244
+ const turnIds = new Set();
245
+ const seenCursors = new Set();
246
+ let cursor;
247
+ do {
248
+ const page = await this.readTurnPage(client, remoteSessionId, {
249
+ cursor,
250
+ limit: 100,
251
+ itemsView: "summary",
252
+ }, CONTROL_REQUEST_TIMEOUT_MS);
253
+ for (const turn of page.data)
254
+ turnIds.add(turn.id);
255
+ const nextCursor = page.nextCursor ?? undefined;
256
+ if (!nextCursor)
257
+ break;
258
+ if (nextCursor === cursor || seenCursors.has(nextCursor)) {
259
+ throw new Error("App Server repeated a Turn history cursor while counting task Turns.");
260
+ }
261
+ seenCursors.add(nextCursor);
262
+ cursor = nextCursor;
263
+ } while (cursor);
264
+ return { turnCount: turnIds.size, turnIds };
265
+ }
266
+ async listRemoteTurnSummaries(remoteSessionId, input) {
267
+ const response = await this.readTurnPage(await this.client(), remoteSessionId, {
268
+ cursor: input.cursor,
269
+ limit: Math.max(1, Math.min(100, Math.trunc(input.limit))),
270
+ itemsView: "summary",
271
+ });
272
+ return {
273
+ turns: remoteSessionSummary({ id: remoteSessionId, turns: response.data }).completedTurns ?? [],
274
+ nextCursor: response.nextCursor ?? undefined,
275
+ };
221
276
  }
222
277
  async readRemoteForkSource(remoteSessionId) {
223
278
  const client = await this.client();
224
279
  const metadata = await client.request("thread/read", { threadId: remoteSessionId, includeTurns: false }, FORK_SOURCE_REQUEST_TIMEOUT_MS);
225
- let turns;
226
- try {
227
- turns = await this.listRecentThreadTurnsThroughCompleted(client, remoteSessionId);
228
- }
229
- catch (error) {
230
- if (!isUnsupportedThreadTurnsListError(error))
231
- throw error;
232
- this.logger.warn({ error, remoteSessionId }, "App Server does not support lightweight Fork source reads; falling back to complete thread history.");
233
- return this.readRemoteSession(remoteSessionId);
234
- }
280
+ const turns = await this.listRecentThreadTurnsThroughCompleted(client, remoteSessionId);
235
281
  return this.decorateRemoteSession(remoteSessionId, remoteSessionSummary({
236
282
  ...metadata.thread,
237
283
  turns,
@@ -251,12 +297,8 @@ export class CodexRuntime {
251
297
  const seenCursors = new Set();
252
298
  let cursor;
253
299
  do {
254
- const response = await client.request("thread/turns/list", {
255
- threadId: remoteSessionId,
256
- ...(cursor ? { cursor } : {}),
257
- limit: FORK_SOURCE_TURN_PAGE_SIZE,
258
- sortDirection: "desc",
259
- itemsView: "summary",
300
+ const response = await this.readTurnPage(client, remoteSessionId, {
301
+ cursor, limit: FORK_SOURCE_TURN_PAGE_SIZE, itemsView: "summary",
260
302
  }, FORK_SOURCE_REQUEST_TIMEOUT_MS);
261
303
  turns.push(...response.data);
262
304
  const completedIndex = turns.findIndex((turn) => turn.status === "completed");
@@ -276,49 +318,25 @@ export class CodexRuntime {
276
318
  } while (cursor);
277
319
  return turns.reverse();
278
320
  }
279
- async readThreadWithTurns(client, remoteSessionId) {
321
+ async readLatestThreadTurns(client, remoteSessionId, itemsView) {
322
+ const response = await this.readTurnPage(client, remoteSessionId, { limit: 1, itemsView });
323
+ return response.data;
324
+ }
325
+ async readTurnPage(client, remoteSessionId, input, timeoutMs = SYNC_REQUEST_TIMEOUT_MS) {
280
326
  try {
281
- return await client.request("thread/read", { threadId: remoteSessionId, includeTurns: true }, SYNC_REQUEST_TIMEOUT_MS);
327
+ return await client.request("thread/turns/list", {
328
+ threadId: remoteSessionId,
329
+ ...(input.cursor ? { cursor: input.cursor } : {}),
330
+ limit: input.limit, sortDirection: "desc", itemsView: input.itemsView,
331
+ }, timeoutMs);
282
332
  }
283
333
  catch (error) {
284
- const paginated = isPaginatedThreadReadError(error);
285
- if (!paginated && !isUnmaterializedThreadReadError(error))
286
- throw error;
287
- const metadata = await client.request("thread/read", { threadId: remoteSessionId, includeTurns: false }, SYNC_REQUEST_TIMEOUT_MS);
288
- if (!paginated)
289
- return { thread: { ...metadata.thread, turns: [] } };
290
- return {
291
- thread: {
292
- ...metadata.thread,
293
- turns: await this.listThreadTurns(client, remoteSessionId),
294
- },
295
- };
296
- }
297
- }
298
- async listThreadTurns(client, remoteSessionId) {
299
- const turns = [];
300
- const seenCursors = new Set();
301
- let cursor;
302
- do {
303
- const response = await client.request("thread/turns/list", {
304
- threadId: remoteSessionId,
305
- ...(cursor ? { cursor } : {}),
306
- limit: THREAD_TURN_PAGE_SIZE,
307
- sortDirection: "asc",
308
- itemsView: "full",
309
- }, SYNC_REQUEST_TIMEOUT_MS);
310
- turns.push(...response.data);
311
- const nextCursor = response.nextCursor ?? undefined;
312
- if (!nextCursor)
313
- break;
314
- if (seenCursors.has(nextCursor)) {
315
- this.logger.warn({ remoteSessionId, cursor: nextCursor }, "App Server repeated a thread Turn pagination cursor; stopping pagination.");
316
- break;
334
+ if (error instanceof AppServerRequestError && error.method === "thread/turns/list"
335
+ && /not materialized yet; thread\/turns\/list is unavailable before first user message/i.test(error.serverMessage)) {
336
+ return { data: [], nextCursor: null };
317
337
  }
318
- seenCursors.add(nextCursor);
319
- cursor = nextCursor;
320
- } while (cursor);
321
- return turns;
338
+ throw error;
339
+ }
322
340
  }
323
341
  async inspectRemoteSessionActivity(remoteSessionId) {
324
342
  const [response, activeThreads] = await Promise.all([
@@ -341,7 +359,7 @@ export class CodexRuntime {
341
359
  const existing = this.sessionSyncs.get(sessionId);
342
360
  if (existing)
343
361
  return existing;
344
- const synchronization = this.synchronizeSessionNow(sessionId);
362
+ const synchronization = this.runSessionOperation(sessionId, () => this.synchronizeSessionNow(sessionId));
345
363
  this.sessionSyncs.set(sessionId, synchronization);
346
364
  try {
347
365
  return await synchronization;
@@ -368,8 +386,10 @@ export class CodexRuntime {
368
386
  this.logger.info({ ...(sessionId ? { sessionId } : {}), threadId: remoteSessionId, turnId }, "App Server accepted the turn interrupt request.");
369
387
  }
370
388
  async closeSession(sessionId) {
371
- const session = this.requireSession(sessionId);
372
- await this.archiveRemoteSession(session.remoteSessionId);
389
+ await this.runSessionOperation(sessionId, async () => {
390
+ const session = this.requireSession(sessionId);
391
+ await this.archiveRemoteSession(session.remoteSessionId);
392
+ });
373
393
  }
374
394
  async archiveRemoteSession(remoteSessionId) {
375
395
  await (await this.client()).request("thread/archive", { threadId: remoteSessionId }, CONTROL_REQUEST_TIMEOUT_MS);
@@ -379,6 +399,9 @@ export class CodexRuntime {
379
399
  }
380
400
  }
381
401
  async setTitle(sessionId, title) {
402
+ return this.runSessionOperation(sessionId, () => this.setTitleNow(sessionId, title));
403
+ }
404
+ async setTitleNow(sessionId, title) {
382
405
  const session = this.requireSession(sessionId);
383
406
  const normalizedTitle = normalizeTaskTitle(title);
384
407
  if (!normalizedTitle)
@@ -387,6 +410,9 @@ export class CodexRuntime {
387
410
  session.title = normalizedTitle;
388
411
  }
389
412
  async getGoal(sessionId) {
413
+ return this.runSessionOperation(sessionId, () => this.getGoalNow(sessionId));
414
+ }
415
+ async getGoalNow(sessionId) {
390
416
  const session = this.requireSession(sessionId);
391
417
  const client = await this.client();
392
418
  await this.ensureSessionResumed(session, client);
@@ -394,13 +420,20 @@ export class CodexRuntime {
394
420
  return response.goal ?? undefined;
395
421
  }
396
422
  async setGoal(sessionId, update) {
423
+ return this.runSessionOperation(sessionId, () => this.setGoalNow(sessionId, update));
424
+ }
425
+ async setGoalNow(sessionId, update) {
397
426
  const session = this.requireSession(sessionId);
398
427
  const client = await this.client();
399
428
  await this.ensureSessionResumed(session, client);
429
+ session.canReplaceEmptyThread = false;
400
430
  const response = await client.request("thread/goal/set", { threadId: session.remoteSessionId, ...update }, CONTROL_REQUEST_TIMEOUT_MS);
401
431
  return response.goal;
402
432
  }
403
433
  async clearGoal(sessionId) {
434
+ return this.runSessionOperation(sessionId, () => this.clearGoalNow(sessionId));
435
+ }
436
+ async clearGoalNow(sessionId) {
404
437
  const session = this.requireSession(sessionId);
405
438
  const client = await this.client();
406
439
  await this.ensureSessionResumed(session, client);
@@ -408,32 +441,132 @@ export class CodexRuntime {
408
441
  return response.cleared;
409
442
  }
410
443
  async setModel(sessionId, model) {
411
- this.requireSession(sessionId).model = model;
444
+ await this.runSessionOperation(sessionId, async () => { this.requireSession(sessionId).model = model; });
412
445
  }
413
446
  async setReasoningEffort(sessionId, effort) {
414
- this.requireSession(sessionId).reasoningEffort = effort;
447
+ await this.runSessionOperation(sessionId, async () => { this.requireSession(sessionId).reasoningEffort = effort; });
415
448
  }
416
449
  async setPermissionMode(sessionId, mode) {
417
- this.requireSession(sessionId).permissionMode = mode;
450
+ await this.runSessionOperation(sessionId, async () => { this.requireSession(sessionId).permissionMode = mode; });
451
+ }
452
+ async setExecutionSettings(sessionId, settings, persist) {
453
+ return this.runSessionOperation(sessionId, () => this.setExecutionSettingsNow(sessionId, settings, persist));
418
454
  }
419
- async setExecutionSettings(sessionId, settings) {
455
+ async setExecutionSettingsNow(sessionId, settings, persist) {
420
456
  const session = this.requireSession(sessionId);
421
- const response = await (await this.client()).request("thread/resume", {
422
- threadId: session.remoteSessionId,
423
- excludeTurns: true,
424
- cwd: session.cwd,
425
- modelProvider: settings.modelProvider,
426
- model: settings.model,
427
- ...threadLifecycleParams(session.cwd),
428
- ...permissionParams(settings.permissionMode),
429
- }, SESSION_REQUEST_TIMEOUT_MS);
430
- session.modelProvider = response.modelProvider ?? settings.modelProvider;
431
- session.model = response.model ?? settings.model;
432
- session.reasoningEffort = settings.reasoningEffort;
433
- session.permissionMode = settings.permissionMode;
434
- session.needsResume = false;
457
+ if (session.activeTurnId)
458
+ throw new Error(PROVIDER_SWITCH_BUSY);
459
+ const client = await this.client();
460
+ const metadata = await client.request("thread/read", {
461
+ threadId: session.remoteSessionId, includeTurns: false,
462
+ }, SYNC_REQUEST_TIMEOUT_MS);
463
+ const localActive = await this.localActivityDetector?.activeThreads([session.remoteSessionId]);
464
+ if (metadata.thread.status?.type === "active" || localActive?.has(session.remoteSessionId) || session.activeTurnId) {
465
+ throw new Error(PROVIDER_SWITCH_BUSY);
466
+ }
467
+ let empty = false;
468
+ if (session.canReplaceEmptyThread && !metadata.thread.forkedFromId && !metadata.thread.preview?.trim()) {
469
+ try {
470
+ const turns = await this.readLatestThreadTurns(client, session.remoteSessionId, "summary");
471
+ empty = turns.length === 0;
472
+ if (!empty)
473
+ session.canReplaceEmptyThread = false;
474
+ }
475
+ catch (error) {
476
+ if (!(error instanceof AppServerRequestError) || error.method !== "thread/turns/list"
477
+ || !/missing source rollout|no rollout found/iu.test(error.serverMessage))
478
+ throw error;
479
+ empty = true;
480
+ }
481
+ }
482
+ const previous = { ...session };
483
+ let replacementId;
484
+ let detached = false;
485
+ try {
486
+ const params = {
487
+ cwd: session.cwd, modelProvider: settings.modelProvider, model: settings.model,
488
+ ...threadLifecycleParams(session.cwd), ...permissionParams(settings.permissionMode),
489
+ };
490
+ if (!empty) {
491
+ await this.detachThreadForSettings(client, session);
492
+ detached = true;
493
+ }
494
+ const response = empty
495
+ ? await client.request("thread/start", {
496
+ ...params, threadSource: "user", allowProviderModelFallback: false,
497
+ }, SESSION_REQUEST_TIMEOUT_MS)
498
+ : await client.request("thread/resume", {
499
+ ...params, threadId: session.remoteSessionId, excludeTurns: true,
500
+ }, SESSION_REQUEST_TIMEOUT_MS);
501
+ if (empty)
502
+ replacementId = response.thread.id;
503
+ assertProviderSettingsApplied(response, settings);
504
+ if (!empty && response.thread.id !== session.remoteSessionId)
505
+ throw new Error("App Server 返回了不同的任务 ID,未保存设置。");
506
+ if (empty && session.title)
507
+ await client.request("thread/name/set", {
508
+ threadId: response.thread.id, name: session.title,
509
+ }, SESSION_REQUEST_TIMEOUT_MS);
510
+ const candidate = { ...session, ...settings, remoteSessionId: response.thread.id,
511
+ needsResume: false, settingsRecoveryError: undefined };
512
+ await persist?.(candidate);
513
+ Object.assign(session, candidate);
514
+ }
515
+ catch (error) {
516
+ if (replacementId)
517
+ await this.discardUnusedThread(client, replacementId);
518
+ let recovery = "原任务设置未更改。";
519
+ if (detached) {
520
+ try {
521
+ await this.detachThreadForSettings(client, previous);
522
+ const restored = await client.request("thread/resume", {
523
+ threadId: previous.remoteSessionId, excludeTurns: true, cwd: previous.cwd,
524
+ modelProvider: previous.modelProvider, model: previous.model,
525
+ ...threadLifecycleParams(previous.cwd), ...permissionParams(previous.permissionMode),
526
+ }, SESSION_REQUEST_TIMEOUT_MS);
527
+ if (previous.modelProvider && previous.model)
528
+ assertProviderSettingsApplied(restored, {
529
+ modelProvider: previous.modelProvider, model: previous.model,
530
+ });
531
+ if (restored.thread.id !== previous.remoteSessionId)
532
+ throw new Error("恢复时返回了不同的任务 ID。");
533
+ session.needsResume = false;
534
+ session.settingsRecoveryError = undefined;
535
+ }
536
+ catch (restoreError) {
537
+ this.logger.warn({ error: restoreError, sessionId }, "Failed to restore Provider after a rejected settings change.");
538
+ recovery = "原设置仍保留,但远端恢复失败;请重新切换 Provider,确认成功后再发送消息。";
539
+ session.settingsRecoveryError = recovery;
540
+ session.needsResume = true;
541
+ }
542
+ }
543
+ throw new Error(`Provider 切换失败:${providerSwitchFailure(error)} ${recovery}`, { cause: error });
544
+ }
545
+ if (replacementId)
546
+ await this.discardUnusedThread(client, previous.remoteSessionId);
435
547
  return session;
436
548
  }
549
+ async detachThreadForSettings(client, session) {
550
+ const current = await client.request("thread/read", {
551
+ threadId: session.remoteSessionId, includeTurns: false,
552
+ }, SYNC_REQUEST_TIMEOUT_MS);
553
+ if (session.activeTurnId || current.thread.status?.type === "active")
554
+ throw new Error(PROVIDER_SWITCH_BUSY);
555
+ const result = await client.request("thread/unsubscribe", {
556
+ threadId: session.remoteSessionId,
557
+ }, CONTROL_REQUEST_TIMEOUT_MS);
558
+ if (result.status !== "unsubscribed" && result.status !== "notLoaded") {
559
+ throw new Error("当前任务仍由其他客户端加载,无法安全切换 Provider。请先在原客户端释放任务。");
560
+ }
561
+ }
562
+ async discardUnusedThread(client, threadId) {
563
+ try {
564
+ await client.request("thread/unsubscribe", { threadId }, CONTROL_REQUEST_TIMEOUT_MS);
565
+ }
566
+ catch (error) {
567
+ this.logger.warn({ error, threadId }, "Failed to release an unused empty Thread after changing Provider.");
568
+ }
569
+ }
437
570
  async respondToApproval(sessionId, requestId, decision) {
438
571
  const pending = this.approvals.get(requestId);
439
572
  if (!pending || pending.sessionId !== sessionId)
@@ -442,7 +575,43 @@ export class CodexRuntime {
442
575
  pending.resolve({ decision });
443
576
  this.emit({ type: "approval_resolved", sessionId, turnId: pending.turnId, requestId, decision });
444
577
  }
445
- async listModels() {
578
+ async listModels(modelProvider, fallbackModel) {
579
+ const requestedProvider = modelProvider?.trim();
580
+ if (!requestedProvider
581
+ || (requestedProvider === BUILT_IN_CODEX_PROVIDER_ID && this.provider.getAgentFamily?.() !== "traex")) {
582
+ return this.listCatalogModels();
583
+ }
584
+ const config = await this.readConfig();
585
+ const configuredProviders = isRecord(config.model_providers) ? config.model_providers : {};
586
+ const providerConfig = configuredProviders[requestedProvider];
587
+ let catalog = [];
588
+ try {
589
+ catalog = await this.listCatalogModels();
590
+ }
591
+ catch (error) {
592
+ this.logger.warn({ error, modelProvider: requestedProvider }, "Failed to read the App Server model catalog while discovering Provider models.");
593
+ }
594
+ const configuredDefaultModel = stringValue(config.model_provider)?.trim() === requestedProvider
595
+ ? stringValue(config.model)?.trim()
596
+ : undefined;
597
+ try {
598
+ return await fetchProviderModels({
599
+ providerId: requestedProvider,
600
+ config: (isRecord(providerConfig) ? providerConfig : {}),
601
+ catalog,
602
+ configuredDefaultModel,
603
+ environmentValue: (name) => this.provider.getEnvironmentVariable?.(name) ?? process.env[name],
604
+ });
605
+ }
606
+ catch (error) {
607
+ const fallback = fallbackProviderModel(catalog, fallbackModel, configuredDefaultModel);
608
+ if (!fallback)
609
+ throw error;
610
+ this.logger.warn({ error, modelProvider: requestedProvider, fallbackModel: fallback.id }, "Provider model discovery failed; using the current or configured model as a fallback.");
611
+ return [fallback];
612
+ }
613
+ }
614
+ async listCatalogModels() {
446
615
  const response = await (await this.client()).request("model/list", {});
447
616
  return response.data.filter((model) => model.id).map((model) => ({
448
617
  id: model.id,
@@ -456,8 +625,7 @@ export class CodexRuntime {
456
625
  }));
457
626
  }
458
627
  async listModelProviders() {
459
- const response = await (await this.client()).request("config/read", {}, CONTROL_REQUEST_TIMEOUT_MS);
460
- const config = response.config ?? {};
628
+ const config = await this.readConfig();
461
629
  const defaultProvider = stringValue(config.model_provider)?.trim();
462
630
  const configuredProviders = isRecord(config.model_providers) ? config.model_providers : {};
463
631
  const providers = new Map();
@@ -471,11 +639,22 @@ export class CodexRuntime {
471
639
  ...(id === defaultProvider ? { isDefault: true } : {}),
472
640
  });
473
641
  }
642
+ if (this.provider.getAgentFamily?.() === "codex" && !providers.has(BUILT_IN_CODEX_PROVIDER_ID)) {
643
+ providers.set(BUILT_IN_CODEX_PROVIDER_ID, {
644
+ id: BUILT_IN_CODEX_PROVIDER_ID,
645
+ displayName: "OpenAI",
646
+ ...(!defaultProvider ? { isDefault: true } : {}),
647
+ });
648
+ }
474
649
  if (defaultProvider && !providers.has(defaultProvider)) {
475
650
  providers.set(defaultProvider, { id: defaultProvider, isDefault: true });
476
651
  }
477
652
  return [...providers.values()].sort((left, right) => Number(Boolean(right.isDefault)) - Number(Boolean(left.isDefault)) || left.id.localeCompare(right.id));
478
653
  }
654
+ async readConfig() {
655
+ const response = await (await this.client()).request("config/read", {}, CONTROL_REQUEST_TIMEOUT_MS);
656
+ return response.config ?? {};
657
+ }
479
658
  onEvent(listener) {
480
659
  this.listeners.add(listener);
481
660
  return () => this.listeners.delete(listener);
@@ -484,7 +663,7 @@ export class CodexRuntime {
484
663
  if (this.releaseInFlight)
485
664
  return this.releaseInFlight;
486
665
  const activeSessionIds = [...this.sessions.values()]
487
- .filter((session) => Boolean(session.activeTurnId))
666
+ .filter((session) => Boolean(session.activeTurnId) || this.sessionOperations.has(session.localSessionId))
488
667
  .map((session) => session.localSessionId);
489
668
  if (activeSessionIds.length > 0 && options.force !== true) {
490
669
  return { status: "busy", activeSessionIds };
@@ -606,6 +785,19 @@ export class CodexRuntime {
606
785
  turnId: mapped.turnId,
607
786
  lastTokens: mapped.lastTokens,
608
787
  cumulativeTokens: mapped.cumulativeTokens,
788
+ contextTokens: mapped.contextTokens,
789
+ });
790
+ }
791
+ else if (mapped.kind === "context_compaction") {
792
+ this.emit({
793
+ type: "context_compaction",
794
+ sessionId,
795
+ turnId: mapped.turnId,
796
+ phase: mapped.phase,
797
+ compactionId: mapped.compactionId,
798
+ timestampMs: mapped.timestampMs,
799
+ turnCount: session.turnCount,
800
+ storageBytes: rolloutStorageBytes(session.rolloutPath),
609
801
  });
610
802
  }
611
803
  else if (mapped.kind === "agent_message_phase") {
@@ -726,11 +918,13 @@ export class CodexRuntime {
726
918
  if (response.reasoningEffort)
727
919
  return response.reasoningEffort;
728
920
  const model = input.model ?? response.model;
729
- const models = await this.listModels();
921
+ const models = await this.listModels(input.modelProvider ?? response.modelProvider, model);
730
922
  return models.find((item) => item.id === model)?.defaultReasoningEffort
731
923
  ?? models.find((item) => item.isDefault)?.defaultReasoningEffort;
732
924
  }
733
925
  async ensureSessionResumed(session, client) {
926
+ if (session.settingsRecoveryError)
927
+ throw new Error(session.settingsRecoveryError);
734
928
  if (!session.needsResume)
735
929
  return;
736
930
  await this.resumeAppServerSession(session, client);
@@ -749,9 +943,12 @@ export class CodexRuntime {
749
943
  }
750
944
  async synchronizeSessionNow(sessionId) {
751
945
  const session = this.requireSession(sessionId);
752
- const response = await (await this.client()).request("thread/read", { threadId: session.remoteSessionId, includeTurns: true }, SYNC_REQUEST_TIMEOUT_MS);
753
- this.reconcileThreadSnapshot(session, response.thread);
754
- return session;
946
+ const client = await this.client();
947
+ const response = await client.request("thread/read", { threadId: session.remoteSessionId, includeTurns: false }, SYNC_REQUEST_TIMEOUT_MS);
948
+ const turns = await this.readLatestThreadTurns(client, session.remoteSessionId, "full");
949
+ const thread = { ...response.thread, turns };
950
+ this.reconcileThreadSnapshot(session, thread);
951
+ return { ...session, remoteSummary: await this.decorateRemoteSession(session.remoteSessionId, remoteSessionSummary(thread)) };
755
952
  }
756
953
  reconcileThreadSnapshot(session, thread) {
757
954
  const turns = Array.isArray(thread.turns) ? thread.turns : [];
@@ -796,6 +993,12 @@ export class CodexRuntime {
796
993
  this.finishSnapshotTurn(session, latest);
797
994
  }
798
995
  adoptTurn(session, turnId, startedAt) {
996
+ session.canReplaceEmptyThread = false;
997
+ if (!session.knownTurnIds.has(turnId)) {
998
+ session.knownTurnIds.add(turnId);
999
+ if (session.turnCount !== undefined)
1000
+ session.turnCount += 1;
1001
+ }
799
1002
  session.activeTurnId = turnId;
800
1003
  session.activeTurnStartedAt = startedAt;
801
1004
  session.finalText = "";
@@ -873,6 +1076,10 @@ export class CodexRuntime {
873
1076
  generatedImagePaths: [],
874
1077
  messagePhases: new Map(),
875
1078
  needsResume: false,
1079
+ canReplaceEmptyThread: !("remoteSessionId" in input),
1080
+ rolloutPath: stringValue(response.thread.path)?.trim() || undefined,
1081
+ turnCount: "remoteSessionId" in input ? undefined : 0,
1082
+ knownTurnIds: new Set(),
876
1083
  };
877
1084
  }
878
1085
  requireSession(sessionId) {
@@ -881,6 +1088,18 @@ export class CodexRuntime {
881
1088
  throw new Error(`Unknown App Server session: ${sessionId}`);
882
1089
  return session;
883
1090
  }
1091
+ async runSessionOperation(sessionId, operation) {
1092
+ const previous = this.sessionOperations.get(sessionId);
1093
+ const current = (previous ?? Promise.resolve()).catch(() => undefined).then(operation);
1094
+ this.sessionOperations.set(sessionId, current);
1095
+ try {
1096
+ return await current;
1097
+ }
1098
+ finally {
1099
+ if (this.sessionOperations.get(sessionId) === current)
1100
+ this.sessionOperations.delete(sessionId);
1101
+ }
1102
+ }
884
1103
  requireActiveTurn(sessionId, turnId) {
885
1104
  const session = this.requireSession(sessionId);
886
1105
  if (session.activeTurnId !== turnId)
@@ -957,6 +1176,31 @@ function isRecord(value) {
957
1176
  function stringValue(value) {
958
1177
  return typeof value === "string" ? value : undefined;
959
1178
  }
1179
+ function rolloutStorageBytes(filePath) {
1180
+ if (!filePath)
1181
+ return undefined;
1182
+ try {
1183
+ const stats = statSync(filePath);
1184
+ return stats.isFile() ? stats.size : undefined;
1185
+ }
1186
+ catch {
1187
+ return undefined;
1188
+ }
1189
+ }
1190
+ function fallbackProviderModel(catalog, requestedModel, configuredDefaultModel) {
1191
+ const id = requestedModel?.trim()
1192
+ || configuredDefaultModel?.trim()
1193
+ || catalog.find((model) => model.isDefault)?.id
1194
+ || catalog[0]?.id;
1195
+ if (!id)
1196
+ return undefined;
1197
+ const catalogModel = catalog.find((model) => model.id === id);
1198
+ return {
1199
+ ...(catalogModel ?? { id, supportedReasoningEfforts: [] }),
1200
+ id,
1201
+ isDefault: true,
1202
+ };
1203
+ }
960
1204
  function turnStartedAt(turn) {
961
1205
  return typeof turn.startedAt === "number" ? turn.startedAt * 1_000 : Date.now();
962
1206
  }
@@ -976,62 +1220,11 @@ function extractGeneratedImagePaths(turn) {
976
1220
  function uniqueStrings(values) {
977
1221
  return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
978
1222
  }
979
- function isUnsupportedExcludeTurnsError(error) {
980
- if (!(error instanceof AppServerRequestError) || error.method !== "thread/fork")
981
- return false;
982
- const data = typeof error.data === "string"
983
- ? error.data
984
- : error.data === undefined
985
- ? ""
986
- : JSON.stringify(error.data);
987
- const details = `${error.serverMessage} ${data}`;
988
- if (!details.toLowerCase().replaceAll(/[^a-z]/g, "").includes("excludeturns"))
989
- return false;
990
- return error.code === -32602
991
- || /unknown|unrecognized|unexpected|unsupported|not supported|experimental/i.test(details);
992
- }
993
1223
  function isInvalidWorkingDirectoryError(error) {
994
1224
  return error instanceof AppServerRequestError
995
1225
  && error.method === "turn/start"
996
1226
  && /invalid cwd/i.test(`${error.serverMessage} ${error.data === undefined ? "" : JSON.stringify(error.data)}`);
997
1227
  }
998
- function isUnsupportedRecencySortError(error) {
999
- if (!(error instanceof AppServerRequestError) || error.method !== "thread/list")
1000
- return false;
1001
- const data = typeof error.data === "string"
1002
- ? error.data
1003
- : error.data === undefined
1004
- ? ""
1005
- : JSON.stringify(error.data);
1006
- const details = `${error.serverMessage} ${data}`;
1007
- return /recency_at/i.test(details)
1008
- && /unknown|unrecognized|unexpected|unsupported|not supported|invalid/i.test(details);
1009
- }
1010
- function isPaginatedThreadReadError(error) {
1011
- const details = appServerErrorDetails(error);
1012
- return /paginated threads?[^\n]*do(?:es)? not support[^\n]*thread\/read[^\n]*includeTurns\s*=\s*true/i.test(details);
1013
- }
1014
- function isUnsupportedThreadTurnsListError(error) {
1015
- if (!(error instanceof AppServerRequestError) || error.method !== "thread/turns/list")
1016
- return false;
1017
- return error.code === -32601
1018
- || /method not found|unknown method|unsupported|not supported/i.test(appServerErrorDetails(error));
1019
- }
1020
- function isUnmaterializedThreadReadError(error) {
1021
- const details = appServerErrorDetails(error);
1022
- return /thread[^\n]*(?:not materialized|not loaded)[^\n]*includeTurns[^\n]*(?:unavailable|before first user message)/i
1023
- .test(details);
1024
- }
1025
- function appServerErrorDetails(error) {
1026
- if (!(error instanceof AppServerRequestError))
1027
- return error instanceof Error ? error.message : String(error);
1028
- const data = typeof error.data === "string"
1029
- ? error.data
1030
- : error.data === undefined
1031
- ? ""
1032
- : JSON.stringify(error.data);
1033
- return `${error.message} ${error.serverMessage} ${data}`;
1034
- }
1035
1228
  function remoteSessionSummary(thread) {
1036
1229
  const lastTurn = thread.turns?.at(-1);
1037
1230
  const lastCompletedTurn = [...(thread.turns ?? [])]