@keyou007/agent-bot 0.1.24-alpha.5 → 0.1.24-alpha.7

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 (105) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +29 -3
  3. package/README.zh.md +29 -3
  4. package/SECURITY.md +12 -0
  5. package/config.example.yaml +5 -0
  6. package/dist/cli/controlProtocol.js.map +1 -1
  7. package/dist/cli/serverStatus.js +1 -0
  8. package/dist/cli/serverStatus.js.map +1 -1
  9. package/dist/cli.js +6 -1
  10. package/dist/cli.js.map +1 -1
  11. package/dist/codex/CodexEventMapper.js +1 -0
  12. package/dist/codex/CodexEventMapper.js.map +1 -1
  13. package/dist/codex/CodexRuntime.js +247 -120
  14. package/dist/codex/CodexRuntime.js.map +1 -1
  15. package/dist/codex/ProviderSwitch.js +12 -13
  16. package/dist/codex/ProviderSwitch.js.map +1 -1
  17. package/dist/codex/TraePlanEntry.js +70 -0
  18. package/dist/codex/TraePlanEntry.js.map +1 -0
  19. package/dist/config/AgentExecutionDefaultsStore.js +4 -0
  20. package/dist/config/AgentExecutionDefaultsStore.js.map +1 -1
  21. package/dist/config/loadConfig.js +4 -0
  22. package/dist/config/loadConfig.js.map +1 -1
  23. package/dist/config/schema.js +3 -0
  24. package/dist/config/schema.js.map +1 -1
  25. package/dist/feishu/CardRenderer.js +92 -8
  26. package/dist/feishu/CardRenderer.js.map +1 -1
  27. package/dist/feishu/FeishuConnector.js +1 -0
  28. package/dist/feishu/FeishuConnector.js.map +1 -1
  29. package/dist/feishu/FeishuTurnPresenter.js +121 -17
  30. package/dist/feishu/FeishuTurnPresenter.js.map +1 -1
  31. package/dist/index.js +57 -7
  32. package/dist/index.js.map +1 -1
  33. package/dist/local-files/LocalFileViewerServer.js +51 -4
  34. package/dist/local-files/LocalFileViewerServer.js.map +1 -1
  35. package/dist/local-files/TurnPreviewPage.js +7 -2
  36. package/dist/local-files/TurnPreviewPage.js.map +1 -1
  37. package/dist/presentation/TurnStateReducer.js +41 -6
  38. package/dist/presentation/TurnStateReducer.js.map +1 -1
  39. package/dist/proxy/ProxySessionController.js +620 -214
  40. package/dist/proxy/ProxySessionController.js.map +1 -1
  41. package/dist/proxy/SessionOperationQueue.js +14 -0
  42. package/dist/proxy/SessionOperationQueue.js.map +1 -0
  43. package/dist/runtime/AcpRuntimeAdapter.js +4 -2
  44. package/dist/runtime/AcpRuntimeAdapter.js.map +1 -1
  45. package/dist/state/StateStore.js +22 -0
  46. package/dist/state/StateStore.js.map +1 -1
  47. package/dist/state/TurnPreviewJournal.js +19 -7
  48. package/dist/state/TurnPreviewJournal.js.map +1 -1
  49. package/dist/state/migrations.js +6 -0
  50. package/dist/state/migrations.js.map +1 -1
  51. package/dist/supervision/SafeRestartNotifier.js +33 -1
  52. package/dist/supervision/SafeRestartNotifier.js.map +1 -1
  53. package/dist/supervision/SafeRestartScheduler.js +22 -6
  54. package/dist/supervision/SafeRestartScheduler.js.map +1 -1
  55. package/dist/terminal/TerminalCards.js +13 -0
  56. package/dist/terminal/TerminalCards.js.map +1 -0
  57. package/dist/terminal/TerminalCommandTracker.js +74 -0
  58. package/dist/terminal/TerminalCommandTracker.js.map +1 -0
  59. package/dist/terminal/TerminalLinks.js +76 -0
  60. package/dist/terminal/TerminalLinks.js.map +1 -0
  61. package/dist/terminal/TerminalPage.js +212 -0
  62. package/dist/terminal/TerminalPage.js.map +1 -0
  63. package/dist/terminal/TerminalProcess.js +95 -0
  64. package/dist/terminal/TerminalProcess.js.map +1 -0
  65. package/dist/terminal/TerminalServer.js +676 -0
  66. package/dist/terminal/TerminalServer.js.map +1 -0
  67. package/docs/technical-reference.md +48 -0
  68. package/docs/technical-reference.zh.md +48 -0
  69. package/npm-shrinkwrap.json +55 -5
  70. package/package.json +11 -4
  71. package/skills/agent-bot/SKILL.md +11 -2
  72. package/src/cli/controlProtocol.ts +2 -1
  73. package/src/cli/serverStatus.ts +1 -0
  74. package/src/cli.ts +6 -1
  75. package/src/codex/CodexEventMapper.ts +1 -0
  76. package/src/codex/CodexRuntime.ts +244 -128
  77. package/src/codex/ProviderSwitch.ts +11 -12
  78. package/src/codex/TraePlanEntry.ts +63 -0
  79. package/src/config/AgentExecutionDefaultsStore.ts +2 -0
  80. package/src/config/loadConfig.ts +4 -0
  81. package/src/config/schema.ts +5 -1
  82. package/src/feishu/CardRenderer.ts +109 -8
  83. package/src/feishu/FeishuConnector.ts +1 -0
  84. package/src/feishu/FeishuTurnPresenter.ts +108 -16
  85. package/src/feishu/types.ts +1 -0
  86. package/src/index.ts +56 -8
  87. package/src/local-files/LocalFileViewerServer.ts +48 -4
  88. package/src/local-files/TurnPreviewPage.ts +7 -2
  89. package/src/presentation/TurnStateReducer.ts +46 -7
  90. package/src/presentation/turnViewTypes.ts +5 -0
  91. package/src/proxy/ProxySessionController.ts +629 -211
  92. package/src/proxy/SessionOperationQueue.ts +13 -0
  93. package/src/runtime/AcpRuntimeAdapter.ts +5 -3
  94. package/src/runtime/types.ts +8 -3
  95. package/src/state/StateStore.ts +38 -1
  96. package/src/state/TurnPreviewJournal.ts +19 -7
  97. package/src/state/migrations.ts +6 -0
  98. package/src/supervision/SafeRestartNotifier.ts +35 -2
  99. package/src/supervision/SafeRestartScheduler.ts +25 -6
  100. package/src/terminal/TerminalCards.ts +18 -0
  101. package/src/terminal/TerminalCommandTracker.ts +74 -0
  102. package/src/terminal/TerminalLinks.ts +61 -0
  103. package/src/terminal/TerminalPage.ts +213 -0
  104. package/src/terminal/TerminalProcess.ts +93 -0
  105. package/src/terminal/TerminalServer.ts +551 -0
