@keyou007/agent-bot 0.1.24-alpha.4 → 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 (108) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/README.md +42 -6
  3. package/README.zh.md +42 -6
  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/AppServerPermissions.js +34 -0
  12. package/dist/codex/AppServerPermissions.js.map +1 -0
  13. package/dist/codex/CodexLocalActivityDetector.js +37 -5
  14. package/dist/codex/CodexLocalActivityDetector.js.map +1 -1
  15. package/dist/codex/CodexRuntime.js +238 -127
  16. package/dist/codex/CodexRuntime.js.map +1 -1
  17. package/dist/codex/ProviderSwitch.js +12 -13
  18. package/dist/codex/ProviderSwitch.js.map +1 -1
  19. package/dist/codex/ThreadWriterProcess.js +19 -5
  20. package/dist/codex/ThreadWriterProcess.js.map +1 -1
  21. package/dist/config/AgentExecutionDefaultsStore.js +4 -0
  22. package/dist/config/AgentExecutionDefaultsStore.js.map +1 -1
  23. package/dist/config/loadConfig.js +4 -0
  24. package/dist/config/loadConfig.js.map +1 -1
  25. package/dist/config/schema.js +3 -0
  26. package/dist/config/schema.js.map +1 -1
  27. package/dist/feishu/CardRenderer.js +146 -72
  28. package/dist/feishu/CardRenderer.js.map +1 -1
  29. package/dist/feishu/FeishuConnector.js +1 -0
  30. package/dist/feishu/FeishuConnector.js.map +1 -1
  31. package/dist/feishu/FeishuTurnPresenter.js +121 -17
  32. package/dist/feishu/FeishuTurnPresenter.js.map +1 -1
  33. package/dist/index.js +43 -6
  34. package/dist/index.js.map +1 -1
  35. package/dist/local-files/CsvPreview.js +148 -0
  36. package/dist/local-files/CsvPreview.js.map +1 -0
  37. package/dist/local-files/LocalFileViewerServer.js +104 -14
  38. package/dist/local-files/LocalFileViewerServer.js.map +1 -1
  39. package/dist/local-files/TurnPreviewPage.js +128 -12
  40. package/dist/local-files/TurnPreviewPage.js.map +1 -1
  41. package/dist/presentation/TurnStateReducer.js +80 -6
  42. package/dist/presentation/TurnStateReducer.js.map +1 -1
  43. package/dist/proxy/ProxySessionController.js +767 -260
  44. package/dist/proxy/ProxySessionController.js.map +1 -1
  45. package/dist/proxy/SessionOperationQueue.js +14 -0
  46. package/dist/proxy/SessionOperationQueue.js.map +1 -0
  47. package/dist/runtime/AcpRuntimeAdapter.js +4 -2
  48. package/dist/runtime/AcpRuntimeAdapter.js.map +1 -1
  49. package/dist/state/StateStore.js +22 -0
  50. package/dist/state/StateStore.js.map +1 -1
  51. package/dist/state/TurnPreviewJournal.js +19 -7
  52. package/dist/state/TurnPreviewJournal.js.map +1 -1
  53. package/dist/state/migrations.js +6 -0
  54. package/dist/state/migrations.js.map +1 -1
  55. package/dist/supervision/SafeRestartNotifier.js +1 -0
  56. package/dist/supervision/SafeRestartNotifier.js.map +1 -1
  57. package/dist/supervision/SafeRestartScheduler.js +19 -6
  58. package/dist/supervision/SafeRestartScheduler.js.map +1 -1
  59. package/dist/terminal/TerminalCards.js +13 -0
  60. package/dist/terminal/TerminalCards.js.map +1 -0
  61. package/dist/terminal/TerminalLinks.js +76 -0
  62. package/dist/terminal/TerminalLinks.js.map +1 -0
  63. package/dist/terminal/TerminalPage.js +212 -0
  64. package/dist/terminal/TerminalPage.js.map +1 -0
  65. package/dist/terminal/TerminalProcess.js +71 -0
  66. package/dist/terminal/TerminalProcess.js.map +1 -0
  67. package/dist/terminal/TerminalServer.js +639 -0
  68. package/dist/terminal/TerminalServer.js.map +1 -0
  69. package/docs/technical-reference.md +53 -3
  70. package/docs/technical-reference.zh.md +52 -2
  71. package/npm-shrinkwrap.json +55 -5
  72. package/package.json +11 -4
  73. package/skills/agent-bot/SKILL.md +15 -2
  74. package/src/cli/controlProtocol.ts +2 -1
  75. package/src/cli/serverStatus.ts +1 -0
  76. package/src/cli.ts +6 -1
  77. package/src/codex/AppServerPermissions.ts +39 -0
  78. package/src/codex/CodexLocalActivityDetector.ts +34 -4
  79. package/src/codex/CodexRuntime.ts +243 -139
  80. package/src/codex/ProviderSwitch.ts +11 -12
  81. package/src/codex/ThreadWriterProcess.ts +17 -9
  82. package/src/config/AgentExecutionDefaultsStore.ts +2 -0
  83. package/src/config/loadConfig.ts +4 -0
  84. package/src/config/schema.ts +5 -1
  85. package/src/feishu/CardRenderer.ts +159 -72
  86. package/src/feishu/FeishuConnector.ts +1 -0
  87. package/src/feishu/FeishuTurnPresenter.ts +108 -16
  88. package/src/feishu/types.ts +1 -0
  89. package/src/index.ts +44 -7
  90. package/src/local-files/CsvPreview.ts +132 -0
  91. package/src/local-files/LocalFileViewerServer.ts +102 -15
  92. package/src/local-files/TurnPreviewPage.ts +131 -13
  93. package/src/presentation/TurnStateReducer.ts +87 -7
  94. package/src/presentation/turnViewTypes.ts +9 -0
  95. package/src/proxy/ProxySessionController.ts +762 -259
  96. package/src/proxy/SessionOperationQueue.ts +13 -0
  97. package/src/runtime/AcpRuntimeAdapter.ts +5 -3
  98. package/src/runtime/types.ts +10 -3
  99. package/src/state/StateStore.ts +38 -1
  100. package/src/state/TurnPreviewJournal.ts +19 -7
  101. package/src/state/migrations.ts +6 -0
  102. package/src/supervision/SafeRestartNotifier.ts +1 -0
  103. package/src/supervision/SafeRestartScheduler.ts +18 -6
  104. package/src/terminal/TerminalCards.ts +18 -0
  105. package/src/terminal/TerminalLinks.ts +61 -0
  106. package/src/terminal/TerminalPage.ts +213 -0
  107. package/src/terminal/TerminalProcess.ts +71 -0
  108. 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,
