@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,7 +4,7 @@ import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { createProjectlessWorkspace, detectProjectlessWorkspace } from "../codex/ProjectlessWorkspace.js";
6
6
  import { CodexVersionError } from "../codex/CodexVersion.js";
7
- import { assertProviderSettingsApplied, isMissingRolloutError } from "../codex/ProviderSwitch.js";
7
+ import { assertProviderSettingsApplied, ProviderSwitchError } from "../codex/ProviderSwitch.js";
8
8
  import { SystemThreadWriterProcessController, } from "../codex/ThreadWriterProcess.js";
9
9
  import { resolveUserPath } from "../config/paths.js";
10
10
  import { DEFAULT_GROUP_NAME_FORMAT, } from "../config/schema.js";
@@ -22,6 +22,7 @@ import { errorLogValue } from "../logging/errorLogValue.js";
22
22
  import { createTurnViewState, hydrateTurnViewState } from "../presentation/TurnStateReducer.js";
23
23
  import { buildTurnGraphRows } from "../presentation/turnGraph.js";
24
24
  import { isActiveShellCommandJob, ShellCommandJobManager, } from "../shell/ShellCommandJobManager.js";
25
+ import { SessionOperationQueue } from "./SessionOperationQueue.js";
25
26
  import { createId } from "../utils/id.js";
26
27
  import { formatStorageSize } from "../utils/formatStorageSize.js";
27
28
  import { truncateMiddle, truncateText } from "../utils/markdown.js";
@@ -48,11 +49,12 @@ const REMOTE_TURN_HISTORY_REFRESH_INTERVAL_MS = 30_000;
48
49
  const MAX_LLM_TURN_RETRIES = 3;
49
50
  const QUEUED_PROMPT_RETRY_DELAY_MS = 5_000;
50
51
  const RECOVERY_ACTIVITY_WINDOW_MS = 10 * 60 * 1_000;
52
+ const RECOVERY_RETRY_WINDOW_MS = 60_000;
51
53
  const RECOVERY_HEARTBEAT_INTERVAL_MS = 60 * 1_000;
52
54
  const SHELL_COMMAND_JOB_POLL_INTERVAL_MS = 1_000;
53
55
  const SHELL_COMMAND_JOB_RELAUNCH_DELAY_MS = 5_000;
54
56
  const APP_SERVER_RELEASE_POLL_INTERVAL_MS = 500;
55
- const FORWARD_ATTACHMENT_WINDOW_MS = 800;
57
+ const FORWARD_ATTACHMENT_WINDOW_MS = 1_500;
56
58
  const DEFAULT_MERGED_FORWARD_INSTRUCTION = "请参考以下内容回复用户";
57
59
  const DEFAULT_REFERENCED_MESSAGE_INSTRUCTION = "请参考引用消息回复用户";
58
60
  const TURN_STOP_REQUESTED_MESSAGE = "已发送停止信号,任务会自动停止。";
