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

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 (96) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +27 -3
  3. package/README.zh.md +27 -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/CodexRuntime.js +202 -115
  12. package/dist/codex/CodexRuntime.js.map +1 -1
  13. package/dist/codex/ProviderSwitch.js +12 -13
  14. package/dist/codex/ProviderSwitch.js.map +1 -1
  15. package/dist/config/AgentExecutionDefaultsStore.js +4 -0
  16. package/dist/config/AgentExecutionDefaultsStore.js.map +1 -1
  17. package/dist/config/loadConfig.js +4 -0
  18. package/dist/config/loadConfig.js.map +1 -1
  19. package/dist/config/schema.js +3 -0
  20. package/dist/config/schema.js.map +1 -1
  21. package/dist/feishu/CardRenderer.js +66 -8
  22. package/dist/feishu/CardRenderer.js.map +1 -1
  23. package/dist/feishu/FeishuConnector.js +1 -0
  24. package/dist/feishu/FeishuConnector.js.map +1 -1
  25. package/dist/feishu/FeishuTurnPresenter.js +121 -17
  26. package/dist/feishu/FeishuTurnPresenter.js.map +1 -1
  27. package/dist/index.js +43 -6
  28. package/dist/index.js.map +1 -1
  29. package/dist/local-files/LocalFileViewerServer.js +51 -4
  30. package/dist/local-files/LocalFileViewerServer.js.map +1 -1
  31. package/dist/local-files/TurnPreviewPage.js +7 -2
  32. package/dist/local-files/TurnPreviewPage.js.map +1 -1
  33. package/dist/presentation/TurnStateReducer.js +41 -6
  34. package/dist/presentation/TurnStateReducer.js.map +1 -1
  35. package/dist/proxy/ProxySessionController.js +615 -214
  36. package/dist/proxy/ProxySessionController.js.map +1 -1
  37. package/dist/proxy/SessionOperationQueue.js +14 -0
  38. package/dist/proxy/SessionOperationQueue.js.map +1 -0
  39. package/dist/runtime/AcpRuntimeAdapter.js +4 -2
  40. package/dist/runtime/AcpRuntimeAdapter.js.map +1 -1
  41. package/dist/state/StateStore.js +22 -0
  42. package/dist/state/StateStore.js.map +1 -1
  43. package/dist/state/TurnPreviewJournal.js +19 -7
  44. package/dist/state/TurnPreviewJournal.js.map +1 -1
  45. package/dist/state/migrations.js +6 -0
  46. package/dist/state/migrations.js.map +1 -1
  47. package/dist/supervision/SafeRestartNotifier.js +1 -0
  48. package/dist/supervision/SafeRestartNotifier.js.map +1 -1
  49. package/dist/supervision/SafeRestartScheduler.js +19 -6
  50. package/dist/supervision/SafeRestartScheduler.js.map +1 -1
  51. package/dist/terminal/TerminalCards.js +13 -0
  52. package/dist/terminal/TerminalCards.js.map +1 -0
  53. package/dist/terminal/TerminalLinks.js +76 -0
  54. package/dist/terminal/TerminalLinks.js.map +1 -0
  55. package/dist/terminal/TerminalPage.js +212 -0
  56. package/dist/terminal/TerminalPage.js.map +1 -0
  57. package/dist/terminal/TerminalProcess.js +71 -0
  58. package/dist/terminal/TerminalProcess.js.map +1 -0
  59. package/dist/terminal/TerminalServer.js +639 -0
  60. package/dist/terminal/TerminalServer.js.map +1 -0
  61. package/docs/technical-reference.md +48 -0
  62. package/docs/technical-reference.zh.md +48 -0
  63. package/npm-shrinkwrap.json +55 -5
  64. package/package.json +11 -4
  65. package/skills/agent-bot/SKILL.md +9 -2
  66. package/src/cli/controlProtocol.ts +2 -1
  67. package/src/cli/serverStatus.ts +1 -0
  68. package/src/cli.ts +6 -1
  69. package/src/codex/CodexRuntime.ts +200 -123
  70. package/src/codex/ProviderSwitch.ts +11 -12
  71. package/src/config/AgentExecutionDefaultsStore.ts +2 -0
  72. package/src/config/loadConfig.ts +4 -0
  73. package/src/config/schema.ts +5 -1
  74. package/src/feishu/CardRenderer.ts +81 -8
  75. package/src/feishu/FeishuConnector.ts +1 -0
  76. package/src/feishu/FeishuTurnPresenter.ts +108 -16
  77. package/src/feishu/types.ts +1 -0
  78. package/src/index.ts +44 -7
  79. package/src/local-files/LocalFileViewerServer.ts +48 -4
  80. package/src/local-files/TurnPreviewPage.ts +7 -2
  81. package/src/presentation/TurnStateReducer.ts +46 -7
  82. package/src/presentation/turnViewTypes.ts +5 -0
  83. package/src/proxy/ProxySessionController.ts +625 -211
  84. package/src/proxy/SessionOperationQueue.ts +13 -0
  85. package/src/runtime/AcpRuntimeAdapter.ts +5 -3
  86. package/src/runtime/types.ts +8 -3
  87. package/src/state/StateStore.ts +38 -1
  88. package/src/state/TurnPreviewJournal.ts +19 -7
  89. package/src/state/migrations.ts +6 -0
  90. package/src/supervision/SafeRestartNotifier.ts +1 -0
  91. package/src/supervision/SafeRestartScheduler.ts +18 -6
  92. package/src/terminal/TerminalCards.ts +18 -0
  93. package/src/terminal/TerminalLinks.ts +61 -0
  94. package/src/terminal/TerminalPage.ts +213 -0
  95. package/src/terminal/TerminalProcess.ts +71 -0
  96. package/src/terminal/TerminalServer.ts +513 -0