@@ -1,9 +1,11 @@
1
1
  import { statSync } from "node:fs";
2
+ import { setTimeout as delay } from "node:timers/promises";
2
3
  import type { Logger } from "pino";
3
4
  import type {
4
5
  AgentRuntime,
5
6
  AgentProcessInfo,
6
7
  ApprovalDecision,
8
+ ApprovalRequest,
7
9
  CreateRuntimeSessionInput,
8
10
  ConversationTurn,
9
11
  ForkRuntimeSessionInput,
@@ -33,9 +35,10 @@ import { AppServerRequestError } from "./AppServerConnection.js";
33
35
  import { workspaceSandboxFromConfig, workspaceSandboxFromResponse, type TurnSandboxPolicy, type WorkspaceSandboxPolicy } from "./AppServerPermissions.js";
34
36
  import { formatCodexError, mapCodexNotification } from "./CodexEventMapper.js";
35
37
  import { CodexLocalActivityDetector } from "./CodexLocalActivityDetector.js";
38
+ import { hasCompletedTraePlanEntry } from "./TraePlanEntry.js";
36
39
  import { detectProjectlessWorkspace } from "./ProjectlessWorkspace.js";
37
40
  import { threadWriterLockPath } from "./ThreadWriterProcess.js";
38
- import { assertProviderSettingsApplied, PROVIDER_SWITCH_BUSY, providerSwitchFailure } from "./ProviderSwitch.js";
41
+ import { assertProviderSettingsApplied, PROVIDER_SWITCH_BUSY, PROVIDER_SWITCH_EMPTY, ProviderSwitchError, providerSwitchFailure } from "./ProviderSwitch.js";
39
42
  import { fetchProviderModels, type ProviderModelConfig } from "./ProviderModels.js";
40
43
 
41
44
  const WINDOWS_SCREENSHOT_DEVELOPER_INSTRUCTIONS = [
@@ -84,6 +87,7 @@ interface CodexSession extends RuntimeSession {
84
87
  activeTurnStartedAt?: number;
85
88
  terminalTurnIds: Set<string>;
86
89
  modeChangeRequestIds: Set<string>;
90
+ planContinuationTurnId?: string;
87
91
  finalText: string;
88
92
  generatedImagePaths: string[];
89
93
  messagePhases: Map<string, "commentary" | "final_answer">;
@@ -91,7 +95,6 @@ interface CodexSession extends RuntimeSession {
91
95
  unphasedFinalMessageId?: string;
92
96
  needsResume: boolean;
93
97
  workspaceSandboxPolicy?: WorkspaceSandboxPolicy;
94
- canReplaceEmptyThread: boolean;
95
98
  settingsRecoveryError?: string;
96
99
  rolloutPath?: string;
97
100
  turnCount?: number;
@@ -109,7 +112,12 @@ interface PendingModeChange {
109
112
  turnId: string;
110
113
  requestId: string;
111
114
  targetMode: "plan" | "default";
115
+ sourceToolCallId?: string;
112
116
  responding: boolean;
117
+ decision?: ApprovalDecision;
118
+ automatic?: boolean;
119
+ continued?: boolean;
120
+ terminalEvent?: Extract<RuntimeEvent, { type: "turn_completed" }>;
113
121
  }
114
122
 
115
123
  export class CodexRuntime implements AgentRuntime {
@@ -184,6 +192,20 @@ export class CodexRuntime implements AgentRuntime {
184
192
  return this.runSessionOperation(input.localSessionId, () => this.resumeSessionNow(input));
185
193
  }
186
194
 
195
+ async prepareSessionForSettings(input: ResumeRuntimeSessionInput): Promise<RuntimeSession> {
196
+ return this.runSessionOperation(input.localSessionId, async () => {
197
+ const existing = this.sessions.get(input.localSessionId);
198
+ if (existing) return existing;
199
+ const session = this.makeSession(input, {
200
+ thread: { id: input.remoteSessionId, name: input.title },
201
+ modelProvider: input.modelProvider, model: input.model,
202
+ }, input.reasoningEffort);
203
+ session.needsResume = true;
204
+ this.sessions.set(input.localSessionId, session);
205
+ return session;
206
+ });
207
+ }
208
+
187
209
  private async resumeSessionNow(input: ResumeRuntimeSessionInput): Promise<RuntimeSession> {
188
210
  const client = await this.client();
189
211
  const response = await client.request<ThreadResponse>("thread/resume", {
@@ -195,8 +217,16 @@ export class CodexRuntime implements AgentRuntime {
195
217
  ...threadLifecycleParams(input.cwd),
196
218
  ...permissionParams(input.permissionMode),
197
219
  }, SESSION_REQUEST_TIMEOUT_MS);
220
+ if (response.thread.id !== input.remoteSessionId) throw new Error("App Server 返回了不同的任务 ID,未恢复会话。");
198
221
  const reasoningEffort = await this.resolveReasoningEffort(input, response);
199
222
  const session = this.makeSession(input, response, reasoningEffort);
223
+ try {
224
+ assertProviderSettingsApplied(response, { modelProvider: input.modelProvider });
225
+ } catch (error) {
226
+ // Keep the attachment available for an explicit settings repair, but never send to the wrong Provider.
227
+ session.settingsRecoveryError = providerSwitchFailure(error);
228
+ session.needsResume = true;
229
+ }
200
230
  this.sessions.set(input.localSessionId, session);
201
231
  return session;
202
232
  }
@@ -243,13 +273,18 @@ export class CodexRuntime implements AgentRuntime {
243
273
  const session = this.requireSession(sessionId);
244
274
  const client = await this.client();
245
275
  await this.ensureSessionResumed(session, client);
246
- session.canReplaceEmptyThread = false;
247
276
  const start = async () => client.request<{ turn: { id: string } }>("turn/start", {
248
277
  threadId: session.remoteSessionId,
249
278
  input: codexUserInput(prompt),
250
279
  cwd: session.cwd,
251
280
  model: session.model,
252
281
  effort: session.reasoningEffort,
282
+ ...(typeof prompt !== "string" && prompt.collaborationMode ? {
283
+ collaborationMode: {
284
+ mode: prompt.collaborationMode,
285
+ settings: { model: session.model, reasoning_effort: session.reasoningEffort ?? null, developer_instructions: null },
286
+ },
287
+ } : {}),
253
288
  summary: "auto",
254
289
  approvalPolicy: session.permissionMode === "auto" ? "never" : "on-request",
255
290
  sandboxPolicy: await this.turnSandboxPolicy(session, client),
@@ -591,9 +626,10 @@ export class CodexRuntime implements AgentRuntime {
591
626
  .find((session) => session.remoteSessionId === remoteSessionId);
592
627
  const detectedTurnId = activeThreads?.get(remoteSessionId);
593
628
  // A turn started in this process is evidence; a restored activeTurnId alone is not.
629
+ const observedTurnId = runtimeSession?.activeTurnStartedAt !== undefined ? runtimeSession.activeTurnId : undefined;
594
630
  const active = status === "active" || Boolean(activeThreads?.has(remoteSessionId))
595
- || Boolean(runtimeSession?.activeTurnId && runtimeSession.activeTurnStartedAt !== undefined);
596
- const activeTurnId = detectedTurnId ?? runtimeSession?.activeTurnId;
631
+ || Boolean(observedTurnId);
632
+ const activeTurnId = detectedTurnId ?? observedTurnId;
597
633
  return {
598
634
  active,
599
635
  ...(active && activeTurnId ? { activeTurnId } : {}),
@@ -627,6 +663,12 @@ export class CodexRuntime implements AgentRuntime {
627
663
  }
628
664
 
629
665
  async interruptRemoteTurn(remoteSessionId: string, turnId: string, sessionId?: string): Promise<void> {
666
+ for (const session of this.sessions.values()) {
667
+ if (session.remoteSessionId === remoteSessionId) session.planContinuationTurnId = undefined;
668
+ }
669
+ for (const pending of this.modeChanges.values()) {
670
+ if (pending.turnId === turnId) pending.decision = "cancel";
671
+ }
630
672
  await (await this.client()).request(
631
673
  "turn/interrupt",
632
674
  { threadId: remoteSessionId, turnId },
@@ -645,17 +687,51 @@ export class CodexRuntime implements AgentRuntime {
645
687
  });
646
688
  }
647
689
 
648
- async archiveRemoteSession(remoteSessionId: string): Promise<void> {
649
- await (await this.client()).request(
650
- "thread/archive",
651
- { threadId: remoteSessionId },
652
- CONTROL_REQUEST_TIMEOUT_MS,
653
- );
690
+ async archiveRemoteSession(remoteSessionId: string, options: { allowUnmaterialized?: boolean } = {}): Promise<void> {
691
+ const client = await this.client();
692
+ try {
693
+ await client.request("thread/archive", { threadId: remoteSessionId }, CONTROL_REQUEST_TIMEOUT_MS);
694
+ } catch (error) {
695
+ if (!options.allowUnmaterialized || !(error instanceof AppServerRequestError)
696
+ || error.method !== "thread/archive" || error.code !== -32600
697
+ || error.serverMessage !== `no rollout found for thread id ${remoteSessionId}`
698
+ || !await this.isUnmaterializedThread(client, remoteSessionId)) throw error;
699
+ // Codex has nothing to move to its archive before the first user message.
700
+ const result = await client.request<{ status: string }>(
701
+ "thread/unsubscribe", { threadId: remoteSessionId }, CONTROL_REQUEST_TIMEOUT_MS,
702
+ );
703
+ if (!["unsubscribed", "notLoaded", "notSubscribed"].includes(result.status)) {
704
+ throw new Error(`Cannot release empty App Server task: ${result.status}`);
705
+ }
706
+ this.logger.info({ remoteSessionId }, "Released an empty App Server task without a materialized rollout.");
707
+ }
654
708
  for (const [localSessionId, session] of this.sessions) {
655
709
  if (session.remoteSessionId === remoteSessionId) this.sessions.delete(localSessionId);
656
710
  }
657
711
  }
658
712
 
713
+ private async isUnmaterializedThread(client: AppServerClient, remoteSessionId: string): Promise<boolean> {
714
+ const { thread } = await client.request<ThreadReadResponse>(
715
+ "thread/read", { threadId: remoteSessionId, includeTurns: false }, CONTROL_REQUEST_TIMEOUT_MS,
716
+ );
717
+ if (thread.id !== remoteSessionId || thread.preview !== "" || thread.forkedFromId !== null
718
+ || !["idle", "notLoaded"].includes(thread.status?.type ?? "") || !thread.path
719
+ || (thread.turns?.length ?? 0) > 0) return false;
720
+ for (const session of this.sessions.values()) {
721
+ if (session.remoteSessionId === remoteSessionId && (session.activeTurnId
722
+ || session.knownTurnIds.size > 0 || (session.turnCount ?? 0) > 0)) return false;
723
+ }
724
+ const active = await this.localActivityDetector?.activeThreads([remoteSessionId]);
725
+ if (active?.has(remoteSessionId)) return false;
726
+ try {
727
+ statSync(thread.path);
728
+ return false;
729
+ } catch (error) {
730
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return true;
731
+ throw error;
732
+ }
733
+ }
734
+
659
735
  async setTitle(sessionId: string, title: string): Promise<void> {
660
736
  return this.runSessionOperation(sessionId, () => this.setTitleNow(sessionId, title));
661
737
  }
@@ -696,7 +772,6 @@ export class CodexRuntime implements AgentRuntime {
696
772
  const session = this.requireSession(sessionId);
697
773
  const client = await this.client();
698
774
  await this.ensureSessionResumed(session, client);
699
- session.canReplaceEmptyThread = false;
700
775
  const response = await client.request<{ goal: RuntimeGoal }>(
701
776
  "thread/goal/set",
702
777
  { threadId: session.remoteSessionId, ...update },
@@ -738,7 +813,16 @@ export class CodexRuntime implements AgentRuntime {
738
813
  settings: RuntimeExecutionSettings,
739
814
  persist?: (session: RuntimeSession) => Promise<void>,
740
815
  ): Promise<RuntimeSession> {
741
- return this.runSessionOperation(sessionId, () => this.setExecutionSettingsNow(sessionId, settings, persist));
816
+ return this.runSessionOperation(sessionId, async () => {
817
+ try {
818
+ return await this.setExecutionSettingsNow(sessionId, settings, persist);
819
+ } catch (error) {
820
+ if (error instanceof ProviderSwitchError) throw error;
821
+ // Errors outside the mutation block are preflight failures; no settings were changed.
822
+ const session = this.sessions.get(sessionId);
823
+ throw new ProviderSwitchError(providerSwitchFailure(error), Boolean(session && !session.needsResume && !session.settingsRecoveryError), { cause: error });
824
+ }
825
+ });
742
826
  }
743
827
 
744
828
  private async setExecutionSettingsNow(
@@ -756,82 +840,54 @@ export class CodexRuntime implements AgentRuntime {
756
840
  if (metadata.thread.status?.type === "active" || localActive?.has(session.remoteSessionId) || session.activeTurnId) {
757
841
  throw new Error(PROVIDER_SWITCH_BUSY);
758
842
  }
759
- let empty = false;
760
- if (session.canReplaceEmptyThread && !metadata.thread.forkedFromId && !metadata.thread.preview?.trim()) {
843
+ if (session.turnCount === 0 && !metadata.thread.forkedFromId && !metadata.thread.preview?.trim()) {
761
844
  try {
762
845
  const turns = await this.readLatestThreadTurns(client, session.remoteSessionId, "summary");
763
- empty = turns.length === 0;
764
- if (!empty) session.canReplaceEmptyThread = false;
846
+ if (turns.length === 0) throw new Error(PROVIDER_SWITCH_EMPTY);
765
847
  } catch (error) {
766
848
  if (!(error instanceof AppServerRequestError) || error.method !== "thread/turns/list"
767
849
  || !/missing source rollout|no rollout found/iu.test(error.serverMessage)) throw error;
768
- empty = true;
850
+ throw new Error(PROVIDER_SWITCH_EMPTY, { cause: error });
769
851
  }
770
852
  }
771
853
  const previous = { ...session };
772
- let replacementId: string | undefined;
854
+ const restoreProvider = previous.modelProvider?.trim()
855
+ || (metadata.thread.id === previous.remoteSessionId ? metadata.thread.modelProvider?.trim() : undefined);
773
856
  let detached = false;
774
857
  try {
775
858
  const params = {
776
859
  cwd: session.cwd, modelProvider: settings.modelProvider, model: settings.model,
777
860
  ...threadLifecycleParams(session.cwd), ...permissionParams(settings.permissionMode),
778
861
  };
779
- if (!empty) {
780
- await this.detachThreadForSettings(client, session);
781
- detached = true;
782
- }
783
- let response: ThreadResponse;
784
- if (empty) {
785
- response = await client.request<ThreadResponse>("thread/start", {
786
- ...params, threadSource: "user", allowProviderModelFallback: false,
787
- }, SESSION_REQUEST_TIMEOUT_MS);
788
- replacementId = response.thread.id;
789
- } else {
790
- response = await client.request<ThreadResponse>("thread/resume", {
791
- ...params, threadId: session.remoteSessionId, excludeTurns: true,
792
- }, SESSION_REQUEST_TIMEOUT_MS);
793
- if (!providerSettingsMatch(response, settings)) {
794
- this.logger.warn(
795
- {
796
- sessionId,
797
- threadId: session.remoteSessionId,
798
- requestedProvider: settings.modelProvider,
799
- actualProvider: response.modelProvider,
800
- requestedModel: settings.model,
801
- actualModel: response.model,
802
- },
803
- "App Server did not apply Provider settings while resuming; forking the latest completed Turn.",
804
- );
805
- await this.detachThreadForSettings(client, session);
806
- response = await this.forkThreadForSettings(client, session, settings);
807
- replacementId = response.thread.id;
808
- }
809
- }
862
+ await this.detachThreadForSettings(client, session, () => { detached = true; });
863
+ const response = await client.request<ThreadResponse>("thread/resume", {
864
+ ...params, threadId: session.remoteSessionId, excludeTurns: true,
865
+ }, SESSION_REQUEST_TIMEOUT_MS);
810
866
  assertProviderSettingsApplied(response, settings);
811
- if (!empty && !replacementId && response.thread.id !== session.remoteSessionId) {
867
+ if (response.thread.id !== session.remoteSessionId) {
812
868
  throw new Error("App Server 返回了不同的任务 ID,未保存设置。");
813
869
  }
814
- if (session.title && (empty || replacementId)) await client.request("thread/name/set", {
815
- threadId: response.thread.id, name: session.title,
816
- }, SESSION_REQUEST_TIMEOUT_MS);
817
- const candidate = { ...session, ...settings, remoteSessionId: response.thread.id,
870
+ const candidate = { ...session, ...settings,
818
871
  workspaceSandboxPolicy: workspaceSandboxFromResponse(response.sandbox),
819
872
  needsResume: false, settingsRecoveryError: undefined };
820
873
  await persist?.(candidate);
821
- Object.assign(session, candidate);
874
+ Object.assign(session, settings);
875
+ session.workspaceSandboxPolicy = candidate.workspaceSandboxPolicy;
876
+ session.needsResume = this.attachedClient !== client;
877
+ session.settingsRecoveryError = undefined;
822
878
  } catch (error) {
823
- if (replacementId) await this.discardUnusedThread(client, replacementId);
824
879
  let recovery = "原任务设置未更改。";
825
880
  if (detached) {
826
881
  try {
882
+ if (!restoreProvider) throw new Error("无法确认切换前的 Provider,不能验证原设置已恢复。");
827
883
  await this.detachThreadForSettings(client, previous);
828
884
  const restored = await client.request<ThreadResponse>("thread/resume", {
829
885
  threadId: previous.remoteSessionId, excludeTurns: true, cwd: previous.cwd,
830
- modelProvider: previous.modelProvider, model: previous.model,
886
+ modelProvider: restoreProvider, model: previous.model,
831
887
  ...threadLifecycleParams(previous.cwd), ...permissionParams(previous.permissionMode),
832
888
  }, SESSION_REQUEST_TIMEOUT_MS);
833
- if (previous.modelProvider && previous.model) assertProviderSettingsApplied(restored, {
834
- modelProvider: previous.modelProvider, model: previous.model,
889
+ assertProviderSettingsApplied(restored, {
890
+ modelProvider: restoreProvider, model: previous.model,
835
891
  });
836
892
  if (restored.thread.id !== previous.remoteSessionId) throw new Error("恢复时返回了不同的任务 ID。");
837
893
  session.needsResume = false;
@@ -844,60 +900,42 @@ export class CodexRuntime implements AgentRuntime {
844
900
  session.needsResume = true;
845
901
  }
846
902
  }
847
- throw new Error(`Provider 切换失败:${providerSwitchFailure(error)} ${recovery}`, { cause: error });
903
+ throw new ProviderSwitchError(
904
+ `Provider 切换失败:${providerSwitchFailure(error)} ${recovery}`,
905
+ !session.needsResume && !session.settingsRecoveryError, { cause: error },
906
+ );
848
907
  }
849
- if (replacementId) await this.discardUnusedThread(client, previous.remoteSessionId);
850
908
  return session;
851
909
  }
852
910
 
853
- private async detachThreadForSettings(client: AppServerClient, session: CodexSession): Promise<void> {
911
+ private async detachThreadForSettings(client: AppServerClient, session: CodexSession, onDetach?: () => void): Promise<void> {
854
912
  const current = await client.request<ThreadReadResponse>("thread/read", {
855
913
  threadId: session.remoteSessionId, includeTurns: false,
856
914
  }, SYNC_REQUEST_TIMEOUT_MS);
857
915
  if (session.activeTurnId || current.thread.status?.type === "active") throw new Error(PROVIDER_SWITCH_BUSY);
916
+ // A timeout does not cancel the remote unsubscribe, so recovery is required once it is sent.
917
+ onDetach?.();
858
918
  const result = await client.request<{ status: string }>("thread/unsubscribe", {
859
919
  threadId: session.remoteSessionId,
860
920
  }, CONTROL_REQUEST_TIMEOUT_MS);
861
- if (result.status !== "unsubscribed" && result.status !== "notLoaded") {
862
- throw new Error("当前任务仍由其他客户端加载,无法安全切换 Provider。请先在原客户端释放任务。");
863
- }
864
- }
865
-
866
- private async forkThreadForSettings(
867
- client: AppServerClient,
868
- session: CodexSession,
869
- settings: RuntimeExecutionSettings,
870
- ): Promise<ThreadResponse> {
871
- const turns = await this.listRecentThreadTurnsThroughCompleted(client, session.remoteSessionId);
872
- const lastTurnId = turns.find((turn) => turn.status === "completed")?.id;
873
- if (!lastTurnId) {
874
- throw new Error("无法为 Provider 切换找到已完成的 Turn。请先完成一轮任务后重试。");
921
+ if (!["unsubscribed", "notLoaded", "notSubscribed"].includes(result.status)) {
922
+ throw new Error(`App Server 返回了未知的取消订阅状态:${result.status}`);
875
923
  }
876
- return client.request<ThreadResponse>("thread/fork", {
877
- threadId: session.remoteSessionId,
878
- lastTurnId,
879
- cwd: session.cwd,
880
- model: settings.model,
881
- modelProvider: settings.modelProvider,
882
- threadSource: "user",
883
- excludeTurns: true,
884
- ...threadLifecycleParams(session.cwd),
885
- ...permissionParams(settings.permissionMode),
886
- }, FORK_REQUEST_TIMEOUT_MS);
887
924
  }
888
925
 
889
- private async discardUnusedThread(client: AppServerClient, threadId: string): Promise<void> {
890
- try {
891
- await client.request("thread/unsubscribe", { threadId }, CONTROL_REQUEST_TIMEOUT_MS);
892
- } catch (error) {
893
- this.logger.warn({ error, threadId }, "Failed to release an unused empty Thread after changing Provider.");
894
- }
926
+ async respondToApproval(
927
+ sessionId: string,
928
+ requestId: string,
929
+ decision: ApprovalDecision,
930
+ ): Promise<void> {
931
+ return this.resolveApproval(sessionId, requestId, decision);
895
932
  }
896
933
 
897
- async respondToApproval(
934
+ private async resolveApproval(
898
935
  sessionId: string,
899
936
  requestId: string,
900
937
  decision: ApprovalDecision,
938
+ automatic = false,
901
939
  ): Promise<void> {
902
940
  const modeChange = this.modeChanges.get(requestId);
903
941
  if (modeChange) {
@@ -910,20 +948,29 @@ export class CodexRuntime implements AgentRuntime {
910
948
  }
911
949
  if (modeChange.responding) throw new Error("Approval response is already in progress.");
912
950
  modeChange.responding = true;
951
+ modeChange.decision = decision;
952
+ modeChange.automatic = automatic;
913
953
  try {
914
954
  const client = await this.client();
915
955
  if (this.modeChanges.get(requestId) !== modeChange) throw new Error("Approval request is no longer pending.");
916
- await client.request("mode/change/respond", {
917
- threadId: this.requireSession(sessionId).remoteSessionId,
918
- requestId: modeChange.requestId,
919
- decision: decision === "accept" ? "approved" : decision === "decline" ? "rejected" : "cancelled",
920
- }, CONTROL_REQUEST_TIMEOUT_MS);
956
+ try {
957
+ await client.request("mode/change/respond", {
958
+ threadId: this.requireSession(sessionId).remoteSessionId,
959
+ requestId: modeChange.requestId,
960
+ decision: decision === "accept" ? "approved" : decision === "decline" ? "rejected" : "cancelled",
961
+ }, CONTROL_REQUEST_TIMEOUT_MS);
962
+ } catch (error) {
963
+ if (!await this.reconcileCompletedPlanEntry(requestId, modeChange, error)) throw error;
964
+ }
921
965
  if (this.modeChanges.get(requestId) === modeChange) {
966
+ this.confirmPlanContinuation(modeChange, decision);
922
967
  this.modeChanges.delete(requestId);
923
- this.emit({ type: "approval_resolved", sessionId, turnId: modeChange.turnId, requestId, decision });
968
+ this.emit({ type: "approval_resolved", sessionId, turnId: modeChange.turnId, requestId, decision,
969
+ ...(automatic ? { automatic: true } : {}) });
924
970
  }
925
971
  } finally {
926
972
  modeChange.responding = false;
973
+ if (modeChange.terminalEvent) this.emit(modeChange.terminalEvent);
927
974
  }
928
975
  return;
929
976
  }
@@ -934,6 +981,36 @@ export class CodexRuntime implements AgentRuntime {
934
981
  this.emit({ type: "approval_resolved", sessionId, turnId: pending.turnId, requestId, decision });
935
982
  }
936
983
 
984
+ private async reconcileCompletedPlanEntry(
985
+ requestId: string, pending: PendingModeChange, error: unknown,
986
+ ): Promise<boolean> {
987
+ const session = this.sessions.get(pending.sessionId);
988
+ if (!pending.automatic || pending.decision !== "accept" || pending.targetMode !== "plan"
989
+ || this.provider.getAgentFamily?.() !== "traex" || !session?.rolloutPath
990
+ || !(error instanceof AppServerRequestError) || error.method !== "mode/change/respond" || error.code !== -32600
991
+ || error.serverMessage !== `failed to respond to mode change request: No pending mode change found for request_id: ${pending.requestId}`) {
992
+ return false;
993
+ }
994
+ const toolCallId = pending.sourceToolCallId ?? pending.requestId;
995
+ for (const waitMs of [0, 100, 200]) {
996
+ if (waitMs) await delay(waitMs);
997
+ if (this.modeChanges.get(requestId) !== pending || this.sessions.get(pending.sessionId) !== session
998
+ || session.activeTurnId !== pending.turnId) return false;
999
+ try {
1000
+ if (!await hasCompletedTraePlanEntry(session.rolloutPath, pending.turnId, toolCallId)) continue;
1001
+ if (this.modeChanges.get(requestId) !== pending || this.sessions.get(pending.sessionId) !== session
1002
+ || session.activeTurnId !== pending.turnId) return false;
1003
+ this.logger.info({ sessionId: pending.sessionId, turnId: pending.turnId, requestId, toolCallId },
1004
+ "Verified that TraeX already completed the automatic plan-mode entry.");
1005
+ return true;
1006
+ } catch (verificationError) {
1007
+ this.logger.debug({ error: verificationError, sessionId: pending.sessionId, requestId },
1008
+ "Could not verify TraeX plan-mode entry from the rollout tail.");
1009
+ }
1010
+ }
1011
+ return false;
1012
+ }
1013
+
937
1014
  async listModels(modelProvider?: string, fallbackModel?: string): Promise<ModelOption[]> {
938
1015
  return (await this.listModelsWithStatus(modelProvider, fallbackModel)).models;
939
1016
  }
@@ -1174,34 +1251,47 @@ export class CodexRuntime implements AgentRuntime {
1174
1251
  const requestId = `mode:${mapped.threadId}:${mapped.requestId}`;
1175
1252
  if (session.modeChangeRequestIds.has(requestId)) return;
1176
1253
  session.modeChangeRequestIds.add(requestId);
1177
- this.modeChanges.set(requestId, {
1254
+ const pending: PendingModeChange = {
1178
1255
  sessionId, turnId: mapped.turnId, requestId: mapped.requestId,
1179
1256
  targetMode: mapped.targetMode, responding: false,
1180
- });
1181
- this.emit({
1182
- type: "approval_requested", sessionId, turnId: mapped.turnId,
1183
- request: {
1184
- id: requestId,
1185
- kind: "mode_change",
1186
- title: mapped.targetMode === "default" ? "确认方案并开始执行" : "确认进入规划模式",
1187
- reason: [mapped.reason, ...(mapped.allowedPrompts ?? []).map((entry) => `- ${entry.tool}: ${entry.prompt}`)]
1188
- .filter(Boolean).join("\n"),
1189
- options: [
1190
- { id: "accept", label: mapped.targetMode === "default" ? "Approve Plan" : "Enter Plan Mode" },
1191
- { id: "decline", label: "Reject" },
1192
- { id: "cancel", label: "Cancel Request" },
1193
- ],
1194
- },
1195
- });
1257
+ sourceToolCallId: mapped.sourceToolCallId ?? undefined,
1258
+ };
1259
+ this.modeChanges.set(requestId, pending);
1260
+ const automatic = session.permissionMode === "auto" && this.provider.getAgentFamily?.() === "traex";
1261
+ const request: ApprovalRequest = {
1262
+ id: requestId,
1263
+ kind: "mode_change",
1264
+ ...(automatic ? { automatic: true } : {}),
1265
+ title: mapped.targetMode === "default" ? "确认方案并开始执行" : "确认进入规划模式",
1266
+ reason: [mapped.reason, ...(mapped.allowedPrompts ?? []).map((entry) => `- ${entry.tool}: ${entry.prompt}`)]
1267
+ .filter(Boolean).join("\n"),
1268
+ options: [
1269
+ { id: "accept", label: mapped.targetMode === "default" ? "Approve Plan" : "Enter Plan Mode" },
1270
+ { id: "decline", label: "Reject" },
1271
+ { id: "cancel", label: "Cancel Request" },
1272
+ ],
1273
+ };
1274
+ this.emit({ type: "approval_requested", sessionId, turnId: mapped.turnId, request });
1275
+ if (automatic) {
1276
+ void this.resolveApproval(sessionId, requestId, "accept", true).catch((error: unknown) => {
1277
+ if (this.modeChanges.get(requestId) !== pending || session.activeTurnId !== mapped.turnId) return;
1278
+ this.logger.warn({ error, sessionId, requestId, turnId: mapped.turnId, targetMode: mapped.targetMode,
1279
+ sourceToolCallId: pending.sourceToolCallId }, "Failed to automatically approve the TraeX mode change.");
1280
+ this.emit({ type: "approval_requested", sessionId, turnId: mapped.turnId,
1281
+ request: { ...request, automatic: false, reason: ["自动同意失败,请手动确认。", request.reason].filter(Boolean).join("\n\n") } });
1282
+ });
1283
+ }
1196
1284
  } else if (mapped.kind === "mode_change_resolved") {
1197
1285
  const requestId = `mode:${mapped.threadId}:${mapped.requestId}`;
1198
1286
  const pending = this.modeChanges.get(requestId);
1199
1287
  if (!pending || pending.sessionId !== sessionId || pending.turnId !== mapped.turnId
1200
1288
  || pending.targetMode !== mapped.targetMode) return;
1289
+ this.confirmPlanContinuation(pending, mapped.decision === "approved" ? "accept" : "decline");
1201
1290
  this.modeChanges.delete(requestId);
1202
1291
  this.emit({
1203
1292
  type: "approval_resolved", sessionId, turnId: mapped.turnId, requestId,
1204
1293
  decision: mapped.decision === "approved" ? "accept" : mapped.decision === "rejected" ? "decline" : "cancel",
1294
+ ...(pending.automatic && mapped.decision === "approved" ? { automatic: true } : {}),
1205
1295
  });
1206
1296
  } else if (mapped.kind === "token_usage") {
1207
1297
  this.emit({
@@ -1232,6 +1322,7 @@ export class CodexRuntime implements AgentRuntime {
1232
1322
  if (mapped.phase === "final_answer") this.promoteUnphasedMessage(session, mapped.turnId, mapped.itemId);
1233
1323
  else if (session.unphasedFinalMessageId === mapped.itemId) session.unphasedFinalMessageId = undefined;
1234
1324
  } else if (mapped.kind === "agent_delta") {
1325
+ this.clearPlanContinuation(session, mapped.turnId);
1235
1326
  const phase = session.messagePhases.get(mapped.itemId);
1236
1327
  if (phase !== "final_answer") {
1237
1328
  // Providers may omit phase. Keep their text in sequence until a tool or completion disambiguates it.
@@ -1283,6 +1374,8 @@ export class CodexRuntime implements AgentRuntime {
1283
1374
  } else if (mapped.kind === "plan") {
1284
1375
  this.emit({ type: "plan_updated", sessionId, turnId: mapped.turnId, steps: mapped.steps });
1285
1376
  } else if (mapped.kind === "tool") {
1377
+ // A backend that resumes tool execution itself needs no client handoff.
1378
+ if (mapped.phase === "started") this.clearPlanContinuation(session, mapped.turnId);
1286
1379
  if (mapped.phase === "started") session.unphasedFinalMessageId = undefined;
1287
1380
  if (
1288
1381
  mapped.phase === "updated"
@@ -1409,6 +1502,8 @@ export class CodexRuntime implements AgentRuntime {
1409
1502
  ...threadLifecycleParams(session.cwd),
1410
1503
  ...permissionParams(session.permissionMode),
1411
1504
  }, SESSION_REQUEST_TIMEOUT_MS);
1505
+ if (response.thread.id !== session.remoteSessionId) throw new Error("App Server 返回了不同的任务 ID,未恢复会话。");
1506
+ assertProviderSettingsApplied(response, { modelProvider: session.modelProvider });
1412
1507
  session.workspaceSandboxPolicy = workspaceSandboxFromResponse(response.sandbox);
1413
1508
  }
1414
1509
 
@@ -1487,13 +1582,13 @@ export class CodexRuntime implements AgentRuntime {
1487
1582
  }
1488
1583
 
1489
1584
  private adoptTurn(session: CodexSession, turnId: string, startedAt: number): void {
1490
- session.canReplaceEmptyThread = false;
1491
1585
  if (!session.knownTurnIds.has(turnId)) {
1492
1586
  session.knownTurnIds.add(turnId);
1493
1587
  if (session.turnCount !== undefined) session.turnCount += 1;
1494
1588
  }
1495
1589
  session.activeTurnId = turnId;
1496
1590
  session.activeTurnStartedAt = startedAt;
1591
+ session.planContinuationTurnId = undefined;
1497
1592
  session.modeChangeRequestIds.clear();
1498
1593
  session.unphasedMessages.clear();
1499
1594
  session.unphasedFinalMessageId = undefined;
@@ -1596,7 +1691,6 @@ export class CodexRuntime implements AgentRuntime {
1596
1691
  unphasedMessages: new Map(),
1597
1692
  needsResume: false,
1598
1693
  workspaceSandboxPolicy: workspaceSandboxFromResponse(response.sandbox),
1599
- canReplaceEmptyThread: !("remoteSessionId" in input),
1600
1694
  rolloutPath: stringValue(response.thread.path)?.trim() || undefined,
1601
1695
  turnCount: "remoteSessionId" in input ? undefined : 0,
1602
1696
  knownTurnIds: new Set(),
@@ -1626,10 +1720,38 @@ export class CodexRuntime implements AgentRuntime {
1626
1720
  return session;
1627
1721
  }
1628
1722
 
1723
+ private clearPlanContinuation(session: CodexSession, turnId: string): void {
1724
+ session.planContinuationTurnId = undefined;
1725
+ for (const pending of this.modeChanges.values()) {
1726
+ if (pending.sessionId === session.localSessionId && pending.turnId === turnId && pending.responding) {
1727
+ pending.continued = true;
1728
+ }
1729
+ }
1730
+ }
1731
+
1732
+ private confirmPlanContinuation(pending: PendingModeChange, decision: ApprovalDecision): void {
1733
+ const session = this.sessions.get(pending.sessionId);
1734
+ // Do not start work approved in a different client.
1735
+ if (session && pending.responding && !pending.continued && pending.decision === "accept" && decision === "accept"
1736
+ && pending.targetMode === "default") session.planContinuationTurnId = pending.turnId;
1737
+ }
1738
+
1629
1739
  private emit(event: RuntimeEvent): void {
1630
1740
  if (event.type === "turn_completed" || event.type === "turn_cancelled" || event.type === "turn_failed") {
1631
1741
  for (const [requestId, pending] of this.modeChanges) {
1632
- if (pending.sessionId === event.sessionId && pending.turnId === event.turnId) this.modeChanges.delete(requestId);
1742
+ if (pending.sessionId !== event.sessionId || pending.turnId !== event.turnId) continue;
1743
+ if (event.type === "turn_completed" && pending.responding && pending.decision === "accept"
1744
+ && pending.targetMode === "default") {
1745
+ // The terminal notification may beat the approval RPC response.
1746
+ pending.terminalEvent = event;
1747
+ return;
1748
+ }
1749
+ this.modeChanges.delete(requestId);
1750
+ }
1751
+ const session = this.sessions.get(event.sessionId);
1752
+ if (session?.planContinuationTurnId === event.turnId) {
1753
+ session.planContinuationTurnId = undefined;
1754
+ if (event.type === "turn_completed") event = { ...event, approvedPlan: true };
1633
1755
  }
1634
1756
  }
1635
1757
  for (const listener of this.listeners) listener(event);
@@ -1642,6 +1764,7 @@ export class CodexRuntime implements AgentRuntime {
1642
1764
  this.modeChanges.clear();
1643
1765
  for (const session of this.sessions.values()) {
1644
1766
  session.needsResume = true;
1767
+ session.planContinuationTurnId = undefined;
1645
1768
  const turnId = session.activeTurnId;
1646
1769
  if (!turnId) continue;
1647
1770
  session.activeTurnId = undefined;
@@ -2037,13 +2160,6 @@ function remoteThreadStatus(value: string | undefined): RemoteSessionSummary["st
2037
2160
  return "not_loaded";
2038
2161
  }
2039
2162
 
2040
- function providerSettingsMatch(
2041
- response: Pick<ThreadResponse, "modelProvider" | "model">,
2042
- settings: RuntimeExecutionSettings,
2043
- ): boolean {
2044
- return response.modelProvider === settings.modelProvider && response.model === settings.model;
2045
- }
2046
-
2047
2163
  function codexSourceLabel(source: unknown): string {
2048
2164
  if (typeof source === "string") return source;
2049
2165
  if (isRecord(source)) {