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

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 (63) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +13 -0
  3. package/README.zh.md +9 -0
  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/CodexLocalActivityDetector.js +32 -10
  11. package/dist/codex/CodexLocalActivityDetector.js.map +1 -1
  12. package/dist/codex/CodexProcessManager.js +53 -7
  13. package/dist/codex/CodexProcessManager.js.map +1 -1
  14. package/dist/codex/CodexRuntime.js +225 -164
  15. package/dist/codex/CodexRuntime.js.map +1 -1
  16. package/dist/codex/CodexVersion.js +18 -0
  17. package/dist/codex/CodexVersion.js.map +1 -0
  18. package/dist/codex/ProviderSwitch.js +32 -0
  19. package/dist/codex/ProviderSwitch.js.map +1 -0
  20. package/dist/feishu/CardRenderer.js +36 -11
  21. package/dist/feishu/CardRenderer.js.map +1 -1
  22. package/dist/feishu/FeishuConnector.js +17 -5
  23. package/dist/feishu/FeishuConnector.js.map +1 -1
  24. package/dist/feishu/InboundText.js +11 -0
  25. package/dist/feishu/InboundText.js.map +1 -1
  26. package/dist/feishu/MergedForwardMessage.js +17 -5
  27. package/dist/feishu/MergedForwardMessage.js.map +1 -1
  28. package/dist/index.js +1 -1
  29. package/dist/index.js.map +1 -1
  30. package/dist/local-files/LocalFileViewerServer.js +14 -4
  31. package/dist/local-files/LocalFileViewerServer.js.map +1 -1
  32. package/dist/local-files/MarkdownFileLinks.js +65 -0
  33. package/dist/local-files/MarkdownFileLinks.js.map +1 -0
  34. package/dist/proxy/ProxySessionController.js +399 -257
  35. package/dist/proxy/ProxySessionController.js.map +1 -1
  36. package/dist/state/StateStore.js +58 -65
  37. package/dist/state/StateStore.js.map +1 -1
  38. package/dist/utils/semanticVersion.js +39 -0
  39. package/dist/utils/semanticVersion.js.map +1 -0
  40. package/docs/technical-reference.md +5 -3
  41. package/docs/technical-reference.zh.md +5 -3
  42. package/npm-shrinkwrap.json +2 -2
  43. package/package.json +1 -1
  44. package/skills/agent-bot/SKILL.md +7 -1
  45. package/src/cli/AgentPrerequisites.ts +14 -40
  46. package/src/cli/DefaultAgentSelection.ts +1 -1
  47. package/src/cli.ts +13 -0
  48. package/src/codex/CodexLocalActivityDetector.ts +41 -9
  49. package/src/codex/CodexProcessManager.ts +44 -7
  50. package/src/codex/CodexRuntime.ts +244 -195
  51. package/src/codex/CodexVersion.ts +18 -0
  52. package/src/codex/ProviderSwitch.ts +36 -0
  53. package/src/feishu/CardRenderer.ts +37 -27
  54. package/src/feishu/FeishuConnector.ts +15 -5
  55. package/src/feishu/InboundText.ts +11 -0
  56. package/src/feishu/MergedForwardMessage.ts +15 -5
  57. package/src/index.ts +1 -1
  58. package/src/local-files/LocalFileViewerServer.ts +14 -4
  59. package/src/local-files/MarkdownFileLinks.ts +64 -0
  60. package/src/proxy/ProxySessionController.ts +369 -238
  61. package/src/runtime/types.ts +15 -2
  62. package/src/state/StateStore.ts +77 -90
  63. package/src/utils/semanticVersion.ts +28 -0