@@ -4,6 +4,7 @@ import type {
4
4
  AgentRuntime,
5
5
  AgentProcessInfo,
6
6
  ApprovalDecision,
7
+ ApprovalRequest,
7
8
  CreateRuntimeSessionInput,
8
9
  ConversationTurn,
9
10
  ForkRuntimeSessionInput,
@@ -35,7 +36,7 @@ import { formatCodexError, mapCodexNotification } from "./CodexEventMapper.js";
35
36
  import { CodexLocalActivityDetector } from "./CodexLocalActivityDetector.js";
36
37
  import { detectProjectlessWorkspace } from "./ProjectlessWorkspace.js";
37
38
  import { threadWriterLockPath } from "./ThreadWriterProcess.js";
38
- import { assertProviderSettingsApplied, PROVIDER_SWITCH_BUSY, providerSwitchFailure } from "./ProviderSwitch.js";
39
+ import { assertProviderSettingsApplied, PROVIDER_SWITCH_BUSY, PROVIDER_SWITCH_EMPTY, ProviderSwitchError, providerSwitchFailure } from "./ProviderSwitch.js";
39
40
  import { fetchProviderModels, type ProviderModelConfig } from "./ProviderModels.js";
40
41
 
41
42
  const WINDOWS_SCREENSHOT_DEVELOPER_INSTRUCTIONS = [
@@ -84,6 +85,7 @@ interface CodexSession extends RuntimeSession {
84
85
  activeTurnStartedAt?: number;
85
86
  terminalTurnIds: Set<string>;
86
87
  modeChangeRequestIds: Set<string>;
88
+ planContinuationTurnId?: string;
87
89
  finalText: string;
88
90
  generatedImagePaths: string[];
89
91
  messagePhases: Map<string, "commentary" | "final_answer">;
@@ -91,7 +93,6 @@ interface CodexSession extends RuntimeSession {
91
93
  unphasedFinalMessageId?: string;
92
94
  needsResume: boolean;
93
95
  workspaceSandboxPolicy?: WorkspaceSandboxPolicy;
94
- canReplaceEmptyThread: boolean;
95
96
  settingsRecoveryError?: string;
96
97
  rolloutPath?: string;
97
98
  turnCount?: number;
@@ -110,6 +111,10 @@ interface PendingModeChange {
110
111
  requestId: string;
111
112
  targetMode: "plan" | "default";
112
113
  responding: boolean;
114
+ decision?: ApprovalDecision;
115
+ automatic?: boolean;
116
+ continued?: boolean;
117
+ terminalEvent?: Extract<RuntimeEvent, { type: "turn_completed" }>;
113
118
  }
114
119
 
115
120
  export class CodexRuntime implements AgentRuntime {
@@ -184,6 +189,20 @@ export class CodexRuntime implements AgentRuntime {
184
189
  return this.runSessionOperation(input.localSessionId, () => this.resumeSessionNow(input));
185
190
  }
186
191
 
192
+ async prepareSessionForSettings(input: ResumeRuntimeSessionInput): Promise<RuntimeSession> {
193
+ return this.runSessionOperation(input.localSessionId, async () => {
194
+ const existing = this.sessions.get(input.localSessionId);
195
+ if (existing) return existing;
196
+ const session = this.makeSession(input, {
197
+ thread: { id: input.remoteSessionId, name: input.title },
198
+ modelProvider: input.modelProvider, model: input.model,
199
+ }, input.reasoningEffort);
200
+ session.needsResume = true;
201
+ this.sessions.set(input.localSessionId, session);
202
+ return session;
203
+ });
204
+ }
205
+
187
206
  private async resumeSessionNow(input: ResumeRuntimeSessionInput): Promise<RuntimeSession> {
188
207
  const client = await this.client();
189
208
  const response = await client.request<ThreadResponse>("thread/resume", {
@@ -195,8 +214,16 @@ export class CodexRuntime implements AgentRuntime {
195
214
  ...threadLifecycleParams(input.cwd),
196
215
  ...permissionParams(input.permissionMode),
197
216
  }, SESSION_REQUEST_TIMEOUT_MS);
217
+ if (response.thread.id !== input.remoteSessionId) throw new Error("App Server 返回了不同的任务 ID,未恢复会话。");
198
218
  const reasoningEffort = await this.resolveReasoningEffort(input, response);
199
219
  const session = this.makeSession(input, response, reasoningEffort);
220
+ try {
221
+ assertProviderSettingsApplied(response, { modelProvider: input.modelProvider });
222
+ } catch (error) {
223
+ // Keep the attachment available for an explicit settings repair, but never send to the wrong Provider.
224
+ session.settingsRecoveryError = providerSwitchFailure(error);
225
+ session.needsResume = true;
226
+ }
200
227
  this.sessions.set(input.localSessionId, session);
201
228
  return session;
202
229
  }
@@ -243,13 +270,18 @@ export class CodexRuntime implements AgentRuntime {
243
270
  const session = this.requireSession(sessionId);
244
271
  const client = await this.client();
245
272
  await this.ensureSessionResumed(session, client);
246
- session.canReplaceEmptyThread = false;
247
273
  const start = async () => client.request<{ turn: { id: string } }>("turn/start", {
248
274
  threadId: session.remoteSessionId,
249
275
  input: codexUserInput(prompt),
250
276
  cwd: session.cwd,
251
277
  model: session.model,
252
278
  effort: session.reasoningEffort,
279
+ ...(typeof prompt !== "string" && prompt.collaborationMode ? {
280
+ collaborationMode: {
281
+ mode: prompt.collaborationMode,
282
+ settings: { model: session.model, reasoning_effort: session.reasoningEffort ?? null, developer_instructions: null },
283
+ },
284
+ } : {}),
253
285
  summary: "auto",
254
286
  approvalPolicy: session.permissionMode === "auto" ? "never" : "on-request",
255
287
  sandboxPolicy: await this.turnSandboxPolicy(session, client),
@@ -591,9 +623,10 @@ export class CodexRuntime implements AgentRuntime {
591
623
  .find((session) => session.remoteSessionId === remoteSessionId);
592
624
  const detectedTurnId = activeThreads?.get(remoteSessionId);
593
625
  // A turn started in this process is evidence; a restored activeTurnId alone is not.
626
+ const observedTurnId = runtimeSession?.activeTurnStartedAt !== undefined ? runtimeSession.activeTurnId : undefined;
594
627
  const active = status === "active" || Boolean(activeThreads?.has(remoteSessionId))
595
- || Boolean(runtimeSession?.activeTurnId && runtimeSession.activeTurnStartedAt !== undefined);
596
- const activeTurnId = detectedTurnId ?? runtimeSession?.activeTurnId;
628
+ || Boolean(observedTurnId);
629
+ const activeTurnId = detectedTurnId ?? observedTurnId;
597
630
  return {
598
631
  active,
599
632
  ...(active && activeTurnId ? { activeTurnId } : {}),
@@ -627,6 +660,12 @@ export class CodexRuntime implements AgentRuntime {
627
660
  }
628
661
 
629
662
  async interruptRemoteTurn(remoteSessionId: string, turnId: string, sessionId?: string): Promise<void> {
663
+ for (const session of this.sessions.values()) {
664
+ if (session.remoteSessionId === remoteSessionId) session.planContinuationTurnId = undefined;
665
+ }
666
+ for (const pending of this.modeChanges.values()) {
667
+ if (pending.turnId === turnId) pending.decision = "cancel";
668
+ }
630
669
  await (await this.client()).request(
631
670
  "turn/interrupt",
632
671
  { threadId: remoteSessionId, turnId },
@@ -645,17 +684,51 @@ export class CodexRuntime implements AgentRuntime {
645
684
  });
646
685
  }
647
686
 
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
- );
687
+ async archiveRemoteSession(remoteSessionId: string, options: { allowUnmaterialized?: boolean } = {}): Promise<void> {
688
+ const client = await this.client();
689
+ try {
690
+ await client.request("thread/archive", { threadId: remoteSessionId }, CONTROL_REQUEST_TIMEOUT_MS);
691
+ } catch (error) {
692
+ if (!options.allowUnmaterialized || !(error instanceof AppServerRequestError)
693
+ || error.method !== "thread/archive" || error.code !== -32600
694
+ || error.serverMessage !== `no rollout found for thread id ${remoteSessionId}`
695
+ || !await this.isUnmaterializedThread(client, remoteSessionId)) throw error;
696
+ // Codex has nothing to move to its archive before the first user message.
697
+ const result = await client.request<{ status: string }>(
698
+ "thread/unsubscribe", { threadId: remoteSessionId }, CONTROL_REQUEST_TIMEOUT_MS,
699
+ );
700
+ if (!["unsubscribed", "notLoaded", "notSubscribed"].includes(result.status)) {
701
+ throw new Error(`Cannot release empty App Server task: ${result.status}`);
702
+ }
703
+ this.logger.info({ remoteSessionId }, "Released an empty App Server task without a materialized rollout.");
704
+ }
654
705
  for (const [localSessionId, session] of this.sessions) {
655
706
  if (session.remoteSessionId === remoteSessionId) this.sessions.delete(localSessionId);
656
707
  }
657
708
  }
658
709
 
710
+ private async isUnmaterializedThread(client: AppServerClient, remoteSessionId: string): Promise<boolean> {
711
+ const { thread } = await client.request<ThreadReadResponse>(
712
+ "thread/read", { threadId: remoteSessionId, includeTurns: false }, CONTROL_REQUEST_TIMEOUT_MS,
713
+ );
714
+ if (thread.id !== remoteSessionId || thread.preview !== "" || thread.forkedFromId !== null
715
+ || !["idle", "notLoaded"].includes(thread.status?.type ?? "") || !thread.path
716
+ || (thread.turns?.length ?? 0) > 0) return false;
717
+ for (const session of this.sessions.values()) {
718
+ if (session.remoteSessionId === remoteSessionId && (session.activeTurnId
719
+ || session.knownTurnIds.size > 0 || (session.turnCount ?? 0) > 0)) return false;
720
+ }
721
+ const active = await this.localActivityDetector?.activeThreads([remoteSessionId]);
722
+ if (active?.has(remoteSessionId)) return false;
723
+ try {
724
+ statSync(thread.path);
725
+ return false;
726
+ } catch (error) {
727
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return true;
728
+ throw error;
729
+ }
730
+ }
731
+
659
732
  async setTitle(sessionId: string, title: string): Promise<void> {
660
733
  return this.runSessionOperation(sessionId, () => this.setTitleNow(sessionId, title));
661
734
  }
@@ -696,7 +769,6 @@ export class CodexRuntime implements AgentRuntime {
696
769
  const session = this.requireSession(sessionId);
697
770
  const client = await this.client();
698
771
  await this.ensureSessionResumed(session, client);
699
- session.canReplaceEmptyThread = false;
700
772
  const response = await client.request<{ goal: RuntimeGoal }>(
701
773
  "thread/goal/set",
702
774
  { threadId: session.remoteSessionId, ...update },
@@ -738,7 +810,16 @@ export class CodexRuntime implements AgentRuntime {
738
810
  settings: RuntimeExecutionSettings,
739
811
  persist?: (session: RuntimeSession) => Promise<void>,
740
812
  ): Promise<RuntimeSession> {
741
- return this.runSessionOperation(sessionId, () => this.setExecutionSettingsNow(sessionId, settings, persist));
813
+ return this.runSessionOperation(sessionId, async () => {
814
+ try {
815
+ return await this.setExecutionSettingsNow(sessionId, settings, persist);
816
+ } catch (error) {
817
+ if (error instanceof ProviderSwitchError) throw error;
818
+ // Errors outside the mutation block are preflight failures; no settings were changed.
819
+ const session = this.sessions.get(sessionId);
820
+ throw new ProviderSwitchError(providerSwitchFailure(error), Boolean(session && !session.needsResume && !session.settingsRecoveryError), { cause: error });
821
+ }
822
+ });
742
823
  }
743
824
 
744
825
  private async setExecutionSettingsNow(
@@ -756,82 +837,54 @@ export class CodexRuntime implements AgentRuntime {
756
837
  if (metadata.thread.status?.type === "active" || localActive?.has(session.remoteSessionId) || session.activeTurnId) {
757
838
  throw new Error(PROVIDER_SWITCH_BUSY);
758
839
  }
759
- let empty = false;
760
- if (session.canReplaceEmptyThread && !metadata.thread.forkedFromId && !metadata.thread.preview?.trim()) {
840
+ if (session.turnCount === 0 && !metadata.thread.forkedFromId && !metadata.thread.preview?.trim()) {
761
841
  try {
762
842
  const turns = await this.readLatestThreadTurns(client, session.remoteSessionId, "summary");
763
- empty = turns.length === 0;
764
- if (!empty) session.canReplaceEmptyThread = false;
843
+ if (turns.length === 0) throw new Error(PROVIDER_SWITCH_EMPTY);
765
844
  } catch (error) {
766
845
  if (!(error instanceof AppServerRequestError) || error.method !== "thread/turns/list"
767
846
  || !/missing source rollout|no rollout found/iu.test(error.serverMessage)) throw error;
768
- empty = true;
847
+ throw new Error(PROVIDER_SWITCH_EMPTY, { cause: error });
769
848
  }
770
849
  }
771
850
  const previous = { ...session };
772
- let replacementId: string | undefined;
851
+ const restoreProvider = previous.modelProvider?.trim()
852
+ || (metadata.thread.id === previous.remoteSessionId ? metadata.thread.modelProvider?.trim() : undefined);
773
853
  let detached = false;
774
854
  try {
775
855
  const params = {
776
856
  cwd: session.cwd, modelProvider: settings.modelProvider, model: settings.model,
777
857
  ...threadLifecycleParams(session.cwd), ...permissionParams(settings.permissionMode),
778
858
  };
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
- }
859
+ await this.detachThreadForSettings(client, session, () => { detached = true; });
860
+ const response = await client.request<ThreadResponse>("thread/resume", {
861
+ ...params, threadId: session.remoteSessionId, excludeTurns: true,
862
+ }, SESSION_REQUEST_TIMEOUT_MS);
810
863
  assertProviderSettingsApplied(response, settings);
811
- if (!empty && !replacementId && response.thread.id !== session.remoteSessionId) {
864
+ if (response.thread.id !== session.remoteSessionId) {
812
865
  throw new Error("App Server 返回了不同的任务 ID,未保存设置。");
813
866
  }
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,
867
+ const candidate = { ...session, ...settings,
818
868
  workspaceSandboxPolicy: workspaceSandboxFromResponse(response.sandbox),
819
869
  needsResume: false, settingsRecoveryError: undefined };
820
870
  await persist?.(candidate);
821
- Object.assign(session, candidate);
871
+ Object.assign(session, settings);
872
+ session.workspaceSandboxPolicy = candidate.workspaceSandboxPolicy;
873
+ session.needsResume = this.attachedClient !== client;
874
+ session.settingsRecoveryError = undefined;
822
875
  } catch (error) {
823
- if (replacementId) await this.discardUnusedThread(client, replacementId);
824
876
  let recovery = "原任务设置未更改。";
825
877
  if (detached) {
826
878
  try {
879
+ if (!restoreProvider) throw new Error("无法确认切换前的 Provider,不能验证原设置已恢复。");
827
880
  await this.detachThreadForSettings(client, previous);
828
881
  const restored = await client.request<ThreadResponse>("thread/resume", {
829
882
  threadId: previous.remoteSessionId, excludeTurns: true, cwd: previous.cwd,
830
- modelProvider: previous.modelProvider, model: previous.model,
883
+ modelProvider: restoreProvider, model: previous.model,
831
884
  ...threadLifecycleParams(previous.cwd), ...permissionParams(previous.permissionMode),
832
885
  }, SESSION_REQUEST_TIMEOUT_MS);
833
- if (previous.modelProvider && previous.model) assertProviderSettingsApplied(restored, {
834
- modelProvider: previous.modelProvider, model: previous.model,
886
+ assertProviderSettingsApplied(restored, {
887
+ modelProvider: restoreProvider, model: previous.model,
835
888
  });
836
889
  if (restored.thread.id !== previous.remoteSessionId) throw new Error("恢复时返回了不同的任务 ID。");
837
890
  session.needsResume = false;
@@ -844,60 +897,42 @@ export class CodexRuntime implements AgentRuntime {
844
897
  session.needsResume = true;
845
898
  }
846
899
  }
847
- throw new Error(`Provider 切换失败:${providerSwitchFailure(error)} ${recovery}`, { cause: error });
900
+ throw new ProviderSwitchError(
901
+ `Provider 切换失败:${providerSwitchFailure(error)} ${recovery}`,
902
+ !session.needsResume && !session.settingsRecoveryError, { cause: error },
903
+ );
848
904
  }
849
- if (replacementId) await this.discardUnusedThread(client, previous.remoteSessionId);
850
905
  return session;
851
906
  }
852
907
 
853
- private async detachThreadForSettings(client: AppServerClient, session: CodexSession): Promise<void> {
908
+ private async detachThreadForSettings(client: AppServerClient, session: CodexSession, onDetach?: () => void): Promise<void> {
854
909
  const current = await client.request<ThreadReadResponse>("thread/read", {
855
910
  threadId: session.remoteSessionId, includeTurns: false,
856
911
  }, SYNC_REQUEST_TIMEOUT_MS);
857
912
  if (session.activeTurnId || current.thread.status?.type === "active") throw new Error(PROVIDER_SWITCH_BUSY);
913
+ // A timeout does not cancel the remote unsubscribe, so recovery is required once it is sent.
914
+ onDetach?.();
858
915
  const result = await client.request<{ status: string }>("thread/unsubscribe", {
859
916
  threadId: session.remoteSessionId,
860
917
  }, CONTROL_REQUEST_TIMEOUT_MS);
861
- if (result.status !== "unsubscribed" && result.status !== "notLoaded") {
862
- throw new Error("当前任务仍由其他客户端加载,无法安全切换 Provider。请先在原客户端释放任务。");
918
+ if (!["unsubscribed", "notLoaded", "notSubscribed"].includes(result.status)) {
919
+ throw new Error(`App Server 返回了未知的取消订阅状态:${result.status}`);
863
920
  }
864
921
  }
865
922
 
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。请先完成一轮任务后重试。");
875
- }
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
- }
888
-
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
- }
923
+ async respondToApproval(
924
+ sessionId: string,
925
+ requestId: string,
926
+ decision: ApprovalDecision,
927
+ ): Promise<void> {
928
+ return this.resolveApproval(sessionId, requestId, decision);
895
929
  }
896
930
 
897
- async respondToApproval(
931
+ private async resolveApproval(
898
932
  sessionId: string,
899
933
  requestId: string,
900
934
  decision: ApprovalDecision,
935
+ automatic = false,
901
936
  ): Promise<void> {
902
937
  const modeChange = this.modeChanges.get(requestId);
903
938
  if (modeChange) {
@@ -910,6 +945,8 @@ export class CodexRuntime implements AgentRuntime {
910
945
  }
911
946
  if (modeChange.responding) throw new Error("Approval response is already in progress.");
912
947
  modeChange.responding = true;
948
+ modeChange.decision = decision;
949
+ modeChange.automatic = automatic;
913
950
  try {
914
951
  const client = await this.client();
915
952
  if (this.modeChanges.get(requestId) !== modeChange) throw new Error("Approval request is no longer pending.");
@@ -919,11 +956,14 @@ export class CodexRuntime implements AgentRuntime {
919
956
  decision: decision === "accept" ? "approved" : decision === "decline" ? "rejected" : "cancelled",
920
957
  }, CONTROL_REQUEST_TIMEOUT_MS);
921
958
  if (this.modeChanges.get(requestId) === modeChange) {
959
+ this.confirmPlanContinuation(modeChange, decision);
922
960
  this.modeChanges.delete(requestId);
923
- this.emit({ type: "approval_resolved", sessionId, turnId: modeChange.turnId, requestId, decision });
961
+ this.emit({ type: "approval_resolved", sessionId, turnId: modeChange.turnId, requestId, decision,
962
+ ...(automatic ? { automatic: true } : {}) });
924
963
  }
925
964
  } finally {
926
965
  modeChange.responding = false;
966
+ if (modeChange.terminalEvent) this.emit(modeChange.terminalEvent);
927
967
  }
928
968
  return;
929
969
  }
@@ -1174,34 +1214,45 @@ export class CodexRuntime implements AgentRuntime {
1174
1214
  const requestId = `mode:${mapped.threadId}:${mapped.requestId}`;
1175
1215
  if (session.modeChangeRequestIds.has(requestId)) return;
1176
1216
  session.modeChangeRequestIds.add(requestId);
1177
- this.modeChanges.set(requestId, {
1217
+ const pending: PendingModeChange = {
1178
1218
  sessionId, turnId: mapped.turnId, requestId: mapped.requestId,
1179
1219
  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
- });
1220
+ };
1221
+ this.modeChanges.set(requestId, pending);
1222
+ const automatic = session.permissionMode === "auto" && this.provider.getAgentFamily?.() === "traex";
1223
+ const request: ApprovalRequest = {
1224
+ id: requestId,
1225
+ kind: "mode_change",
1226
+ ...(automatic ? { automatic: true } : {}),
1227
+ title: mapped.targetMode === "default" ? "确认方案并开始执行" : "确认进入规划模式",
1228
+ reason: [mapped.reason, ...(mapped.allowedPrompts ?? []).map((entry) => `- ${entry.tool}: ${entry.prompt}`)]
1229
+ .filter(Boolean).join("\n"),
1230
+ options: [
1231
+ { id: "accept", label: mapped.targetMode === "default" ? "Approve Plan" : "Enter Plan Mode" },
1232
+ { id: "decline", label: "Reject" },
1233
+ { id: "cancel", label: "Cancel Request" },
1234
+ ],
1235
+ };
1236
+ this.emit({ type: "approval_requested", sessionId, turnId: mapped.turnId, request });
1237
+ if (automatic) {
1238
+ void this.resolveApproval(sessionId, requestId, "accept", true).catch((error: unknown) => {
1239
+ this.logger.warn({ error, sessionId, requestId }, "Failed to automatically approve the TraeX mode change.");
1240
+ if (this.modeChanges.get(requestId) !== pending || session.activeTurnId !== mapped.turnId) return;
1241
+ this.emit({ type: "approval_requested", sessionId, turnId: mapped.turnId,
1242
+ request: { ...request, automatic: false, reason: ["自动同意失败,请手动确认。", request.reason].filter(Boolean).join("\n\n") } });
1243
+ });
1244
+ }
1196
1245
  } else if (mapped.kind === "mode_change_resolved") {
1197
1246
  const requestId = `mode:${mapped.threadId}:${mapped.requestId}`;
1198
1247
  const pending = this.modeChanges.get(requestId);
1199
1248
  if (!pending || pending.sessionId !== sessionId || pending.turnId !== mapped.turnId
1200
1249
  || pending.targetMode !== mapped.targetMode) return;
1250
+ this.confirmPlanContinuation(pending, mapped.decision === "approved" ? "accept" : "decline");
1201
1251
  this.modeChanges.delete(requestId);
1202
1252
  this.emit({
1203
1253
  type: "approval_resolved", sessionId, turnId: mapped.turnId, requestId,
1204
1254
  decision: mapped.decision === "approved" ? "accept" : mapped.decision === "rejected" ? "decline" : "cancel",
1255
+ ...(pending.automatic && mapped.decision === "approved" ? { automatic: true } : {}),
1205
1256
  });
1206
1257
  } else if (mapped.kind === "token_usage") {
1207
1258
  this.emit({
@@ -1232,6 +1283,7 @@ export class CodexRuntime implements AgentRuntime {
1232
1283
  if (mapped.phase === "final_answer") this.promoteUnphasedMessage(session, mapped.turnId, mapped.itemId);
1233
1284
  else if (session.unphasedFinalMessageId === mapped.itemId) session.unphasedFinalMessageId = undefined;
1234
1285
  } else if (mapped.kind === "agent_delta") {
1286
+ this.clearPlanContinuation(session, mapped.turnId);
1235
1287
  const phase = session.messagePhases.get(mapped.itemId);
1236
1288
  if (phase !== "final_answer") {
1237
1289
  // Providers may omit phase. Keep their text in sequence until a tool or completion disambiguates it.
@@ -1283,6 +1335,8 @@ export class CodexRuntime implements AgentRuntime {
1283
1335
  } else if (mapped.kind === "plan") {
1284
1336
  this.emit({ type: "plan_updated", sessionId, turnId: mapped.turnId, steps: mapped.steps });
1285
1337
  } else if (mapped.kind === "tool") {
1338
+ // A backend that resumes tool execution itself needs no client handoff.
1339
+ if (mapped.phase === "started") this.clearPlanContinuation(session, mapped.turnId);
1286
1340
  if (mapped.phase === "started") session.unphasedFinalMessageId = undefined;
1287
1341
  if (
1288
1342
  mapped.phase === "updated"
@@ -1409,6 +1463,8 @@ export class CodexRuntime implements AgentRuntime {
1409
1463
  ...threadLifecycleParams(session.cwd),
1410
1464
  ...permissionParams(session.permissionMode),
1411
1465
  }, SESSION_REQUEST_TIMEOUT_MS);
1466
+ if (response.thread.id !== session.remoteSessionId) throw new Error("App Server 返回了不同的任务 ID,未恢复会话。");
1467
+ assertProviderSettingsApplied(response, { modelProvider: session.modelProvider });
1412
1468
  session.workspaceSandboxPolicy = workspaceSandboxFromResponse(response.sandbox);
1413
1469
  }
1414
1470
 
@@ -1487,13 +1543,13 @@ export class CodexRuntime implements AgentRuntime {
1487
1543
  }
1488
1544
 
1489
1545
  private adoptTurn(session: CodexSession, turnId: string, startedAt: number): void {
1490
- session.canReplaceEmptyThread = false;
1491
1546
  if (!session.knownTurnIds.has(turnId)) {
1492
1547
  session.knownTurnIds.add(turnId);
1493
1548
  if (session.turnCount !== undefined) session.turnCount += 1;
1494
1549
  }
1495
1550
  session.activeTurnId = turnId;
1496
1551
  session.activeTurnStartedAt = startedAt;
1552
+ session.planContinuationTurnId = undefined;
1497
1553
  session.modeChangeRequestIds.clear();
1498
1554
  session.unphasedMessages.clear();
1499
1555
  session.unphasedFinalMessageId = undefined;
@@ -1596,7 +1652,6 @@ export class CodexRuntime implements AgentRuntime {
1596
1652
  unphasedMessages: new Map(),
1597
1653
  needsResume: false,
1598
1654
  workspaceSandboxPolicy: workspaceSandboxFromResponse(response.sandbox),
1599
- canReplaceEmptyThread: !("remoteSessionId" in input),
1600
1655
  rolloutPath: stringValue(response.thread.path)?.trim() || undefined,
1601
1656
  turnCount: "remoteSessionId" in input ? undefined : 0,
1602
1657
  knownTurnIds: new Set(),
@@ -1626,10 +1681,38 @@ export class CodexRuntime implements AgentRuntime {
1626
1681
  return session;
1627
1682
  }
1628
1683
 
1684
+ private clearPlanContinuation(session: CodexSession, turnId: string): void {
1685
+ session.planContinuationTurnId = undefined;
1686
+ for (const pending of this.modeChanges.values()) {
1687
+ if (pending.sessionId === session.localSessionId && pending.turnId === turnId && pending.responding) {
1688
+ pending.continued = true;
1689
+ }
1690
+ }
1691
+ }
1692
+
1693
+ private confirmPlanContinuation(pending: PendingModeChange, decision: ApprovalDecision): void {
1694
+ const session = this.sessions.get(pending.sessionId);
1695
+ // Do not start work approved in a different client.
1696
+ if (session && pending.responding && !pending.continued && pending.decision === "accept" && decision === "accept"
1697
+ && pending.targetMode === "default") session.planContinuationTurnId = pending.turnId;
1698
+ }
1699
+
1629
1700
  private emit(event: RuntimeEvent): void {
1630
1701
  if (event.type === "turn_completed" || event.type === "turn_cancelled" || event.type === "turn_failed") {
1631
1702
  for (const [requestId, pending] of this.modeChanges) {
1632
- if (pending.sessionId === event.sessionId && pending.turnId === event.turnId) this.modeChanges.delete(requestId);
1703
+ if (pending.sessionId !== event.sessionId || pending.turnId !== event.turnId) continue;
1704
+ if (event.type === "turn_completed" && pending.responding && pending.decision === "accept"
1705
+ && pending.targetMode === "default") {
1706
+ // The terminal notification may beat the approval RPC response.
1707
+ pending.terminalEvent = event;
1708
+ return;
1709
+ }
1710
+ this.modeChanges.delete(requestId);
1711
+ }
1712
+ const session = this.sessions.get(event.sessionId);
1713
+ if (session?.planContinuationTurnId === event.turnId) {
1714
+ session.planContinuationTurnId = undefined;
1715
+ if (event.type === "turn_completed") event = { ...event, approvedPlan: true };
1633
1716
  }
1634
1717
  }
1635
1718
  for (const listener of this.listeners) listener(event);
@@ -1642,6 +1725,7 @@ export class CodexRuntime implements AgentRuntime {
1642
1725
  this.modeChanges.clear();
1643
1726
  for (const session of this.sessions.values()) {
1644
1727
  session.needsResume = true;
1728
+ session.planContinuationTurnId = undefined;
1645
1729
  const turnId = session.activeTurnId;
1646
1730
  if (!turnId) continue;
1647
1731
  session.activeTurnId = undefined;
@@ -2037,13 +2121,6 @@ function remoteThreadStatus(value: string | undefined): RemoteSessionSummary["st
2037
2121
  return "not_loaded";
2038
2122
  }
2039
2123
 
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
2124
  function codexSourceLabel(source: unknown): string {
2048
2125
  if (typeof source === "string") return source;
2049
2126
  if (isRecord(source)) {
@@ -1,24 +1,23 @@
1
- import { AppServerRequestError } from "./AppServerConnection.js";
2
1
  import type { RuntimeExecutionSettings } from "../runtime/types.js";
3
2
 
4
3
  export const PROVIDER_SWITCH_BUSY = "当前任务正在执行,暂时不能切换 Provider。请等待任务完成,或停止任务后重试。";
4
+ export const PROVIDER_SWITCH_EMPTY = "App Server 尚未保存此空会话,无法在保留任务 ID 的前提下切换 Provider。未改变会话或设置,也不会自动创建替代任务。";
5
5
 
6
- export function isMissingRolloutError(error: unknown): boolean {
7
- if (error instanceof AppServerRequestError && error.method !== "thread/resume") return false;
8
- const message = error instanceof AppServerRequestError ? error.serverMessage
9
- : error instanceof Error ? error.message : String(error);
10
- if (/paginated history lineage|missing source rollout/iu.test(message)) return false;
11
- return /no rollout found|rollout[^\n]*(?:not found|missing)/iu.test(message);
6
+ export class ProviderSwitchError extends Error {
7
+ constructor(message: string, readonly previousSettingsRestored: boolean, options?: ErrorOptions) {
8
+ super(message, options);
9
+ this.name = "ProviderSwitchError";
10
+ }
12
11
  }
13
12
 
14
13
  export function assertProviderSettingsApplied(
15
14
  actual: { modelProvider?: string; model?: string },
16
- requested: Pick<RuntimeExecutionSettings, "modelProvider" | "model">,
15
+ requested: Partial<Pick<RuntimeExecutionSettings, "modelProvider" | "model">>,
17
16
  ): void {
18
- if (actual.modelProvider !== requested.modelProvider) {
19
- throw new Error(`Provider 未生效:请求 ${requested.modelProvider},实际返回 ${actual.modelProvider ?? "未知"}。`);
17
+ if (requested.modelProvider !== undefined && actual.modelProvider !== requested.modelProvider) {
18
+ throw new Error(`Provider 未生效:请求 ${requested.modelProvider},实际返回 ${actual.modelProvider ?? "未知"}。App Server 未应用设置;可在任务空闲后安全重启再重试,不会自动 Fork 或更改历史。`);
20
19
  }
21
- if (actual.model !== requested.model) {
20
+ if (requested.model !== undefined && actual.model !== requested.model) {
22
21
  throw new Error(`模型未生效:请求 ${requested.model},实际返回 ${actual.model ?? "未知"}。请确认目标 Provider 支持该模型。`);
23
22
  }
24
23
  }
@@ -29,7 +28,7 @@ export function providerSwitchFailure(error: unknown): string {
29
28
  if (/model[^\n]*(?:unsupported|not supported|not found|unavailable|does not exist)|unknown model/iu.test(message)) {
30
29
  return `目标 Provider 不支持当前模型,请选择兼容的模型后重试。${message}`;
31
30
  }
32
- if (/unauthorized|forbidden|auth(?:entication|orization)|api.?key|provider[^\n]*(?:not found|unknown|not configured)/iu.test(message)) {
31
+ if (/unauthorized|forbidden|auth(?:enticat(?:e|ion)|orization)|api.?key|provider[^\n]*(?:not found|unknown|not configured)/iu.test(message)) {
33
32
  return `请检查目标 Provider 的配置和认证信息。${message}`;
34
33
  }
35
34
  return message;
@@ -33,7 +33,9 @@ export function writeAgentExecutionDefaults(
33
33
  for (const key of DEFAULT_KEYS) {
34
34
  const value = defaults[key];
35
35
  if (value !== undefined) target.set(key, value);
36
+ else target.delete(key);
36
37
  }
38
+ if (target.items.length === 0) agent.delete("defaults");
37
39
 
38
40
  const updated = document.toString();
39
41
  if (updated === original) return false;
@@ -30,6 +30,10 @@ updates:
30
30
  # Daily stable update check, private 60-second cancellation window, then safe update.
31
31
  enabled: true
32
32
 
33
+ terminal:
34
+ enabled: true
35
+ # Shares fileViewer host, port and publicBaseUrl; keeps independent owner authorization.
36
+
33
37
  fileViewer:
34
38
  enabled: true
35
39
  # Use 0.0.0.0 to accept LAN connections and automatically prefer