@@ -30,11 +31,12 @@ import type {
30
31
  import { appendGeneratedImageMarkdown } from "../utils/generatedImageMarkdown.js";
31
32
  import { normalizeTaskTitle } from "../utils/taskTitle.js";
32
33
  import { AppServerRequestError } from "./AppServerConnection.js";
34
+ import { workspaceSandboxFromConfig, workspaceSandboxFromResponse, type TurnSandboxPolicy, type WorkspaceSandboxPolicy } from "./AppServerPermissions.js";
33
35
  import { formatCodexError, mapCodexNotification } from "./CodexEventMapper.js";
34
36
  import { CodexLocalActivityDetector } from "./CodexLocalActivityDetector.js";
35
37
  import { detectProjectlessWorkspace } from "./ProjectlessWorkspace.js";
36
38
  import { threadWriterLockPath } from "./ThreadWriterProcess.js";
37
- import { assertProviderSettingsApplied, PROVIDER_SWITCH_BUSY, providerSwitchFailure } from "./ProviderSwitch.js";
39
+ import { assertProviderSettingsApplied, PROVIDER_SWITCH_BUSY, PROVIDER_SWITCH_EMPTY, ProviderSwitchError, providerSwitchFailure } from "./ProviderSwitch.js";
38
40
  import { fetchProviderModels, type ProviderModelConfig } from "./ProviderModels.js";
39
41
 
40
42
  const WINDOWS_SCREENSHOT_DEVELOPER_INSTRUCTIONS = [
@@ -83,13 +85,14 @@ interface CodexSession extends RuntimeSession {
83
85
  activeTurnStartedAt?: number;
84
86
  terminalTurnIds: Set<string>;
85
87
  modeChangeRequestIds: Set<string>;
88
+ planContinuationTurnId?: string;
86
89
  finalText: string;
87
90
  generatedImagePaths: string[];
88
91
  messagePhases: Map<string, "commentary" | "final_answer">;
89
92
  unphasedMessages: Map<string, string>;
90
93
  unphasedFinalMessageId?: string;
91
94
  needsResume: boolean;
92
- canReplaceEmptyThread: boolean;
95
+ workspaceSandboxPolicy?: WorkspaceSandboxPolicy;
93
96
  settingsRecoveryError?: string;
94
97
  rolloutPath?: string;
95
98
  turnCount?: number;
@@ -108,6 +111,10 @@ interface PendingModeChange {
108
111
  requestId: string;
109
112
  targetMode: "plan" | "default";
110
113
  responding: boolean;
114
+ decision?: ApprovalDecision;
115
+ automatic?: boolean;
116
+ continued?: boolean;
117
+ terminalEvent?: Extract<RuntimeEvent, { type: "turn_completed" }>;
111
118
  }
112
119
 
113
120
  export class CodexRuntime implements AgentRuntime {
@@ -138,6 +145,10 @@ export class CodexRuntime implements AgentRuntime {
138
145
  return this.sessions.get(localSessionId);
139
146
  }
140
147
 
148
+ forgetSession(localSessionId: string): void {
149
+ this.sessions.delete(localSessionId);
150
+ }
151
+
141
152
  getProcessInfo(): AgentProcessInfo {
142
153
  return this.provider.getProcessInfo?.() ?? {};
143
154
  }
@@ -178,6 +189,20 @@ export class CodexRuntime implements AgentRuntime {
178
189
  return this.runSessionOperation(input.localSessionId, () => this.resumeSessionNow(input));
179
190
  }
180
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
+
181
206
  private async resumeSessionNow(input: ResumeRuntimeSessionInput): Promise<RuntimeSession> {
182
207
  const client = await this.client();
183
208
  const response = await client.request<ThreadResponse>("thread/resume", {
@@ -189,8 +214,16 @@ export class CodexRuntime implements AgentRuntime {
189
214
  ...threadLifecycleParams(input.cwd),
190
215
  ...permissionParams(input.permissionMode),
191
216
  }, SESSION_REQUEST_TIMEOUT_MS);
217
+ if (response.thread.id !== input.remoteSessionId) throw new Error("App Server 返回了不同的任务 ID,未恢复会话。");
192
218
  const reasoningEffort = await this.resolveReasoningEffort(input, response);
193
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
+ }
194
227
  this.sessions.set(input.localSessionId, session);
195
228
  return session;
196
229
  }
@@ -237,15 +270,21 @@ export class CodexRuntime implements AgentRuntime {
237
270
  const session = this.requireSession(sessionId);
238
271
  const client = await this.client();
239
272
  await this.ensureSessionResumed(session, client);
240
- session.canReplaceEmptyThread = false;
241
- const start = () => client.request<{ turn: { id: string } }>("turn/start", {
273
+ const start = async () => client.request<{ turn: { id: string } }>("turn/start", {
242
274
  threadId: session.remoteSessionId,
243
275
  input: codexUserInput(prompt),
244
276
  cwd: session.cwd,
245
277
  model: session.model,
246
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
+ } : {}),
247
285
  summary: "auto",
248
286
  approvalPolicy: session.permissionMode === "auto" ? "never" : "on-request",
287
+ sandboxPolicy: await this.turnSandboxPolicy(session, client),
249
288
  }, SESSION_REQUEST_TIMEOUT_MS);
250
289
  let response: { turn: { id: string } };
251
290
  try {
@@ -294,7 +333,9 @@ export class CodexRuntime implements AgentRuntime {
294
333
  return listed;
295
334
  }
296
335
  }));
297
- const activeThreads = await this.localActivityDetector?.activeThreads(sessions.map((session) => session.id));
336
+ const activeThreads = await this.localActivityDetector?.activeThreads(
337
+ sessions.filter((session) => session.status === "not_loaded").map((session) => session.id),
338
+ );
298
339
  return {
299
340
  sessions: activeThreads
300
341
  ? sessions.map((session) => markLocallyDetectedActive(session, activeThreads))
@@ -499,7 +540,8 @@ export class CodexRuntime implements AgentRuntime {
499
540
  remoteSessionId: string,
500
541
  summary: RemoteSessionSummary,
501
542
  ): Promise<RemoteSessionSummary> {
502
- const activeThreads = await this.localActivityDetector?.activeThreads([remoteSessionId]);
543
+ const activeThreads = summary.status === "not_loaded"
544
+ ? await this.localActivityDetector?.activeThreads([remoteSessionId]) : undefined;
503
545
  const activeSummary = activeThreads ? markLocallyDetectedActive(summary, activeThreads) : summary;
504
546
  const settings = await this.localActivityDetector?.threadSettings([remoteSessionId]);
505
547
  return {
@@ -571,24 +613,23 @@ export class CodexRuntime implements AgentRuntime {
571
613
  }
572
614
 
573
615
  async inspectRemoteSessionActivity(remoteSessionId: string): Promise<RemoteSessionActivity> {
574
- const [response, activeThreads] = await Promise.all([
575
- (await this.client()).request<ThreadReadResponse>(
576
- "thread/read",
577
- { threadId: remoteSessionId, includeTurns: false },
578
- SYNC_REQUEST_TIMEOUT_MS,
579
- ),
580
- this.localActivityDetector?.activeThreads([remoteSessionId]),
581
- ]);
616
+ const response = await (await this.client()).request<ThreadReadResponse>(
617
+ "thread/read", { threadId: remoteSessionId, includeTurns: false }, SYNC_REQUEST_TIMEOUT_MS,
618
+ );
619
+ const status = remoteThreadStatus(response.thread.status?.type);
620
+ const activeThreads = status === "not_loaded"
621
+ ? await this.localActivityDetector?.activeThreads([remoteSessionId]) : undefined;
582
622
  const runtimeSession = [...this.sessions.values()]
583
623
  .find((session) => session.remoteSessionId === remoteSessionId);
584
624
  const detectedTurnId = activeThreads?.get(remoteSessionId);
585
- const active = remoteThreadStatus(response.thread.status?.type) === "active"
586
- || Boolean(runtimeSession?.activeTurnId)
587
- || Boolean(activeThreads?.has(remoteSessionId));
588
- const activeTurnId = detectedTurnId ?? runtimeSession?.activeTurnId;
625
+ // A turn started in this process is evidence; a restored activeTurnId alone is not.
626
+ const observedTurnId = runtimeSession?.activeTurnStartedAt !== undefined ? runtimeSession.activeTurnId : undefined;
627
+ const active = status === "active" || Boolean(activeThreads?.has(remoteSessionId))
628
+ || Boolean(observedTurnId);
629
+ const activeTurnId = detectedTurnId ?? observedTurnId;
589
630
  return {
590
631
  active,
591
- ...(activeTurnId ? { activeTurnId } : {}),
632
+ ...(active && activeTurnId ? { activeTurnId } : {}),
592
633
  };
593
634
  }
594
635
 
@@ -619,6 +660,12 @@ export class CodexRuntime implements AgentRuntime {
619
660
  }
620
661
 
621
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
+ }
622
669
  await (await this.client()).request(
623
670
  "turn/interrupt",
624
671
  { threadId: remoteSessionId, turnId },
@@ -637,17 +684,51 @@ export class CodexRuntime implements AgentRuntime {
637
684
  });
638
685
  }
639
686
 
640
- async archiveRemoteSession(remoteSessionId: string): Promise<void> {
641
- await (await this.client()).request(
642
- "thread/archive",
643
- { threadId: remoteSessionId },
644
- CONTROL_REQUEST_TIMEOUT_MS,
645
- );
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
+ }
646
705
  for (const [localSessionId, session] of this.sessions) {
647
706
  if (session.remoteSessionId === remoteSessionId) this.sessions.delete(localSessionId);
648
707
  }
649
708
  }
650
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
+
651
732
  async setTitle(sessionId: string, title: string): Promise<void> {
652
733
  return this.runSessionOperation(sessionId, () => this.setTitleNow(sessionId, title));
653
734
  }
@@ -688,7 +769,6 @@ export class CodexRuntime implements AgentRuntime {
688
769
  const session = this.requireSession(sessionId);
689
770
  const client = await this.client();
690
771
  await this.ensureSessionResumed(session, client);
691
- session.canReplaceEmptyThread = false;
692
772
  const response = await client.request<{ goal: RuntimeGoal }>(
693
773
  "thread/goal/set",
694
774
  { threadId: session.remoteSessionId, ...update },
@@ -730,7 +810,16 @@ export class CodexRuntime implements AgentRuntime {
730
810
  settings: RuntimeExecutionSettings,
731
811
  persist?: (session: RuntimeSession) => Promise<void>,
732
812
  ): Promise<RuntimeSession> {
733
- 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
+ });
734
823
  }
735
824
 
736
825
  private async setExecutionSettingsNow(
@@ -748,84 +837,58 @@ export class CodexRuntime implements AgentRuntime {
748
837
  if (metadata.thread.status?.type === "active" || localActive?.has(session.remoteSessionId) || session.activeTurnId) {
749
838
  throw new Error(PROVIDER_SWITCH_BUSY);
750
839
  }
751
- let empty = false;
752
- if (session.canReplaceEmptyThread && !metadata.thread.forkedFromId && !metadata.thread.preview?.trim()) {
840
+ if (session.turnCount === 0 && !metadata.thread.forkedFromId && !metadata.thread.preview?.trim()) {
753
841
  try {
754
842
  const turns = await this.readLatestThreadTurns(client, session.remoteSessionId, "summary");
755
- empty = turns.length === 0;
756
- if (!empty) session.canReplaceEmptyThread = false;
843
+ if (turns.length === 0) throw new Error(PROVIDER_SWITCH_EMPTY);
757
844
  } catch (error) {
758
845
  if (!(error instanceof AppServerRequestError) || error.method !== "thread/turns/list"
759
846
  || !/missing source rollout|no rollout found/iu.test(error.serverMessage)) throw error;
760
- empty = true;
847
+ throw new Error(PROVIDER_SWITCH_EMPTY, { cause: error });
761
848
  }
762
849
  }
763
850
  const previous = { ...session };
764
- let replacementId: string | undefined;
851
+ const restoreProvider = previous.modelProvider?.trim()
852
+ || (metadata.thread.id === previous.remoteSessionId ? metadata.thread.modelProvider?.trim() : undefined);
765
853
  let detached = false;
766
854
  try {
767
855
  const params = {
768
856
  cwd: session.cwd, modelProvider: settings.modelProvider, model: settings.model,
769
857
  ...threadLifecycleParams(session.cwd), ...permissionParams(settings.permissionMode),
770
858
  };
771
- if (!empty) {
772
- await this.detachThreadForSettings(client, session);
773
- detached = true;
774
- }
775
- let response: ThreadResponse;
776
- if (empty) {
777
- response = await client.request<ThreadResponse>("thread/start", {
778
- ...params, threadSource: "user", allowProviderModelFallback: false,
779
- }, SESSION_REQUEST_TIMEOUT_MS);
780
- replacementId = response.thread.id;
781
- } else {
782
- response = await client.request<ThreadResponse>("thread/resume", {
783
- ...params, threadId: session.remoteSessionId, excludeTurns: true,
784
- }, SESSION_REQUEST_TIMEOUT_MS);
785
- if (!providerSettingsMatch(response, settings)) {
786
- this.logger.warn(
787
- {
788
- sessionId,
789
- threadId: session.remoteSessionId,
790
- requestedProvider: settings.modelProvider,
791
- actualProvider: response.modelProvider,
792
- requestedModel: settings.model,
793
- actualModel: response.model,
794
- },
795
- "App Server did not apply Provider settings while resuming; forking the latest completed Turn.",
796
- );
797
- await this.detachThreadForSettings(client, session);
798
- response = await this.forkThreadForSettings(client, session, settings);
799
- replacementId = response.thread.id;
800
- }
801
- }
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);
802
863
  assertProviderSettingsApplied(response, settings);
803
- if (!empty && !replacementId && response.thread.id !== session.remoteSessionId) {
864
+ if (response.thread.id !== session.remoteSessionId) {
804
865
  throw new Error("App Server 返回了不同的任务 ID,未保存设置。");
805
866
  }
806
- if (session.title && (empty || replacementId)) await client.request("thread/name/set", {
807
- threadId: response.thread.id, name: session.title,
808
- }, SESSION_REQUEST_TIMEOUT_MS);
809
- const candidate = { ...session, ...settings, remoteSessionId: response.thread.id,
867
+ const candidate = { ...session, ...settings,
868
+ workspaceSandboxPolicy: workspaceSandboxFromResponse(response.sandbox),
810
869
  needsResume: false, settingsRecoveryError: undefined };
811
870
  await persist?.(candidate);
812
- Object.assign(session, candidate);
871
+ Object.assign(session, settings);
872
+ session.workspaceSandboxPolicy = candidate.workspaceSandboxPolicy;
873
+ session.needsResume = this.attachedClient !== client;
874
+ session.settingsRecoveryError = undefined;
813
875
  } catch (error) {
814
- if (replacementId) await this.discardUnusedThread(client, replacementId);
815
876
  let recovery = "原任务设置未更改。";
816
877
  if (detached) {
817
878
  try {
879
+ if (!restoreProvider) throw new Error("无法确认切换前的 Provider,不能验证原设置已恢复。");
818
880
  await this.detachThreadForSettings(client, previous);
819
881
  const restored = await client.request<ThreadResponse>("thread/resume", {
820
882
  threadId: previous.remoteSessionId, excludeTurns: true, cwd: previous.cwd,
821
- modelProvider: previous.modelProvider, model: previous.model,
883
+ modelProvider: restoreProvider, model: previous.model,
822
884
  ...threadLifecycleParams(previous.cwd), ...permissionParams(previous.permissionMode),
823
885
  }, SESSION_REQUEST_TIMEOUT_MS);
824
- if (previous.modelProvider && previous.model) assertProviderSettingsApplied(restored, {
825
- modelProvider: previous.modelProvider, model: previous.model,
886
+ assertProviderSettingsApplied(restored, {
887
+ modelProvider: restoreProvider, model: previous.model,
826
888
  });
827
889
  if (restored.thread.id !== previous.remoteSessionId) throw new Error("恢复时返回了不同的任务 ID。");
828
890
  session.needsResume = false;
891
+ session.workspaceSandboxPolicy = workspaceSandboxFromResponse(restored.sandbox);
829
892
  session.settingsRecoveryError = undefined;
830
893
  } catch (restoreError) {
831
894
  this.logger.warn({ error: restoreError, sessionId }, "Failed to restore Provider after a rejected settings change.");
@@ -834,60 +897,42 @@ export class CodexRuntime implements AgentRuntime {
834
897
  session.needsResume = true;
835
898
  }
836
899
  }
837
- 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
+ );
838
904
  }
839
- if (replacementId) await this.discardUnusedThread(client, previous.remoteSessionId);
840
905
  return session;
841
906
  }
842
907
 
843
- private async detachThreadForSettings(client: AppServerClient, session: CodexSession): Promise<void> {
908
+ private async detachThreadForSettings(client: AppServerClient, session: CodexSession, onDetach?: () => void): Promise<void> {
844
909
  const current = await client.request<ThreadReadResponse>("thread/read", {
845
910
  threadId: session.remoteSessionId, includeTurns: false,
846
911
  }, SYNC_REQUEST_TIMEOUT_MS);
847
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?.();
848
915
  const result = await client.request<{ status: string }>("thread/unsubscribe", {
849
916
  threadId: session.remoteSessionId,
850
917
  }, CONTROL_REQUEST_TIMEOUT_MS);
851
- if (result.status !== "unsubscribed" && result.status !== "notLoaded") {
852
- throw new Error("当前任务仍由其他客户端加载,无法安全切换 Provider。请先在原客户端释放任务。");
918
+ if (!["unsubscribed", "notLoaded", "notSubscribed"].includes(result.status)) {
919
+ throw new Error(`App Server 返回了未知的取消订阅状态:${result.status}`);
853
920
  }
854
921
  }
855
922
 
856
- private async forkThreadForSettings(
857
- client: AppServerClient,
858
- session: CodexSession,
859
- settings: RuntimeExecutionSettings,
860
- ): Promise<ThreadResponse> {
861
- const turns = await this.listRecentThreadTurnsThroughCompleted(client, session.remoteSessionId);
862
- const lastTurnId = turns.find((turn) => turn.status === "completed")?.id;
863
- if (!lastTurnId) {
864
- throw new Error("无法为 Provider 切换找到已完成的 Turn。请先完成一轮任务后重试。");
865
- }
866
- return client.request<ThreadResponse>("thread/fork", {
867
- threadId: session.remoteSessionId,
868
- lastTurnId,
869
- cwd: session.cwd,
870
- model: settings.model,
871
- modelProvider: settings.modelProvider,
872
- threadSource: "user",
873
- excludeTurns: true,
874
- ...threadLifecycleParams(session.cwd),
875
- ...permissionParams(settings.permissionMode),
876
- }, FORK_REQUEST_TIMEOUT_MS);
877
- }
878
-
879
- private async discardUnusedThread(client: AppServerClient, threadId: string): Promise<void> {
880
- try {
881
- await client.request("thread/unsubscribe", { threadId }, CONTROL_REQUEST_TIMEOUT_MS);
882
- } catch (error) {
883
- this.logger.warn({ error, threadId }, "Failed to release an unused empty Thread after changing Provider.");
884
- }
923
+ async respondToApproval(
924
+ sessionId: string,
925
+ requestId: string,
926
+ decision: ApprovalDecision,
927
+ ): Promise<void> {
928
+ return this.resolveApproval(sessionId, requestId, decision);
885
929
  }
886
930
 
887
- async respondToApproval(
931
+ private async resolveApproval(
888
932
  sessionId: string,
889
933
  requestId: string,
890
934
  decision: ApprovalDecision,
935
+ automatic = false,
891
936
  ): Promise<void> {
892
937
  const modeChange = this.modeChanges.get(requestId);
893
938
  if (modeChange) {
@@ -900,6 +945,8 @@ export class CodexRuntime implements AgentRuntime {
900
945
  }
901
946
  if (modeChange.responding) throw new Error("Approval response is already in progress.");
902
947
  modeChange.responding = true;
948
+ modeChange.decision = decision;
949
+ modeChange.automatic = automatic;
903
950
  try {
904
951
  const client = await this.client();
905
952
  if (this.modeChanges.get(requestId) !== modeChange) throw new Error("Approval request is no longer pending.");
@@ -909,11 +956,14 @@ export class CodexRuntime implements AgentRuntime {
909
956
  decision: decision === "accept" ? "approved" : decision === "decline" ? "rejected" : "cancelled",
910
957
  }, CONTROL_REQUEST_TIMEOUT_MS);
911
958
  if (this.modeChanges.get(requestId) === modeChange) {
959
+ this.confirmPlanContinuation(modeChange, decision);
912
960
  this.modeChanges.delete(requestId);
913
- 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 } : {}) });
914
963
  }
915
964
  } finally {
916
965
  modeChange.responding = false;
966
+ if (modeChange.terminalEvent) this.emit(modeChange.terminalEvent);
917
967
  }
918
968
  return;
919
969
  }
@@ -1164,34 +1214,45 @@ export class CodexRuntime implements AgentRuntime {
1164
1214
  const requestId = `mode:${mapped.threadId}:${mapped.requestId}`;
1165
1215
  if (session.modeChangeRequestIds.has(requestId)) return;
1166
1216
  session.modeChangeRequestIds.add(requestId);
1167
- this.modeChanges.set(requestId, {
1217
+ const pending: PendingModeChange = {
1168
1218
  sessionId, turnId: mapped.turnId, requestId: mapped.requestId,
1169
1219
  targetMode: mapped.targetMode, responding: false,
1170
- });
1171
- this.emit({
1172
- type: "approval_requested", sessionId, turnId: mapped.turnId,
1173
- request: {
1174
- id: requestId,
1175
- kind: "mode_change",
1176
- title: mapped.targetMode === "default" ? "确认方案并开始执行" : "确认进入规划模式",
1177
- reason: [mapped.reason, ...(mapped.allowedPrompts ?? []).map((entry) => `- ${entry.tool}: ${entry.prompt}`)]
1178
- .filter(Boolean).join("\n"),
1179
- options: [
1180
- { id: "accept", label: mapped.targetMode === "default" ? "Approve Plan" : "Enter Plan Mode" },
1181
- { id: "decline", label: "Reject" },
1182
- { id: "cancel", label: "Cancel Request" },
1183
- ],
1184
- },
1185
- });
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
+ }
1186
1245
  } else if (mapped.kind === "mode_change_resolved") {
1187
1246
  const requestId = `mode:${mapped.threadId}:${mapped.requestId}`;
1188
1247
  const pending = this.modeChanges.get(requestId);
1189
1248
  if (!pending || pending.sessionId !== sessionId || pending.turnId !== mapped.turnId
1190
1249
  || pending.targetMode !== mapped.targetMode) return;
1250
+ this.confirmPlanContinuation(pending, mapped.decision === "approved" ? "accept" : "decline");
1191
1251
  this.modeChanges.delete(requestId);
1192
1252
  this.emit({
1193
1253
  type: "approval_resolved", sessionId, turnId: mapped.turnId, requestId,
1194
1254
  decision: mapped.decision === "approved" ? "accept" : mapped.decision === "rejected" ? "decline" : "cancel",
1255
+ ...(pending.automatic && mapped.decision === "approved" ? { automatic: true } : {}),
1195
1256
  });
1196
1257
  } else if (mapped.kind === "token_usage") {
1197
1258
  this.emit({
@@ -1222,6 +1283,7 @@ export class CodexRuntime implements AgentRuntime {
1222
1283
  if (mapped.phase === "final_answer") this.promoteUnphasedMessage(session, mapped.turnId, mapped.itemId);
1223
1284
  else if (session.unphasedFinalMessageId === mapped.itemId) session.unphasedFinalMessageId = undefined;
1224
1285
  } else if (mapped.kind === "agent_delta") {
1286
+ this.clearPlanContinuation(session, mapped.turnId);
1225
1287
  const phase = session.messagePhases.get(mapped.itemId);
1226
1288
  if (phase !== "final_answer") {
1227
1289
  // Providers may omit phase. Keep their text in sequence until a tool or completion disambiguates it.
@@ -1273,6 +1335,8 @@ export class CodexRuntime implements AgentRuntime {
1273
1335
  } else if (mapped.kind === "plan") {
1274
1336
  this.emit({ type: "plan_updated", sessionId, turnId: mapped.turnId, steps: mapped.steps });
1275
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);
1276
1340
  if (mapped.phase === "started") session.unphasedFinalMessageId = undefined;
1277
1341
  if (
1278
1342
  mapped.phase === "updated"
@@ -1390,7 +1454,7 @@ export class CodexRuntime implements AgentRuntime {
1390
1454
  }
1391
1455
 
1392
1456
  private async resumeAppServerSession(session: CodexSession, client: AppServerClient): Promise<void> {
1393
- await client.request("thread/resume", {
1457
+ const response = await client.request<ThreadResponse>("thread/resume", {
1394
1458
  threadId: session.remoteSessionId,
1395
1459
  excludeTurns: true,
1396
1460
  cwd: session.cwd,
@@ -1399,6 +1463,21 @@ export class CodexRuntime implements AgentRuntime {
1399
1463
  ...threadLifecycleParams(session.cwd),
1400
1464
  ...permissionParams(session.permissionMode),
1401
1465
  }, SESSION_REQUEST_TIMEOUT_MS);
1466
+ if (response.thread.id !== session.remoteSessionId) throw new Error("App Server 返回了不同的任务 ID,未恢复会话。");
1467
+ assertProviderSettingsApplied(response, { modelProvider: session.modelProvider });
1468
+ session.workspaceSandboxPolicy = workspaceSandboxFromResponse(response.sandbox);
1469
+ }
1470
+
1471
+ private async turnSandboxPolicy(session: CodexSession, client: AppServerClient): Promise<TurnSandboxPolicy> {
1472
+ if (session.permissionMode === "auto") return { type: "dangerFullAccess" };
1473
+ if (!session.workspaceSandboxPolicy) {
1474
+ // Resolve project-specific options once, without resuming or scanning history.
1475
+ const response = await client.request<{ config?: unknown }>("config/read", {
1476
+ cwd: session.cwd, includeLayers: false,
1477
+ }, CONTROL_REQUEST_TIMEOUT_MS);
1478
+ session.workspaceSandboxPolicy = workspaceSandboxFromConfig(response.config);
1479
+ }
1480
+ return session.workspaceSandboxPolicy;
1402
1481
  }
1403
1482
 
1404
1483
  private async synchronizeSessionNow(sessionId: string): Promise<RuntimeSession> {
@@ -1435,7 +1514,9 @@ export class CodexRuntime implements AgentRuntime {
1435
1514
  }
1436
1515
  if (latestInProgress) {
1437
1516
  this.supersedeTurn(session, activeTurnId);
1438
- this.adoptTurn(session, latestInProgress.id, turnStartedAt(latestInProgress));
1517
+ if (thread.status?.type === "active") {
1518
+ this.adoptTurn(session, latestInProgress.id, turnStartedAt(latestInProgress));
1519
+ }
1439
1520
  return;
1440
1521
  }
1441
1522
 
@@ -1462,13 +1543,13 @@ export class CodexRuntime implements AgentRuntime {
1462
1543
  }
1463
1544
 
1464
1545
  private adoptTurn(session: CodexSession, turnId: string, startedAt: number): void {
1465
- session.canReplaceEmptyThread = false;
1466
1546
  if (!session.knownTurnIds.has(turnId)) {
1467
1547
  session.knownTurnIds.add(turnId);
1468
1548
  if (session.turnCount !== undefined) session.turnCount += 1;
1469
1549
  }
1470
1550
  session.activeTurnId = turnId;
1471
1551
  session.activeTurnStartedAt = startedAt;
1552
+ session.planContinuationTurnId = undefined;
1472
1553
  session.modeChangeRequestIds.clear();
1473
1554
  session.unphasedMessages.clear();
1474
1555
  session.unphasedFinalMessageId = undefined;
@@ -1570,7 +1651,7 @@ export class CodexRuntime implements AgentRuntime {
1570
1651
  messagePhases: new Map(),
1571
1652
  unphasedMessages: new Map(),
1572
1653
  needsResume: false,
1573
- canReplaceEmptyThread: !("remoteSessionId" in input),
1654
+ workspaceSandboxPolicy: workspaceSandboxFromResponse(response.sandbox),
1574
1655
  rolloutPath: stringValue(response.thread.path)?.trim() || undefined,
1575
1656
  turnCount: "remoteSessionId" in input ? undefined : 0,
1576
1657
  knownTurnIds: new Set(),
@@ -1600,10 +1681,38 @@ export class CodexRuntime implements AgentRuntime {
1600
1681
  return session;
1601
1682
  }
1602
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
+
1603
1700
  private emit(event: RuntimeEvent): void {
1604
1701
  if (event.type === "turn_completed" || event.type === "turn_cancelled" || event.type === "turn_failed") {
1605
1702
  for (const [requestId, pending] of this.modeChanges) {
1606
- 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 };
1607
1716
  }
1608
1717
  }
1609
1718
  for (const listener of this.listeners) listener(event);
@@ -1616,6 +1725,7 @@ export class CodexRuntime implements AgentRuntime {
1616
1725
  this.modeChanges.clear();
1617
1726
  for (const session of this.sessions.values()) {
1618
1727
  session.needsResume = true;
1728
+ session.planContinuationTurnId = undefined;
1619
1729
  const turnId = session.activeTurnId;
1620
1730
  if (!turnId) continue;
1621
1731
  session.activeTurnId = undefined;
@@ -1642,6 +1752,7 @@ interface ThreadResponse {
1642
1752
  modelProvider?: string;
1643
1753
  model?: string;
1644
1754
  reasoningEffort?: string | null;
1755
+ sandbox?: unknown;
1645
1756
  }
1646
1757
 
1647
1758
  interface ThreadReadResponse {
@@ -2010,13 +2121,6 @@ function remoteThreadStatus(value: string | undefined): RemoteSessionSummary["st
2010
2121
  return "not_loaded";
2011
2122
  }
2012
2123
 
2013
- function providerSettingsMatch(
2014
- response: Pick<ThreadResponse, "modelProvider" | "model">,
2015
- settings: RuntimeExecutionSettings,
2016
- ): boolean {
2017
- return response.modelProvider === settings.modelProvider && response.model === settings.model;
2018
- }
2019
-
2020
2124
  function codexSourceLabel(source: unknown): string {
2021
2125
  if (typeof source === "string") return source;
2022
2126
  if (isRecord(source)) {