@@ -226,9 +228,14 @@ export class ProxySessionController {
226
228
  messageQueues = new Map();
227
229
  switchGroupOperations = new Set();
228
230
  sessionLoads = new Map();
231
+ settingsOnlySessions = new Set();
229
232
  queuedPromptStarts = new Map();
230
233
  queuedPromptCards = new Map();
231
234
  queuedPromptCardWrites = new Map();
235
+ settingsOperations = new SessionOperationQueue();
236
+ // Loads may run inside settingsOperations; never acquire that queue while hydrating.
237
+ sessionHydrationOperations = new SessionOperationQueue();
238
+ agentDefaultsOperations = new SessionOperationQueue();
232
239
  sessionResets = new Map();
233
240
  resetHistoryOperations = new Map();
234
241
  remoteTurnHistoryRefreshes = new Map();
@@ -238,9 +245,13 @@ export class ProxySessionController {
238
245
  lastSessionListings = new Map();
239
246
  threadInitializations = new Map();
240
247
  llmRetryingSessions = new Set();
248
+ planHandoffs = new Map();
241
249
  retriedFailureTurnIds = new Set();
242
250
  queuedPromptRetryTimers = new Map();
243
251
  recoveryRetryTimers = new Map();
252
+ recoveryRetryStartedAt = new Map();
253
+ recoveredLiveAttempts = new Set();
254
+ recoveryClosed = false;
244
255
  pendingMergedForwards = new Map();
245
256
  pendingResourceForwards = new Map();
246
257
  relatedReactionMessageIds = new Map();
@@ -300,7 +311,7 @@ export class ProxySessionController {
300
311
  const pendingMergedForward = message.mergedForwardMessageId
301
312
  ? this.registerPendingForwardAttachment(message, this.pendingMergedForwards)
302
313
  : undefined;
303
- const pendingResourceForward = !attachmentReservation && isStandaloneResourceMessage(message)
314
+ const pendingResourceForward = !attachmentReservation && isForwardAttachmentSource(message)
304
315
  ? this.registerPendingForwardAttachment(message, this.pendingResourceForwards)
305
316
  : undefined;
306
317
  try {
@@ -338,7 +349,9 @@ export class ProxySessionController {
338
349
  const attachment = await this.waitForForwardAttachment(message.messageId, pendingResourceForward, this.pendingResourceForwards);
339
350
  if (attachment) {
340
351
  coalescedForwardAttachment = true;
341
- const instruction = normalizeFeishuPostText(attachment.text) || defaultResourcePrompt(resourceMessage);
352
+ const sourceText = resourceMessage.text.trim();
353
+ const instruction = normalizeFeishuPostText(attachment.text)
354
+ || (sourceText ? DEFAULT_REFERENCED_MESSAGE_INSTRUCTION : defaultResourcePrompt(resourceMessage));
342
355
  forwardedImages = (resourceMessage.images ?? []).map((image) => ({
343
356
  messageId: resourceMessage.messageId,
344
357
  imageKey: image.imageKey,
@@ -350,7 +363,7 @@ export class ProxySessionController {
350
363
  }));
351
364
  message = {
352
365
  ...attachment,
353
- text: instruction,
366
+ text: sourceText ? referencedMessagePrompt(instruction, sourceText) : instruction,
354
367
  displayText: instruction,
355
368
  };
356
369
  this.relatedReactionMessageIds.set(message.messageId, [resourceMessage.messageId]);
@@ -619,7 +632,12 @@ export class ProxySessionController {
619
632
  await this.outbound.withReplyTarget(contextKey, replyTarget, async () => {
620
633
  try {
621
634
  const kind = String(scopedAction.value.action ?? "");
622
- if (kind === "agentbot_update_cancel") {
635
+ if (kind === "terminal_authorize") {
636
+ if (!this.lifecycle?.authorizeTerminal)
637
+ throw new Error("网页终端未启用。");
638
+ await this.outbound.sendText(contextKey, await this.lifecycle.authorizeTerminal(scopedAction));
639
+ }
640
+ else if (kind === "agentbot_update_cancel") {
623
641
  if (!this.lifecycle?.cancelAutomaticUpdate)
624
642
  throw new Error("自动更新功能未启用。");
625
643
  await this.lifecycle.cancelAutomaticUpdate(scopedAction);
@@ -812,7 +830,7 @@ export class ProxySessionController {
812
830
  if (scopedAction.value.cardView === "status")
813
831
  await this.refreshStatusCardFromAction(scopedAction, sessionId);
814
832
  else if (scopedAction.value.cardView !== "safe_restart") {
815
- await this.refreshSessionsCardFromAction(scopedAction, { forceSwitchTaskId: sessionId });
833
+ await this.refreshSessionsCardFromAction(scopedAction);
816
834
  }
817
835
  }
818
836
  else if (kind === "session_turns" || kind === "session_turns_page") {
@@ -838,9 +856,11 @@ export class ProxySessionController {
838
856
  await this.refreshStatusCardFromAction(scopedAction);
839
857
  }
840
858
  else if (kind === "settings_tab_open") {
859
+ const sessionId = String(scopedAction.value.sessionId ?? "");
841
860
  await this.openExecutionSettings(contextKey, executionSettingsTabValue(scopedAction.value.tab), {
842
- sessionId: String(scopedAction.value.sessionId ?? ""),
843
- updateMessageId: scopedAction.messageId,
861
+ sessionId, updateMessageId: scopedAction.messageId,
862
+ modelPage: modelCardPageValue(scopedAction.value.page),
863
+ expectedProvider: typeof scopedAction.value.provider === "string" ? scopedAction.value.provider : undefined,
844
864
  });
845
865
  }
846
866
  else if (kind === "settings_agent_select") {
@@ -857,12 +877,14 @@ export class ProxySessionController {
857
877
  }
858
878
  else if (kind === "settings_model_select") {
859
879
  await this.model(contextKey, String(scopedAction.value.model ?? ""), {
880
+ expectedProvider: String(scopedAction.value.provider ?? ""),
860
881
  sessionId: String(scopedAction.value.sessionId ?? ""),
861
882
  updateMessageId: scopedAction.messageId,
862
883
  });
863
884
  }
864
885
  else if (kind === "settings_thinking_select") {
865
886
  await this.thinking(contextKey, String(scopedAction.value.effort ?? ""), {
887
+ expectedProvider: String(scopedAction.value.provider ?? ""),
866
888
  sessionId: String(scopedAction.value.sessionId ?? ""),
867
889
  updateMessageId: scopedAction.messageId,
868
890
  expectedModel: String(scopedAction.value.model ?? ""),
@@ -887,6 +909,7 @@ export class ProxySessionController {
887
909
  await this.model(contextKey, undefined, {
888
910
  sessionId,
889
911
  updateMessageId: scopedAction.messageId,
912
+ modelPage: modelCardPageValue(scopedAction.value.page),
890
913
  });
891
914
  }
892
915
  else if (kind === "reasoning_select") {
@@ -906,7 +929,7 @@ export class ProxySessionController {
906
929
  });
907
930
  }
908
931
  else if (kind === "provider_select" || kind === "provider_model_open") {
909
- await this.openProviderModelSelector(contextKey, String(scopedAction.value.provider ?? ""), permissionModeValue(scopedAction.value.permissionMode), String(scopedAction.value.sessionId ?? ""), requiredCardMessageId(scopedAction.messageId));
932
+ await this.openProviderModelSelector(contextKey, String(scopedAction.value.provider ?? ""), permissionModeValue(scopedAction.value.permissionMode), String(scopedAction.value.sessionId ?? ""), requiredCardMessageId(scopedAction.messageId), modelCardPageValue(scopedAction.value.page));
910
933
  }
911
934
  else if (kind === "provider_model_select" || kind === "provider_reasoning_open") {
912
935
  await this.openProviderReasoningSelector(contextKey, String(scopedAction.value.provider ?? ""), String(scopedAction.value.model ?? ""), permissionModeValue(scopedAction.value.permissionMode), String(scopedAction.value.sessionId ?? ""), requiredCardMessageId(scopedAction.messageId));
@@ -953,6 +976,7 @@ export class ProxySessionController {
953
976
  }
954
977
  }
955
978
  close() {
979
+ this.recoveryClosed = true;
956
980
  for (const unsubscribe of this.unsubscribe)
957
981
  unsubscribe();
958
982
  this.unsubscribe.length = 0;
@@ -960,6 +984,7 @@ export class ProxySessionController {
960
984
  this.queuedPromptCards.clear();
961
985
  this.queuedPromptCardWrites.clear();
962
986
  this.sessionResets.clear();
987
+ this.planHandoffs.clear();
963
988
  this.resetHistoryOperations.clear();
964
989
  this.remoteTurnHistoryRefreshes.clear();
965
990
  this.forkHistoryCursors.clear();
@@ -977,6 +1002,8 @@ export class ProxySessionController {
977
1002
  for (const timer of this.recoveryRetryTimers.values())
978
1003
  clearTimeout(timer);
979
1004
  this.recoveryRetryTimers.clear();
1005
+ this.recoveryRetryStartedAt.clear();
1006
+ this.recoveredLiveAttempts.clear();
980
1007
  for (const schedule of this.appServerReleaseSchedules.values()) {
981
1008
  if (schedule.timer)
982
1009
  clearTimeout(schedule.timer);
@@ -1263,78 +1290,43 @@ export class ProxySessionController {
1263
1290
  if (setting && !value?.trim())
1264
1291
  throw new Error(`task ${setting} requires a value.`);
1265
1292
  const nextValue = value?.trim();
1266
- if (setting === "provider" && nextValue) {
1293
+ if (setting && nextValue) {
1267
1294
  const contextKey = this.controlSessionContextKey(record);
1268
- await this.outbound.withReplyTarget(contextKey, this.controlSessionReplyTarget(record), () => this.outbound.sendText(contextKey, `正在切换到 Provider ${nextValue},请稍后。`));
1269
- }
1270
- const loaded = await this.loadSession(record);
1271
- if (setting === "provider" && nextValue) {
1272
- await this.assertModelProvider(loaded, nextValue);
1273
- const models = await loaded.runtime.listModels(nextValue, loaded.session.model);
1274
- const model = selectProviderModel(models, loaded.session.model);
1275
- if (!model)
1276
- throw new Error("The current runtime has no model available for a Provider change.");
1277
- const effort = selectProviderReasoningEffort(model, loaded.session.reasoningEffort);
1278
- await this.changeProviderSettings(loaded, {
1279
- modelProvider: nextValue,
1280
- model: model.id,
1281
- reasoningEffort: effort,
1282
- permissionMode: loaded.session.permissionMode,
1295
+ await this.outbound.withReplyTarget(contextKey, this.controlSessionReplyTarget(record), async () => {
1296
+ const options = { sessionId: record.localSessionId };
1297
+ if (setting === "provider")
1298
+ await this.selectProvider(contextKey, nextValue, options);
1299
+ else if (setting === "model")
1300
+ await this.model(contextKey, nextValue, options);
1301
+ else if (setting === "thinking")
1302
+ await this.thinking(contextKey, nextValue, options);
1303
+ else {
1304
+ if (nextValue !== "auto" && nextValue !== "confirm")
1305
+ throw new Error("task permissions accepts auto or confirm.");
1306
+ await this.permissions(contextKey, nextValue, options);
1307
+ }
1283
1308
  });
1284
1309
  }
1285
- else if (setting === "model" && nextValue) {
1286
- const models = await loaded.runtime.listModels(loaded.session.modelProvider, loaded.session.model);
1287
- const selected = models.find((candidate) => candidate.id === nextValue);
1288
- if (!selected)
1289
- throw new Error(`Unknown model: ${nextValue}`);
1290
- const currentEffort = loaded.session.reasoningEffort;
1291
- const nextEffort = currentEffort && (selected.supportedReasoningEfforts.length === 0
1292
- || selected.supportedReasoningEfforts.some((candidate) => candidate.value === currentEffort))
1293
- ? currentEffort
1294
- : selected.defaultReasoningEffort;
1295
- await loaded.runtime.setModel(record.localSessionId, nextValue);
1296
- if (nextEffort && nextEffort !== currentEffort) {
1297
- await loaded.runtime.setReasoningEffort(record.localSessionId, nextEffort);
1298
- }
1299
- this.store.updateRuntimeSession(record.localSessionId, { model: nextValue, reasoningEffort: nextEffort });
1300
- this.outbound.updateSessionModel(record.localSessionId, nextValue, loaded.session.modelProvider);
1301
- }
1302
- else if (setting === "thinking" && nextValue) {
1303
- const models = await loaded.runtime.listModels(loaded.session.modelProvider, loaded.session.model);
1304
- const currentModel = models.find((candidate) => candidate.id === loaded.session.model)
1310
+ return this.settingsOperations.run(localSessionId, async () => {
1311
+ const loaded = await this.loadSession(this.store.getSession(localSessionId) ?? record, true);
1312
+ const session = this.store.getSession(record.localSessionId) ?? record;
1313
+ const pending = this.store.getPendingProviderSettings(record.localSessionId);
1314
+ const selected = pending?.settings ?? session;
1315
+ const models = await loaded.runtime.listModels(selected.modelProvider, selected.model);
1316
+ const currentModel = models.find((candidate) => candidate.id === selected.model)
1305
1317
  ?? models.find((candidate) => candidate.isDefault);
1306
- if (!currentModel)
1307
- throw new Error("The current runtime has no model with configurable reasoning effort.");
1308
- if (!currentModel.supportedReasoningEfforts.some((candidate) => candidate.value === nextValue)) {
1309
- const supported = currentModel.supportedReasoningEfforts.map((candidate) => candidate.value).join(", ") || "none";
1310
- throw new Error(`Unsupported reasoning effort: ${nextValue}. Supported values: ${supported}.`);
1311
- }
1312
- await loaded.runtime.setReasoningEffort(record.localSessionId, nextValue);
1313
- this.store.updateRuntimeSession(record.localSessionId, { reasoningEffort: nextValue });
1314
- }
1315
- else if (setting === "permissions" && nextValue) {
1316
- if (nextValue !== "auto" && nextValue !== "confirm") {
1317
- throw new Error("task permissions accepts auto or confirm.");
1318
- }
1319
- await loaded.runtime.setPermissionMode(record.localSessionId, nextValue);
1320
- this.store.updateRuntimeSession(record.localSessionId, { permissionMode: nextValue });
1321
- }
1322
- const session = this.store.getSession(record.localSessionId) ?? record;
1323
- if (setting && setting !== "provider")
1324
- await this.persistAgentExecutionDefaults(session.localSessionId);
1325
- const models = await loaded.runtime.listModels(session.modelProvider, session.model);
1326
- const currentModel = models.find((candidate) => candidate.id === session.model)
1327
- ?? models.find((candidate) => candidate.isDefault);
1328
- const providers = loaded.runtime.listModelProviders
1329
- ? await this.modelProviderOptions(loaded)
1330
- : [];
1331
- return {
1332
- session,
1333
- providers,
1334
- models,
1335
- reasoningOptions: currentModel?.supportedReasoningEfforts ?? [],
1336
- permissionModes: ["auto", "confirm"],
1337
- };
1318
+ const providers = loaded.runtime.listModelProviders
1319
+ ? await this.modelProviderOptions(loaded)
1320
+ : [];
1321
+ return {
1322
+ session,
1323
+ pendingProvider: pending,
1324
+ providers,
1325
+ models,
1326
+ reasoningOptions: currentModel?.supportedReasoningEfforts ?? [],
1327
+ permissionModes: ["auto", "confirm"],
1328
+ };
1329
+ });
1338
1330
  }
1339
1331
  async controlTaskGoal(localSessionId, action, objective) {
1340
1332
  const record = this.requireControlSession(localSessionId);
@@ -1727,14 +1719,21 @@ export class ProxySessionController {
1727
1719
  }
1728
1720
  if (!this.outbound.canRoute(attempt.contextKey))
1729
1721
  continue;
1722
+ this.recoveryRetryStartedAt.set(attempt.attemptId, Date.now());
1730
1723
  try {
1731
1724
  await this.recoverTurnAttempt(attempt);
1732
1725
  }
1733
1726
  catch (error) {
1734
- this.logger.error({ error, attemptId: attempt.attemptId, sessionId: attempt.localSessionId }, "Failed to recover an unfinished task; it will be retried while the server is running.");
1735
- this.scheduleRecoveryRetry(attempt.attemptId);
1727
+ await this.handleRecoveryError(attempt.attemptId, error);
1728
+ }
1729
+ finally {
1730
+ if (!this.recoveryRetryTimers.has(attempt.attemptId))
1731
+ this.recoveryRetryStartedAt.delete(attempt.attemptId);
1736
1732
  }
1737
1733
  }
1734
+ for (const pending of this.store.listPendingProviderSettings()) {
1735
+ await this.applyPendingProviderWhenIdle(pending.localSessionId);
1736
+ }
1738
1737
  this.restorePersistedQueuedPrompts();
1739
1738
  }
1740
1739
  async finishPersistedCancellationRequests() {
@@ -1815,22 +1814,36 @@ export class ProxySessionController {
1815
1814
  });
1816
1815
  }
1817
1816
  }
1818
- async expireStaleTurnAttempt(attempt) {
1819
- const session = this.store.getSession(attempt.localSessionId);
1817
+ async expireStaleTurnAttempt(attempt, message = "执行中断已超过 10 分钟,未自动恢复。") {
1818
+ clearTimeout(this.recoveryRetryTimers.get(attempt.attemptId));
1819
+ this.recoveryRetryTimers.delete(attempt.attemptId);
1820
+ this.recoveryRetryStartedAt.delete(attempt.attemptId);
1821
+ this.recoveredLiveAttempts.delete(attempt.attemptId);
1822
+ let session = this.store.getSession(attempt.localSessionId);
1823
+ this.store.updateTurnAttempt(attempt.attemptId, { status: "interrupted" });
1820
1824
  const oldCardTurnId = attempt.pendingTurnId ?? attempt.turnId;
1821
1825
  if (session && oldCardTurnId && this.outbound.canRoute(attempt.contextKey)) {
1822
- await this.outbound.interruptTurnForRecovery(session.localSessionId, attempt.contextKey, oldCardTurnId, "执行中断已超过 10 分钟,未自动恢复。").catch((error) => {
1826
+ await this.outbound.interruptTurnForRecovery(session.localSessionId, attempt.contextKey, oldCardTurnId, message).catch((error) => {
1823
1827
  this.logger.warn({ error, attemptId: attempt.attemptId, turnId: oldCardTurnId }, "Failed to close an expired interrupted thinking card.");
1824
1828
  });
1825
1829
  }
1826
- this.store.updateTurnAttempt(attempt.attemptId, { status: "interrupted" });
1830
+ session = this.store.getSession(attempt.localSessionId);
1827
1831
  if (!session || session.status === "closed")
1828
1832
  return;
1829
- if (attempt.turnId && session.lastTurnId === attempt.turnId) {
1833
+ const runtime = this.runtimes.forAgent(session.agentName);
1834
+ const activeTurnId = runtime.getSession(session.localSessionId)?.activeTurnId;
1835
+ const sameTurn = session.lastTurnId === attempt.turnId
1836
+ || session.lastTurnId === attempt.recoveredFromTurnId
1837
+ || !session.lastTurnId;
1838
+ if (sameTurn && session.lastTurnStatus !== "completed" && session.lastTurnStatus !== "failed"
1839
+ && (!activeTurnId || activeTurnId === attempt.turnId)) {
1840
+ // Forget monitoring, not the remote task. Never cancel an unknown writer.
1841
+ if (activeTurnId)
1842
+ runtime.forgetSession?.(session.localSessionId);
1830
1843
  this.store.updateRuntimeSession(session.localSessionId, { lastTurnStatus: "cancelled" });
1831
- }
1832
- if (session.status === "starting" || session.status === "running") {
1833
- this.store.updateSession(session.localSessionId, { status: "ready" });
1844
+ if (session.status === "starting" || session.status === "running") {
1845
+ this.store.updateSession(session.localSessionId, { status: "ready" });
1846
+ }
1834
1847
  }
1835
1848
  if (attempt.turnId && this.outbound.canRoute(attempt.contextKey)) {
1836
1849
  await this.finalizeTurnMessageReactions(attempt.turnId, "cancelled").catch((error) => {
@@ -1838,6 +1851,47 @@ export class ProxySessionController {
1838
1851
  });
1839
1852
  }
1840
1853
  }
1854
+ async handleRecoveryError(attemptId, error, activeTurnId) {
1855
+ if (this.recoveryClosed)
1856
+ return;
1857
+ const attempt = this.store.getTurnAttempt(attemptId);
1858
+ if (!attempt || !isIncompleteTurnAttemptStatus(attempt.status))
1859
+ return;
1860
+ this.logger.warn({ error, attemptId, sessionId: attempt.localSessionId }, "Task recovery failed.");
1861
+ const session = this.store.getSession(attempt.localSessionId);
1862
+ const writerConflict = session?.remoteSessionId === activeWriterThreadId(error) && Boolean(session?.remoteSessionId);
1863
+ const startedAt = this.recoveryRetryStartedAt.get(attemptId) ?? Date.now();
1864
+ this.recoveryRetryStartedAt.set(attemptId, startedAt);
1865
+ const localActiveTurnId = session ? this.runtimes.forAgent(session.agentName).getSession(session.localSessionId)?.activeTurnId : undefined;
1866
+ const monitoredTurnId = activeTurnId ?? (localActiveTurnId === attempt.turnId ? localActiveTurnId : undefined);
1867
+ // Once a live turn was confirmed, a monitoring outage must not make safe restart
1868
+ // consider it idle. Keep reconciling without cancelling or replaying the prompt.
1869
+ if (monitoredTurnId && this.recoveredLiveAttempts.has(attemptId) && !writerConflict) {
1870
+ this.scheduleRecoveryRetry(attemptId, monitoredTurnId);
1871
+ return;
1872
+ }
1873
+ if (writerConflict || error instanceof CodexVersionError || Date.now() - startedAt >= RECOVERY_RETRY_WINDOW_MS) {
1874
+ const message = writerConflict
1875
+ ? "自动恢复未完成:任务仍被另一个 App Server 占用。已停止自动重试,请确认占用进程后释放任务再继续。"
1876
+ : `自动恢复未完成,已停止自动重试。请检查 Agent 状态后重新发送消息。\n${error instanceof Error ? error.message : String(error)}`;
1877
+ await this.expireStaleTurnAttempt(attempt, message);
1878
+ try {
1879
+ const replyTarget = attempt.replyMessageId
1880
+ ? { messageId: attempt.replyMessageId, replyInThread: true } : undefined;
1881
+ await this.outbound.withReplyTarget(attempt.contextKey, replyTarget, async () => {
1882
+ await this.outbound.sendText(attempt.contextKey, message);
1883
+ if (writerConflict && session)
1884
+ await this.presentThreadWriterConflict(session, attempt.contextKey, error, false);
1885
+ });
1886
+ }
1887
+ catch (notificationError) {
1888
+ this.logger.warn({ error: notificationError, attemptId }, "Failed to notify about stopped task recovery.");
1889
+ }
1890
+ return;
1891
+ }
1892
+ this.store.updateTurnAttempt(attemptId, { status: "recovering" });
1893
+ this.scheduleRecoveryRetry(attemptId, monitoredTurnId);
1894
+ }
1841
1895
  persistActiveTurnHeartbeats() {
1842
1896
  for (const attempt of this.store.listIncompleteTurnAttempts()) {
1843
1897
  if (!attempt.turnId)
@@ -1851,6 +1905,8 @@ export class ProxySessionController {
1851
1905
  }
1852
1906
  }
1853
1907
  async recoverTurnAttempt(attempt, announce = true) {
1908
+ if (this.recoveryClosed)
1909
+ return;
1854
1910
  const session = this.store.getSession(attempt.localSessionId);
1855
1911
  if (!session || session.status === "closed") {
1856
1912
  this.store.updateTurnAttempt(attempt.attemptId, { status: "interrupted" });
@@ -1879,9 +1935,9 @@ export class ProxySessionController {
1879
1935
  remote = await runtime.readRemoteSession(session.remoteSessionId);
1880
1936
  }
1881
1937
  catch (error) {
1882
- if (error instanceof CodexVersionError)
1938
+ // A failed activity check is not proof of interruption. Do not replay a prompt blindly.
1939
+ if (sourceTurnId || !isUnmaterializedCodexThreadError(error))
1883
1940
  throw error;
1884
- this.logger.warn({ error, attemptId: attempt.attemptId, remoteSessionId: session.remoteSessionId }, "Failed to read the remote task during startup recovery; treating the old local execution as interrupted.");
1885
1941
  }
1886
1942
  }
1887
1943
  const remoteTurnBelongsToAttempt = remoteTurnMatchesAttempt(remote, attempt);
@@ -1927,14 +1983,22 @@ export class ProxySessionController {
1927
1983
  this.scheduleRecoveryRetry(attempt.attemptId, synchronized.activeTurnId);
1928
1984
  }
1929
1985
  scheduleRecoveryRetry(attemptId, activeTurnId) {
1930
- if (this.recoveryRetryTimers.has(attemptId))
1986
+ if (this.recoveryClosed || this.recoveryRetryTimers.has(attemptId))
1931
1987
  return;
1988
+ if (!this.recoveryRetryStartedAt.has(attemptId))
1989
+ this.recoveryRetryStartedAt.set(attemptId, Date.now());
1932
1990
  const timer = setTimeout(() => {
1933
1991
  this.recoveryRetryTimers.delete(attemptId);
1934
1992
  const attempt = this.store.getTurnAttempt(attemptId);
1935
- if (!attempt || !isIncompleteTurnAttemptStatus(attempt.status))
1993
+ if (!attempt || !isIncompleteTurnAttemptStatus(attempt.status)) {
1994
+ this.recoveryRetryStartedAt.delete(attemptId);
1995
+ this.recoveredLiveAttempts.delete(attemptId);
1936
1996
  return;
1997
+ }
1937
1998
  const retry = async () => {
1999
+ if (!activeTurnId && Date.now() - this.recoveryRetryStartedAt.get(attemptId) >= RECOVERY_RETRY_WINDOW_MS) {
2000
+ throw new Error("等待恢复超过 1 分钟,尚未确认执行已恢复。");
2001
+ }
1938
2002
  let recoveryAttempt = attempt;
1939
2003
  if (activeTurnId) {
1940
2004
  const session = this.store.getSession(attempt.localSessionId);
@@ -1942,6 +2006,8 @@ export class ProxySessionController {
1942
2006
  const runtime = this.runtimes.forAgent(session.agentName);
1943
2007
  const synchronized = await runtime.synchronizeSession(session.localSessionId);
1944
2008
  if (synchronized.activeTurnId === activeTurnId) {
2009
+ this.recoveredLiveAttempts.add(attemptId);
2010
+ this.recoveryRetryStartedAt.delete(attemptId);
1945
2011
  this.scheduleRecoveryRetry(attemptId, activeTurnId);
1946
2012
  return;
1947
2013
  }
@@ -1952,16 +2018,38 @@ export class ProxySessionController {
1952
2018
  }
1953
2019
  }
1954
2020
  await this.recoverTurnAttempt(recoveryAttempt, false);
2021
+ if (!this.recoveryRetryTimers.has(attemptId))
2022
+ this.recoveryRetryStartedAt.delete(attemptId);
1955
2023
  };
1956
2024
  void retry().catch((error) => {
1957
- this.logger.warn({ error, attemptId, sessionId: attempt.localSessionId }, "Failed to retry an unfinished task recovery.");
1958
- this.scheduleRecoveryRetry(attemptId, activeTurnId);
2025
+ return this.handleRecoveryError(attemptId, error, activeTurnId);
2026
+ }).catch((error) => {
2027
+ this.logger.error({ error, attemptId }, "Failed to finalize task recovery.");
1959
2028
  });
1960
2029
  }, 5_000);
1961
2030
  timer.unref?.();
1962
2031
  this.recoveryRetryTimers.set(attemptId, timer);
1963
2032
  }
1964
2033
  async reattachActiveRecoveredTurn(attempt, session, turnId, replyTarget) {
2034
+ // Confirm resume before replacing the old card or announcing a running turn.
2035
+ const current = this.store.getSession(session.localSessionId) ?? session;
2036
+ const loaded = await this.loadSession({
2037
+ ...current, contextKey: attempt.contextKey, status: "running", lastTurnId: turnId, lastTurnStatus: "running",
2038
+ });
2039
+ const pendingAttempt = this.store.getTurnAttempt(attempt.attemptId);
2040
+ if (!pendingAttempt || !isIncompleteTurnAttemptStatus(pendingAttempt.status))
2041
+ return;
2042
+ const continued = turnViewSnapshot(this.store.getTurnSnapshot(turnId));
2043
+ if (continued?.continuedFromTurnId && this.store.getTurnDelivery(turnId)?.progressMessageId) {
2044
+ this.store.updateTurnAttempt(attempt.attemptId, { pendingTurnId: null, turnId, status: "running" });
2045
+ await this.outbound.onEvent({ type: "turn_started", sessionId: session.localSessionId,
2046
+ turnId, startedAt: continued.startedAt });
2047
+ const synchronized = await loaded.runtime.synchronizeSession(session.localSessionId);
2048
+ this.recoveryRetryStartedAt.delete(attempt.attemptId);
2049
+ if (synchronized.activeTurnId)
2050
+ this.recoveredLiveAttempts.add(attempt.attemptId);
2051
+ return;
2052
+ }
1965
2053
  const oldCardTurnId = attempt.pendingTurnId ?? attempt.turnId;
1966
2054
  if (oldCardTurnId) {
1967
2055
  await this.outbound.interruptTurnForRecovery(session.localSessionId, attempt.contextKey, oldCardTurnId, "Agent Bot 已重启,执行仍在继续;进度已转移到新的思考卡片。");
@@ -1979,17 +2067,12 @@ export class ProxySessionController {
1979
2067
  startedAt: Date.now(),
1980
2068
  });
1981
2069
  this.store.updateTurnAttempt(attempt.attemptId, { pendingTurnId: null });
1982
- const current = this.store.getSession(session.localSessionId) ?? session;
1983
- const loaded = await this.loadSession({
1984
- ...current,
1985
- contextKey: attempt.contextKey,
1986
- status: "running",
1987
- lastTurnId: turnId,
1988
- lastTurnStatus: "running",
1989
- });
1990
2070
  const synchronized = await loaded.runtime.synchronizeSession(session.localSessionId);
1991
- if (synchronized.activeTurnId)
2071
+ this.recoveryRetryStartedAt.delete(attempt.attemptId);
2072
+ if (synchronized.activeTurnId) {
2073
+ this.recoveredLiveAttempts.add(attempt.attemptId);
1992
2074
  this.scheduleRecoveryRetry(attempt.attemptId, synchronized.activeTurnId);
2075
+ }
1993
2076
  }
1994
2077
  async continueInterruptedAttempt(attempt, session, sourceTurnId, replyTarget) {
1995
2078
  const oldCardTurnId = attempt.pendingTurnId ?? sourceTurnId;
@@ -2007,7 +2090,7 @@ export class ProxySessionController {
2007
2090
  });
2008
2091
  }
2009
2092
  const current = this.store.getSession(session.localSessionId) ?? session;
2010
- const loaded = await this.loadSession({ ...current, contextKey: attempt.contextKey });
2093
+ const loaded = await this.loadSession({ ...current, contextKey: attempt.contextKey }, Boolean(this.store.getPendingProviderSettings(current.localSessionId)));
2011
2094
  const recoveryText = recoveryRuntimePrompt(attempt.promptText);
2012
2095
  const turnId = await this.startTurn(loaded, recoveryText, replyTarget, attempt.localImagePaths, {
2013
2096
  attemptId: attempt.attemptId,
@@ -2046,7 +2129,7 @@ export class ProxySessionController {
2046
2129
  await this.promptSession(record, contextKey, text, messageId, replyTarget, localImagePaths, displayPrompt);
2047
2130
  }
2048
2131
  async promptSession(record, contextKey, text, messageId, replyTarget, localImagePaths, displayPrompt) {
2049
- if (this.llmRetryingSessions.has(record.localSessionId)) {
2132
+ if (this.llmRetryingSessions.has(record.localSessionId) || this.planHandoffs.has(record.localSessionId)) {
2050
2133
  const queued = this.persistQueuedPrompt(record.localSessionId, contextKey, text, {
2051
2134
  localImagePaths,
2052
2135
  messageId,
@@ -2067,7 +2150,7 @@ export class ProxySessionController {
2067
2150
  }
2068
2151
  let loaded;
2069
2152
  try {
2070
- loaded = await this.loadSession(record);
2153
+ loaded = await this.loadSession(record, Boolean(this.store.getPendingProviderSettings(record.localSessionId)));
2071
2154
  }
2072
2155
  catch (error) {
2073
2156
  if (await this.presentThreadWriterConflict(record, contextKey, error, true)) {
@@ -3043,6 +3126,22 @@ export class ProxySessionController {
3043
3126
  };
3044
3127
  }
3045
3128
  async startTurn(loaded, text, replyTarget, localImagePaths, options = {}) {
3129
+ return this.settingsOperations.run(loaded.record.localSessionId, async () => {
3130
+ try {
3131
+ await this.applyPendingProviderSettings(loaded);
3132
+ }
3133
+ catch (error) {
3134
+ await this.outbound.failPendingTurn(loaded.record.localSessionId, runtimeErrorMessage(error));
3135
+ throw error;
3136
+ }
3137
+ if (this.store.getPendingProviderSettings(loaded.record.localSessionId)) {
3138
+ throw new Error("Provider 切换尚未完成,请稍后重试。");
3139
+ }
3140
+ loaded.session = loaded.runtime.getSession(loaded.record.localSessionId) ?? loaded.session;
3141
+ return this.startTurnNow(loaded, text, replyTarget, localImagePaths, options);
3142
+ });
3143
+ }
3144
+ async startTurnNow(loaded, text, replyTarget, localImagePaths, options = {}) {
3046
3145
  const currentRecord = this.store.getSession(loaded.record.localSessionId);
3047
3146
  const title = currentRecord?.title ?? normalizeTaskTitle(options.displayPrompt ?? text);
3048
3147
  if (!currentRecord?.title && title)
@@ -3088,7 +3187,13 @@ export class ProxySessionController {
3088
3187
  }
3089
3188
  this.store.updateTurnAttempt(attemptId, { pendingTurnId: pendingTurnId ?? null });
3090
3189
  try {
3091
- turnId = await loaded.runtime.startTurn(loaded.record.localSessionId, runtimePrompt(runtimeText, localImagePaths));
3190
+ if (options.approvedPlanTurnId && (this.planHandoffs.get(loaded.record.localSessionId) !== options.approvedPlanTurnId
3191
+ || this.sessionResets.has(loaded.record.localSessionId))) {
3192
+ throw new Error("方案执行已取消。");
3193
+ }
3194
+ turnId = await loaded.runtime.startTurn(loaded.record.localSessionId, options.approvedPlanTurnId
3195
+ ? { text: runtimeText, collaborationMode: "default" }
3196
+ : runtimePrompt(runtimeText, localImagePaths));
3092
3197
  break;
3093
3198
  }
3094
3199
  catch (error) {
@@ -3503,22 +3608,44 @@ export class ProxySessionController {
3503
3608
  this.store.setBoundProjectCwd(groupContextKey, boundProjectCwd);
3504
3609
  return { chatId: group.chatId, contextKey: groupContextKey, name: group.name };
3505
3610
  }
3506
- async loadSession(record) {
3611
+ async loadSession(record, forSettings = false) {
3507
3612
  const agent = this.ensureAgent(record.agentName);
3508
3613
  const runtime = this.runtimes.forAgent(record.agentName);
3509
3614
  const existing = runtime.getSession(record.localSessionId);
3510
- if (existing)
3615
+ if (existing && (forSettings || !this.settingsOnlySessions.has(record.localSessionId))) {
3511
3616
  return { record, runtime, session: existing };
3617
+ }
3512
3618
  const pending = this.sessionLoads.get(record.localSessionId);
3513
3619
  if (pending) {
3514
- const loaded = await pending;
3515
- return {
3516
- ...loaded,
3517
- record: { ...loaded.record, contextKey: record.contextKey },
3518
- };
3620
+ try {
3621
+ const loaded = await pending;
3622
+ if (!forSettings && this.settingsOnlySessions.has(record.localSessionId)) {
3623
+ if (this.sessionLoads.get(record.localSessionId) === pending)
3624
+ this.sessionLoads.delete(record.localSessionId);
3625
+ return this.loadSession(this.store.getSession(record.localSessionId) ?? record);
3626
+ }
3627
+ return { ...loaded, record: { ...loaded.record, contextKey: record.contextKey } };
3628
+ }
3629
+ catch (error) {
3630
+ if (!forSettings || !runtime.prepareSessionForSettings)
3631
+ throw error;
3632
+ if (this.sessionLoads.get(record.localSessionId) === pending)
3633
+ this.sessionLoads.delete(record.localSessionId);
3634
+ return this.loadSession(record, forSettings);
3635
+ }
3519
3636
  }
3520
3637
  this.outbound.registerSession(record.localSessionId, record.contextKey, record.title, record.cwd, this.agentLabel(record.agentName), record.model, record.modelProvider);
3521
- const loading = (async () => {
3638
+ const loading = this.sessionHydrationOperations.run(record.localSessionId, async () => {
3639
+ const latest = this.store.getSession(record.localSessionId);
3640
+ if (latest)
3641
+ record = {
3642
+ ...record, modelProvider: latest.modelProvider, model: latest.model,
3643
+ reasoningEffort: latest.reasoningEffort, permissionMode: latest.permissionMode,
3644
+ };
3645
+ const current = runtime.getSession(record.localSessionId);
3646
+ if (current && (forSettings || !this.settingsOnlySessions.has(record.localSessionId))) {
3647
+ return { record, runtime, session: current };
3648
+ }
3522
3649
  if (record.lastTurnId) {
3523
3650
  try {
3524
3651
  await this.outbound.resumeDelivery(record.localSessionId, record.contextKey, record.lastTurnId);
@@ -3528,45 +3655,32 @@ export class ProxySessionController {
3528
3655
  }
3529
3656
  }
3530
3657
  const permissionMode = record.permissionMode ?? "auto";
3531
- if (record.remoteSessionId)
3658
+ const prepare = forSettings && record.remoteSessionId && runtime.prepareSessionForSettings;
3659
+ if (record.remoteSessionId && !prepare)
3532
3660
  await this.assertSessionTurnOwnership(record, runtime);
3533
3661
  let session;
3534
3662
  if (record.remoteSessionId) {
3535
- try {
3536
- session = await runtime.resumeSession({
3537
- localSessionId: record.localSessionId,
3538
- remoteSessionId: record.remoteSessionId,
3539
- agentName: record.agentName,
3540
- cwd: record.cwd,
3541
- title: record.title,
3542
- modelProvider: record.modelProvider,
3543
- model: record.model,
3544
- reasoningEffort: record.reasoningEffort,
3545
- permissionMode,
3546
- activeTurnId: agent.kind === "app-server" && record.status === "running" && record.lastTurnStatus === "running"
3547
- ? record.lastTurnId
3548
- : undefined,
3549
- lastTurnId: record.lastTurnId,
3550
- lastTurnStatus: record.lastTurnStatus,
3551
- });
3552
- }
3553
- catch (error) {
3554
- if (activeWriterThreadId(error))
3555
- throw error;
3556
- if (!(agent.kind === "app-server" && !record.lastTurnId
3557
- && !this.store.getForkHistorySource(record.localSessionId) && isMissingRolloutError(error)))
3558
- throw error;
3559
- this.logger.warn({ error, sessionId: record.localSessionId }, "App Server task has no rollout; creating a replacement task.");
3560
- session = await runtime.createSession({
3561
- localSessionId: record.localSessionId,
3562
- agentName: record.agentName,
3563
- cwd: record.cwd,
3564
- title: record.title,
3565
- modelProvider: record.modelProvider,
3566
- model: record.model,
3567
- reasoningEffort: record.reasoningEffort,
3568
- permissionMode,
3569
- });
3663
+ const input = {
3664
+ localSessionId: record.localSessionId,
3665
+ remoteSessionId: record.remoteSessionId,
3666
+ agentName: record.agentName,
3667
+ cwd: record.cwd,
3668
+ title: record.title,
3669
+ modelProvider: record.modelProvider,
3670
+ model: record.model,
3671
+ reasoningEffort: record.reasoningEffort,
3672
+ permissionMode,
3673
+ activeTurnId: agent.kind === "app-server" && record.status === "running" && record.lastTurnStatus === "running"
3674
+ ? record.lastTurnId
3675
+ : undefined,
3676
+ lastTurnId: record.lastTurnId,
3677
+ lastTurnStatus: record.lastTurnStatus,
3678
+ };
3679
+ if (prepare)
3680
+ this.settingsOnlySessions.add(record.localSessionId);
3681
+ session = prepare ? await prepare.call(runtime, input) : await runtime.resumeSession(input);
3682
+ if (session.remoteSessionId !== record.remoteSessionId) {
3683
+ throw new Error("加载设置返回了不同的任务 ID,不允许替换任务或更改历史。");
3570
3684
  }
3571
3685
  }
3572
3686
  else {
@@ -3581,20 +3695,24 @@ export class ProxySessionController {
3581
3695
  permissionMode,
3582
3696
  });
3583
3697
  }
3584
- this.persistRuntimeSession(record, session, session.activeTurnId ? "running" : "ready");
3698
+ if (!prepare) {
3699
+ this.settingsOnlySessions.delete(record.localSessionId);
3700
+ this.persistRuntimeSession(record, session, session.activeTurnId ? "running" : "ready");
3701
+ }
3585
3702
  const saved = this.store.getSession(record.localSessionId) ?? record;
3586
3703
  return {
3587
3704
  record: { ...saved, contextKey: record.contextKey },
3588
3705
  runtime,
3589
3706
  session,
3590
3707
  };
3591
- })();
3708
+ });
3592
3709
  this.sessionLoads.set(record.localSessionId, loading);
3593
3710
  try {
3594
3711
  return await loading;
3595
3712
  }
3596
3713
  finally {
3597
- this.sessionLoads.delete(record.localSessionId);
3714
+ if (this.sessionLoads.get(record.localSessionId) === loading)
3715
+ this.sessionLoads.delete(record.localSessionId);
3598
3716
  }
3599
3717
  }
3600
3718
  persistRuntimeSession(record, session, status) {
@@ -3614,6 +3732,11 @@ export class ProxySessionController {
3614
3732
  }
3615
3733
  async handleRuntimeEvent(event) {
3616
3734
  if ("turnId" in event) {
3735
+ const recoveryAttempt = this.recoveryRetryStartedAt.size > 0
3736
+ ? this.store.findIncompleteTurnAttemptByTurnId(event.turnId) : undefined;
3737
+ if (recoveryAttempt && this.recoveryRetryStartedAt.has(recoveryAttempt.attemptId)) {
3738
+ this.recoveredLiveAttempts.add(recoveryAttempt.attemptId);
3739
+ }
3617
3740
  this.store.touchTurnAttempt(event.turnId);
3618
3741
  const source = this.store.getSession(event.sessionId);
3619
3742
  if (source?.remoteSessionId && this.isCodexSession(source)) {
@@ -3627,6 +3750,15 @@ export class ProxySessionController {
3627
3750
  this.store.saveTurnParent(event.turnId, event.sessionId, this.completedParentTurnId(source));
3628
3751
  }
3629
3752
  }
3753
+ if (event.type === "turn_completed" && event.approvedPlan) {
3754
+ if (this.planHandoffs.get(event.sessionId) === event.turnId)
3755
+ return;
3756
+ const attempt = this.store.findIncompleteTurnAttemptByTurnId(event.turnId);
3757
+ if (attempt) {
3758
+ await this.continueApprovedPlan(event, attempt);
3759
+ return;
3760
+ }
3761
+ }
3630
3762
  const failedAttempt = event.type === "turn_failed"
3631
3763
  ? this.store.findIncompleteTurnAttemptByTurnId(event.turnId)
3632
3764
  : undefined;
@@ -3652,6 +3784,13 @@ export class ProxySessionController {
3652
3784
  this.store.updateRuntimeSession(event.sessionId, { lastTurnId: event.turnId, lastTurnStatus: "running" });
3653
3785
  }
3654
3786
  else if (event.type === "turn_completed" || event.type === "turn_cancelled" || event.type === "turn_failed") {
3787
+ const attempt = this.store.findIncompleteTurnAttemptByTurnId(event.turnId);
3788
+ if (attempt) {
3789
+ clearTimeout(this.recoveryRetryTimers.get(attempt.attemptId));
3790
+ this.recoveryRetryTimers.delete(attempt.attemptId);
3791
+ this.recoveryRetryStartedAt.delete(attempt.attemptId);
3792
+ this.recoveredLiveAttempts.delete(attempt.attemptId);
3793
+ }
3655
3794
  this.store.markTurnAttemptTerminal(event.turnId, event.type === "turn_completed" ? "completed" : event.type === "turn_cancelled" ? "cancelled" : "failed");
3656
3795
  this.store.updateRuntimeSession(event.sessionId, {
3657
3796
  lastTurnId: event.turnId,
@@ -3663,7 +3802,7 @@ export class ProxySessionController {
3663
3802
  ...event,
3664
3803
  turnCount: event.turnCount ?? this.store.countTaskTurns(event.sessionId),
3665
3804
  }
3666
- : event;
3805
+ : event.type === "turn_completed" && event.approvedPlan ? { ...event, approvedPlan: false } : event;
3667
3806
  const presentationEvent = event.type === "turn_failed"
3668
3807
  && isRetryableLlmTurnFailure(event.message)
3669
3808
  && (failedAttempt?.retryCount ?? 0) >= MAX_LLM_TURN_RETRIES
@@ -3698,12 +3837,87 @@ export class ProxySessionController {
3698
3837
  : undefined;
3699
3838
  if (latest?.lastTurnId === event.turnId && !activeTurnId) {
3700
3839
  this.store.updateSession(event.sessionId, { status: event.type === "turn_failed" ? "failed" : "ready" });
3840
+ await this.applyPendingProviderWhenIdle(event.sessionId);
3701
3841
  await this.scheduleNextQueuedPrompt(event.sessionId);
3702
3842
  }
3703
3843
  }
3704
3844
  if (presentationError)
3705
3845
  throw presentationError;
3706
3846
  }
3847
+ async continueApprovedPlan(event, attempt) {
3848
+ const { sessionId, turnId } = event;
3849
+ const source = this.store.getSession(sessionId);
3850
+ if (!source || source.status === "closed" || source.lastTurnId !== turnId)
3851
+ return;
3852
+ this.planHandoffs.set(sessionId, turnId);
3853
+ this.store.markTurnAttemptTerminal(turnId, "completed");
3854
+ this.store.updateRuntimeSession(sessionId, { lastTurnId: turnId, lastTurnStatus: "completed" });
3855
+ let continued = false;
3856
+ let failed = false;
3857
+ try {
3858
+ try {
3859
+ await this.outbound.onEvent(event);
3860
+ }
3861
+ catch (error) {
3862
+ this.logger.warn({ error, sessionId, turnId }, "Failed to finalize the approved plan card.");
3863
+ }
3864
+ await this.settingsOperations.run(sessionId, async () => {
3865
+ const current = this.store.getSessionForContext(sessionId, attempt.contextKey);
3866
+ if (this.planHandoffs.get(sessionId) !== turnId || this.sessionResets.has(sessionId)
3867
+ || !current || current.status === "closed" || current.remoteSessionId !== source.remoteSessionId)
3868
+ return;
3869
+ const loaded = await this.loadSession(current);
3870
+ await this.assertSessionTurnOwnership(current, loaded.runtime);
3871
+ if (this.planHandoffs.get(sessionId) !== turnId || this.sessionResets.has(sessionId))
3872
+ return;
3873
+ // Another client may already have started the implementation.
3874
+ let nextTurnId = loaded.session.activeTurnId;
3875
+ if (!nextTurnId) {
3876
+ if (current.lastTurnId !== turnId)
3877
+ return;
3878
+ await this.applyPendingProviderSettings(loaded);
3879
+ if (this.store.getPendingProviderSettings(sessionId))
3880
+ throw new Error("Provider 切换尚未完成。");
3881
+ if (this.planHandoffs.get(sessionId) !== turnId || this.sessionResets.has(sessionId))
3882
+ return;
3883
+ nextTurnId = await this.startTurnNow(loaded, "The proposed plan has been approved. Continue in the current task and workspace without asking for plan approval again; preserve the existing conversation and completed work. Implement the plan unless the user explicitly requested discussion or planning only, or prohibited implementation; those constraints still apply.", attempt.replyMessageId ? { messageId: attempt.replyMessageId, replyInThread: true } : undefined, undefined, { messageId: attempt.messageId, displayPrompt: "方案已确认,继续执行", approvedPlanTurnId: turnId });
3884
+ }
3885
+ continued = true;
3886
+ this.store.rebindPendingTurnMessages(sessionId, turnId, nextTurnId);
3887
+ const latest = this.store.getSession(sessionId);
3888
+ if (latest?.lastTurnId === nextTurnId && (latest.lastTurnStatus === "completed"
3889
+ || latest.lastTurnStatus === "failed" || latest.lastTurnStatus === "cancelled")) {
3890
+ await this.finalizeTurnMessageReactions(nextTurnId, latest.lastTurnStatus);
3891
+ }
3892
+ this.store.audit(attempt.contextKey, "approved_plan_continued", { sessionId, turnId, nextTurnId });
3893
+ });
3894
+ }
3895
+ catch (error) {
3896
+ failed = true;
3897
+ this.logger.error({ error, sessionId, turnId }, "Failed to continue an approved plan.");
3898
+ await this.outbound.sendText(attempt.contextKey, "方案已确认,但后续执行启动失败:" + runtimeErrorMessage(error) + "。请发送消息继续。");
3899
+ }
3900
+ finally {
3901
+ if (this.planHandoffs.get(sessionId) === turnId)
3902
+ this.planHandoffs.delete(sessionId);
3903
+ if (!continued) {
3904
+ try {
3905
+ await this.outbound.onEvent(failed
3906
+ ? { type: "turn_failed", sessionId, turnId, message: "方案已确认,但后续执行未能启动。" }
3907
+ : { type: "turn_cancelled", sessionId, turnId });
3908
+ }
3909
+ catch (error) {
3910
+ this.logger.warn({ error, sessionId, turnId }, "Failed to finish the plan handoff card.");
3911
+ }
3912
+ await this.finalizeTurnMessageReactions(turnId, failed ? "failed" : "cancelled");
3913
+ }
3914
+ const latest = this.store.getSession(sessionId);
3915
+ if (latest?.lastTurnId === turnId && latest.status !== "closed") {
3916
+ this.store.updateSession(sessionId, { status: failed ? "failed" : "ready" });
3917
+ }
3918
+ await this.scheduleNextQueuedPrompt(sessionId);
3919
+ }
3920
+ }
3707
3921
  async retryFailedLlmTurn(event, attempt) {
3708
3922
  const retryNumber = attempt.retryCount;
3709
3923
  const session = this.store.getSession(event.sessionId);
@@ -3737,7 +3951,7 @@ export class ProxySessionController {
3737
3951
  : undefined;
3738
3952
  try {
3739
3953
  const current = this.store.getSession(session.localSessionId) ?? session;
3740
- const loaded = await this.loadSession(current);
3954
+ const loaded = await this.loadSession(current, Boolean(this.store.getPendingProviderSettings(current.localSessionId)));
3741
3955
  const retryTurnId = await this.startTurn(loaded, llmRetryRuntimePrompt(attempt.promptText, retryNumber), replyTarget, attempt.localImagePaths, {
3742
3956
  attemptId: attempt.attemptId,
3743
3957
  messageId: attempt.messageId,
@@ -3774,6 +3988,8 @@ export class ProxySessionController {
3774
3988
  });
3775
3989
  }
3776
3990
  scheduleQueuedPromptRetry(sessionId) {
3991
+ if (this.store.getPendingProviderSettings(sessionId)?.error)
3992
+ return;
3777
3993
  if (this.queuedPromptRetryTimers.has(sessionId))
3778
3994
  return;
3779
3995
  if (this.store.countQueuedPrompts(sessionId) === 0)
@@ -3789,6 +4005,11 @@ export class ProxySessionController {
3789
4005
  this.queuedPromptRetryTimers.set(sessionId, timer);
3790
4006
  }
3791
4007
  async startNextQueuedPromptIfIdle(sessionId) {
4008
+ return this.settingsOperations.run(sessionId, () => this.startNextQueuedPromptIfIdleNow(sessionId));
4009
+ }
4010
+ async startNextQueuedPromptIfIdleNow(sessionId) {
4011
+ if (this.planHandoffs.has(sessionId) || this.sessionResets.has(sessionId))
4012
+ return;
3792
4013
  if (this.store.countQueuedPrompts(sessionId) === 0)
3793
4014
  return;
3794
4015
  const baseRecord = this.store.getSession(sessionId);
@@ -3796,20 +4017,23 @@ export class ProxySessionController {
3796
4017
  return;
3797
4018
  let prompt;
3798
4019
  try {
3799
- let loaded = await this.loadSession(baseRecord);
4020
+ let loaded = await this.loadSession(baseRecord, Boolean(this.store.getPendingProviderSettings(sessionId)));
3800
4021
  const remoteActivity = await this.assertSessionTurnOwnership(loaded.record, loaded.runtime);
3801
4022
  if (needsFullSessionSynchronization(loaded.record, loaded.session, remoteActivity)) {
3802
4023
  loaded = { ...loaded, session: await loaded.runtime.synchronizeSession(sessionId) };
3803
4024
  }
3804
4025
  if (loaded.session.activeTurnId)
3805
4026
  return;
4027
+ await this.applyPendingProviderSettings(loaded);
4028
+ if (this.store.getPendingProviderSettings(sessionId))
4029
+ return;
3806
4030
  prompt = this.store.takeNextQueuedPrompt(sessionId);
3807
4031
  if (!prompt)
3808
4032
  return;
3809
4033
  const record = this.store.getSessionForContext(sessionId, prompt.contextKey) ?? baseRecord;
3810
4034
  loaded = { ...loaded, record };
3811
4035
  await this.refreshPromptQueueCards(sessionId);
3812
- const turnId = await this.startTurn(loaded, prompt.text, prompt.replyMessageId ? { messageId: prompt.replyMessageId, replyInThread: true } : undefined, prompt.localImagePaths, { messageId: prompt.messageId, displayPrompt: prompt.displayPrompt });
4036
+ const turnId = await this.startTurnNow(loaded, prompt.text, prompt.replyMessageId ? { messageId: prompt.replyMessageId, replyInThread: true } : undefined, prompt.localImagePaths, { messageId: prompt.messageId, displayPrompt: prompt.displayPrompt });
3813
4037
  if (prompt.messageId)
3814
4038
  await this.bindMessageReactionToTurn(prompt.messageId, sessionId, turnId);
3815
4039
  }
@@ -3868,6 +4092,7 @@ export class ProxySessionController {
3868
4092
  });
3869
4093
  const pending = {
3870
4094
  contextKey: message.contextKey,
4095
+ userId: message.userId,
3871
4096
  attachmentPromise,
3872
4097
  resolveAttachment,
3873
4098
  };
@@ -3875,7 +4100,7 @@ export class ProxySessionController {
3875
4100
  return pending;
3876
4101
  }
3877
4102
  reserveForwardAttachment(message) {
3878
- if (!message.parentMessageId)
4103
+ if (!message.parentMessageId || isCommandMessage(message))
3879
4104
  return undefined;
3880
4105
  const candidates = [
3881
4106
  { sourceKind: "merged_forward", registry: this.pendingMergedForwards },
@@ -3883,7 +4108,8 @@ export class ProxySessionController {
3883
4108
  ];
3884
4109
  for (const candidate of candidates) {
3885
4110
  const pending = candidate.registry.get(message.parentMessageId);
3886
- if (!pending || pending.contextKey !== message.contextKey || pending.attachmentMessageId)
4111
+ if (!pending || pending.contextKey !== message.contextKey
4112
+ || pending.userId !== message.userId || pending.attachmentMessageId)
3887
4113
  continue;
3888
4114
  pending.attachmentMessageId = message.messageId;
3889
4115
  return {
@@ -3908,17 +4134,20 @@ export class ProxySessionController {
3908
4134
  return true;
3909
4135
  }
3910
4136
  async waitForForwardAttachment(messageId, pending, registry) {
3911
- let timer;
3912
- const timeout = new Promise((resolve) => {
3913
- timer = setTimeout(() => resolve(undefined), FORWARD_ATTACHMENT_WINDOW_MS);
3914
- timer.unref?.();
3915
- });
4137
+ const timer = setTimeout(() => {
4138
+ // Arrival reserves the pair before acknowledgement. A slow Reaction must not split it.
4139
+ if (pending.attachmentMessageId)
4140
+ return;
4141
+ if (registry.get(messageId) === pending)
4142
+ registry.delete(messageId);
4143
+ pending.resolveAttachment(undefined);
4144
+ }, FORWARD_ATTACHMENT_WINDOW_MS);
4145
+ timer.unref?.();
3916
4146
  try {
3917
- return await Promise.race([pending.attachmentPromise, timeout]);
4147
+ return await pending.attachmentPromise;
3918
4148
  }
3919
4149
  finally {
3920
- if (timer)
3921
- clearTimeout(timer);
4150
+ clearTimeout(timer);
3922
4151
  if (registry.get(messageId) === pending) {
3923
4152
  registry.delete(messageId);
3924
4153
  }
@@ -3979,6 +4208,7 @@ export class ProxySessionController {
3979
4208
  await this.cancelSession(record);
3980
4209
  }
3981
4210
  async cancelSession(record) {
4211
+ this.planHandoffs.delete(record.localSessionId);
3982
4212
  const runtime = this.runtimes.forAgent(record.agentName);
3983
4213
  if (runtime.kind === "codex" && record.remoteSessionId && runtime.interruptRemoteTurn) {
3984
4214
  if (runtime.getGoal && runtime.setGoal) {
@@ -4055,6 +4285,7 @@ export class ProxySessionController {
4055
4285
  });
4056
4286
  }
4057
4287
  async runSessionReset(sessionId, operation) {
4288
+ this.planHandoffs.delete(sessionId);
4058
4289
  const previous = this.sessionResets.get(sessionId) ?? Promise.resolve();
4059
4290
  const reset = previous.catch(() => undefined).then(operation);
4060
4291
  this.sessionResets.set(sessionId, reset);
@@ -4199,6 +4430,14 @@ export class ProxySessionController {
4199
4430
  await loaded.runtime.respondToApproval(sessionId, requestId, decision);
4200
4431
  }
4201
4432
  async openExecutionSettings(contextKey, activeTab, options = {}) {
4433
+ const record = options.sessionId
4434
+ ? this.requireSession(contextKey, options.sessionId)
4435
+ : this.currentSession(contextKey);
4436
+ if (!record)
4437
+ return this.openExecutionSettingsNow(contextKey, activeTab, options);
4438
+ return this.settingsOperations.run(record.localSessionId, () => this.openExecutionSettingsNow(contextKey, activeTab, { ...options, sessionId: record.localSessionId }));
4439
+ }
4440
+ async openExecutionSettingsNow(contextKey, activeTab, options = {}) {
4202
4441
  const context = this.store.getOrCreateUserContext(contextKey, this.config.defaults.agent);
4203
4442
  const record = options.sessionId
4204
4443
  ? this.requireSession(contextKey, options.sessionId)
@@ -4217,22 +4456,31 @@ export class ProxySessionController {
4217
4456
  reasoningOptions: [],
4218
4457
  };
4219
4458
  if (record) {
4220
- const loaded = await this.loadSession(record);
4459
+ const loaded = await this.loadSession(record, true);
4460
+ const pending = this.store.getPendingProviderSettings(record.localSessionId);
4461
+ const selected = pending?.settings ?? loaded.session;
4462
+ if (pending && options.updateMessageId) {
4463
+ this.store.savePendingProviderSettings({
4464
+ ...pending, contextKey, messageId: options.updateMessageId, replyMessageId: options.updateMessageId, activeTab,
4465
+ });
4466
+ }
4221
4467
  const providerSupported = loaded.runtime.kind === "codex" && Boolean(loaded.runtime.listModelProviders);
4222
4468
  const providers = providerSupported ? await this.modelProviderOptions(loaded) : [];
4223
- const currentProvider = loaded.session.modelProvider ?? providers.find((provider) => provider.isDefault)?.id;
4224
- const { models, warning } = await this.modelOptions(loaded.runtime, currentProvider, loaded.session.model);
4225
- const currentModel = models.find((model) => model.id === loaded.session.model)
4469
+ const currentProvider = selected.modelProvider ?? providers.find((provider) => provider.isDefault)?.id;
4470
+ const { models, warning } = await this.modelOptions(loaded.runtime, currentProvider, selected.model);
4471
+ const currentModel = models.find((model) => model.id === selected.model)
4226
4472
  ?? models.find((model) => model.isDefault)
4227
4473
  ?? models[0];
4228
- const currentEffort = currentModel?.supportedReasoningEfforts.some((option) => option.value === loaded.session.reasoningEffort)
4229
- ? loaded.session.reasoningEffort
4230
- : currentModel?.defaultReasoningEffort ?? loaded.session.reasoningEffort;
4474
+ const currentEffort = currentModel?.supportedReasoningEfforts.some((option) => option.value === selected.reasoningEffort)
4475
+ ? selected.reasoningEffort
4476
+ : currentModel?.defaultReasoningEffort ?? selected.reasoningEffort;
4231
4477
  runtimeSettings = {
4478
+ pendingNotice: pending ? this.pendingProviderNotice(pending) : undefined,
4479
+ pendingFailed: Boolean(pending?.error),
4232
4480
  currentProvider,
4233
- currentModel: currentModel?.id ?? loaded.session.model,
4481
+ currentModel: pending?.settings.model ?? currentModel?.id ?? selected.model,
4234
4482
  currentEffort,
4235
- currentPermissionMode: loaded.session.permissionMode,
4483
+ currentPermissionMode: selected.permissionMode,
4236
4484
  providers,
4237
4485
  providerSupported,
4238
4486
  models,
@@ -4248,7 +4496,10 @@ export class ProxySessionController {
4248
4496
  agents,
4249
4497
  runtimeSettingsAvailable: Boolean(record),
4250
4498
  ...runtimeSettings,
4499
+ modelPage: options.expectedProvider !== undefined && options.expectedProvider !== (runtimeSettings.currentProvider ?? "")
4500
+ ? undefined : options.modelPage,
4251
4501
  notice: options.notice,
4502
+ warning: [runtimeSettings.warning, options.warning].filter(Boolean).join("\n") || undefined,
4252
4503
  });
4253
4504
  if (options.updateMessageId) {
4254
4505
  await this.outbound.updateInteractiveCard(contextKey, options.updateMessageId, card);
@@ -4266,35 +4517,40 @@ export class ProxySessionController {
4266
4517
  await this.openExecutionSettings(contextKey, "agent");
4267
4518
  }
4268
4519
  async selectProvider(contextKey, modelProvider, options = {}) {
4520
+ const record = options.sessionId ? this.requireSession(contextKey, options.sessionId) : this.requireCurrentSession(contextKey);
4521
+ return this.settingsOperations.run(record.localSessionId, () => this.selectProviderNow(contextKey, modelProvider, { ...options, sessionId: record.localSessionId }));
4522
+ }
4523
+ async selectProviderNow(contextKey, modelProvider, options = {}) {
4269
4524
  const record = options.sessionId
4270
4525
  ? this.requireSession(contextKey, options.sessionId)
4271
4526
  : this.requireCurrentSession(contextKey);
4272
- await this.outbound.sendText(contextKey, `正在切换到 Provider ${modelProvider},请稍后。`);
4273
- const loaded = await this.loadSession(record);
4527
+ const loaded = await this.loadSession(record, true);
4528
+ const selected = this.store.getPendingProviderSettings(record.localSessionId)?.settings ?? loaded.session;
4274
4529
  await this.assertModelProvider(loaded, modelProvider);
4275
- const models = await loaded.runtime.listModels(modelProvider, loaded.session.model);
4276
- const model = selectProviderModel(models, loaded.session.model);
4530
+ const models = await loaded.runtime.listModels(modelProvider, selected.model);
4531
+ const model = selectProviderModel(models, selected.model);
4277
4532
  if (!model)
4278
4533
  throw new Error("当前运行时没有可用于 Provider 切换的模型。");
4279
- const effort = selectProviderReasoningEffort(model, loaded.session.reasoningEffort);
4280
- await this.applyProviderSettings(contextKey, {
4534
+ const effort = selectProviderReasoningEffort(model, selected.reasoningEffort);
4535
+ await this.applyProviderSettingsNow(contextKey, {
4281
4536
  provider: modelProvider,
4282
4537
  model: model.id,
4283
4538
  effort,
4284
- mode: loaded.session.permissionMode,
4285
- }, options, false);
4539
+ mode: selected.permissionMode,
4540
+ }, options);
4286
4541
  }
4287
4542
  async openProviderSelector(contextKey, options = {}) {
4288
4543
  await this.openExecutionSettings(contextKey, "provider", options);
4289
4544
  }
4290
- async openProviderModelSelector(contextKey, modelProvider, permissionMode, sessionId, updateMessageId) {
4291
- const loaded = await this.loadSession(this.requireSession(contextKey, sessionId));
4545
+ async openProviderModelSelector(contextKey, modelProvider, permissionMode, sessionId, updateMessageId, modelPage) {
4546
+ const loaded = await this.loadSession(this.requireSession(contextKey, sessionId), true);
4292
4547
  await this.assertModelProvider(loaded, modelProvider);
4293
4548
  const { models, warning } = await this.modelOptions(loaded.runtime, modelProvider, loaded.session.model);
4294
4549
  const card = this.cardRenderer.renderModelSelector({
4295
4550
  sessionId,
4296
4551
  contextKey,
4297
4552
  currentModel: selectProviderModel(models, loaded.session.model)?.id,
4553
+ modelPage,
4298
4554
  reasoningEffort: loaded.session.reasoningEffort,
4299
4555
  models,
4300
4556
  warning,
@@ -4305,7 +4561,7 @@ export class ProxySessionController {
4305
4561
  await this.outbound.updateInteractiveCard(contextKey, updateMessageId, card);
4306
4562
  }
4307
4563
  async openProviderReasoningSelector(contextKey, modelProvider, model, permissionMode, sessionId, updateMessageId) {
4308
- const loaded = await this.loadSession(this.requireSession(contextKey, sessionId));
4564
+ const loaded = await this.loadSession(this.requireSession(contextKey, sessionId), true);
4309
4565
  await this.assertModelProvider(loaded, modelProvider);
4310
4566
  const { models, warning } = await this.modelOptions(loaded.runtime, modelProvider, model);
4311
4567
  const selected = models.find((candidate) => candidate.id === model);
@@ -4331,7 +4587,7 @@ export class ProxySessionController {
4331
4587
  await this.outbound.updateInteractiveCard(contextKey, updateMessageId, card);
4332
4588
  }
4333
4589
  async openProviderPermissionSelector(contextKey, modelProvider, model, effort, permissionMode, sessionId, updateMessageId) {
4334
- const loaded = await this.loadSession(this.requireSession(contextKey, sessionId));
4590
+ const loaded = await this.loadSession(this.requireSession(contextKey, sessionId), true);
4335
4591
  await this.assertProviderModelSettings(loaded, modelProvider, model, effort);
4336
4592
  await this.outbound.updateInteractiveCard(contextKey, updateMessageId, this.cardRenderer.renderPermissionSelector({
4337
4593
  sessionId,
@@ -4343,20 +4599,47 @@ export class ProxySessionController {
4343
4599
  }));
4344
4600
  }
4345
4601
  async applyProviderSettings(contextKey, settings, options = {}, announceStart = true) {
4602
+ const record = options.sessionId ? this.requireSession(contextKey, options.sessionId) : this.requireCurrentSession(contextKey);
4603
+ return this.settingsOperations.run(record.localSessionId, () => this.applyProviderSettingsNow(contextKey, settings, { ...options, sessionId: record.localSessionId }, announceStart));
4604
+ }
4605
+ async applyProviderSettingsNow(contextKey, settings, options = {}, announceStart = true) {
4346
4606
  const record = options.sessionId
4347
4607
  ? this.requireSession(contextKey, options.sessionId)
4348
4608
  : this.requireCurrentSession(contextKey);
4349
- if (announceStart) {
4609
+ const loaded = await this.loadSession(record, true);
4610
+ const warning = await this.assertProviderModelSettings(loaded, settings.provider, settings.model, settings.effort);
4611
+ const next = {
4612
+ modelProvider: settings.provider, model: settings.model,
4613
+ reasoningEffort: settings.effort, permissionMode: settings.mode,
4614
+ };
4615
+ await this.reconcilePreparedSettingsSession(loaded);
4616
+ if (loaded.session.activeTurnId || this.store.getPendingProviderSettings(record.localSessionId)) {
4617
+ await this.savePendingProviderSelection(loaded, next, contextKey, options, "provider");
4618
+ return;
4619
+ }
4620
+ if (announceStart)
4350
4621
  await this.outbound.sendText(contextKey, `正在切换到 Provider ${settings.provider},请稍后。`);
4622
+ const previous = { ...loaded.session };
4623
+ let session;
4624
+ try {
4625
+ session = await this.changeProviderSettings(loaded, next);
4626
+ }
4627
+ catch (error) {
4628
+ if (!this.providerSettingsRestored(loaded, previous, error)) {
4629
+ this.store.savePendingProviderSettings({
4630
+ localSessionId: record.localSessionId, contextKey, settings: next, activeTab: "provider",
4631
+ messageId: options.updateMessageId,
4632
+ replyMessageId: options.updateMessageId ?? this.controlSessionReplyTarget(record)?.messageId,
4633
+ error: runtimeErrorMessage(error),
4634
+ });
4635
+ if (options.updateMessageId) {
4636
+ await this.openExecutionSettingsNow(contextKey, "provider", options).catch((presentationError) => {
4637
+ this.logger.warn({ error: presentationError, sessionId: record.localSessionId }, "Failed to refresh rejected Provider selection.");
4638
+ });
4639
+ }
4640
+ }
4641
+ throw error;
4351
4642
  }
4352
- const loaded = await this.loadSession(record);
4353
- const warning = await this.assertProviderModelSettings(loaded, settings.provider, settings.model, settings.effort);
4354
- const session = await this.changeProviderSettings(loaded, {
4355
- modelProvider: settings.provider,
4356
- model: settings.model,
4357
- reasoningEffort: settings.effort,
4358
- permissionMode: settings.mode,
4359
- });
4360
4643
  const notice = [
4361
4644
  `Provider 已切换为 ${cardCode(session.modelProvider ?? settings.provider)}`,
4362
4645
  `模型 ${cardCode(session.model ?? settings.model)}`,
@@ -4365,7 +4648,7 @@ export class ProxySessionController {
4365
4648
  `并已保存为 ${cardCode(loaded.record.agentName)} Agent 的默认设置,从下一次请求生效。`,
4366
4649
  ].join(",");
4367
4650
  if (options.updateMessageId) {
4368
- await this.openProviderSelector(contextKey, {
4651
+ await this.openExecutionSettingsNow(contextKey, "provider", {
4369
4652
  sessionId: loaded.record.localSessionId,
4370
4653
  updateMessageId: options.updateMessageId,
4371
4654
  notice,
@@ -4376,16 +4659,23 @@ export class ProxySessionController {
4376
4659
  }
4377
4660
  }
4378
4661
  async changeProviderSettings(loaded, settings) {
4662
+ return this.sessionHydrationOperations.run(loaded.record.localSessionId, () => this.changeProviderSettingsNow(loaded, settings));
4663
+ }
4664
+ async changeProviderSettingsNow(loaded, settings) {
4379
4665
  if (!loaded.runtime.setExecutionSettings)
4380
4666
  throw new Error("当前运行时不支持 Provider 设置。");
4381
- const persist = async (candidate) => {
4667
+ loaded.session = loaded.runtime.getSession(loaded.record.localSessionId) ?? loaded.session;
4668
+ const remoteSessionId = loaded.session.remoteSessionId;
4669
+ const persist = (candidate) => this.agentDefaultsOperations.run(loaded.record.agentName, async () => {
4670
+ if (candidate.remoteSessionId !== remoteSessionId) {
4671
+ throw new Error("设置切换返回了不同的任务 ID,未保存设置;不允许替换任务或更改历史。");
4672
+ }
4382
4673
  assertProviderSettingsApplied(candidate, settings);
4383
4674
  const agent = this.ensureAgent(loaded.record.agentName);
4384
4675
  const previousDefaults = agent.defaults;
4385
- await this.persistAgentExecutionDefaults(loaded.record.localSessionId, candidate);
4676
+ await this.persistAgentExecutionDefaultsNow(loaded.record.localSessionId, candidate);
4386
4677
  try {
4387
4678
  this.store.updateRuntimeSession(loaded.record.localSessionId, {
4388
- remoteSessionId: candidate.remoteSessionId,
4389
4679
  modelProvider: candidate.modelProvider,
4390
4680
  model: candidate.model,
4391
4681
  reasoningEffort: candidate.reasoningEffort,
@@ -4397,11 +4687,147 @@ export class ProxySessionController {
4397
4687
  agent.defaults = previousDefaults;
4398
4688
  throw error;
4399
4689
  }
4400
- };
4690
+ });
4401
4691
  const session = await loaded.runtime.setExecutionSettings(loaded.record.localSessionId, settings, persist);
4692
+ this.settingsOnlySessions.delete(loaded.record.localSessionId);
4402
4693
  this.outbound.updateSessionModel(loaded.record.localSessionId, session.model, session.modelProvider);
4403
4694
  return session;
4404
4695
  }
4696
+ async reconcilePreparedSettingsSession(loaded) {
4697
+ if (!this.settingsOnlySessions.has(loaded.record.localSessionId) || !loaded.session.activeTurnId)
4698
+ return;
4699
+ const activity = await this.assertSessionTurnOwnership(loaded.record, loaded.runtime);
4700
+ if (activity && !activity.active) {
4701
+ loaded.session = await loaded.runtime.synchronizeSession(loaded.record.localSessionId);
4702
+ }
4703
+ }
4704
+ providerSettingsRestored(loaded, previous, error) {
4705
+ const restored = loaded.runtime.getSession(loaded.record.localSessionId);
4706
+ const saved = this.store.getSession(loaded.record.localSessionId);
4707
+ const keys = ["remoteSessionId", "modelProvider", "model", "reasoningEffort", "permissionMode"];
4708
+ return error instanceof ProviderSwitchError && error.previousSettingsRestored
4709
+ && Boolean(restored && saved && keys.every((key) => restored[key] === previous[key] && saved[key] === previous[key]));
4710
+ }
4711
+ pendingProviderNotice(pending) {
4712
+ const target = `Provider ${cardCode(pending.settings.modelProvider)},模型 ${cardCode(pending.settings.model)}`;
4713
+ return pending.error
4714
+ ? `切换失败:${pending.error}。目标为 ${target};后续轮次已暂停,请重新选择 Provider 或模型重试。`
4715
+ : `待生效:当前轮次不变,结束后切换到 ${target}。可继续选择模型,以最后选择为准。`;
4716
+ }
4717
+ async savePendingProviderSelection(loaded, settings, contextKey, options, activeTab) {
4718
+ const previous = this.store.getPendingProviderSettings(loaded.record.localSessionId);
4719
+ const pending = {
4720
+ localSessionId: loaded.record.localSessionId, contextKey, settings, activeTab,
4721
+ messageId: options.updateMessageId ?? previous?.messageId,
4722
+ replyMessageId: options.updateMessageId ?? previous?.replyMessageId
4723
+ ?? this.controlSessionReplyTarget(loaded.record)?.messageId,
4724
+ };
4725
+ this.store.savePendingProviderSettings(pending);
4726
+ await this.reconcilePreparedSettingsSession(loaded);
4727
+ if (!loaded.session.activeTurnId) {
4728
+ await this.applyPendingProviderSettings(loaded);
4729
+ void this.scheduleNextQueuedPrompt(loaded.record.localSessionId).catch((error) => {
4730
+ this.logger.warn({ error }, "Failed to resume queue after Provider selection.");
4731
+ });
4732
+ return;
4733
+ }
4734
+ if (options.updateMessageId) {
4735
+ await this.openExecutionSettingsNow(contextKey, activeTab, options);
4736
+ }
4737
+ else {
4738
+ await this.outbound.sendText(contextKey, this.pendingProviderNotice(pending).replaceAll("`", ""));
4739
+ }
4740
+ }
4741
+ // Caller holds settingsOperations; only a verified rollback can unblock the next turn.
4742
+ async applyPendingProviderSettings(loaded) {
4743
+ const pending = this.store.getPendingProviderSettings(loaded.record.localSessionId);
4744
+ if (!pending)
4745
+ return;
4746
+ await this.reconcilePreparedSettingsSession(loaded);
4747
+ if (loaded.runtime.getSession(loaded.record.localSessionId)?.activeTurnId)
4748
+ return;
4749
+ if (pending.error)
4750
+ throw new Error(this.pendingProviderNotice(pending));
4751
+ const previous = { ...(loaded.runtime.getSession(loaded.record.localSessionId) ?? loaded.session) };
4752
+ let notice;
4753
+ let failure;
4754
+ let rolledBack = false;
4755
+ try {
4756
+ loaded.session = await this.changeProviderSettings(loaded, pending.settings);
4757
+ this.store.deletePendingProviderSettings(pending.localSessionId);
4758
+ notice = `Provider 已切换为 ${cardCode(pending.settings.modelProvider)},模型 ${cardCode(pending.settings.model)},从下一轮生效。`;
4759
+ }
4760
+ catch (error) {
4761
+ const restored = loaded.runtime.getSession(pending.localSessionId);
4762
+ rolledBack = this.providerSettingsRestored(loaded, previous, error);
4763
+ if (rolledBack) {
4764
+ loaded.session = restored;
4765
+ this.store.deletePendingProviderSettings(pending.localSessionId);
4766
+ this.outbound.updateSessionModel(pending.localSessionId, previous.model, previous.modelProvider);
4767
+ notice = `切换到 Provider ${cardCode(pending.settings.modelProvider)}、模型 ${cardCode(pending.settings.model)} 失败:${runtimeErrorMessage(error)} 已回退到 Provider ${cardCode(previous.modelProvider ?? "Agent 默认")}、模型 ${cardCode(previous.model ?? "默认")},后续轮次继续使用原设置。`;
4768
+ }
4769
+ else {
4770
+ failure = error;
4771
+ const failed = { ...pending, error: runtimeErrorMessage(error) };
4772
+ this.store.savePendingProviderSettings(failed);
4773
+ notice = this.pendingProviderNotice(failed);
4774
+ }
4775
+ }
4776
+ try {
4777
+ await this.outbound.withReplyTarget(pending.contextKey, pending.replyMessageId
4778
+ ? { messageId: pending.replyMessageId, replyInThread: true } : undefined, async () => {
4779
+ if (pending.messageId) {
4780
+ try {
4781
+ await this.openExecutionSettingsNow(pending.contextKey, pending.activeTab ?? "provider", {
4782
+ sessionId: pending.localSessionId, updateMessageId: pending.messageId,
4783
+ notice: failure || rolledBack ? undefined : notice,
4784
+ warning: rolledBack ? notice.replaceAll("`", "") : undefined,
4785
+ });
4786
+ return;
4787
+ }
4788
+ catch (error) {
4789
+ this.logger.warn({ error, sessionId: pending.localSessionId }, "Failed to refresh Provider settings card.");
4790
+ }
4791
+ }
4792
+ await this.outbound.sendText(pending.contextKey, notice.replaceAll("`", ""));
4793
+ });
4794
+ }
4795
+ catch (error) {
4796
+ this.logger.warn({ error, sessionId: pending.localSessionId }, "Failed to notify Provider settings result.");
4797
+ }
4798
+ if (failure)
4799
+ throw failure;
4800
+ }
4801
+ async applyPendingProviderWhenIdle(sessionId) {
4802
+ await this.settingsOperations.run(sessionId, async () => {
4803
+ if (this.recoveryClosed)
4804
+ return;
4805
+ const pending = this.store.getPendingProviderSettings(sessionId);
4806
+ if (!pending || pending.error)
4807
+ return;
4808
+ const record = this.store.getSession(sessionId);
4809
+ if (!record || record.status === "closed") {
4810
+ this.store.deletePendingProviderSettings(sessionId);
4811
+ return;
4812
+ }
4813
+ try {
4814
+ const loaded = await this.loadSession(record, true);
4815
+ await this.applyPendingProviderSettings(loaded);
4816
+ }
4817
+ catch (error) {
4818
+ this.logger.warn({ error, sessionId }, "Deferred Provider switch could not be applied.");
4819
+ if (!this.store.getPendingProviderSettings(sessionId)?.error) {
4820
+ const failed = { ...pending, error: runtimeErrorMessage(error) };
4821
+ this.store.savePendingProviderSettings(failed);
4822
+ await this.outbound.withReplyTarget(pending.contextKey, pending.replyMessageId
4823
+ ? { messageId: pending.replyMessageId, replyInThread: true } : undefined, () => this.outbound.sendText(pending.contextKey, this.pendingProviderNotice(failed).replaceAll("`", "")))
4824
+ .catch((notificationError) => {
4825
+ this.logger.warn({ error: notificationError, sessionId }, "Failed to notify Provider recovery failure.");
4826
+ });
4827
+ }
4828
+ }
4829
+ });
4830
+ }
4405
4831
  async assertProviderModelSettings(loaded, modelProvider, model, effort) {
4406
4832
  await this.assertModelProvider(loaded, modelProvider);
4407
4833
  const { models, warning } = await this.modelOptions(loaded.runtime, modelProvider, model);
@@ -4438,36 +4864,51 @@ export class ProxySessionController {
4438
4864
  return providers;
4439
4865
  }
4440
4866
  async model(contextKey, model, options = {}) {
4867
+ const record = options.sessionId ? this.requireSession(contextKey, options.sessionId) : this.requireCurrentSession(contextKey);
4868
+ return this.settingsOperations.run(record.localSessionId, () => this.modelNow(contextKey, model, { ...options, sessionId: record.localSessionId }));
4869
+ }
4870
+ async modelNow(contextKey, model, options = {}) {
4441
4871
  if (!model) {
4442
- await this.openExecutionSettings(contextKey, "model", options);
4872
+ await this.openExecutionSettingsNow(contextKey, "model", options);
4443
4873
  return;
4444
4874
  }
4445
4875
  const record = options.sessionId
4446
4876
  ? this.requireSession(contextKey, options.sessionId)
4447
4877
  : this.requireCurrentSession(contextKey);
4448
- const loaded = await this.loadSession(record);
4449
- const models = await loaded.runtime.listModels(loaded.session.modelProvider, loaded.session.model);
4878
+ const loaded = await this.loadSession(record, true);
4879
+ const pending = this.store.getPendingProviderSettings(record.localSessionId);
4880
+ const selectedSettings = pending?.settings ?? loaded.session;
4881
+ if (options.expectedProvider && options.expectedProvider !== selectedSettings.modelProvider) {
4882
+ throw new Error("Provider 已发生变化,请重新打开运行设置后选择。");
4883
+ }
4884
+ const models = await loaded.runtime.listModels(selectedSettings.modelProvider, selectedSettings.model);
4450
4885
  const selected = models.find((item) => item.id === model);
4451
4886
  if (!selected)
4452
4887
  throw new Error(`未知模型:${model}`);
4453
- const currentEffort = loaded.session.reasoningEffort;
4888
+ const currentEffort = selectedSettings.reasoningEffort;
4454
4889
  const compatible = currentEffort
4455
4890
  ? selected.supportedReasoningEfforts.length === 0
4456
4891
  || selected.supportedReasoningEfforts.some((option) => option.value === currentEffort)
4457
4892
  : false;
4458
4893
  const nextEffort = compatible ? currentEffort : selected.defaultReasoningEffort;
4459
- await loaded.runtime.setModel(loaded.record.localSessionId, model);
4460
- if (nextEffort && nextEffort !== currentEffort) {
4461
- await loaded.runtime.setReasoningEffort(loaded.record.localSessionId, nextEffort);
4894
+ if (pending) {
4895
+ await this.savePendingProviderSelection(loaded, { ...pending.settings, model, reasoningEffort: selectProviderReasoningEffort(selected, nextEffort) }, contextKey, options, "model");
4896
+ return;
4462
4897
  }
4463
- this.store.updateRuntimeSession(loaded.record.localSessionId, { model, reasoningEffort: nextEffort });
4464
- this.outbound.updateSessionModel(loaded.record.localSessionId, model, loaded.session.modelProvider);
4465
- await this.persistAgentExecutionDefaults(loaded.record.localSessionId);
4898
+ await this.sessionHydrationOperations.run(loaded.record.localSessionId, async () => {
4899
+ await loaded.runtime.setModel(loaded.record.localSessionId, model);
4900
+ if (nextEffort && nextEffort !== currentEffort) {
4901
+ await loaded.runtime.setReasoningEffort(loaded.record.localSessionId, nextEffort);
4902
+ }
4903
+ this.store.updateRuntimeSession(loaded.record.localSessionId, { model, reasoningEffort: nextEffort });
4904
+ this.outbound.updateSessionModel(loaded.record.localSessionId, model, loaded.session.modelProvider);
4905
+ await this.persistAgentExecutionDefaults(loaded.record.localSessionId);
4906
+ });
4466
4907
  const effortMessage = nextEffort && nextEffort !== currentEffort
4467
4908
  ? `,思考强度已自动调整为 ${nextEffort}`
4468
4909
  : "";
4469
4910
  if (options.updateMessageId) {
4470
- await this.openExecutionSettings(contextKey, "model", {
4911
+ await this.openExecutionSettingsNow(contextKey, "model", {
4471
4912
  sessionId: loaded.record.localSessionId,
4472
4913
  updateMessageId: options.updateMessageId,
4473
4914
  notice: `模型已切换为 ${cardCode(model)}${effortMessage},并已保存为 ${cardCode(loaded.record.agentName)} Agent 的默认设置,从下一次请求生效。`,
@@ -4607,6 +5048,7 @@ export class ProxySessionController {
4607
5048
  const created = await this.shellCommandJobs.createJob({ contextKey, sourceMessageId, command, cwd });
4608
5049
  let cardMessageId;
4609
5050
  const initialState = {
5051
+ terminalUrl: this.lifecycle?.terminalUrl?.(created.id),
4610
5052
  jobId: created.id,
4611
5053
  contextKey,
4612
5054
  command,
@@ -4629,7 +5071,7 @@ export class ProxySessionController {
4629
5071
  if (cardMessageId) {
4630
5072
  try {
4631
5073
  const failed = await this.shellCommandJobs.readJob(created.id);
4632
- await this.outbound.updateInteractiveCard(contextKey, cardMessageId, this.cardRenderer.renderShellCommandCard(shellCommandJobCardView(failed)));
5074
+ await this.outbound.updateInteractiveCard(contextKey, cardMessageId, this.cardRenderer.renderShellCommandCard({ ...shellCommandJobCardView(failed), terminalUrl: this.lifecycle?.terminalUrl?.(failed.id) }));
4633
5075
  await this.shellCommandJobs.markPresented(created.id);
4634
5076
  }
4635
5077
  catch (cardError) {
@@ -4680,7 +5122,7 @@ export class ProxySessionController {
4680
5122
  if (!job.cardMessageId || this.shellCommandJobMonitors.has(job.id))
4681
5123
  return;
4682
5124
  const scheduler = new CardUpdateScheduler({
4683
- render: (state) => this.cardRenderer.renderShellCommandCard(state),
5125
+ render: (state) => this.cardRenderer.renderShellCommandCard({ ...state, terminalUrl: this.lifecycle?.terminalUrl?.(job.id) }),
4684
5126
  write: (card) => this.outbound.updateInteractiveCard(job.contextKey, job.cardMessageId, card),
4685
5127
  onError: (error) => this.logger.warn({ error, jobId: job.id, contextKey: job.contextKey, messageId: job.cardMessageId }, "Failed to update a background shell command card."),
4686
5128
  });
@@ -4829,16 +5271,25 @@ export class ProxySessionController {
4829
5271
  });
4830
5272
  }
4831
5273
  async thinking(contextKey, effort, options = {}) {
5274
+ const record = options.sessionId ? this.requireSession(contextKey, options.sessionId) : this.requireCurrentSession(contextKey);
5275
+ return this.settingsOperations.run(record.localSessionId, () => this.thinkingNow(contextKey, effort, { ...options, sessionId: record.localSessionId }));
5276
+ }
5277
+ async thinkingNow(contextKey, effort, options = {}) {
4832
5278
  if (!effort) {
4833
- await this.openExecutionSettings(contextKey, "thinking", options);
5279
+ await this.openExecutionSettingsNow(contextKey, "thinking", options);
4834
5280
  return;
4835
5281
  }
4836
5282
  const record = options.sessionId
4837
5283
  ? this.requireSession(contextKey, options.sessionId)
4838
5284
  : this.requireCurrentSession(contextKey);
4839
- const loaded = await this.loadSession(record);
4840
- const models = await loaded.runtime.listModels(loaded.session.modelProvider, loaded.session.model);
4841
- const currentModel = models.find((item) => item.id === loaded.session.model)
5285
+ const loaded = await this.loadSession(record, true);
5286
+ const pending = this.store.getPendingProviderSettings(record.localSessionId);
5287
+ const selectedSettings = pending?.settings ?? loaded.session;
5288
+ if (options.expectedProvider && options.expectedProvider !== selectedSettings.modelProvider) {
5289
+ throw new Error("Provider 已发生变化,请重新打开运行设置后选择。");
5290
+ }
5291
+ const models = await loaded.runtime.listModels(selectedSettings.modelProvider, selectedSettings.model);
5292
+ const currentModel = models.find((item) => item.id === selectedSettings.model)
4842
5293
  ?? models.find((item) => item.isDefault);
4843
5294
  if (!currentModel)
4844
5295
  throw new Error("当前运行时没有可配置思考强度的模型。");
@@ -4850,11 +5301,17 @@ export class ProxySessionController {
4850
5301
  const options = supported.map((option) => option.value).join("、") || "无";
4851
5302
  throw new Error(`不支持的思考强度:${effort}。支持的强度:${options}`);
4852
5303
  }
4853
- await loaded.runtime.setReasoningEffort(loaded.record.localSessionId, effort);
4854
- this.store.updateRuntimeSession(loaded.record.localSessionId, { reasoningEffort: effort });
4855
- await this.persistAgentExecutionDefaults(loaded.record.localSessionId);
5304
+ if (pending) {
5305
+ await this.savePendingProviderSelection(loaded, { ...pending.settings, reasoningEffort: effort }, contextKey, options, "thinking");
5306
+ return;
5307
+ }
5308
+ await this.sessionHydrationOperations.run(loaded.record.localSessionId, async () => {
5309
+ await loaded.runtime.setReasoningEffort(loaded.record.localSessionId, effort);
5310
+ this.store.updateRuntimeSession(loaded.record.localSessionId, { reasoningEffort: effort });
5311
+ await this.persistAgentExecutionDefaults(loaded.record.localSessionId);
5312
+ });
4856
5313
  if (options.updateMessageId) {
4857
- await this.openExecutionSettings(contextKey, "thinking", {
5314
+ await this.openExecutionSettingsNow(contextKey, "thinking", {
4858
5315
  sessionId: loaded.record.localSessionId,
4859
5316
  updateMessageId: options.updateMessageId,
4860
5317
  notice: `思考强度已切换为 ${cardCode(effort)},并已保存为 ${cardCode(loaded.record.agentName)} Agent 的默认设置,从下一次请求生效。`,
@@ -4864,20 +5321,32 @@ export class ProxySessionController {
4864
5321
  await this.outbound.sendText(contextKey, `思考强度已切换为 ${effort},并已保存为 ${loaded.record.agentName} Agent 的默认设置,从下一次请求生效。`);
4865
5322
  }
4866
5323
  async permissions(contextKey, mode, options = {}) {
5324
+ const record = options.sessionId ? this.requireSession(contextKey, options.sessionId) : this.requireCurrentSession(contextKey);
5325
+ return this.settingsOperations.run(record.localSessionId, () => this.permissionsNow(contextKey, mode, { ...options, sessionId: record.localSessionId }));
5326
+ }
5327
+ async permissionsNow(contextKey, mode, options = {}) {
4867
5328
  if (!mode) {
4868
- await this.openExecutionSettings(contextKey, "permission", options);
5329
+ await this.openExecutionSettingsNow(contextKey, "permission", options);
4869
5330
  return;
4870
5331
  }
4871
5332
  const record = options.sessionId
4872
5333
  ? this.requireSession(contextKey, options.sessionId)
4873
5334
  : this.requireCurrentSession(contextKey);
4874
- const loaded = await this.loadSession(record);
4875
- await loaded.runtime.setPermissionMode(record.localSessionId, mode);
4876
- loaded.session.permissionMode = mode;
4877
- this.store.updateRuntimeSession(record.localSessionId, { permissionMode: mode });
4878
- await this.persistAgentExecutionDefaults(record.localSessionId);
5335
+ const loaded = await this.loadSession(record, true);
5336
+ const pending = this.store.getPendingProviderSettings(record.localSessionId);
5337
+ if (pending) {
5338
+ await this.savePendingProviderSelection(loaded, { ...pending.settings, permissionMode: mode }, contextKey, options, "permission");
5339
+ return;
5340
+ }
5341
+ await this.sessionHydrationOperations.run(record.localSessionId, async () => {
5342
+ await loaded.runtime.setPermissionMode(record.localSessionId, mode);
5343
+ loaded.session = loaded.runtime.getSession(record.localSessionId) ?? loaded.session;
5344
+ loaded.session.permissionMode = mode;
5345
+ this.store.updateRuntimeSession(record.localSessionId, { permissionMode: mode });
5346
+ await this.persistAgentExecutionDefaults(record.localSessionId);
5347
+ });
4879
5348
  if (options.updateMessageId) {
4880
- await this.openExecutionSettings(contextKey, "permission", {
5349
+ await this.openExecutionSettingsNow(contextKey, "permission", {
4881
5350
  sessionId: loaded.record.localSessionId,
4882
5351
  updateMessageId: options.updateMessageId,
4883
5352
  notice: mode === "auto"
@@ -4918,8 +5387,9 @@ export class ProxySessionController {
4918
5387
  if (existing)
4919
5388
  return existing;
4920
5389
  const snapshot = turnViewSnapshot(this.store.getTurnSnapshot(turnId));
4921
- if ((!snapshot || snapshot.previewJournal) && this.store.previews.has(turnId))
4922
- return this.store.previews.load(turnId);
5390
+ const previewTurnId = snapshot?.previewTurnId ?? turnId;
5391
+ if ((!snapshot || snapshot.previewJournal) && this.store.previews.has(previewTurnId))
5392
+ return this.store.previews.load(previewTurnId);
4923
5393
  if (snapshot && !needsHistoricalDetails(snapshot))
4924
5394
  return snapshot;
4925
5395
  const origin = !snapshot ? this.store.getTurnRuntimeOrigin(turnId) : undefined;
@@ -4952,7 +5422,7 @@ export class ProxySessionController {
4952
5422
  // An Agent Bot turn may have arrived while the read-only request was in flight.
4953
5423
  const latest = turnViewSnapshot(this.store.getTurnSnapshot(origin.turnId));
4954
5424
  if (latest)
4955
- return latest.previewJournal ? this.store.previews.load(origin.turnId) ?? latest : latest;
5425
+ return latest.previewJournal ? this.store.previews.load(latest.previewTurnId ?? origin.turnId) ?? latest : latest;
4956
5426
  const state = hydrateTurnViewState(createTurnViewState(origin.localSessionId, origin.turnId, details.startedAt ?? 0, remote?.title, undefined, remote?.cwd, undefined, this.agentLabel(origin.agentName)), details);
4957
5427
  // Viewing external work must not create a task, a chat binding or a pending final reply.
4958
5428
  this.store.previews.seed(state);
@@ -5489,20 +5959,29 @@ export class ProxySessionController {
5489
5959
  const project = taskProjectInfo(entry);
5490
5960
  const projectActionSource = projectActionSources.get(project.key) ?? entry;
5491
5961
  const marker = entry.current ? "✅ " : entry.active ? "🟢 " : "";
5492
- const showStop = entry.status === "外部执行中"
5493
- && entry.reference !== options.forceSwitchTaskId
5494
- && entry.id !== options.forceSwitchTaskId;
5495
- const actions = entry.current ? [] : [bindCardAction({
5496
- text: showStop ? "Stop" : "Switch",
5497
- type: showStop ? "danger" : "default",
5962
+ const stopAction = entry.active ? bindCardAction({
5963
+ text: "Stop",
5964
+ type: "danger",
5965
+ value: {
5966
+ action: "session_stop",
5967
+ sessionId: entry.reference,
5968
+ ...(searchTerm ? { searchTerm } : {}),
5969
+ page: String(page),
5970
+ contextKey,
5971
+ },
5972
+ }) : undefined;
5973
+ const actions = [];
5974
+ if (!entry.current && entry.status !== "外部执行中")
5975
+ actions.push(bindCardAction({
5976
+ text: "Switch",
5498
5977
  value: {
5499
- action: showStop ? "session_stop" : "session_switch",
5978
+ action: "session_switch",
5500
5979
  sessionId: entry.reference,
5501
5980
  ...(searchTerm ? { searchTerm } : {}),
5502
5981
  page: String(page),
5503
5982
  contextKey,
5504
5983
  },
5505
- })];
5984
+ }));
5506
5985
  actions.push(bindCardAction({
5507
5986
  text: "SwitchGroup",
5508
5987
  value: {
@@ -5568,16 +6047,17 @@ export class ProxySessionController {
5568
6047
  contextKey,
5569
6048
  },
5570
6049
  }));
5571
- actions.push(bindCardAction({
5572
- text: "Archive",
5573
- value: {
5574
- action: "session_archive",
5575
- sessionId: entry.reference,
5576
- ...(searchTerm ? { searchTerm } : {}),
5577
- page: String(page),
5578
- contextKey,
5579
- },
5580
- }));
6050
+ if (!entry.active)
6051
+ actions.push(bindCardAction({
6052
+ text: "Archive",
6053
+ value: {
6054
+ action: "session_archive",
6055
+ sessionId: entry.reference,
6056
+ ...(searchTerm ? { searchTerm } : {}),
6057
+ page: String(page),
6058
+ contextKey,
6059
+ },
6060
+ }));
5581
6061
  const title = truncateText(entry.title.replace(/\s+/gu, " ").trim() || "未命名任务", 56);
5582
6062
  const lastUserPrompt = sessionPromptPreview(entry.lastUserPrompt);
5583
6063
  return {
@@ -5590,6 +6070,7 @@ export class ProxySessionController {
5590
6070
  `**最新 Prompt**:${cardText(lastUserPrompt)}`,
5591
6071
  `<font color='grey'>最近更新:${cardText(entry.updatedLabel)}</font>`,
5592
6072
  ],
6073
+ stopAction,
5593
6074
  actions,
5594
6075
  current: entry.current,
5595
6076
  },
@@ -5616,7 +6097,7 @@ export class ProxySessionController {
5616
6097
  `第 ${page + 1} 页 · 每页 ${SESSION_PAGE_SIZE} 个任务${hasNext ? "" : ` · 当前共 ${entries.length} 个任务`}`,
5617
6098
  "",
5618
6099
  "> 项目菜单:**New** 新建任务,**NewGroup** 新建群。",
5619
- "> 任务详情:**Switch** 切换,**Stop** 停止,**Fork** / **ForkGroup** 创建分支,**Status** 查看状态,**Archive** 归档。",
6100
+ "> 任务详情:**Switch** 切换,运行中任务可用 **Stop** 发送停止信号,**Fork** / **ForkGroup** 创建分支,**Status** 查看状态,**Archive** 归档。",
5620
6101
  ], [
5621
6102
  ...(hasPrevious ? [bindCardAction({
5622
6103
  text: "Previous",
@@ -5690,7 +6171,6 @@ export class ProxySessionController {
5690
6171
  : undefined;
5691
6172
  await this.listSessions(action.contextKey, searchTerm, {
5692
6173
  updateMessageId: action.messageId,
5693
- forceSwitchTaskId: options.forceSwitchTaskId,
5694
6174
  page: options.page ?? parseSessionPage(action.value.page),
5695
6175
  });
5696
6176
  }
@@ -5845,7 +6325,7 @@ export class ProxySessionController {
5845
6325
  const remoteReference = local
5846
6326
  ? remoteSessionReference(local.agentName, local.remoteSessionId)
5847
6327
  : taskId;
5848
- const { agentName, runtime, remote } = await this.resolveRemoteCodexSession(remoteReference);
6328
+ const { agentName, runtime, remote } = await this.resolveRemoteCodexSession(remoteReference, { view: "metadata" });
5849
6329
  if (isRemoteSessionActive(remote)) {
5850
6330
  throw new Error("任务正在执行,无法归档。请先停止任务或等待本轮完成。");
5851
6331
  }
@@ -5853,7 +6333,11 @@ export class ProxySessionController {
5853
6333
  throw new Error(`Agent ${agentName} 不支持归档任务。`);
5854
6334
  }
5855
6335
  const wasCurrent = Boolean(local && this.store.getUserContext(contextKey)?.currentSessionId === local.localSessionId);
5856
- await runtime.archiveRemoteSession(remote.id);
6336
+ const allowUnmaterialized = Boolean(local && !local.lastTurnId && !local.lastTurnStatus
6337
+ && !this.store.hasTurnAttemptsForSession(local.localSessionId)
6338
+ && !this.store.getForkHistorySource(local.localSessionId)
6339
+ && this.store.listTaskTurnGraphIndex(local.localSessionId).length === 0);
6340
+ await runtime.archiveRemoteSession(remote.id, { allowUnmaterialized });
5857
6341
  const title = local?.title ?? remote.title ?? remote.preview ?? remote.id;
5858
6342
  if (local) {
5859
6343
  this.store.archiveSession(local.localSessionId);
@@ -5991,6 +6475,7 @@ export class ProxySessionController {
5991
6475
  }
5992
6476
  const localActiveTurnId = runtime.getSession(record.localSessionId)?.activeTurnId;
5993
6477
  const runtimeOwnsActiveTurn = Boolean(localActiveTurnId)
6478
+ && (!this.settingsOnlySessions.has(record.localSessionId) || Boolean(activity.activeTurnId))
5994
6479
  && (!activity.activeTurnId || activity.activeTurnId === localActiveTurnId);
5995
6480
  const persistedTurnMatches = record.status === "running"
5996
6481
  && record.lastTurnStatus === "running"
@@ -6337,6 +6822,12 @@ export class ProxySessionController {
6337
6822
  return agent;
6338
6823
  }
6339
6824
  async persistAgentExecutionDefaults(localSessionId, candidate) {
6825
+ const record = this.store.getSession(localSessionId);
6826
+ if (!record)
6827
+ throw new Error(`找不到任务:${localSessionId}`);
6828
+ return this.agentDefaultsOperations.run(record.agentName, () => this.persistAgentExecutionDefaultsNow(localSessionId, candidate));
6829
+ }
6830
+ async persistAgentExecutionDefaultsNow(localSessionId, candidate) {
6340
6831
  const record = this.store.getSession(localSessionId);
6341
6832
  if (!record)
6342
6833
  throw new Error(`找不到任务:${localSessionId}`);
@@ -7261,6 +7752,14 @@ function executionSettingsTabValue(value) {
7261
7752
  return value;
7262
7753
  throw new Error("设置卡片的 tab 无效,请重新发送设置命令。");
7263
7754
  }
7755
+ function modelCardPageValue(value) {
7756
+ if (value === undefined)
7757
+ return undefined;
7758
+ const page = typeof value === "number" || (typeof value === "string" && value.trim()) ? Number(value) : NaN;
7759
+ if (!Number.isSafeInteger(page) || page < 0)
7760
+ throw new Error("模型卡片页码无效,请重新发送 /model。");
7761
+ return page;
7762
+ }
7264
7763
  function resetHistoryPageValue(value) {
7265
7764
  const page = Number(value);
7266
7765
  if (!Number.isSafeInteger(page) || page < 0)
@@ -7637,10 +8136,17 @@ function defaultResourcePromptFromCounts(imageCount, fileCount) {
7637
8136
  return fileCount === 1 ? "请查看这个文件" : "请查看这些文件";
7638
8137
  return imageCount === 1 ? "请查看这张图片" : "请查看这些图片";
7639
8138
  }
7640
- function isStandaloneResourceMessage(message) {
8139
+ function isCommandMessage(message) {
8140
+ const text = normalizeFeishuPostText(message.text).trimStart();
8141
+ return text.startsWith("/") || text.startsWith("!");
8142
+ }
8143
+ function isForwardAttachmentSource(message) {
7641
8144
  return !message.mergedForwardMessageId
7642
- && message.text.trim().length === 0
7643
- && ((message.images?.length ?? 0) > 0 || (message.files?.length ?? 0) > 0);
8145
+ && !message.parentMessageId
8146
+ && !message.contextKey.startsWith("console:")
8147
+ && !isCommandMessage(message)
8148
+ && (message.messageType === "post"
8149
+ || (message.images?.length ?? 0) > 0 || (message.files?.length ?? 0) > 0);
7644
8150
  }
7645
8151
  function deduplicateImageReferences(images) {
7646
8152
  const seen = new Set();
@@ -7707,7 +8213,8 @@ function isRetryableLlmTurnFailure(message) {
7707
8213
  if (!normalized)
7708
8214
  return false;
7709
8215
  const permanentFailure = [
7710
- /auth(?:entication|orization)?\b|unauthorized|forbidden|invalid api key|incorrect api key/u,
8216
+ /\b(?:auth|authenticat\w*|unauthenticat\w*|authoriz\w*|unauthorized|forbidden)\b|invalid api key|incorrect api key/u,
8217
+ /(?:^|\D)(?:401|403)(?:\D|$)/u,
7711
8218
  /insufficient[_ -]?quota|quota (?:exceeded|exhausted)|usage limit|billing|credit balance/u,
7712
8219
  /context (?:length|window)|maximum context|too many tokens|token limit/u,
7713
8220
  /invalid request|bad request|invalid argument|malformed|unsupported|not supported/u,