@@ -11,6 +11,7 @@ import type {
11
11
  RemoteSessionActivity,
12
12
  RemoteSessionPage,
13
13
  RemoteSessionSummary,
14
+ RemoteTurnPage,
14
15
  ResumeRuntimeSessionInput,
15
16
  RuntimeGoal,
16
17
  RuntimeGoalUpdate,
@@ -28,6 +29,7 @@ import { mapCodexNotification } from "./CodexEventMapper.js";
28
29
  import { CodexLocalActivityDetector } from "./CodexLocalActivityDetector.js";
29
30
  import { detectProjectlessWorkspace } from "./ProjectlessWorkspace.js";
30
31
  import { threadWriterLockPath } from "./ThreadWriterProcess.js";
32
+ import { assertProviderSettingsApplied, PROVIDER_SWITCH_BUSY, providerSwitchFailure } from "./ProviderSwitch.js";
31
33
 
32
34
  const WINDOWS_SCREENSHOT_DEVELOPER_INSTRUCTIONS = [
33
35
  "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 +43,13 @@ const WINDOWS_SCREENSHOT_DEVELOPER_INSTRUCTIONS = [
41
43
  const SESSION_REQUEST_TIMEOUT_MS = 60_000;
42
44
  const CONTROL_REQUEST_TIMEOUT_MS = 10_000;
43
45
  const SYNC_REQUEST_TIMEOUT_MS = 5_000;
44
- const THREAD_TURN_PAGE_SIZE = 100;
45
46
  const FORK_SOURCE_TURN_PAGE_SIZE = 20;
46
47
  const FORK_SOURCE_REQUEST_TIMEOUT_MS = 15_000;
47
48
  const USER_RESUMABLE_THREAD_SOURCE_KINDS = ["cli", "vscode", "exec", "appServer"] as const;
49
+ const BUILT_IN_CODEX_PROVIDER_ID = "openai";
48
50
  // A timed-out fork keeps running in App Server and can create an orphan thread.
49
51
  // Wait for its response; connection closure still rejects the request.
50
52
  const FORK_REQUEST_TIMEOUT_MS = 0;
51
- type ThreadListSortKey = "recency_at" | "updated_at";
52
53
 
53
54
  export interface AppServerClient {
54
55
  request<T = unknown>(method: string, params?: unknown, timeoutMs?: number): Promise<T>;
@@ -62,6 +63,7 @@ export interface AppServerClient {
62
63
 
63
64
  export interface AppServerClientProvider {
64
65
  getClient(): Promise<AppServerClient>;
66
+ getAgentFamily?(): "codex" | "traex" | undefined;
65
67
  getProcessInfo?(): AgentProcessInfo;
66
68
  getCodexHome?(): string;
67
69
  onDisconnect?(listener: (error: Error) => void): () => void;
@@ -76,6 +78,8 @@ interface CodexSession extends RuntimeSession {
76
78
  generatedImagePaths: string[];
77
79
  messagePhases: Map<string, "commentary" | "final_answer">;
78
80
  needsResume: boolean;
81
+ canReplaceEmptyThread: boolean;
82
+ settingsRecoveryError?: string;
79
83
  }
80
84
 
81
85
  interface PendingApproval {
@@ -93,9 +97,9 @@ export class CodexRuntime implements AgentRuntime {
93
97
  private unsubscribe?: () => void;
94
98
  private readonly unsubscribeDisconnect?: () => void;
95
99
  private readonly sessionSyncs = new Map<string, Promise<RuntimeSession>>();
100
+ private readonly sessionOperations = new Map<string, Promise<unknown>>();
96
101
  private readonly localActivityDetector?: CodexLocalActivityDetector;
97
102
  private readonly codexHome?: string;
98
- private threadListSortKey: ThreadListSortKey = "recency_at";
99
103
  private releaseInFlight?: Promise<RuntimeReleaseResult>;
100
104
 
101
105
  constructor(
@@ -120,6 +124,10 @@ export class CodexRuntime implements AgentRuntime {
120
124
  }
121
125
 
122
126
  async createSession(input: CreateRuntimeSessionInput): Promise<RuntimeSession> {
127
+ return this.runSessionOperation(input.localSessionId, () => this.createSessionNow(input));
128
+ }
129
+
130
+ private async createSessionNow(input: CreateRuntimeSessionInput): Promise<RuntimeSession> {
123
131
  const client = await this.client();
124
132
  const response = await client.request<ThreadResponse>("thread/start", {
125
133
  cwd: input.cwd,
@@ -144,6 +152,10 @@ export class CodexRuntime implements AgentRuntime {
144
152
  }
145
153
 
146
154
  async resumeSession(input: ResumeRuntimeSessionInput): Promise<RuntimeSession> {
155
+ return this.runSessionOperation(input.localSessionId, () => this.resumeSessionNow(input));
156
+ }
157
+
158
+ private async resumeSessionNow(input: ResumeRuntimeSessionInput): Promise<RuntimeSession> {
147
159
  const client = await this.client();
148
160
  const response = await client.request<ThreadResponse>("thread/resume", {
149
161
  threadId: input.remoteSessionId,
@@ -161,6 +173,10 @@ export class CodexRuntime implements AgentRuntime {
161
173
  }
162
174
 
163
175
  async forkSession(input: ForkRuntimeSessionInput): Promise<RuntimeSession> {
176
+ return this.runSessionOperation(input.localSessionId, () => this.forkSessionNow(input));
177
+ }
178
+
179
+ private async forkSessionNow(input: ForkRuntimeSessionInput): Promise<RuntimeSession> {
164
180
  const client = await this.client();
165
181
  const forkParams = {
166
182
  threadId: input.remoteSessionId,
@@ -172,20 +188,10 @@ export class CodexRuntime implements AgentRuntime {
172
188
  ...threadLifecycleParams(input.cwd),
173
189
  ...permissionParams(input.permissionMode),
174
190
  };
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
- }
191
+ const response = await client.request<ThreadResponse>("thread/fork", {
192
+ ...forkParams,
193
+ excludeTurns: true,
194
+ }, FORK_REQUEST_TIMEOUT_MS);
189
195
  const requestedTitle = normalizeTaskTitle(input.title);
190
196
  if (requestedTitle) {
191
197
  await client.request("thread/name/set", {
@@ -201,9 +207,14 @@ export class CodexRuntime implements AgentRuntime {
201
207
  }
202
208
 
203
209
  async startTurn(sessionId: string, prompt: RuntimePrompt): Promise<string> {
210
+ return this.runSessionOperation(sessionId, () => this.startTurnNow(sessionId, prompt));
211
+ }
212
+
213
+ private async startTurnNow(sessionId: string, prompt: RuntimePrompt): Promise<string> {
204
214
  const session = this.requireSession(sessionId);
205
215
  const client = await this.client();
206
216
  await this.ensureSessionResumed(session, client);
217
+ session.canReplaceEmptyThread = false;
207
218
  const start = () => client.request<{ turn: { id: string } }>("turn/start", {
208
219
  threadId: session.remoteSessionId,
209
220
  input: codexUserInput(prompt),
@@ -249,28 +260,13 @@ export class CodexRuntime implements AgentRuntime {
249
260
  limit?: number;
250
261
  } = {}): Promise<RemoteSessionPage> {
251
262
  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
- }
263
+ const response = await this.requestThreadList(client, input);
264
264
  const sessions = await Promise.all(response.data.map(async (thread) => {
265
265
  const listed = remoteSessionSummary(thread);
266
266
  if (listed.status === "active" || listed.lastTurnStatus === "inProgress") return listed;
267
267
  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));
268
+ const turns = await this.readLatestThreadTurns(client, thread.id, "summary");
269
+ return mergeRemoteSessionSummary(listed, remoteSessionSummary({ ...thread, turns }));
274
270
  } catch {
275
271
  return listed;
276
272
  }
@@ -287,14 +283,13 @@ export class CodexRuntime implements AgentRuntime {
287
283
  private requestThreadList(
288
284
  client: AppServerClient,
289
285
  input: { searchTerm?: string; cursor?: string; limit?: number },
290
- sortKey: ThreadListSortKey,
291
286
  ): Promise<ThreadListResponse> {
292
287
  return client.request<ThreadListResponse>(
293
288
  "thread/list",
294
289
  {
295
290
  cursor: input.cursor,
296
291
  limit: input.limit ?? 20,
297
- sortKey,
292
+ sortKey: this.provider.getAgentFamily?.() === "traex" ? "updated_at" : "recency_at",
298
293
  sortDirection: "desc",
299
294
  sourceKinds: USER_RESUMABLE_THREAD_SOURCE_KINDS,
300
295
  archived: false,
@@ -304,10 +299,32 @@ export class CodexRuntime implements AgentRuntime {
304
299
  );
305
300
  }
306
301
 
307
- async readRemoteSession(remoteSessionId: string): Promise<RemoteSessionSummary> {
302
+ async readRemoteSession(
303
+ remoteSessionId: string,
304
+ view: "metadata" | "latest" | "latest-full" = "latest",
305
+ ): Promise<RemoteSessionSummary> {
308
306
  const client = await this.client();
309
- const response = await this.readThreadWithTurns(client, remoteSessionId);
310
- return this.decorateRemoteSession(remoteSessionId, remoteSessionSummary(response.thread));
307
+ const response = await client.request<ThreadReadResponse>(
308
+ "thread/read", { threadId: remoteSessionId, includeTurns: false }, SYNC_REQUEST_TIMEOUT_MS,
309
+ );
310
+ const turns = view === "metadata" ? []
311
+ : await this.readLatestThreadTurns(client, remoteSessionId, view === "latest-full" ? "full" : "summary");
312
+ return this.decorateRemoteSession(remoteSessionId, remoteSessionSummary({ ...response.thread, turns }));
313
+ }
314
+
315
+ async listRemoteTurnSummaries(
316
+ remoteSessionId: string,
317
+ input: { cursor?: string; limit: number },
318
+ ): Promise<RemoteTurnPage> {
319
+ const response = await this.readTurnPage(await this.client(), remoteSessionId, {
320
+ cursor: input.cursor,
321
+ limit: Math.max(1, Math.min(100, Math.trunc(input.limit))),
322
+ itemsView: "summary",
323
+ });
324
+ return {
325
+ turns: remoteSessionSummary({ id: remoteSessionId, turns: response.data }).completedTurns ?? [],
326
+ nextCursor: response.nextCursor ?? undefined,
327
+ };
311
328
  }
312
329
 
313
330
  async readRemoteForkSource(remoteSessionId: string): Promise<RemoteSessionSummary> {
@@ -317,17 +334,7 @@ export class CodexRuntime implements AgentRuntime {
317
334
  { threadId: remoteSessionId, includeTurns: false },
318
335
  FORK_SOURCE_REQUEST_TIMEOUT_MS,
319
336
  );
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
- }
337
+ const turns = await this.listRecentThreadTurnsThroughCompleted(client, remoteSessionId);
331
338
  return this.decorateRemoteSession(remoteSessionId, remoteSessionSummary({
332
339
  ...metadata.thread,
333
340
  turns,
@@ -355,17 +362,9 @@ export class CodexRuntime implements AgentRuntime {
355
362
  const seenCursors = new Set<string>();
356
363
  let cursor: string | undefined;
357
364
  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
- );
365
+ const response = await this.readTurnPage(client, remoteSessionId, {
366
+ cursor, limit: FORK_SOURCE_TURN_PAGE_SIZE, itemsView: "summary",
367
+ }, FORK_SOURCE_REQUEST_TIMEOUT_MS);
369
368
  turns.push(...response.data);
370
369
  const completedIndex = turns.findIndex((turn) => turn.status === "completed");
371
370
  if (completedIndex >= 0) {
@@ -387,69 +386,34 @@ export class CodexRuntime implements AgentRuntime {
387
386
  return turns.reverse();
388
387
  }
389
388
 
390
- private async readThreadWithTurns(
389
+ private async readLatestThreadTurns(
391
390
  client: AppServerClient,
392
391
  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
- }
392
+ itemsView: "summary" | "full",
393
+ ): Promise<CodexTurnSnapshot[]> {
394
+ const response = await this.readTurnPage(client, remoteSessionId, { limit: 1, itemsView });
395
+ return response.data;
418
396
  }
419
397
 
420
- private async listThreadTurns(
398
+ private async readTurnPage(
421
399
  client: AppServerClient,
422
400
  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;
401
+ input: { cursor?: string; limit: number; itemsView: "summary" | "full" },
402
+ timeoutMs = SYNC_REQUEST_TIMEOUT_MS,
403
+ ): Promise<ThreadTurnsListResponse> {
404
+ try {
405
+ return await client.request<ThreadTurnsListResponse>("thread/turns/list", {
406
+ threadId: remoteSessionId,
407
+ ...(input.cursor ? { cursor: input.cursor } : {}),
408
+ limit: input.limit, sortDirection: "desc", itemsView: input.itemsView,
409
+ }, timeoutMs);
410
+ } catch (error) {
411
+ if (error instanceof AppServerRequestError && error.method === "thread/turns/list"
412
+ && /not materialized yet; thread\/turns\/list is unavailable before first user message/i.test(error.serverMessage)) {
413
+ return { data: [], nextCursor: null };
448
414
  }
449
- seenCursors.add(nextCursor);
450
- cursor = nextCursor;
451
- } while (cursor);
452
- return turns;
415
+ throw error;
416
+ }
453
417
  }
454
418
 
455
419
  async inspectRemoteSessionActivity(remoteSessionId: string): Promise<RemoteSessionActivity> {
@@ -477,7 +441,7 @@ export class CodexRuntime implements AgentRuntime {
477
441
  async synchronizeSession(sessionId: string): Promise<RuntimeSession> {
478
442
  const existing = this.sessionSyncs.get(sessionId);
479
443
  if (existing) return existing;
480
- const synchronization = this.synchronizeSessionNow(sessionId);
444
+ const synchronization = this.runSessionOperation(sessionId, () => this.synchronizeSessionNow(sessionId));
481
445
  this.sessionSyncs.set(sessionId, synchronization);
482
446
  try {
483
447
  return await synchronization;
@@ -513,8 +477,10 @@ export class CodexRuntime implements AgentRuntime {
513
477
  }
514
478
 
515
479
  async closeSession(sessionId: string): Promise<void> {
516
- const session = this.requireSession(sessionId);
517
- await this.archiveRemoteSession(session.remoteSessionId);
480
+ await this.runSessionOperation(sessionId, async () => {
481
+ const session = this.requireSession(sessionId);
482
+ await this.archiveRemoteSession(session.remoteSessionId);
483
+ });
518
484
  }
519
485
 
520
486
  async archiveRemoteSession(remoteSessionId: string): Promise<void> {
@@ -529,6 +495,10 @@ export class CodexRuntime implements AgentRuntime {
529
495
  }
530
496
 
531
497
  async setTitle(sessionId: string, title: string): Promise<void> {
498
+ return this.runSessionOperation(sessionId, () => this.setTitleNow(sessionId, title));
499
+ }
500
+
501
+ private async setTitleNow(sessionId: string, title: string): Promise<void> {
532
502
  const session = this.requireSession(sessionId);
533
503
  const normalizedTitle = normalizeTaskTitle(title);
534
504
  if (!normalizedTitle) throw new Error("任务标题不能为空。");
@@ -541,6 +511,10 @@ export class CodexRuntime implements AgentRuntime {
541
511
  }
542
512
 
543
513
  async getGoal(sessionId: string): Promise<RuntimeGoal | undefined> {
514
+ return this.runSessionOperation(sessionId, () => this.getGoalNow(sessionId));
515
+ }
516
+
517
+ private async getGoalNow(sessionId: string): Promise<RuntimeGoal | undefined> {
544
518
  const session = this.requireSession(sessionId);
545
519
  const client = await this.client();
546
520
  await this.ensureSessionResumed(session, client);
@@ -553,9 +527,14 @@ export class CodexRuntime implements AgentRuntime {
553
527
  }
554
528
 
555
529
  async setGoal(sessionId: string, update: RuntimeGoalUpdate): Promise<RuntimeGoal> {
530
+ return this.runSessionOperation(sessionId, () => this.setGoalNow(sessionId, update));
531
+ }
532
+
533
+ private async setGoalNow(sessionId: string, update: RuntimeGoalUpdate): Promise<RuntimeGoal> {
556
534
  const session = this.requireSession(sessionId);
557
535
  const client = await this.client();
558
536
  await this.ensureSessionResumed(session, client);
537
+ session.canReplaceEmptyThread = false;
559
538
  const response = await client.request<{ goal: RuntimeGoal }>(
560
539
  "thread/goal/set",
561
540
  { threadId: session.remoteSessionId, ...update },
@@ -565,6 +544,10 @@ export class CodexRuntime implements AgentRuntime {
565
544
  }
566
545
 
567
546
  async clearGoal(sessionId: string): Promise<boolean> {
547
+ return this.runSessionOperation(sessionId, () => this.clearGoalNow(sessionId));
548
+ }
549
+
550
+ private async clearGoalNow(sessionId: string): Promise<boolean> {
568
551
  const session = this.requireSession(sessionId);
569
552
  const client = await this.client();
570
553
  await this.ensureSessionResumed(session, client);
@@ -577,39 +560,132 @@ export class CodexRuntime implements AgentRuntime {
577
560
  }
578
561
 
579
562
  async setModel(sessionId: string, model: string): Promise<void> {
580
- this.requireSession(sessionId).model = model;
563
+ await this.runSessionOperation(sessionId, async () => { this.requireSession(sessionId).model = model; });
581
564
  }
582
565
 
583
566
  async setReasoningEffort(sessionId: string, effort: string): Promise<void> {
584
- this.requireSession(sessionId).reasoningEffort = effort;
567
+ await this.runSessionOperation(sessionId, async () => { this.requireSession(sessionId).reasoningEffort = effort; });
585
568
  }
586
569
 
587
570
  async setPermissionMode(sessionId: string, mode: PermissionMode): Promise<void> {
588
- this.requireSession(sessionId).permissionMode = mode;
571
+ await this.runSessionOperation(sessionId, async () => { this.requireSession(sessionId).permissionMode = mode; });
589
572
  }
590
573
 
591
574
  async setExecutionSettings(
592
575
  sessionId: string,
593
576
  settings: RuntimeExecutionSettings,
577
+ persist?: (session: RuntimeSession) => Promise<void>,
578
+ ): Promise<RuntimeSession> {
579
+ return this.runSessionOperation(sessionId, () => this.setExecutionSettingsNow(sessionId, settings, persist));
580
+ }
581
+
582
+ private async setExecutionSettingsNow(
583
+ sessionId: string,
584
+ settings: RuntimeExecutionSettings,
585
+ persist?: (session: RuntimeSession) => Promise<void>,
594
586
  ): Promise<RuntimeSession> {
595
587
  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;
588
+ if (session.activeTurnId) throw new Error(PROVIDER_SWITCH_BUSY);
589
+ const client = await this.client();
590
+ const metadata = await client.request<ThreadReadResponse>("thread/read", {
591
+ threadId: session.remoteSessionId, includeTurns: false,
592
+ }, SYNC_REQUEST_TIMEOUT_MS);
593
+ const localActive = await this.localActivityDetector?.activeThreads([session.remoteSessionId]);
594
+ if (metadata.thread.status?.type === "active" || localActive?.has(session.remoteSessionId) || session.activeTurnId) {
595
+ throw new Error(PROVIDER_SWITCH_BUSY);
596
+ }
597
+ let empty = false;
598
+ if (session.canReplaceEmptyThread && !metadata.thread.forkedFromId && !metadata.thread.preview?.trim()) {
599
+ try {
600
+ const turns = await this.readLatestThreadTurns(client, session.remoteSessionId, "summary");
601
+ empty = turns.length === 0;
602
+ if (!empty) session.canReplaceEmptyThread = false;
603
+ } catch (error) {
604
+ if (!(error instanceof AppServerRequestError) || error.method !== "thread/turns/list"
605
+ || !/missing source rollout|no rollout found/iu.test(error.serverMessage)) throw error;
606
+ empty = true;
607
+ }
608
+ }
609
+ const previous = { ...session };
610
+ let replacementId: string | undefined;
611
+ let detached = false;
612
+ try {
613
+ const params = {
614
+ cwd: session.cwd, modelProvider: settings.modelProvider, model: settings.model,
615
+ ...threadLifecycleParams(session.cwd), ...permissionParams(settings.permissionMode),
616
+ };
617
+ if (!empty) {
618
+ await this.detachThreadForSettings(client, session);
619
+ detached = true;
620
+ }
621
+ const response = empty
622
+ ? await client.request<ThreadResponse>("thread/start", {
623
+ ...params, threadSource: "user", allowProviderModelFallback: false,
624
+ }, SESSION_REQUEST_TIMEOUT_MS)
625
+ : await client.request<ThreadResponse>("thread/resume", {
626
+ ...params, threadId: session.remoteSessionId, excludeTurns: true,
627
+ }, SESSION_REQUEST_TIMEOUT_MS);
628
+ if (empty) replacementId = response.thread.id;
629
+ assertProviderSettingsApplied(response, settings);
630
+ if (!empty && response.thread.id !== session.remoteSessionId) throw new Error("App Server 返回了不同的任务 ID,未保存设置。");
631
+ if (empty && session.title) await client.request("thread/name/set", {
632
+ threadId: response.thread.id, name: session.title,
633
+ }, SESSION_REQUEST_TIMEOUT_MS);
634
+ const candidate = { ...session, ...settings, remoteSessionId: response.thread.id,
635
+ needsResume: false, settingsRecoveryError: undefined };
636
+ await persist?.(candidate);
637
+ Object.assign(session, candidate);
638
+ } catch (error) {
639
+ if (replacementId) await this.discardUnusedThread(client, replacementId);
640
+ let recovery = "原任务设置未更改。";
641
+ if (detached) {
642
+ try {
643
+ await this.detachThreadForSettings(client, previous);
644
+ const restored = await client.request<ThreadResponse>("thread/resume", {
645
+ threadId: previous.remoteSessionId, excludeTurns: true, cwd: previous.cwd,
646
+ modelProvider: previous.modelProvider, model: previous.model,
647
+ ...threadLifecycleParams(previous.cwd), ...permissionParams(previous.permissionMode),
648
+ }, SESSION_REQUEST_TIMEOUT_MS);
649
+ if (previous.modelProvider && previous.model) assertProviderSettingsApplied(restored, {
650
+ modelProvider: previous.modelProvider, model: previous.model,
651
+ });
652
+ if (restored.thread.id !== previous.remoteSessionId) throw new Error("恢复时返回了不同的任务 ID。");
653
+ session.needsResume = false;
654
+ session.settingsRecoveryError = undefined;
655
+ } catch (restoreError) {
656
+ this.logger.warn({ error: restoreError, sessionId }, "Failed to restore Provider after a rejected settings change.");
657
+ recovery = "原设置仍保留,但远端恢复失败;请重新切换 Provider,确认成功后再发送消息。";
658
+ session.settingsRecoveryError = recovery;
659
+ session.needsResume = true;
660
+ }
661
+ }
662
+ throw new Error(`Provider 切换失败:${providerSwitchFailure(error)} ${recovery}`, { cause: error });
663
+ }
664
+ if (replacementId) await this.discardUnusedThread(client, previous.remoteSessionId);
610
665
  return session;
611
666
  }
612
667
 
668
+ private async detachThreadForSettings(client: AppServerClient, session: CodexSession): Promise<void> {
669
+ const current = await client.request<ThreadReadResponse>("thread/read", {
670
+ threadId: session.remoteSessionId, includeTurns: false,
671
+ }, SYNC_REQUEST_TIMEOUT_MS);
672
+ if (session.activeTurnId || current.thread.status?.type === "active") throw new Error(PROVIDER_SWITCH_BUSY);
673
+ const result = await client.request<{ status: string }>("thread/unsubscribe", {
674
+ threadId: session.remoteSessionId,
675
+ }, CONTROL_REQUEST_TIMEOUT_MS);
676
+ if (result.status !== "unsubscribed" && result.status !== "notLoaded") {
677
+ throw new Error("当前任务仍由其他客户端加载,无法安全切换 Provider。请先在原客户端释放任务。");
678
+ }
679
+ }
680
+
681
+ private async discardUnusedThread(client: AppServerClient, threadId: string): Promise<void> {
682
+ try {
683
+ await client.request("thread/unsubscribe", { threadId }, CONTROL_REQUEST_TIMEOUT_MS);
684
+ } catch (error) {
685
+ this.logger.warn({ error, threadId }, "Failed to release an unused empty Thread after changing Provider.");
686
+ }
687
+ }
688
+
613
689
  async respondToApproval(
614
690
  sessionId: string,
615
691
  requestId: string,
@@ -666,6 +742,13 @@ export class CodexRuntime implements AgentRuntime {
666
742
  ...(id === defaultProvider ? { isDefault: true } : {}),
667
743
  });
668
744
  }
745
+ if (this.provider.getAgentFamily?.() === "codex" && !providers.has(BUILT_IN_CODEX_PROVIDER_ID)) {
746
+ providers.set(BUILT_IN_CODEX_PROVIDER_ID, {
747
+ id: BUILT_IN_CODEX_PROVIDER_ID,
748
+ displayName: "OpenAI",
749
+ ...(!defaultProvider ? { isDefault: true } : {}),
750
+ });
751
+ }
669
752
  if (defaultProvider && !providers.has(defaultProvider)) {
670
753
  providers.set(defaultProvider, { id: defaultProvider, isDefault: true });
671
754
  }
@@ -682,7 +765,7 @@ export class CodexRuntime implements AgentRuntime {
682
765
  async release(options: { force?: boolean } = {}): Promise<RuntimeReleaseResult> {
683
766
  if (this.releaseInFlight) return this.releaseInFlight;
684
767
  const activeSessionIds = [...this.sessions.values()]
685
- .filter((session) => Boolean(session.activeTurnId))
768
+ .filter((session) => Boolean(session.activeTurnId) || this.sessionOperations.has(session.localSessionId))
686
769
  .map((session) => session.localSessionId);
687
770
  if (activeSessionIds.length > 0 && options.force !== true) {
688
771
  return { status: "busy", activeSessionIds };
@@ -928,6 +1011,7 @@ export class CodexRuntime implements AgentRuntime {
928
1011
  }
929
1012
 
930
1013
  private async ensureSessionResumed(session: CodexSession, client: AppServerClient): Promise<void> {
1014
+ if (session.settingsRecoveryError) throw new Error(session.settingsRecoveryError);
931
1015
  if (!session.needsResume) return;
932
1016
  await this.resumeAppServerSession(session, client);
933
1017
  session.needsResume = false;
@@ -947,13 +1031,16 @@ export class CodexRuntime implements AgentRuntime {
947
1031
 
948
1032
  private async synchronizeSessionNow(sessionId: string): Promise<RuntimeSession> {
949
1033
  const session = this.requireSession(sessionId);
950
- const response = await (await this.client()).request<ThreadReadResponse>(
1034
+ const client = await this.client();
1035
+ const response = await client.request<ThreadReadResponse>(
951
1036
  "thread/read",
952
- { threadId: session.remoteSessionId, includeTurns: true },
1037
+ { threadId: session.remoteSessionId, includeTurns: false },
953
1038
  SYNC_REQUEST_TIMEOUT_MS,
954
1039
  );
955
- this.reconcileThreadSnapshot(session, response.thread);
956
- return session;
1040
+ const turns = await this.readLatestThreadTurns(client, session.remoteSessionId, "full");
1041
+ const thread = { ...response.thread, turns };
1042
+ this.reconcileThreadSnapshot(session, thread);
1043
+ return { ...session, remoteSummary: await this.decorateRemoteSession(session.remoteSessionId, remoteSessionSummary(thread)) };
957
1044
  }
958
1045
 
959
1046
  private reconcileThreadSnapshot(session: CodexSession, thread: ThreadReadResponse["thread"]): void {
@@ -1003,6 +1090,7 @@ export class CodexRuntime implements AgentRuntime {
1003
1090
  }
1004
1091
 
1005
1092
  private adoptTurn(session: CodexSession, turnId: string, startedAt: number): void {
1093
+ session.canReplaceEmptyThread = false;
1006
1094
  session.activeTurnId = turnId;
1007
1095
  session.activeTurnStartedAt = startedAt;
1008
1096
  session.finalText = "";
@@ -1094,6 +1182,7 @@ export class CodexRuntime implements AgentRuntime {
1094
1182
  generatedImagePaths: [],
1095
1183
  messagePhases: new Map(),
1096
1184
  needsResume: false,
1185
+ canReplaceEmptyThread: !("remoteSessionId" in input),
1097
1186
  };
1098
1187
  }
1099
1188
 
@@ -1103,6 +1192,17 @@ export class CodexRuntime implements AgentRuntime {
1103
1192
  return session;
1104
1193
  }
1105
1194
 
1195
+ private async runSessionOperation<T>(sessionId: string, operation: () => Promise<T>): Promise<T> {
1196
+ const previous = this.sessionOperations.get(sessionId);
1197
+ const current = (previous ?? Promise.resolve()).catch(() => undefined).then(operation);
1198
+ this.sessionOperations.set(sessionId, current);
1199
+ try {
1200
+ return await current;
1201
+ } finally {
1202
+ if (this.sessionOperations.get(sessionId) === current) this.sessionOperations.delete(sessionId);
1203
+ }
1204
+ }
1205
+
1106
1206
  private requireActiveTurn(sessionId: string, turnId: string): CodexSession {
1107
1207
  const session = this.requireSession(sessionId);
1108
1208
  if (session.activeTurnId !== turnId) throw new Error(`App Server turn is no longer active: ${turnId}`);
@@ -1172,6 +1272,7 @@ interface CodexThreadSnapshot {
1172
1272
  updatedAt?: number;
1173
1273
  recencyAt?: number | null;
1174
1274
  status?: { type?: string };
1275
+ forkedFromId?: string | null;
1175
1276
  turns?: CodexTurnSnapshot[];
1176
1277
  }
1177
1278
 
@@ -1265,64 +1366,12 @@ function uniqueStrings(values: string[]): string[] {
1265
1366
  return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
1266
1367
  }
1267
1368
 
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
1369
  function isInvalidWorkingDirectoryError(error: unknown): boolean {
1282
1370
  return error instanceof AppServerRequestError
1283
1371
  && error.method === "turn/start"
1284
1372
  && /invalid cwd/i.test(`${error.serverMessage} ${error.data === undefined ? "" : JSON.stringify(error.data)}`);
1285
1373
  }
1286
1374
 
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
1375
  function remoteSessionSummary(thread: CodexThreadSnapshot): RemoteSessionSummary {
1327
1376
  const lastTurn = thread.turns?.at(-1);
1328
1377
  const lastCompletedTurn = [...(thread.turns ?? [])]