@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 type { Logger } from "pino";
2
3
  import type {
3
4
  AgentRuntime,
@@ -9,8 +10,10 @@ import type {
9
10
  ModelProviderOption,
10
11
  PermissionMode,
11
12
  RemoteSessionActivity,
13
+ RemoteSessionMetrics,
12
14
  RemoteSessionPage,
13
15
  RemoteSessionSummary,
16
+ RemoteTurnPage,
14
17
  ResumeRuntimeSessionInput,
15
18
  RuntimeGoal,
16
19
  RuntimeGoalUpdate,
@@ -28,6 +31,8 @@ import { mapCodexNotification } from "./CodexEventMapper.js";
28
31
  import { CodexLocalActivityDetector } from "./CodexLocalActivityDetector.js";
29
32
  import { detectProjectlessWorkspace } from "./ProjectlessWorkspace.js";
30
33
  import { threadWriterLockPath } from "./ThreadWriterProcess.js";
34
+ import { assertProviderSettingsApplied, PROVIDER_SWITCH_BUSY, providerSwitchFailure } from "./ProviderSwitch.js";
35
+ import { fetchProviderModels, type ProviderModelConfig } from "./ProviderModels.js";
31
36
 
32
37
  const WINDOWS_SCREENSHOT_DEVELOPER_INSTRUCTIONS = [
33
38
  "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.",
@@ -41,14 +46,13 @@ const WINDOWS_SCREENSHOT_DEVELOPER_INSTRUCTIONS = [
41
46
  const SESSION_REQUEST_TIMEOUT_MS = 60_000;
42
47
  const CONTROL_REQUEST_TIMEOUT_MS = 10_000;
43
48
  const SYNC_REQUEST_TIMEOUT_MS = 5_000;
44
- const THREAD_TURN_PAGE_SIZE = 100;
45
49
  const FORK_SOURCE_TURN_PAGE_SIZE = 20;
46
50
  const FORK_SOURCE_REQUEST_TIMEOUT_MS = 15_000;
47
51
  const USER_RESUMABLE_THREAD_SOURCE_KINDS = ["cli", "vscode", "exec", "appServer"] as const;
52
+ const BUILT_IN_CODEX_PROVIDER_ID = "openai";
48
53
  // A timed-out fork keeps running in App Server and can create an orphan thread.
49
54
  // Wait for its response; connection closure still rejects the request.
50
55
  const FORK_REQUEST_TIMEOUT_MS = 0;
51
- type ThreadListSortKey = "recency_at" | "updated_at";
52
56
 
53
57
  export interface AppServerClient {
54
58
  request<T = unknown>(method: string, params?: unknown, timeoutMs?: number): Promise<T>;
@@ -62,8 +66,10 @@ export interface AppServerClient {
62
66
 
63
67
  export interface AppServerClientProvider {
64
68
  getClient(): Promise<AppServerClient>;
69
+ getAgentFamily?(): "codex" | "traex" | undefined;
65
70
  getProcessInfo?(): AgentProcessInfo;
66
71
  getCodexHome?(): string;
72
+ getEnvironmentVariable?(name: string): string | undefined;
67
73
  onDisconnect?(listener: (error: Error) => void): () => void;
68
74
  release?(): Promise<void>;
69
75
  close(): void;
@@ -76,6 +82,11 @@ interface CodexSession extends RuntimeSession {
76
82
  generatedImagePaths: string[];
77
83
  messagePhases: Map<string, "commentary" | "final_answer">;
78
84
  needsResume: boolean;
85
+ canReplaceEmptyThread: boolean;
86
+ settingsRecoveryError?: string;
87
+ rolloutPath?: string;
88
+ turnCount?: number;
89
+ knownTurnIds: Set<string>;
79
90
  }
80
91
 
81
92
  interface PendingApproval {
@@ -93,9 +104,9 @@ export class CodexRuntime implements AgentRuntime {
93
104
  private unsubscribe?: () => void;
94
105
  private readonly unsubscribeDisconnect?: () => void;
95
106
  private readonly sessionSyncs = new Map<string, Promise<RuntimeSession>>();
107
+ private readonly sessionOperations = new Map<string, Promise<unknown>>();
96
108
  private readonly localActivityDetector?: CodexLocalActivityDetector;
97
109
  private readonly codexHome?: string;
98
- private threadListSortKey: ThreadListSortKey = "recency_at";
99
110
  private releaseInFlight?: Promise<RuntimeReleaseResult>;
100
111
 
101
112
  constructor(
@@ -120,6 +131,10 @@ export class CodexRuntime implements AgentRuntime {
120
131
  }
121
132
 
122
133
  async createSession(input: CreateRuntimeSessionInput): Promise<RuntimeSession> {
134
+ return this.runSessionOperation(input.localSessionId, () => this.createSessionNow(input));
135
+ }
136
+
137
+ private async createSessionNow(input: CreateRuntimeSessionInput): Promise<RuntimeSession> {
123
138
  const client = await this.client();
124
139
  const response = await client.request<ThreadResponse>("thread/start", {
125
140
  cwd: input.cwd,
@@ -144,6 +159,10 @@ export class CodexRuntime implements AgentRuntime {
144
159
  }
145
160
 
146
161
  async resumeSession(input: ResumeRuntimeSessionInput): Promise<RuntimeSession> {
162
+ return this.runSessionOperation(input.localSessionId, () => this.resumeSessionNow(input));
163
+ }
164
+
165
+ private async resumeSessionNow(input: ResumeRuntimeSessionInput): Promise<RuntimeSession> {
147
166
  const client = await this.client();
148
167
  const response = await client.request<ThreadResponse>("thread/resume", {
149
168
  threadId: input.remoteSessionId,
@@ -161,6 +180,10 @@ export class CodexRuntime implements AgentRuntime {
161
180
  }
162
181
 
163
182
  async forkSession(input: ForkRuntimeSessionInput): Promise<RuntimeSession> {
183
+ return this.runSessionOperation(input.localSessionId, () => this.forkSessionNow(input));
184
+ }
185
+
186
+ private async forkSessionNow(input: ForkRuntimeSessionInput): Promise<RuntimeSession> {
164
187
  const client = await this.client();
165
188
  const forkParams = {
166
189
  threadId: input.remoteSessionId,
@@ -172,20 +195,10 @@ export class CodexRuntime implements AgentRuntime {
172
195
  ...threadLifecycleParams(input.cwd),
173
196
  ...permissionParams(input.permissionMode),
174
197
  };
175
- let response: ThreadResponse;
176
- try {
177
- response = await client.request<ThreadResponse>("thread/fork", {
178
- ...forkParams,
179
- excludeTurns: true,
180
- }, FORK_REQUEST_TIMEOUT_MS);
181
- } catch (error) {
182
- if (!isUnsupportedExcludeTurnsError(error)) throw error;
183
- this.logger.warn(
184
- { error },
185
- "App Server does not support thread/fork excludeTurns; retrying without it.",
186
- );
187
- response = await client.request<ThreadResponse>("thread/fork", forkParams, FORK_REQUEST_TIMEOUT_MS);
188
- }
198
+ const response = await client.request<ThreadResponse>("thread/fork", {
199
+ ...forkParams,
200
+ excludeTurns: true,
201
+ }, FORK_REQUEST_TIMEOUT_MS);
189
202
  const requestedTitle = normalizeTaskTitle(input.title);
190
203
  if (requestedTitle) {
191
204
  await client.request("thread/name/set", {
@@ -201,9 +214,14 @@ export class CodexRuntime implements AgentRuntime {
201
214
  }
202
215
 
203
216
  async startTurn(sessionId: string, prompt: RuntimePrompt): Promise<string> {
217
+ return this.runSessionOperation(sessionId, () => this.startTurnNow(sessionId, prompt));
218
+ }
219
+
220
+ private async startTurnNow(sessionId: string, prompt: RuntimePrompt): Promise<string> {
204
221
  const session = this.requireSession(sessionId);
205
222
  const client = await this.client();
206
223
  await this.ensureSessionResumed(session, client);
224
+ session.canReplaceEmptyThread = false;
207
225
  const start = () => client.request<{ turn: { id: string } }>("turn/start", {
208
226
  threadId: session.remoteSessionId,
209
227
  input: codexUserInput(prompt),
@@ -249,28 +267,13 @@ export class CodexRuntime implements AgentRuntime {
249
267
  limit?: number;
250
268
  } = {}): Promise<RemoteSessionPage> {
251
269
  const client = await this.client();
252
- let response: ThreadListResponse;
253
- try {
254
- response = await this.requestThreadList(client, input, this.threadListSortKey);
255
- } catch (error) {
256
- if (this.threadListSortKey !== "recency_at" || !isUnsupportedRecencySortError(error)) throw error;
257
- this.threadListSortKey = "updated_at";
258
- this.logger.warn(
259
- { error },
260
- "App Server does not support thread/list recency_at sorting; retrying with updated_at.",
261
- );
262
- response = await this.requestThreadList(client, input, this.threadListSortKey);
263
- }
270
+ const response = await this.requestThreadList(client, input);
264
271
  const sessions = await Promise.all(response.data.map(async (thread) => {
265
272
  const listed = remoteSessionSummary(thread);
266
273
  if (listed.status === "active" || listed.lastTurnStatus === "inProgress") return listed;
267
274
  try {
268
- const detail = await client.request<ThreadReadResponse>(
269
- "thread/read",
270
- { threadId: thread.id, includeTurns: true },
271
- SYNC_REQUEST_TIMEOUT_MS,
272
- );
273
- return mergeRemoteSessionSummary(listed, remoteSessionSummary(detail.thread));
275
+ const turns = await this.readLatestThreadTurns(client, thread.id, "summary");
276
+ return mergeRemoteSessionSummary(listed, remoteSessionSummary({ ...thread, turns }));
274
277
  } catch {
275
278
  return listed;
276
279
  }
@@ -287,15 +290,16 @@ export class CodexRuntime implements AgentRuntime {
287
290
  private requestThreadList(
288
291
  client: AppServerClient,
289
292
  input: { searchTerm?: string; cursor?: string; limit?: number },
290
- sortKey: ThreadListSortKey,
291
293
  ): Promise<ThreadListResponse> {
292
294
  return client.request<ThreadListResponse>(
293
295
  "thread/list",
294
296
  {
295
297
  cursor: input.cursor,
296
298
  limit: input.limit ?? 20,
297
- sortKey,
299
+ sortKey: this.provider.getAgentFamily?.() === "traex" ? "updated_at" : "recency_at",
298
300
  sortDirection: "desc",
301
+ // Omitting this filter only lists the App Server's current Provider.
302
+ modelProviders: [],
299
303
  sourceKinds: USER_RESUMABLE_THREAD_SOURCE_KINDS,
300
304
  archived: false,
301
305
  searchTerm: input.searchTerm,
@@ -304,10 +308,79 @@ export class CodexRuntime implements AgentRuntime {
304
308
  );
305
309
  }
306
310
 
307
- async readRemoteSession(remoteSessionId: string): Promise<RemoteSessionSummary> {
311
+ async readRemoteSession(
312
+ remoteSessionId: string,
313
+ view: "metadata" | "latest" | "latest-full" = "latest",
314
+ ): Promise<RemoteSessionSummary> {
315
+ const client = await this.client();
316
+ const response = await client.request<ThreadReadResponse>(
317
+ "thread/read", { threadId: remoteSessionId, includeTurns: false }, SYNC_REQUEST_TIMEOUT_MS,
318
+ );
319
+ const turns = view === "metadata" ? []
320
+ : await this.readLatestThreadTurns(client, remoteSessionId, view === "latest-full" ? "full" : "summary");
321
+ return this.decorateRemoteSession(remoteSessionId, remoteSessionSummary({ ...response.thread, turns }));
322
+ }
323
+
324
+ async readRemoteSessionMetrics(remoteSessionId: string): Promise<RemoteSessionMetrics> {
308
325
  const client = await this.client();
309
- const response = await this.readThreadWithTurns(client, remoteSessionId);
310
- return this.decorateRemoteSession(remoteSessionId, remoteSessionSummary(response.thread));
326
+ const metadata = await client.request<ThreadReadResponse>(
327
+ "thread/read", { threadId: remoteSessionId, includeTurns: false }, SYNC_REQUEST_TIMEOUT_MS,
328
+ );
329
+ const { turnCount, turnIds } = await this.countRemoteTurns(client, remoteSessionId);
330
+ const rolloutPath = stringValue(metadata.thread.path)?.trim() || undefined;
331
+ const loaded = [...this.sessions.values()].filter((session) => session.remoteSessionId === remoteSessionId);
332
+ for (const session of loaded) {
333
+ session.rolloutPath = rolloutPath ?? session.rolloutPath;
334
+ const knownTurnIds = new Set(turnIds);
335
+ if (session.activeTurnId && !knownTurnIds.has(session.activeTurnId)) knownTurnIds.add(session.activeTurnId);
336
+ session.knownTurnIds = knownTurnIds;
337
+ session.turnCount = knownTurnIds.size;
338
+ }
339
+ const effectivePath = rolloutPath ?? loaded[0]?.rolloutPath;
340
+ return {
341
+ turnCount: loaded[0]?.turnCount ?? turnCount,
342
+ storageBytes: rolloutStorageBytes(effectivePath),
343
+ };
344
+ }
345
+
346
+ private async countRemoteTurns(
347
+ client: AppServerClient,
348
+ remoteSessionId: string,
349
+ ): Promise<{ turnCount: number; turnIds: Set<string> }> {
350
+ const turnIds = new Set<string>();
351
+ const seenCursors = new Set<string>();
352
+ let cursor: string | undefined;
353
+ do {
354
+ const page = await this.readTurnPage(client, remoteSessionId, {
355
+ cursor,
356
+ limit: 100,
357
+ itemsView: "summary",
358
+ }, CONTROL_REQUEST_TIMEOUT_MS);
359
+ for (const turn of page.data) turnIds.add(turn.id);
360
+ const nextCursor = page.nextCursor ?? undefined;
361
+ if (!nextCursor) break;
362
+ if (nextCursor === cursor || seenCursors.has(nextCursor)) {
363
+ throw new Error("App Server repeated a Turn history cursor while counting task Turns.");
364
+ }
365
+ seenCursors.add(nextCursor);
366
+ cursor = nextCursor;
367
+ } while (cursor);
368
+ return { turnCount: turnIds.size, turnIds };
369
+ }
370
+
371
+ async listRemoteTurnSummaries(
372
+ remoteSessionId: string,
373
+ input: { cursor?: string; limit: number },
374
+ ): Promise<RemoteTurnPage> {
375
+ const response = await this.readTurnPage(await this.client(), remoteSessionId, {
376
+ cursor: input.cursor,
377
+ limit: Math.max(1, Math.min(100, Math.trunc(input.limit))),
378
+ itemsView: "summary",
379
+ });
380
+ return {
381
+ turns: remoteSessionSummary({ id: remoteSessionId, turns: response.data }).completedTurns ?? [],
382
+ nextCursor: response.nextCursor ?? undefined,
383
+ };
311
384
  }
312
385
 
313
386
  async readRemoteForkSource(remoteSessionId: string): Promise<RemoteSessionSummary> {
@@ -317,17 +390,7 @@ export class CodexRuntime implements AgentRuntime {
317
390
  { threadId: remoteSessionId, includeTurns: false },
318
391
  FORK_SOURCE_REQUEST_TIMEOUT_MS,
319
392
  );
320
- let turns: CodexTurnSnapshot[];
321
- try {
322
- turns = await this.listRecentThreadTurnsThroughCompleted(client, remoteSessionId);
323
- } catch (error) {
324
- if (!isUnsupportedThreadTurnsListError(error)) throw error;
325
- this.logger.warn(
326
- { error, remoteSessionId },
327
- "App Server does not support lightweight Fork source reads; falling back to complete thread history.",
328
- );
329
- return this.readRemoteSession(remoteSessionId);
330
- }
393
+ const turns = await this.listRecentThreadTurnsThroughCompleted(client, remoteSessionId);
331
394
  return this.decorateRemoteSession(remoteSessionId, remoteSessionSummary({
332
395
  ...metadata.thread,
333
396
  turns,
@@ -355,17 +418,9 @@ export class CodexRuntime implements AgentRuntime {
355
418
  const seenCursors = new Set<string>();
356
419
  let cursor: string | undefined;
357
420
  do {
358
- const response = await client.request<ThreadTurnsListResponse>(
359
- "thread/turns/list",
360
- {
361
- threadId: remoteSessionId,
362
- ...(cursor ? { cursor } : {}),
363
- limit: FORK_SOURCE_TURN_PAGE_SIZE,
364
- sortDirection: "desc",
365
- itemsView: "summary",
366
- },
367
- FORK_SOURCE_REQUEST_TIMEOUT_MS,
368
- );
421
+ const response = await this.readTurnPage(client, remoteSessionId, {
422
+ cursor, limit: FORK_SOURCE_TURN_PAGE_SIZE, itemsView: "summary",
423
+ }, FORK_SOURCE_REQUEST_TIMEOUT_MS);
369
424
  turns.push(...response.data);
370
425
  const completedIndex = turns.findIndex((turn) => turn.status === "completed");
371
426
  if (completedIndex >= 0) {
@@ -387,69 +442,34 @@ export class CodexRuntime implements AgentRuntime {
387
442
  return turns.reverse();
388
443
  }
389
444
 
390
- private async readThreadWithTurns(
445
+ private async readLatestThreadTurns(
391
446
  client: AppServerClient,
392
447
  remoteSessionId: string,
393
- ): Promise<ThreadReadResponse> {
394
- try {
395
- return await client.request<ThreadReadResponse>(
396
- "thread/read",
397
- { threadId: remoteSessionId, includeTurns: true },
398
- SYNC_REQUEST_TIMEOUT_MS,
399
- );
400
- } catch (error) {
401
- const paginated = isPaginatedThreadReadError(error);
402
- if (!paginated && !isUnmaterializedThreadReadError(error)) throw error;
403
-
404
- const metadata = await client.request<ThreadReadResponse>(
405
- "thread/read",
406
- { threadId: remoteSessionId, includeTurns: false },
407
- SYNC_REQUEST_TIMEOUT_MS,
408
- );
409
- if (!paginated) return { thread: { ...metadata.thread, turns: [] } };
410
-
411
- return {
412
- thread: {
413
- ...metadata.thread,
414
- turns: await this.listThreadTurns(client, remoteSessionId),
415
- },
416
- };
417
- }
448
+ itemsView: "summary" | "full",
449
+ ): Promise<CodexTurnSnapshot[]> {
450
+ const response = await this.readTurnPage(client, remoteSessionId, { limit: 1, itemsView });
451
+ return response.data;
418
452
  }
419
453
 
420
- private async listThreadTurns(
454
+ private async readTurnPage(
421
455
  client: AppServerClient,
422
456
  remoteSessionId: string,
423
- ): Promise<CodexTurnSnapshot[]> {
424
- const turns: CodexTurnSnapshot[] = [];
425
- const seenCursors = new Set<string>();
426
- let cursor: string | undefined;
427
- do {
428
- const response = await client.request<ThreadTurnsListResponse>(
429
- "thread/turns/list",
430
- {
431
- threadId: remoteSessionId,
432
- ...(cursor ? { cursor } : {}),
433
- limit: THREAD_TURN_PAGE_SIZE,
434
- sortDirection: "asc",
435
- itemsView: "full",
436
- },
437
- SYNC_REQUEST_TIMEOUT_MS,
438
- );
439
- turns.push(...response.data);
440
- const nextCursor = response.nextCursor ?? undefined;
441
- if (!nextCursor) break;
442
- if (seenCursors.has(nextCursor)) {
443
- this.logger.warn(
444
- { remoteSessionId, cursor: nextCursor },
445
- "App Server repeated a thread Turn pagination cursor; stopping pagination.",
446
- );
447
- break;
457
+ input: { cursor?: string; limit: number; itemsView: "summary" | "full" },
458
+ timeoutMs = SYNC_REQUEST_TIMEOUT_MS,
459
+ ): Promise<ThreadTurnsListResponse> {
460
+ try {
461
+ return await client.request<ThreadTurnsListResponse>("thread/turns/list", {
462
+ threadId: remoteSessionId,
463
+ ...(input.cursor ? { cursor: input.cursor } : {}),
464
+ limit: input.limit, sortDirection: "desc", itemsView: input.itemsView,
465
+ }, timeoutMs);
466
+ } catch (error) {
467
+ if (error instanceof AppServerRequestError && error.method === "thread/turns/list"
468
+ && /not materialized yet; thread\/turns\/list is unavailable before first user message/i.test(error.serverMessage)) {
469
+ return { data: [], nextCursor: null };
448
470
  }
449
- seenCursors.add(nextCursor);
450
- cursor = nextCursor;
451
- } while (cursor);
452
- return turns;
471
+ throw error;
472
+ }
453
473
  }
454
474
 
455
475
  async inspectRemoteSessionActivity(remoteSessionId: string): Promise<RemoteSessionActivity> {
@@ -477,7 +497,7 @@ export class CodexRuntime implements AgentRuntime {
477
497
  async synchronizeSession(sessionId: string): Promise<RuntimeSession> {
478
498
  const existing = this.sessionSyncs.get(sessionId);
479
499
  if (existing) return existing;
480
- const synchronization = this.synchronizeSessionNow(sessionId);
500
+ const synchronization = this.runSessionOperation(sessionId, () => this.synchronizeSessionNow(sessionId));
481
501
  this.sessionSyncs.set(sessionId, synchronization);
482
502
  try {
483
503
  return await synchronization;
@@ -513,8 +533,10 @@ export class CodexRuntime implements AgentRuntime {
513
533
  }
514
534
 
515
535
  async closeSession(sessionId: string): Promise<void> {
516
- const session = this.requireSession(sessionId);
517
- await this.archiveRemoteSession(session.remoteSessionId);
536
+ await this.runSessionOperation(sessionId, async () => {
537
+ const session = this.requireSession(sessionId);
538
+ await this.archiveRemoteSession(session.remoteSessionId);
539
+ });
518
540
  }
519
541
 
520
542
  async archiveRemoteSession(remoteSessionId: string): Promise<void> {
@@ -529,6 +551,10 @@ export class CodexRuntime implements AgentRuntime {
529
551
  }
530
552
 
531
553
  async setTitle(sessionId: string, title: string): Promise<void> {
554
+ return this.runSessionOperation(sessionId, () => this.setTitleNow(sessionId, title));
555
+ }
556
+
557
+ private async setTitleNow(sessionId: string, title: string): Promise<void> {
532
558
  const session = this.requireSession(sessionId);
533
559
  const normalizedTitle = normalizeTaskTitle(title);
534
560
  if (!normalizedTitle) throw new Error("任务标题不能为空。");
@@ -541,6 +567,10 @@ export class CodexRuntime implements AgentRuntime {
541
567
  }
542
568
 
543
569
  async getGoal(sessionId: string): Promise<RuntimeGoal | undefined> {
570
+ return this.runSessionOperation(sessionId, () => this.getGoalNow(sessionId));
571
+ }
572
+
573
+ private async getGoalNow(sessionId: string): Promise<RuntimeGoal | undefined> {
544
574
  const session = this.requireSession(sessionId);
545
575
  const client = await this.client();
546
576
  await this.ensureSessionResumed(session, client);
@@ -553,9 +583,14 @@ export class CodexRuntime implements AgentRuntime {
553
583
  }
554
584
 
555
585
  async setGoal(sessionId: string, update: RuntimeGoalUpdate): Promise<RuntimeGoal> {
586
+ return this.runSessionOperation(sessionId, () => this.setGoalNow(sessionId, update));
587
+ }
588
+
589
+ private async setGoalNow(sessionId: string, update: RuntimeGoalUpdate): Promise<RuntimeGoal> {
556
590
  const session = this.requireSession(sessionId);
557
591
  const client = await this.client();
558
592
  await this.ensureSessionResumed(session, client);
593
+ session.canReplaceEmptyThread = false;
559
594
  const response = await client.request<{ goal: RuntimeGoal }>(
560
595
  "thread/goal/set",
561
596
  { threadId: session.remoteSessionId, ...update },
@@ -565,6 +600,10 @@ export class CodexRuntime implements AgentRuntime {
565
600
  }
566
601
 
567
602
  async clearGoal(sessionId: string): Promise<boolean> {
603
+ return this.runSessionOperation(sessionId, () => this.clearGoalNow(sessionId));
604
+ }
605
+
606
+ private async clearGoalNow(sessionId: string): Promise<boolean> {
568
607
  const session = this.requireSession(sessionId);
569
608
  const client = await this.client();
570
609
  await this.ensureSessionResumed(session, client);
@@ -577,39 +616,132 @@ export class CodexRuntime implements AgentRuntime {
577
616
  }
578
617
 
579
618
  async setModel(sessionId: string, model: string): Promise<void> {
580
- this.requireSession(sessionId).model = model;
619
+ await this.runSessionOperation(sessionId, async () => { this.requireSession(sessionId).model = model; });
581
620
  }
582
621
 
583
622
  async setReasoningEffort(sessionId: string, effort: string): Promise<void> {
584
- this.requireSession(sessionId).reasoningEffort = effort;
623
+ await this.runSessionOperation(sessionId, async () => { this.requireSession(sessionId).reasoningEffort = effort; });
585
624
  }
586
625
 
587
626
  async setPermissionMode(sessionId: string, mode: PermissionMode): Promise<void> {
588
- this.requireSession(sessionId).permissionMode = mode;
627
+ await this.runSessionOperation(sessionId, async () => { this.requireSession(sessionId).permissionMode = mode; });
589
628
  }
590
629
 
591
630
  async setExecutionSettings(
592
631
  sessionId: string,
593
632
  settings: RuntimeExecutionSettings,
633
+ persist?: (session: RuntimeSession) => Promise<void>,
634
+ ): Promise<RuntimeSession> {
635
+ return this.runSessionOperation(sessionId, () => this.setExecutionSettingsNow(sessionId, settings, persist));
636
+ }
637
+
638
+ private async setExecutionSettingsNow(
639
+ sessionId: string,
640
+ settings: RuntimeExecutionSettings,
641
+ persist?: (session: RuntimeSession) => Promise<void>,
594
642
  ): Promise<RuntimeSession> {
595
643
  const session = this.requireSession(sessionId);
596
- const response = await (await this.client()).request<ThreadResponse>("thread/resume", {
597
- threadId: session.remoteSessionId,
598
- excludeTurns: true,
599
- cwd: session.cwd,
600
- modelProvider: settings.modelProvider,
601
- model: settings.model,
602
- ...threadLifecycleParams(session.cwd),
603
- ...permissionParams(settings.permissionMode),
604
- }, SESSION_REQUEST_TIMEOUT_MS);
605
- session.modelProvider = response.modelProvider ?? settings.modelProvider;
606
- session.model = response.model ?? settings.model;
607
- session.reasoningEffort = settings.reasoningEffort;
608
- session.permissionMode = settings.permissionMode;
609
- session.needsResume = false;
644
+ if (session.activeTurnId) throw new Error(PROVIDER_SWITCH_BUSY);
645
+ const client = await this.client();
646
+ const metadata = await client.request<ThreadReadResponse>("thread/read", {
647
+ threadId: session.remoteSessionId, includeTurns: false,
648
+ }, SYNC_REQUEST_TIMEOUT_MS);
649
+ const localActive = await this.localActivityDetector?.activeThreads([session.remoteSessionId]);
650
+ if (metadata.thread.status?.type === "active" || localActive?.has(session.remoteSessionId) || session.activeTurnId) {
651
+ throw new Error(PROVIDER_SWITCH_BUSY);
652
+ }
653
+ let empty = false;
654
+ if (session.canReplaceEmptyThread && !metadata.thread.forkedFromId && !metadata.thread.preview?.trim()) {
655
+ try {
656
+ const turns = await this.readLatestThreadTurns(client, session.remoteSessionId, "summary");
657
+ empty = turns.length === 0;
658
+ if (!empty) session.canReplaceEmptyThread = false;
659
+ } catch (error) {
660
+ if (!(error instanceof AppServerRequestError) || error.method !== "thread/turns/list"
661
+ || !/missing source rollout|no rollout found/iu.test(error.serverMessage)) throw error;
662
+ empty = true;
663
+ }
664
+ }
665
+ const previous = { ...session };
666
+ let replacementId: string | undefined;
667
+ let detached = false;
668
+ try {
669
+ const params = {
670
+ cwd: session.cwd, modelProvider: settings.modelProvider, model: settings.model,
671
+ ...threadLifecycleParams(session.cwd), ...permissionParams(settings.permissionMode),
672
+ };
673
+ if (!empty) {
674
+ await this.detachThreadForSettings(client, session);
675
+ detached = true;
676
+ }
677
+ const response = empty
678
+ ? await client.request<ThreadResponse>("thread/start", {
679
+ ...params, threadSource: "user", allowProviderModelFallback: false,
680
+ }, SESSION_REQUEST_TIMEOUT_MS)
681
+ : await client.request<ThreadResponse>("thread/resume", {
682
+ ...params, threadId: session.remoteSessionId, excludeTurns: true,
683
+ }, SESSION_REQUEST_TIMEOUT_MS);
684
+ if (empty) replacementId = response.thread.id;
685
+ assertProviderSettingsApplied(response, settings);
686
+ if (!empty && response.thread.id !== session.remoteSessionId) throw new Error("App Server 返回了不同的任务 ID,未保存设置。");
687
+ if (empty && session.title) await client.request("thread/name/set", {
688
+ threadId: response.thread.id, name: session.title,
689
+ }, SESSION_REQUEST_TIMEOUT_MS);
690
+ const candidate = { ...session, ...settings, remoteSessionId: response.thread.id,
691
+ needsResume: false, settingsRecoveryError: undefined };
692
+ await persist?.(candidate);
693
+ Object.assign(session, candidate);
694
+ } catch (error) {
695
+ if (replacementId) await this.discardUnusedThread(client, replacementId);
696
+ let recovery = "原任务设置未更改。";
697
+ if (detached) {
698
+ try {
699
+ await this.detachThreadForSettings(client, previous);
700
+ const restored = await client.request<ThreadResponse>("thread/resume", {
701
+ threadId: previous.remoteSessionId, excludeTurns: true, cwd: previous.cwd,
702
+ modelProvider: previous.modelProvider, model: previous.model,
703
+ ...threadLifecycleParams(previous.cwd), ...permissionParams(previous.permissionMode),
704
+ }, SESSION_REQUEST_TIMEOUT_MS);
705
+ if (previous.modelProvider && previous.model) assertProviderSettingsApplied(restored, {
706
+ modelProvider: previous.modelProvider, model: previous.model,
707
+ });
708
+ if (restored.thread.id !== previous.remoteSessionId) throw new Error("恢复时返回了不同的任务 ID。");
709
+ session.needsResume = false;
710
+ session.settingsRecoveryError = undefined;
711
+ } catch (restoreError) {
712
+ this.logger.warn({ error: restoreError, sessionId }, "Failed to restore Provider after a rejected settings change.");
713
+ recovery = "原设置仍保留,但远端恢复失败;请重新切换 Provider,确认成功后再发送消息。";
714
+ session.settingsRecoveryError = recovery;
715
+ session.needsResume = true;
716
+ }
717
+ }
718
+ throw new Error(`Provider 切换失败:${providerSwitchFailure(error)} ${recovery}`, { cause: error });
719
+ }
720
+ if (replacementId) await this.discardUnusedThread(client, previous.remoteSessionId);
610
721
  return session;
611
722
  }
612
723
 
724
+ private async detachThreadForSettings(client: AppServerClient, session: CodexSession): Promise<void> {
725
+ const current = await client.request<ThreadReadResponse>("thread/read", {
726
+ threadId: session.remoteSessionId, includeTurns: false,
727
+ }, SYNC_REQUEST_TIMEOUT_MS);
728
+ if (session.activeTurnId || current.thread.status?.type === "active") throw new Error(PROVIDER_SWITCH_BUSY);
729
+ const result = await client.request<{ status: string }>("thread/unsubscribe", {
730
+ threadId: session.remoteSessionId,
731
+ }, CONTROL_REQUEST_TIMEOUT_MS);
732
+ if (result.status !== "unsubscribed" && result.status !== "notLoaded") {
733
+ throw new Error("当前任务仍由其他客户端加载,无法安全切换 Provider。请先在原客户端释放任务。");
734
+ }
735
+ }
736
+
737
+ private async discardUnusedThread(client: AppServerClient, threadId: string): Promise<void> {
738
+ try {
739
+ await client.request("thread/unsubscribe", { threadId }, CONTROL_REQUEST_TIMEOUT_MS);
740
+ } catch (error) {
741
+ this.logger.warn({ error, threadId }, "Failed to release an unused empty Thread after changing Provider.");
742
+ }
743
+ }
744
+
613
745
  async respondToApproval(
614
746
  sessionId: string,
615
747
  requestId: string,
@@ -622,7 +754,43 @@ export class CodexRuntime implements AgentRuntime {
622
754
  this.emit({ type: "approval_resolved", sessionId, turnId: pending.turnId, requestId, decision });
623
755
  }
624
756
 
625
- async listModels(): Promise<ModelOption[]> {
757
+ async listModels(modelProvider?: string, fallbackModel?: string): Promise<ModelOption[]> {
758
+ const requestedProvider = modelProvider?.trim();
759
+ if (!requestedProvider
760
+ || (requestedProvider === BUILT_IN_CODEX_PROVIDER_ID && this.provider.getAgentFamily?.() !== "traex")) {
761
+ return this.listCatalogModels();
762
+ }
763
+ const config = await this.readConfig();
764
+ const configuredProviders = isRecord(config.model_providers) ? config.model_providers : {};
765
+ const providerConfig = configuredProviders[requestedProvider];
766
+ let catalog: ModelOption[] = [];
767
+ try {
768
+ catalog = await this.listCatalogModels();
769
+ } catch (error) {
770
+ this.logger.warn({ error, modelProvider: requestedProvider },
771
+ "Failed to read the App Server model catalog while discovering Provider models.");
772
+ }
773
+ const configuredDefaultModel = stringValue(config.model_provider)?.trim() === requestedProvider
774
+ ? stringValue(config.model)?.trim()
775
+ : undefined;
776
+ try {
777
+ return await fetchProviderModels({
778
+ providerId: requestedProvider,
779
+ config: (isRecord(providerConfig) ? providerConfig : {}) as ProviderModelConfig,
780
+ catalog,
781
+ configuredDefaultModel,
782
+ environmentValue: (name) => this.provider.getEnvironmentVariable?.(name) ?? process.env[name],
783
+ });
784
+ } catch (error) {
785
+ const fallback = fallbackProviderModel(catalog, fallbackModel, configuredDefaultModel);
786
+ if (!fallback) throw error;
787
+ this.logger.warn({ error, modelProvider: requestedProvider, fallbackModel: fallback.id },
788
+ "Provider model discovery failed; using the current or configured model as a fallback.");
789
+ return [fallback];
790
+ }
791
+ }
792
+
793
+ private async listCatalogModels(): Promise<ModelOption[]> {
626
794
  const response = await (await this.client()).request<{
627
795
  data: Array<{
628
796
  id: string;
@@ -648,12 +816,7 @@ export class CodexRuntime implements AgentRuntime {
648
816
  }
649
817
 
650
818
  async listModelProviders(): Promise<ModelProviderOption[]> {
651
- const response = await (await this.client()).request<{ config?: Record<string, unknown> }>(
652
- "config/read",
653
- {},
654
- CONTROL_REQUEST_TIMEOUT_MS,
655
- );
656
- const config = response.config ?? {};
819
+ const config = await this.readConfig();
657
820
  const defaultProvider = stringValue(config.model_provider)?.trim();
658
821
  const configuredProviders = isRecord(config.model_providers) ? config.model_providers : {};
659
822
  const providers = new Map<string, ModelProviderOption>();
@@ -666,6 +829,13 @@ export class CodexRuntime implements AgentRuntime {
666
829
  ...(id === defaultProvider ? { isDefault: true } : {}),
667
830
  });
668
831
  }
832
+ if (this.provider.getAgentFamily?.() === "codex" && !providers.has(BUILT_IN_CODEX_PROVIDER_ID)) {
833
+ providers.set(BUILT_IN_CODEX_PROVIDER_ID, {
834
+ id: BUILT_IN_CODEX_PROVIDER_ID,
835
+ displayName: "OpenAI",
836
+ ...(!defaultProvider ? { isDefault: true } : {}),
837
+ });
838
+ }
669
839
  if (defaultProvider && !providers.has(defaultProvider)) {
670
840
  providers.set(defaultProvider, { id: defaultProvider, isDefault: true });
671
841
  }
@@ -674,6 +844,15 @@ export class CodexRuntime implements AgentRuntime {
674
844
  );
675
845
  }
676
846
 
847
+ private async readConfig(): Promise<Record<string, unknown>> {
848
+ const response = await (await this.client()).request<{ config?: Record<string, unknown> }>(
849
+ "config/read",
850
+ {},
851
+ CONTROL_REQUEST_TIMEOUT_MS,
852
+ );
853
+ return response.config ?? {};
854
+ }
855
+
677
856
  onEvent(listener: (event: RuntimeEvent) => void): () => void {
678
857
  this.listeners.add(listener);
679
858
  return () => this.listeners.delete(listener);
@@ -682,7 +861,7 @@ export class CodexRuntime implements AgentRuntime {
682
861
  async release(options: { force?: boolean } = {}): Promise<RuntimeReleaseResult> {
683
862
  if (this.releaseInFlight) return this.releaseInFlight;
684
863
  const activeSessionIds = [...this.sessions.values()]
685
- .filter((session) => Boolean(session.activeTurnId))
864
+ .filter((session) => Boolean(session.activeTurnId) || this.sessionOperations.has(session.localSessionId))
686
865
  .map((session) => session.localSessionId);
687
866
  if (activeSessionIds.length > 0 && options.force !== true) {
688
867
  return { status: "busy", activeSessionIds };
@@ -807,6 +986,18 @@ export class CodexRuntime implements AgentRuntime {
807
986
  turnId: mapped.turnId,
808
987
  lastTokens: mapped.lastTokens,
809
988
  cumulativeTokens: mapped.cumulativeTokens,
989
+ contextTokens: mapped.contextTokens,
990
+ });
991
+ } else if (mapped.kind === "context_compaction") {
992
+ this.emit({
993
+ type: "context_compaction",
994
+ sessionId,
995
+ turnId: mapped.turnId,
996
+ phase: mapped.phase,
997
+ compactionId: mapped.compactionId,
998
+ timestampMs: mapped.timestampMs,
999
+ turnCount: session.turnCount,
1000
+ storageBytes: rolloutStorageBytes(session.rolloutPath),
810
1001
  });
811
1002
  } else if (mapped.kind === "agent_message_phase") {
812
1003
  session.messagePhases.set(mapped.itemId, mapped.phase);
@@ -922,12 +1113,13 @@ export class CodexRuntime implements AgentRuntime {
922
1113
  if (input.reasoningEffort) return input.reasoningEffort;
923
1114
  if (response.reasoningEffort) return response.reasoningEffort;
924
1115
  const model = input.model ?? response.model;
925
- const models = await this.listModels();
1116
+ const models = await this.listModels(input.modelProvider ?? response.modelProvider, model);
926
1117
  return models.find((item) => item.id === model)?.defaultReasoningEffort
927
1118
  ?? models.find((item) => item.isDefault)?.defaultReasoningEffort;
928
1119
  }
929
1120
 
930
1121
  private async ensureSessionResumed(session: CodexSession, client: AppServerClient): Promise<void> {
1122
+ if (session.settingsRecoveryError) throw new Error(session.settingsRecoveryError);
931
1123
  if (!session.needsResume) return;
932
1124
  await this.resumeAppServerSession(session, client);
933
1125
  session.needsResume = false;
@@ -947,13 +1139,16 @@ export class CodexRuntime implements AgentRuntime {
947
1139
 
948
1140
  private async synchronizeSessionNow(sessionId: string): Promise<RuntimeSession> {
949
1141
  const session = this.requireSession(sessionId);
950
- const response = await (await this.client()).request<ThreadReadResponse>(
1142
+ const client = await this.client();
1143
+ const response = await client.request<ThreadReadResponse>(
951
1144
  "thread/read",
952
- { threadId: session.remoteSessionId, includeTurns: true },
1145
+ { threadId: session.remoteSessionId, includeTurns: false },
953
1146
  SYNC_REQUEST_TIMEOUT_MS,
954
1147
  );
955
- this.reconcileThreadSnapshot(session, response.thread);
956
- return session;
1148
+ const turns = await this.readLatestThreadTurns(client, session.remoteSessionId, "full");
1149
+ const thread = { ...response.thread, turns };
1150
+ this.reconcileThreadSnapshot(session, thread);
1151
+ return { ...session, remoteSummary: await this.decorateRemoteSession(session.remoteSessionId, remoteSessionSummary(thread)) };
957
1152
  }
958
1153
 
959
1154
  private reconcileThreadSnapshot(session: CodexSession, thread: ThreadReadResponse["thread"]): void {
@@ -1003,6 +1198,11 @@ export class CodexRuntime implements AgentRuntime {
1003
1198
  }
1004
1199
 
1005
1200
  private adoptTurn(session: CodexSession, turnId: string, startedAt: number): void {
1201
+ session.canReplaceEmptyThread = false;
1202
+ if (!session.knownTurnIds.has(turnId)) {
1203
+ session.knownTurnIds.add(turnId);
1204
+ if (session.turnCount !== undefined) session.turnCount += 1;
1205
+ }
1006
1206
  session.activeTurnId = turnId;
1007
1207
  session.activeTurnStartedAt = startedAt;
1008
1208
  session.finalText = "";
@@ -1094,6 +1294,10 @@ export class CodexRuntime implements AgentRuntime {
1094
1294
  generatedImagePaths: [],
1095
1295
  messagePhases: new Map(),
1096
1296
  needsResume: false,
1297
+ canReplaceEmptyThread: !("remoteSessionId" in input),
1298
+ rolloutPath: stringValue(response.thread.path)?.trim() || undefined,
1299
+ turnCount: "remoteSessionId" in input ? undefined : 0,
1300
+ knownTurnIds: new Set(),
1097
1301
  };
1098
1302
  }
1099
1303
 
@@ -1103,6 +1307,17 @@ export class CodexRuntime implements AgentRuntime {
1103
1307
  return session;
1104
1308
  }
1105
1309
 
1310
+ private async runSessionOperation<T>(sessionId: string, operation: () => Promise<T>): Promise<T> {
1311
+ const previous = this.sessionOperations.get(sessionId);
1312
+ const current = (previous ?? Promise.resolve()).catch(() => undefined).then(operation);
1313
+ this.sessionOperations.set(sessionId, current);
1314
+ try {
1315
+ return await current;
1316
+ } finally {
1317
+ if (this.sessionOperations.get(sessionId) === current) this.sessionOperations.delete(sessionId);
1318
+ }
1319
+ }
1320
+
1106
1321
  private requireActiveTurn(sessionId: string, turnId: string): CodexSession {
1107
1322
  const session = this.requireSession(sessionId);
1108
1323
  if (session.activeTurnId !== turnId) throw new Error(`App Server turn is no longer active: ${turnId}`);
@@ -1141,7 +1356,7 @@ export class CodexRuntime implements AgentRuntime {
1141
1356
  }
1142
1357
 
1143
1358
  interface ThreadResponse {
1144
- thread: { id: string; name?: string | null; preview?: string };
1359
+ thread: { id: string; name?: string | null; preview?: string; path?: string | null };
1145
1360
  modelProvider?: string;
1146
1361
  model?: string;
1147
1362
  reasoningEffort?: string | null;
@@ -1172,6 +1387,8 @@ interface CodexThreadSnapshot {
1172
1387
  updatedAt?: number;
1173
1388
  recencyAt?: number | null;
1174
1389
  status?: { type?: string };
1390
+ forkedFromId?: string | null;
1391
+ path?: string | null;
1175
1392
  turns?: CodexTurnSnapshot[];
1176
1393
  }
1177
1394
 
@@ -1240,6 +1457,34 @@ function stringValue(value: unknown): string | undefined {
1240
1457
  return typeof value === "string" ? value : undefined;
1241
1458
  }
1242
1459
 
1460
+ function rolloutStorageBytes(filePath: string | undefined): number | undefined {
1461
+ if (!filePath) return undefined;
1462
+ try {
1463
+ const stats = statSync(filePath);
1464
+ return stats.isFile() ? stats.size : undefined;
1465
+ } catch {
1466
+ return undefined;
1467
+ }
1468
+ }
1469
+
1470
+ function fallbackProviderModel(
1471
+ catalog: ModelOption[],
1472
+ requestedModel?: string,
1473
+ configuredDefaultModel?: string,
1474
+ ): ModelOption | undefined {
1475
+ const id = requestedModel?.trim()
1476
+ || configuredDefaultModel?.trim()
1477
+ || catalog.find((model) => model.isDefault)?.id
1478
+ || catalog[0]?.id;
1479
+ if (!id) return undefined;
1480
+ const catalogModel = catalog.find((model) => model.id === id);
1481
+ return {
1482
+ ...(catalogModel ?? { id, supportedReasoningEfforts: [] }),
1483
+ id,
1484
+ isDefault: true,
1485
+ };
1486
+ }
1487
+
1243
1488
  function turnStartedAt(turn: CodexTurnSnapshot): number {
1244
1489
  return typeof turn.startedAt === "number" ? turn.startedAt * 1_000 : Date.now();
1245
1490
  }
@@ -1265,64 +1510,12 @@ function uniqueStrings(values: string[]): string[] {
1265
1510
  return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
1266
1511
  }
1267
1512
 
1268
- function isUnsupportedExcludeTurnsError(error: unknown): boolean {
1269
- if (!(error instanceof AppServerRequestError) || error.method !== "thread/fork") return false;
1270
- const data = typeof error.data === "string"
1271
- ? error.data
1272
- : error.data === undefined
1273
- ? ""
1274
- : JSON.stringify(error.data);
1275
- const details = `${error.serverMessage} ${data}`;
1276
- if (!details.toLowerCase().replaceAll(/[^a-z]/g, "").includes("excludeturns")) return false;
1277
- return error.code === -32602
1278
- || /unknown|unrecognized|unexpected|unsupported|not supported|experimental/i.test(details);
1279
- }
1280
-
1281
1513
  function isInvalidWorkingDirectoryError(error: unknown): boolean {
1282
1514
  return error instanceof AppServerRequestError
1283
1515
  && error.method === "turn/start"
1284
1516
  && /invalid cwd/i.test(`${error.serverMessage} ${error.data === undefined ? "" : JSON.stringify(error.data)}`);
1285
1517
  }
1286
1518
 
1287
- function isUnsupportedRecencySortError(error: unknown): boolean {
1288
- if (!(error instanceof AppServerRequestError) || error.method !== "thread/list") return false;
1289
- const data = typeof error.data === "string"
1290
- ? error.data
1291
- : error.data === undefined
1292
- ? ""
1293
- : JSON.stringify(error.data);
1294
- const details = `${error.serverMessage} ${data}`;
1295
- return /recency_at/i.test(details)
1296
- && /unknown|unrecognized|unexpected|unsupported|not supported|invalid/i.test(details);
1297
- }
1298
-
1299
- function isPaginatedThreadReadError(error: unknown): boolean {
1300
- const details = appServerErrorDetails(error);
1301
- return /paginated threads?[^\n]*do(?:es)? not support[^\n]*thread\/read[^\n]*includeTurns\s*=\s*true/i.test(details);
1302
- }
1303
-
1304
- function isUnsupportedThreadTurnsListError(error: unknown): boolean {
1305
- if (!(error instanceof AppServerRequestError) || error.method !== "thread/turns/list") return false;
1306
- return error.code === -32601
1307
- || /method not found|unknown method|unsupported|not supported/i.test(appServerErrorDetails(error));
1308
- }
1309
-
1310
- function isUnmaterializedThreadReadError(error: unknown): boolean {
1311
- const details = appServerErrorDetails(error);
1312
- return /thread[^\n]*(?:not materialized|not loaded)[^\n]*includeTurns[^\n]*(?:unavailable|before first user message)/i
1313
- .test(details);
1314
- }
1315
-
1316
- function appServerErrorDetails(error: unknown): string {
1317
- if (!(error instanceof AppServerRequestError)) return error instanceof Error ? error.message : String(error);
1318
- const data = typeof error.data === "string"
1319
- ? error.data
1320
- : error.data === undefined
1321
- ? ""
1322
- : JSON.stringify(error.data);
1323
- return `${error.message} ${error.serverMessage} ${data}`;
1324
- }
1325
-
1326
1519
  function remoteSessionSummary(thread: CodexThreadSnapshot): RemoteSessionSummary {
1327
1520
  const lastTurn = thread.turns?.at(-1);
1328
1521
  const lastCompletedTurn = [...(thread.turns ?? [])]