@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
@@ -5,7 +5,7 @@ import path from "node:path";
5
5
  import type { Logger } from "pino";
6
6
  import { createProjectlessWorkspace, detectProjectlessWorkspace } from "../codex/ProjectlessWorkspace.js";
7
7
  import { CodexVersionError } from "../codex/CodexVersion.js";
8
- import { assertProviderSettingsApplied, isMissingRolloutError } from "../codex/ProviderSwitch.js";
8
+ import { assertProviderSettingsApplied, ProviderSwitchError } from "../codex/ProviderSwitch.js";
9
9
  import {
10
10
  SystemThreadWriterProcessController,
11
11
  type ThreadWriterProcess,
@@ -90,11 +90,13 @@ import {
90
90
  type MessageReactionRecord,
91
91
  type MessageReactionStatus,
92
92
  type QueuedPromptRecord,
93
+ type PendingProviderSettings,
93
94
  type SessionRecord,
94
95
  type TurnAttemptRecord,
95
96
  type TurnAnchorRecord,
96
97
  type TurnRuntimeOriginRecord,
97
98
  } from "../state/StateStore.js";
99
+ import { SessionOperationQueue } from "./SessionOperationQueue.js";
98
100
  import { createId } from "../utils/id.js";
99
101
  import { formatStorageSize } from "../utils/formatStorageSize.js";
100
102
  import { truncateMiddle, truncateText } from "../utils/markdown.js";
@@ -117,10 +119,12 @@ interface StartTurnOptions {
117
119
  messageId?: string;
118
120
  displayPrompt?: string;
119
121
  preserveAttemptOnFailure?: boolean;
122
+ approvedPlanTurnId?: string;
120
123
  }
121
124
 
122
125
  interface PendingForwardAttachment {
123
126
  contextKey: string;
127
+ userId?: string;
124
128
  attachmentMessageId?: string;
125
129
  attachmentPromise: Promise<IncomingMessage | undefined>;
126
130
  resolveAttachment: (message: IncomingMessage | undefined) => void;
@@ -193,11 +197,12 @@ const REMOTE_TURN_HISTORY_REFRESH_INTERVAL_MS = 30_000;
193
197
  const MAX_LLM_TURN_RETRIES = 3;
194
198
  const QUEUED_PROMPT_RETRY_DELAY_MS = 5_000;
195
199
  const RECOVERY_ACTIVITY_WINDOW_MS = 10 * 60 * 1_000;
200
+ const RECOVERY_RETRY_WINDOW_MS = 60_000;
196
201
  const RECOVERY_HEARTBEAT_INTERVAL_MS = 60 * 1_000;
197
202
  const SHELL_COMMAND_JOB_POLL_INTERVAL_MS = 1_000;
198
203
  const SHELL_COMMAND_JOB_RELAUNCH_DELAY_MS = 5_000;
199
204
  const APP_SERVER_RELEASE_POLL_INTERVAL_MS = 500;
200
- const FORWARD_ATTACHMENT_WINDOW_MS = 800;
205
+ const FORWARD_ATTACHMENT_WINDOW_MS = 1_500;
201
206
  const DEFAULT_MERGED_FORWARD_INSTRUCTION = "请参考以下内容回复用户";
202
207
  const DEFAULT_REFERENCED_MESSAGE_INSTRUCTION = "请参考引用消息回复用户";
203
208
  const TURN_STOP_REQUESTED_MESSAGE = "已发送停止信号,任务会自动停止。";
@@ -383,7 +388,6 @@ interface AgentRemoteSessionSummary {
383
388
 
384
389
  interface SessionsCardOptions {
385
390
  updateMessageId?: string;
386
- forceSwitchTaskId?: string;
387
391
  page?: number;
388
392
  }
389
393
 
@@ -406,6 +410,8 @@ interface ResetHistoryCardOptions {
406
410
  }
407
411
 
408
412
  interface ModelCardOptions {
413
+ modelPage?: number;
414
+ expectedProvider?: string;
409
415
  sessionId?: string;
410
416
  updateMessageId?: string;
411
417
  }
@@ -416,6 +422,7 @@ interface ThinkingCardOptions extends ModelCardOptions {
416
422
 
417
423
  interface ExecutionSettingsCardOptions extends ModelCardOptions {
418
424
  notice?: string;
425
+ warning?: string;
419
426
  }
420
427
 
421
428
  type SessionExecutionSettings = AgentExecutionDefaults;
@@ -506,6 +513,8 @@ export interface ControlTaskReleaseResult {
506
513
  }
507
514
 
508
515
  export interface ProxyLifecycle {
516
+ terminalUrl?(jobId: string): string | undefined;
517
+ authorizeTerminal?(action: CardAction): Promise<string>;
509
518
  supervised?: boolean;
510
519
  restart(contextKey: string, force: boolean, replyTarget?: MessageReplyTarget): Promise<void>;
511
520
  cancelSafeRestart?(scheduleId: number): Promise<boolean>;
@@ -539,9 +548,14 @@ export class ProxySessionController {
539
548
  private readonly messageQueues = new Map<string, Promise<void>>();
540
549
  private readonly switchGroupOperations = new Set<string>();
541
550
  private readonly sessionLoads = new Map<string, Promise<LoadedSession>>();
551
+ private readonly settingsOnlySessions = new Set<string>();
542
552
  private readonly queuedPromptStarts = new Map<string, Promise<void>>();
543
553
  private readonly queuedPromptCards = new Map<string, Map<string, string>>();
544
554
  private readonly queuedPromptCardWrites = new Map<string, Promise<void>>();
555
+ private readonly settingsOperations = new SessionOperationQueue();
556
+ // Loads may run inside settingsOperations; never acquire that queue while hydrating.
557
+ private readonly sessionHydrationOperations = new SessionOperationQueue();
558
+ private readonly agentDefaultsOperations = new SessionOperationQueue();
545
559
  private readonly sessionResets = new Map<string, Promise<void>>();
546
560
  private readonly resetHistoryOperations = new Map<string, string>();
547
561
  private readonly remoteTurnHistoryRefreshes = new Map<string, number>();
@@ -551,9 +565,13 @@ export class ProxySessionController {
551
565
  private readonly lastSessionListings = new Map<string, string[]>();
552
566
  private readonly threadInitializations = new Map<string, Promise<void>>();
553
567
  private readonly llmRetryingSessions = new Set<string>();
568
+ private readonly planHandoffs = new Map<string, string>();
554
569
  private readonly retriedFailureTurnIds = new Set<string>();
555
570
  private readonly queuedPromptRetryTimers = new Map<string, NodeJS.Timeout>();
556
571
  private readonly recoveryRetryTimers = new Map<string, NodeJS.Timeout>();
572
+ private readonly recoveryRetryStartedAt = new Map<string, number>();
573
+ private readonly recoveredLiveAttempts = new Set<string>();
574
+ private recoveryClosed = false;
557
575
  private readonly pendingMergedForwards = new Map<string, PendingForwardAttachment>();
558
576
  private readonly pendingResourceForwards = new Map<string, PendingForwardAttachment>();
559
577
  private readonly relatedReactionMessageIds = new Map<string, string[]>();
@@ -630,7 +648,7 @@ export class ProxySessionController {
630
648
  const pendingMergedForward = message.mergedForwardMessageId
631
649
  ? this.registerPendingForwardAttachment(message, this.pendingMergedForwards)
632
650
  : undefined;
633
- const pendingResourceForward = !attachmentReservation && isStandaloneResourceMessage(message)
651
+ const pendingResourceForward = !attachmentReservation && isForwardAttachmentSource(message)
634
652
  ? this.registerPendingForwardAttachment(message, this.pendingResourceForwards)
635
653
  : undefined;
636
654
  try {
@@ -680,7 +698,9 @@ export class ProxySessionController {
680
698
  );
681
699
  if (attachment) {
682
700
  coalescedForwardAttachment = true;
683
- const instruction = normalizeFeishuPostText(attachment.text) || defaultResourcePrompt(resourceMessage);
701
+ const sourceText = resourceMessage.text.trim();
702
+ const instruction = normalizeFeishuPostText(attachment.text)
703
+ || (sourceText ? DEFAULT_REFERENCED_MESSAGE_INSTRUCTION : defaultResourcePrompt(resourceMessage));
684
704
  forwardedImages = (resourceMessage.images ?? []).map((image) => ({
685
705
  messageId: resourceMessage.messageId,
686
706
  imageKey: image.imageKey,
@@ -692,7 +712,7 @@ export class ProxySessionController {
692
712
  }));
693
713
  message = {
694
714
  ...attachment,
695
- text: instruction,
715
+ text: sourceText ? referencedMessagePrompt(instruction, sourceText) : instruction,
696
716
  displayText: instruction,
697
717
  };
698
718
  this.relatedReactionMessageIds.set(message.messageId, [resourceMessage.messageId]);
@@ -1012,7 +1032,10 @@ export class ProxySessionController {
1012
1032
  await this.outbound.withReplyTarget(contextKey, replyTarget, async () => {
1013
1033
  try {
1014
1034
  const kind = String(scopedAction.value.action ?? "");
1015
- if (kind === "agentbot_update_cancel") {
1035
+ if (kind === "terminal_authorize") {
1036
+ if (!this.lifecycle?.authorizeTerminal) throw new Error("网页终端未启用。");
1037
+ await this.outbound.sendText(contextKey, await this.lifecycle.authorizeTerminal(scopedAction));
1038
+ } else if (kind === "agentbot_update_cancel") {
1016
1039
  if (!this.lifecycle?.cancelAutomaticUpdate) throw new Error("自动更新功能未启用。");
1017
1040
  await this.lifecycle.cancelAutomaticUpdate(scopedAction);
1018
1041
  } else if (kind === "agentbot_update_select") {
@@ -1208,7 +1231,7 @@ export class ProxySessionController {
1208
1231
  await this.stopSessionReference(contextKey, sessionId);
1209
1232
  if (scopedAction.value.cardView === "status") await this.refreshStatusCardFromAction(scopedAction, sessionId);
1210
1233
  else if (scopedAction.value.cardView !== "safe_restart") {
1211
- await this.refreshSessionsCardFromAction(scopedAction, { forceSwitchTaskId: sessionId });
1234
+ await this.refreshSessionsCardFromAction(scopedAction);
1212
1235
  }
1213
1236
  } else if (kind === "session_turns" || kind === "session_turns_page") {
1214
1237
  const sessionReference = String(scopedAction.value.sessionId ?? "").trim();
@@ -1228,10 +1251,14 @@ export class ProxySessionController {
1228
1251
  } else if (kind === "session_status_refresh") {
1229
1252
  await this.refreshStatusCardFromAction(scopedAction);
1230
1253
  } else if (kind === "settings_tab_open") {
1231
- await this.openExecutionSettings(contextKey, executionSettingsTabValue(scopedAction.value.tab), {
1232
- sessionId: String(scopedAction.value.sessionId ?? ""),
1233
- updateMessageId: scopedAction.messageId,
1234
- });
1254
+ const sessionId = String(scopedAction.value.sessionId ?? "");
1255
+ await this.openExecutionSettings(
1256
+ contextKey, executionSettingsTabValue(scopedAction.value.tab), {
1257
+ sessionId, updateMessageId: scopedAction.messageId,
1258
+ modelPage: modelCardPageValue(scopedAction.value.page),
1259
+ expectedProvider: typeof scopedAction.value.provider === "string" ? scopedAction.value.provider : undefined,
1260
+ },
1261
+ );
1235
1262
  } else if (kind === "settings_agent_select") {
1236
1263
  await this.setDefaultAgent(contextKey, String(scopedAction.value.agent ?? ""), {
1237
1264
  sessionId: String(scopedAction.value.sessionId ?? ""),
@@ -1244,11 +1271,13 @@ export class ProxySessionController {
1244
1271
  });
1245
1272
  } else if (kind === "settings_model_select") {
1246
1273
  await this.model(contextKey, String(scopedAction.value.model ?? ""), {
1274
+ expectedProvider: String(scopedAction.value.provider ?? ""),
1247
1275
  sessionId: String(scopedAction.value.sessionId ?? ""),
1248
1276
  updateMessageId: scopedAction.messageId,
1249
1277
  });
1250
1278
  } else if (kind === "settings_thinking_select") {
1251
1279
  await this.thinking(contextKey, String(scopedAction.value.effort ?? ""), {
1280
+ expectedProvider: String(scopedAction.value.provider ?? ""),
1252
1281
  sessionId: String(scopedAction.value.sessionId ?? ""),
1253
1282
  updateMessageId: scopedAction.messageId,
1254
1283
  expectedModel: String(scopedAction.value.model ?? ""),
@@ -1270,6 +1299,7 @@ export class ProxySessionController {
1270
1299
  await this.model(contextKey, undefined, {
1271
1300
  sessionId,
1272
1301
  updateMessageId: scopedAction.messageId,
1302
+ modelPage: modelCardPageValue(scopedAction.value.page),
1273
1303
  });
1274
1304
  } else if (kind === "reasoning_select") {
1275
1305
  const sessionId = String(scopedAction.value.sessionId ?? "");
@@ -1292,6 +1322,7 @@ export class ProxySessionController {
1292
1322
  permissionModeValue(scopedAction.value.permissionMode),
1293
1323
  String(scopedAction.value.sessionId ?? ""),
1294
1324
  requiredCardMessageId(scopedAction.messageId),
1325
+ modelCardPageValue(scopedAction.value.page),
1295
1326
  );
1296
1327
  } else if (kind === "provider_model_select" || kind === "provider_reasoning_open") {
1297
1328
  await this.openProviderReasoningSelector(
@@ -1353,12 +1384,14 @@ export class ProxySessionController {
1353
1384
  }
1354
1385
 
1355
1386
  close(): void {
1387
+ this.recoveryClosed = true;
1356
1388
  for (const unsubscribe of this.unsubscribe) unsubscribe();
1357
1389
  this.unsubscribe.length = 0;
1358
1390
  this.lastSessionListings.clear();
1359
1391
  this.queuedPromptCards.clear();
1360
1392
  this.queuedPromptCardWrites.clear();
1361
1393
  this.sessionResets.clear();
1394
+ this.planHandoffs.clear();
1362
1395
  this.resetHistoryOperations.clear();
1363
1396
  this.remoteTurnHistoryRefreshes.clear();
1364
1397
  this.forkHistoryCursors.clear();
@@ -1372,6 +1405,8 @@ export class ProxySessionController {
1372
1405
  this.queuedPromptRetryTimers.clear();
1373
1406
  for (const timer of this.recoveryRetryTimers.values()) clearTimeout(timer);
1374
1407
  this.recoveryRetryTimers.clear();
1408
+ this.recoveryRetryStartedAt.clear();
1409
+ this.recoveredLiveAttempts.clear();
1375
1410
  for (const schedule of this.appServerReleaseSchedules.values()) {
1376
1411
  if (schedule.timer) clearTimeout(schedule.timer);
1377
1412
  }
@@ -1760,79 +1795,44 @@ export class ProxySessionController {
1760
1795
  models: Awaited<ReturnType<AgentRuntime["listModels"]>>;
1761
1796
  reasoningOptions: Awaited<ReturnType<AgentRuntime["listModels"]>>[number]["supportedReasoningEfforts"];
1762
1797
  permissionModes: PermissionMode[];
1798
+ pendingProvider?: PendingProviderSettings;
1763
1799
  }> {
1764
1800
  const record = this.requireControlSession(localSessionId);
1765
1801
  if (setting && !value?.trim()) throw new Error(`task ${setting} requires a value.`);
1766
1802
  const nextValue = value?.trim();
1767
- if (setting === "provider" && nextValue) {
1803
+ if (setting && nextValue) {
1768
1804
  const contextKey = this.controlSessionContextKey(record);
1769
- await this.outbound.withReplyTarget(
1770
- contextKey,
1771
- this.controlSessionReplyTarget(record),
1772
- () => this.outbound.sendText(contextKey, `正在切换到 Provider ${nextValue},请稍后。`),
1773
- );
1774
- }
1775
- const loaded = await this.loadSession(record);
1776
- if (setting === "provider" && nextValue) {
1777
- await this.assertModelProvider(loaded, nextValue);
1778
- const models = await loaded.runtime.listModels(nextValue, loaded.session.model);
1779
- const model = selectProviderModel(models, loaded.session.model);
1780
- if (!model) throw new Error("The current runtime has no model available for a Provider change.");
1781
- const effort = selectProviderReasoningEffort(model, loaded.session.reasoningEffort);
1782
- await this.changeProviderSettings(loaded, {
1783
- modelProvider: nextValue,
1784
- model: model.id,
1785
- reasoningEffort: effort,
1786
- permissionMode: loaded.session.permissionMode,
1805
+ await this.outbound.withReplyTarget(contextKey, this.controlSessionReplyTarget(record), async () => {
1806
+ const options = { sessionId: record.localSessionId };
1807
+ if (setting === "provider") await this.selectProvider(contextKey, nextValue, options);
1808
+ else if (setting === "model") await this.model(contextKey, nextValue, options);
1809
+ else if (setting === "thinking") await this.thinking(contextKey, nextValue, options);
1810
+ else {
1811
+ if (nextValue !== "auto" && nextValue !== "confirm") throw new Error("task permissions accepts auto or confirm.");
1812
+ await this.permissions(contextKey, nextValue, options);
1813
+ }
1787
1814
  });
1788
- } else if (setting === "model" && nextValue) {
1789
- const models = await loaded.runtime.listModels(loaded.session.modelProvider, loaded.session.model);
1790
- const selected = models.find((candidate) => candidate.id === nextValue);
1791
- if (!selected) throw new Error(`Unknown model: ${nextValue}`);
1792
- const currentEffort = loaded.session.reasoningEffort;
1793
- const nextEffort = currentEffort && (selected.supportedReasoningEfforts.length === 0
1794
- || selected.supportedReasoningEfforts.some((candidate) => candidate.value === currentEffort))
1795
- ? currentEffort
1796
- : selected.defaultReasoningEffort;
1797
- await loaded.runtime.setModel(record.localSessionId, nextValue);
1798
- if (nextEffort && nextEffort !== currentEffort) {
1799
- await loaded.runtime.setReasoningEffort(record.localSessionId, nextEffort);
1800
- }
1801
- this.store.updateRuntimeSession(record.localSessionId, { model: nextValue, reasoningEffort: nextEffort });
1802
- this.outbound.updateSessionModel(record.localSessionId, nextValue, loaded.session.modelProvider);
1803
- } else if (setting === "thinking" && nextValue) {
1804
- const models = await loaded.runtime.listModels(loaded.session.modelProvider, loaded.session.model);
1805
- const currentModel = models.find((candidate) => candidate.id === loaded.session.model)
1815
+ }
1816
+ return this.settingsOperations.run(localSessionId, async () => {
1817
+ const loaded = await this.loadSession(this.store.getSession(localSessionId) ?? record, true);
1818
+ const session = this.store.getSession(record.localSessionId) ?? record;
1819
+ const pending = this.store.getPendingProviderSettings(record.localSessionId);
1820
+ const selected = pending?.settings ?? session;
1821
+ const models = await loaded.runtime.listModels(selected.modelProvider, selected.model);
1822
+ const currentModel = models.find((candidate) => candidate.id === selected.model)
1806
1823
  ?? models.find((candidate) => candidate.isDefault);
1807
- if (!currentModel) throw new Error("The current runtime has no model with configurable reasoning effort.");
1808
- if (!currentModel.supportedReasoningEfforts.some((candidate) => candidate.value === nextValue)) {
1809
- const supported = currentModel.supportedReasoningEfforts.map((candidate) => candidate.value).join(", ") || "none";
1810
- throw new Error(`Unsupported reasoning effort: ${nextValue}. Supported values: ${supported}.`);
1811
- }
1812
- await loaded.runtime.setReasoningEffort(record.localSessionId, nextValue);
1813
- this.store.updateRuntimeSession(record.localSessionId, { reasoningEffort: nextValue });
1814
- } else if (setting === "permissions" && nextValue) {
1815
- if (nextValue !== "auto" && nextValue !== "confirm") {
1816
- throw new Error("task permissions accepts auto or confirm.");
1817
- }
1818
- await loaded.runtime.setPermissionMode(record.localSessionId, nextValue);
1819
- this.store.updateRuntimeSession(record.localSessionId, { permissionMode: nextValue });
1820
- }
1821
- const session = this.store.getSession(record.localSessionId) ?? record;
1822
- if (setting && setting !== "provider") await this.persistAgentExecutionDefaults(session.localSessionId);
1823
- const models = await loaded.runtime.listModels(session.modelProvider, session.model);
1824
- const currentModel = models.find((candidate) => candidate.id === session.model)
1825
- ?? models.find((candidate) => candidate.isDefault);
1826
- const providers = loaded.runtime.listModelProviders
1827
- ? await this.modelProviderOptions(loaded)
1828
- : [];
1829
- return {
1830
- session,
1831
- providers,
1832
- models,
1833
- reasoningOptions: currentModel?.supportedReasoningEfforts ?? [],
1834
- permissionModes: ["auto", "confirm"],
1835
- };
1824
+ const providers = loaded.runtime.listModelProviders
1825
+ ? await this.modelProviderOptions(loaded)
1826
+ : [];
1827
+ return {
1828
+ session,
1829
+ pendingProvider: pending,
1830
+ providers,
1831
+ models,
1832
+ reasoningOptions: currentModel?.supportedReasoningEfforts ?? [],
1833
+ permissionModes: ["auto", "confirm"],
1834
+ };
1835
+ });
1836
1836
  }
1837
1837
 
1838
1838
  async controlTaskGoal(
@@ -2294,16 +2294,18 @@ export class ProxySessionController {
2294
2294
  continue;
2295
2295
  }
2296
2296
  if (!this.outbound.canRoute(attempt.contextKey)) continue;
2297
+ this.recoveryRetryStartedAt.set(attempt.attemptId, Date.now());
2297
2298
  try {
2298
2299
  await this.recoverTurnAttempt(attempt);
2299
2300
  } catch (error) {
2300
- this.logger.error(
2301
- { error, attemptId: attempt.attemptId, sessionId: attempt.localSessionId },
2302
- "Failed to recover an unfinished task; it will be retried while the server is running.",
2303
- );
2304
- this.scheduleRecoveryRetry(attempt.attemptId);
2301
+ await this.handleRecoveryError(attempt.attemptId, error);
2302
+ } finally {
2303
+ if (!this.recoveryRetryTimers.has(attempt.attemptId)) this.recoveryRetryStartedAt.delete(attempt.attemptId);
2305
2304
  }
2306
2305
  }
2306
+ for (const pending of this.store.listPendingProviderSettings()) {
2307
+ await this.applyPendingProviderWhenIdle(pending.localSessionId);
2308
+ }
2307
2309
  this.restorePersistedQueuedPrompts();
2308
2310
  }
2309
2311
 
@@ -2394,15 +2396,23 @@ export class ProxySessionController {
2394
2396
  }
2395
2397
  }
2396
2398
 
2397
- private async expireStaleTurnAttempt(attempt: TurnAttemptRecord): Promise<void> {
2398
- const session = this.store.getSession(attempt.localSessionId);
2399
+ private async expireStaleTurnAttempt(
2400
+ attempt: TurnAttemptRecord,
2401
+ message = "执行中断已超过 10 分钟,未自动恢复。",
2402
+ ): Promise<void> {
2403
+ clearTimeout(this.recoveryRetryTimers.get(attempt.attemptId));
2404
+ this.recoveryRetryTimers.delete(attempt.attemptId);
2405
+ this.recoveryRetryStartedAt.delete(attempt.attemptId);
2406
+ this.recoveredLiveAttempts.delete(attempt.attemptId);
2407
+ let session = this.store.getSession(attempt.localSessionId);
2408
+ this.store.updateTurnAttempt(attempt.attemptId, { status: "interrupted" });
2399
2409
  const oldCardTurnId = attempt.pendingTurnId ?? attempt.turnId;
2400
2410
  if (session && oldCardTurnId && this.outbound.canRoute(attempt.contextKey)) {
2401
2411
  await this.outbound.interruptTurnForRecovery(
2402
2412
  session.localSessionId,
2403
2413
  attempt.contextKey,
2404
2414
  oldCardTurnId,
2405
- "执行中断已超过 10 分钟,未自动恢复。",
2415
+ message,
2406
2416
  ).catch((error: unknown) => {
2407
2417
  this.logger.warn(
2408
2418
  { error, attemptId: attempt.attemptId, turnId: oldCardTurnId },
@@ -2410,13 +2420,21 @@ export class ProxySessionController {
2410
2420
  );
2411
2421
  });
2412
2422
  }
2413
- this.store.updateTurnAttempt(attempt.attemptId, { status: "interrupted" });
2423
+ session = this.store.getSession(attempt.localSessionId);
2414
2424
  if (!session || session.status === "closed") return;
2415
- if (attempt.turnId && session.lastTurnId === attempt.turnId) {
2425
+ const runtime = this.runtimes.forAgent(session.agentName);
2426
+ const activeTurnId = runtime.getSession(session.localSessionId)?.activeTurnId;
2427
+ const sameTurn = session.lastTurnId === attempt.turnId
2428
+ || session.lastTurnId === attempt.recoveredFromTurnId
2429
+ || !session.lastTurnId;
2430
+ if (sameTurn && session.lastTurnStatus !== "completed" && session.lastTurnStatus !== "failed"
2431
+ && (!activeTurnId || activeTurnId === attempt.turnId)) {
2432
+ // Forget monitoring, not the remote task. Never cancel an unknown writer.
2433
+ if (activeTurnId) runtime.forgetSession?.(session.localSessionId);
2416
2434
  this.store.updateRuntimeSession(session.localSessionId, { lastTurnStatus: "cancelled" });
2417
- }
2418
- if (session.status === "starting" || session.status === "running") {
2419
- this.store.updateSession(session.localSessionId, { status: "ready" });
2435
+ if (session.status === "starting" || session.status === "running") {
2436
+ this.store.updateSession(session.localSessionId, { status: "ready" });
2437
+ }
2420
2438
  }
2421
2439
  if (attempt.turnId && this.outbound.canRoute(attempt.contextKey)) {
2422
2440
  await this.finalizeTurnMessageReactions(attempt.turnId, "cancelled").catch((error: unknown) => {
@@ -2428,6 +2446,44 @@ export class ProxySessionController {
2428
2446
  }
2429
2447
  }
2430
2448
 
2449
+ private async handleRecoveryError(attemptId: string, error: unknown, activeTurnId?: string): Promise<void> {
2450
+ if (this.recoveryClosed) return;
2451
+ const attempt = this.store.getTurnAttempt(attemptId);
2452
+ if (!attempt || !isIncompleteTurnAttemptStatus(attempt.status)) return;
2453
+ this.logger.warn({ error, attemptId, sessionId: attempt.localSessionId }, "Task recovery failed.");
2454
+ const session = this.store.getSession(attempt.localSessionId);
2455
+ const writerConflict = session?.remoteSessionId === activeWriterThreadId(error) && Boolean(session?.remoteSessionId);
2456
+ const startedAt = this.recoveryRetryStartedAt.get(attemptId) ?? Date.now();
2457
+ this.recoveryRetryStartedAt.set(attemptId, startedAt);
2458
+ const localActiveTurnId = session ? this.runtimes.forAgent(session.agentName).getSession(session.localSessionId)?.activeTurnId : undefined;
2459
+ const monitoredTurnId = activeTurnId ?? (localActiveTurnId === attempt.turnId ? localActiveTurnId : undefined);
2460
+ // Once a live turn was confirmed, a monitoring outage must not make safe restart
2461
+ // consider it idle. Keep reconciling without cancelling or replaying the prompt.
2462
+ if (monitoredTurnId && this.recoveredLiveAttempts.has(attemptId) && !writerConflict) {
2463
+ this.scheduleRecoveryRetry(attemptId, monitoredTurnId);
2464
+ return;
2465
+ }
2466
+ if (writerConflict || error instanceof CodexVersionError || Date.now() - startedAt >= RECOVERY_RETRY_WINDOW_MS) {
2467
+ const message = writerConflict
2468
+ ? "自动恢复未完成:任务仍被另一个 App Server 占用。已停止自动重试,请确认占用进程后释放任务再继续。"
2469
+ : `自动恢复未完成,已停止自动重试。请检查 Agent 状态后重新发送消息。\n${error instanceof Error ? error.message : String(error)}`;
2470
+ await this.expireStaleTurnAttempt(attempt, message);
2471
+ try {
2472
+ const replyTarget = attempt.replyMessageId
2473
+ ? { messageId: attempt.replyMessageId, replyInThread: true as const } : undefined;
2474
+ await this.outbound.withReplyTarget(attempt.contextKey, replyTarget, async () => {
2475
+ await this.outbound.sendText(attempt.contextKey, message);
2476
+ if (writerConflict && session) await this.presentThreadWriterConflict(session, attempt.contextKey, error, false);
2477
+ });
2478
+ } catch (notificationError) {
2479
+ this.logger.warn({ error: notificationError, attemptId }, "Failed to notify about stopped task recovery.");
2480
+ }
2481
+ return;
2482
+ }
2483
+ this.store.updateTurnAttempt(attemptId, { status: "recovering" });
2484
+ this.scheduleRecoveryRetry(attemptId, monitoredTurnId);
2485
+ }
2486
+
2431
2487
  private persistActiveTurnHeartbeats(): void {
2432
2488
  for (const attempt of this.store.listIncompleteTurnAttempts()) {
2433
2489
  if (!attempt.turnId) continue;
@@ -2439,6 +2495,7 @@ export class ProxySessionController {
2439
2495
  }
2440
2496
 
2441
2497
  private async recoverTurnAttempt(attempt: TurnAttemptRecord, announce = true): Promise<void> {
2498
+ if (this.recoveryClosed) return;
2442
2499
  const session = this.store.getSession(attempt.localSessionId);
2443
2500
  if (!session || session.status === "closed") {
2444
2501
  this.store.updateTurnAttempt(attempt.attemptId, { status: "interrupted" });
@@ -2481,11 +2538,8 @@ export class ProxySessionController {
2481
2538
  try {
2482
2539
  remote = await runtime.readRemoteSession(session.remoteSessionId);
2483
2540
  } catch (error) {
2484
- if (error instanceof CodexVersionError) throw error;
2485
- this.logger.warn(
2486
- { error, attemptId: attempt.attemptId, remoteSessionId: session.remoteSessionId },
2487
- "Failed to read the remote task during startup recovery; treating the old local execution as interrupted.",
2488
- );
2541
+ // A failed activity check is not proof of interruption. Do not replay a prompt blindly.
2542
+ if (sourceTurnId || !isUnmaterializedCodexThreadError(error)) throw error;
2489
2543
  }
2490
2544
  }
2491
2545
  const remoteTurnBelongsToAttempt = remoteTurnMatchesAttempt(remote, attempt);
@@ -2538,12 +2592,20 @@ export class ProxySessionController {
2538
2592
  }
2539
2593
 
2540
2594
  private scheduleRecoveryRetry(attemptId: string, activeTurnId?: string): void {
2541
- if (this.recoveryRetryTimers.has(attemptId)) return;
2595
+ if (this.recoveryClosed || this.recoveryRetryTimers.has(attemptId)) return;
2596
+ if (!this.recoveryRetryStartedAt.has(attemptId)) this.recoveryRetryStartedAt.set(attemptId, Date.now());
2542
2597
  const timer = setTimeout(() => {
2543
2598
  this.recoveryRetryTimers.delete(attemptId);
2544
2599
  const attempt = this.store.getTurnAttempt(attemptId);
2545
- if (!attempt || !isIncompleteTurnAttemptStatus(attempt.status)) return;
2600
+ if (!attempt || !isIncompleteTurnAttemptStatus(attempt.status)) {
2601
+ this.recoveryRetryStartedAt.delete(attemptId);
2602
+ this.recoveredLiveAttempts.delete(attemptId);
2603
+ return;
2604
+ }
2546
2605
  const retry = async (): Promise<void> => {
2606
+ if (!activeTurnId && Date.now() - this.recoveryRetryStartedAt.get(attemptId)! >= RECOVERY_RETRY_WINDOW_MS) {
2607
+ throw new Error("等待恢复超过 1 分钟,尚未确认执行已恢复。");
2608
+ }
2547
2609
  let recoveryAttempt = attempt;
2548
2610
  if (activeTurnId) {
2549
2611
  const session = this.store.getSession(attempt.localSessionId);
@@ -2551,6 +2613,8 @@ export class ProxySessionController {
2551
2613
  const runtime = this.runtimes.forAgent(session.agentName);
2552
2614
  const synchronized = await runtime.synchronizeSession(session.localSessionId);
2553
2615
  if (synchronized.activeTurnId === activeTurnId) {
2616
+ this.recoveredLiveAttempts.add(attemptId);
2617
+ this.recoveryRetryStartedAt.delete(attemptId);
2554
2618
  this.scheduleRecoveryRetry(attemptId, activeTurnId);
2555
2619
  return;
2556
2620
  }
@@ -2560,13 +2624,12 @@ export class ProxySessionController {
2560
2624
  }
2561
2625
  }
2562
2626
  await this.recoverTurnAttempt(recoveryAttempt, false);
2627
+ if (!this.recoveryRetryTimers.has(attemptId)) this.recoveryRetryStartedAt.delete(attemptId);
2563
2628
  };
2564
2629
  void retry().catch((error: unknown) => {
2565
- this.logger.warn(
2566
- { error, attemptId, sessionId: attempt.localSessionId },
2567
- "Failed to retry an unfinished task recovery.",
2568
- );
2569
- this.scheduleRecoveryRetry(attemptId, activeTurnId);
2630
+ return this.handleRecoveryError(attemptId, error, activeTurnId);
2631
+ }).catch((error: unknown) => {
2632
+ this.logger.error({ error, attemptId }, "Failed to finalize task recovery.");
2570
2633
  });
2571
2634
  }, 5_000);
2572
2635
  timer.unref?.();
@@ -2579,6 +2642,23 @@ export class ProxySessionController {
2579
2642
  turnId: string,
2580
2643
  replyTarget?: MessageReplyTarget,
2581
2644
  ): Promise<void> {
2645
+ // Confirm resume before replacing the old card or announcing a running turn.
2646
+ const current = this.store.getSession(session.localSessionId) ?? session;
2647
+ const loaded = await this.loadSession({
2648
+ ...current, contextKey: attempt.contextKey, status: "running", lastTurnId: turnId, lastTurnStatus: "running",
2649
+ });
2650
+ const pendingAttempt = this.store.getTurnAttempt(attempt.attemptId);
2651
+ if (!pendingAttempt || !isIncompleteTurnAttemptStatus(pendingAttempt.status)) return;
2652
+ const continued = turnViewSnapshot(this.store.getTurnSnapshot(turnId));
2653
+ if (continued?.continuedFromTurnId && this.store.getTurnDelivery(turnId)?.progressMessageId) {
2654
+ this.store.updateTurnAttempt(attempt.attemptId, { pendingTurnId: null, turnId, status: "running" });
2655
+ await this.outbound.onEvent({ type: "turn_started", sessionId: session.localSessionId,
2656
+ turnId, startedAt: continued.startedAt });
2657
+ const synchronized = await loaded.runtime.synchronizeSession(session.localSessionId);
2658
+ this.recoveryRetryStartedAt.delete(attempt.attemptId);
2659
+ if (synchronized.activeTurnId) this.recoveredLiveAttempts.add(attempt.attemptId);
2660
+ return;
2661
+ }
2582
2662
  const oldCardTurnId = attempt.pendingTurnId ?? attempt.turnId;
2583
2663
  if (oldCardTurnId) {
2584
2664
  await this.outbound.interruptTurnForRecovery(
@@ -2607,16 +2687,12 @@ export class ProxySessionController {
2607
2687
  startedAt: Date.now(),
2608
2688
  });
2609
2689
  this.store.updateTurnAttempt(attempt.attemptId, { pendingTurnId: null });
2610
- const current = this.store.getSession(session.localSessionId) ?? session;
2611
- const loaded = await this.loadSession({
2612
- ...current,
2613
- contextKey: attempt.contextKey,
2614
- status: "running",
2615
- lastTurnId: turnId,
2616
- lastTurnStatus: "running",
2617
- });
2618
2690
  const synchronized = await loaded.runtime.synchronizeSession(session.localSessionId);
2619
- if (synchronized.activeTurnId) this.scheduleRecoveryRetry(attempt.attemptId, synchronized.activeTurnId);
2691
+ this.recoveryRetryStartedAt.delete(attempt.attemptId);
2692
+ if (synchronized.activeTurnId) {
2693
+ this.recoveredLiveAttempts.add(attempt.attemptId);
2694
+ this.scheduleRecoveryRetry(attempt.attemptId, synchronized.activeTurnId);
2695
+ }
2620
2696
  }
2621
2697
 
2622
2698
  private async continueInterruptedAttempt(
@@ -2648,7 +2724,8 @@ export class ProxySessionController {
2648
2724
  });
2649
2725
  }
2650
2726
  const current = this.store.getSession(session.localSessionId) ?? session;
2651
- const loaded = await this.loadSession({ ...current, contextKey: attempt.contextKey });
2727
+ const loaded = await this.loadSession({ ...current, contextKey: attempt.contextKey },
2728
+ Boolean(this.store.getPendingProviderSettings(current.localSessionId)));
2652
2729
  const recoveryText = recoveryRuntimePrompt(attempt.promptText);
2653
2730
  const turnId = await this.startTurn(
2654
2731
  loaded,
@@ -2715,7 +2792,7 @@ export class ProxySessionController {
2715
2792
  localImagePaths?: string[],
2716
2793
  displayPrompt?: string,
2717
2794
  ): Promise<void> {
2718
- if (this.llmRetryingSessions.has(record.localSessionId)) {
2795
+ if (this.llmRetryingSessions.has(record.localSessionId) || this.planHandoffs.has(record.localSessionId)) {
2719
2796
  const queued = this.persistQueuedPrompt(record.localSessionId, contextKey, text, {
2720
2797
  localImagePaths,
2721
2798
  messageId,
@@ -2751,7 +2828,7 @@ export class ProxySessionController {
2751
2828
  }
2752
2829
  let loaded: LoadedSession;
2753
2830
  try {
2754
- loaded = await this.loadSession(record);
2831
+ loaded = await this.loadSession(record, Boolean(this.store.getPendingProviderSettings(record.localSessionId)));
2755
2832
  } catch (error) {
2756
2833
  if (await this.presentThreadWriterConflict(record, contextKey, error, true)) {
2757
2834
  throw new PresentedRequestError(error);
@@ -3891,6 +3968,25 @@ export class ProxySessionController {
3891
3968
  }
3892
3969
 
3893
3970
  private async startTurn(
3971
+ loaded: LoadedSession, text: string, replyTarget?: MessageReplyTarget,
3972
+ localImagePaths?: string[], options: StartTurnOptions = {},
3973
+ ): Promise<string> {
3974
+ return this.settingsOperations.run(loaded.record.localSessionId, async () => {
3975
+ try {
3976
+ await this.applyPendingProviderSettings(loaded);
3977
+ } catch (error) {
3978
+ await this.outbound.failPendingTurn(loaded.record.localSessionId, runtimeErrorMessage(error));
3979
+ throw error;
3980
+ }
3981
+ if (this.store.getPendingProviderSettings(loaded.record.localSessionId)) {
3982
+ throw new Error("Provider 切换尚未完成,请稍后重试。");
3983
+ }
3984
+ loaded.session = loaded.runtime.getSession(loaded.record.localSessionId) ?? loaded.session;
3985
+ return this.startTurnNow(loaded, text, replyTarget, localImagePaths, options);
3986
+ });
3987
+ }
3988
+
3989
+ private async startTurnNow(
3894
3990
  loaded: LoadedSession,
3895
3991
  text: string,
3896
3992
  replyTarget?: MessageReplyTarget,
@@ -3946,9 +4042,15 @@ export class ProxySessionController {
3946
4042
  this.store.updateTurnAttempt(attemptId, { pendingTurnId: pendingTurnId ?? null });
3947
4043
 
3948
4044
  try {
4045
+ if (options.approvedPlanTurnId && (this.planHandoffs.get(loaded.record.localSessionId) !== options.approvedPlanTurnId
4046
+ || this.sessionResets.has(loaded.record.localSessionId))) {
4047
+ throw new Error("方案执行已取消。");
4048
+ }
3949
4049
  turnId = await loaded.runtime.startTurn(
3950
4050
  loaded.record.localSessionId,
3951
- runtimePrompt(runtimeText, localImagePaths),
4051
+ options.approvedPlanTurnId
4052
+ ? { text: runtimeText, collaborationMode: "default" }
4053
+ : runtimePrompt(runtimeText, localImagePaths),
3952
4054
  );
3953
4055
  break;
3954
4056
  } catch (error) {
@@ -4521,18 +4623,27 @@ export class ProxySessionController {
4521
4623
  return { chatId: group.chatId, contextKey: groupContextKey, name: group.name };
4522
4624
  }
4523
4625
 
4524
- private async loadSession(record: SessionRecord): Promise<LoadedSession> {
4626
+ private async loadSession(record: SessionRecord, forSettings = false): Promise<LoadedSession> {
4525
4627
  const agent = this.ensureAgent(record.agentName);
4526
4628
  const runtime = this.runtimes.forAgent(record.agentName);
4527
4629
  const existing = runtime.getSession(record.localSessionId);
4528
- if (existing) return { record, runtime, session: existing };
4630
+ if (existing && (forSettings || !this.settingsOnlySessions.has(record.localSessionId))) {
4631
+ return { record, runtime, session: existing };
4632
+ }
4529
4633
  const pending = this.sessionLoads.get(record.localSessionId);
4530
4634
  if (pending) {
4531
- const loaded = await pending;
4532
- return {
4533
- ...loaded,
4534
- record: { ...loaded.record, contextKey: record.contextKey },
4535
- };
4635
+ try {
4636
+ const loaded = await pending;
4637
+ if (!forSettings && this.settingsOnlySessions.has(record.localSessionId)) {
4638
+ if (this.sessionLoads.get(record.localSessionId) === pending) this.sessionLoads.delete(record.localSessionId);
4639
+ return this.loadSession(this.store.getSession(record.localSessionId) ?? record);
4640
+ }
4641
+ return { ...loaded, record: { ...loaded.record, contextKey: record.contextKey } };
4642
+ } catch (error) {
4643
+ if (!forSettings || !runtime.prepareSessionForSettings) throw error;
4644
+ if (this.sessionLoads.get(record.localSessionId) === pending) this.sessionLoads.delete(record.localSessionId);
4645
+ return this.loadSession(record, forSettings);
4646
+ }
4536
4647
  }
4537
4648
 
4538
4649
  this.outbound.registerSession(
@@ -4544,7 +4655,16 @@ export class ProxySessionController {
4544
4655
  record.model,
4545
4656
  record.modelProvider,
4546
4657
  );
4547
- const loading = (async (): Promise<LoadedSession> => {
4658
+ const loading = this.sessionHydrationOperations.run(record.localSessionId, async (): Promise<LoadedSession> => {
4659
+ const latest = this.store.getSession(record.localSessionId);
4660
+ if (latest) record = {
4661
+ ...record, modelProvider: latest.modelProvider, model: latest.model,
4662
+ reasoningEffort: latest.reasoningEffort, permissionMode: latest.permissionMode,
4663
+ };
4664
+ const current = runtime.getSession(record.localSessionId);
4665
+ if (current && (forSettings || !this.settingsOnlySessions.has(record.localSessionId))) {
4666
+ return { record, runtime, session: current };
4667
+ }
4548
4668
  if (record.lastTurnId) {
4549
4669
  try {
4550
4670
  await this.outbound.resumeDelivery(record.localSessionId, record.contextKey, record.lastTurnId);
@@ -4556,41 +4676,30 @@ export class ProxySessionController {
4556
4676
  }
4557
4677
  }
4558
4678
  const permissionMode = record.permissionMode ?? "auto";
4559
- if (record.remoteSessionId) await this.assertSessionTurnOwnership(record, runtime);
4679
+ const prepare = forSettings && record.remoteSessionId && runtime.prepareSessionForSettings;
4680
+ if (record.remoteSessionId && !prepare) await this.assertSessionTurnOwnership(record, runtime);
4560
4681
  let session: RuntimeSession;
4561
4682
  if (record.remoteSessionId) {
4562
- try {
4563
- session = await runtime.resumeSession({
4564
- localSessionId: record.localSessionId,
4565
- remoteSessionId: record.remoteSessionId,
4566
- agentName: record.agentName,
4567
- cwd: record.cwd,
4568
- title: record.title,
4569
- modelProvider: record.modelProvider,
4570
- model: record.model,
4571
- reasoningEffort: record.reasoningEffort,
4572
- permissionMode,
4573
- activeTurnId: agent.kind === "app-server" && record.status === "running" && record.lastTurnStatus === "running"
4574
- ? record.lastTurnId
4575
- : undefined,
4576
- lastTurnId: record.lastTurnId,
4577
- lastTurnStatus: record.lastTurnStatus,
4578
- });
4579
- } catch (error) {
4580
- if (activeWriterThreadId(error)) throw error;
4581
- if (!(agent.kind === "app-server" && !record.lastTurnId
4582
- && !this.store.getForkHistorySource(record.localSessionId) && isMissingRolloutError(error))) throw error;
4583
- this.logger.warn({ error, sessionId: record.localSessionId }, "App Server task has no rollout; creating a replacement task.");
4584
- session = await runtime.createSession({
4585
- localSessionId: record.localSessionId,
4586
- agentName: record.agentName,
4587
- cwd: record.cwd,
4588
- title: record.title,
4589
- modelProvider: record.modelProvider,
4590
- model: record.model,
4591
- reasoningEffort: record.reasoningEffort,
4592
- permissionMode,
4593
- });
4683
+ const input = {
4684
+ localSessionId: record.localSessionId,
4685
+ remoteSessionId: record.remoteSessionId,
4686
+ agentName: record.agentName,
4687
+ cwd: record.cwd,
4688
+ title: record.title,
4689
+ modelProvider: record.modelProvider,
4690
+ model: record.model,
4691
+ reasoningEffort: record.reasoningEffort,
4692
+ permissionMode,
4693
+ activeTurnId: agent.kind === "app-server" && record.status === "running" && record.lastTurnStatus === "running"
4694
+ ? record.lastTurnId
4695
+ : undefined,
4696
+ lastTurnId: record.lastTurnId,
4697
+ lastTurnStatus: record.lastTurnStatus,
4698
+ };
4699
+ if (prepare) this.settingsOnlySessions.add(record.localSessionId);
4700
+ session = prepare ? await prepare.call(runtime, input) : await runtime.resumeSession(input);
4701
+ if (session.remoteSessionId !== record.remoteSessionId) {
4702
+ throw new Error("加载设置返回了不同的任务 ID,不允许替换任务或更改历史。");
4594
4703
  }
4595
4704
  } else {
4596
4705
  session = await runtime.createSession({
@@ -4604,19 +4713,22 @@ export class ProxySessionController {
4604
4713
  permissionMode,
4605
4714
  });
4606
4715
  }
4607
- this.persistRuntimeSession(record, session, session.activeTurnId ? "running" : "ready");
4716
+ if (!prepare) {
4717
+ this.settingsOnlySessions.delete(record.localSessionId);
4718
+ this.persistRuntimeSession(record, session, session.activeTurnId ? "running" : "ready");
4719
+ }
4608
4720
  const saved = this.store.getSession(record.localSessionId) ?? record;
4609
4721
  return {
4610
4722
  record: { ...saved, contextKey: record.contextKey },
4611
4723
  runtime,
4612
4724
  session,
4613
4725
  };
4614
- })();
4726
+ });
4615
4727
  this.sessionLoads.set(record.localSessionId, loading);
4616
4728
  try {
4617
4729
  return await loading;
4618
4730
  } finally {
4619
- this.sessionLoads.delete(record.localSessionId);
4731
+ if (this.sessionLoads.get(record.localSessionId) === loading) this.sessionLoads.delete(record.localSessionId);
4620
4732
  }
4621
4733
  }
4622
4734
 
@@ -4638,6 +4750,11 @@ export class ProxySessionController {
4638
4750
 
4639
4751
  private async handleRuntimeEvent(event: RuntimeEvent): Promise<void> {
4640
4752
  if ("turnId" in event) {
4753
+ const recoveryAttempt = this.recoveryRetryStartedAt.size > 0
4754
+ ? this.store.findIncompleteTurnAttemptByTurnId(event.turnId) : undefined;
4755
+ if (recoveryAttempt && this.recoveryRetryStartedAt.has(recoveryAttempt.attemptId)) {
4756
+ this.recoveredLiveAttempts.add(recoveryAttempt.attemptId);
4757
+ }
4641
4758
  this.store.touchTurnAttempt(event.turnId);
4642
4759
  const source = this.store.getSession(event.sessionId);
4643
4760
  if (source?.remoteSessionId && this.isCodexSession(source)) {
@@ -4660,6 +4777,14 @@ export class ProxySessionController {
4660
4777
  );
4661
4778
  }
4662
4779
  }
4780
+ if (event.type === "turn_completed" && event.approvedPlan) {
4781
+ if (this.planHandoffs.get(event.sessionId) === event.turnId) return;
4782
+ const attempt = this.store.findIncompleteTurnAttemptByTurnId(event.turnId);
4783
+ if (attempt) {
4784
+ await this.continueApprovedPlan(event, attempt);
4785
+ return;
4786
+ }
4787
+ }
4663
4788
  const failedAttempt = event.type === "turn_failed"
4664
4789
  ? this.store.findIncompleteTurnAttemptByTurnId(event.turnId)
4665
4790
  : undefined;
@@ -4683,6 +4808,13 @@ export class ProxySessionController {
4683
4808
  this.store.updateSession(event.sessionId, { status: "running" });
4684
4809
  this.store.updateRuntimeSession(event.sessionId, { lastTurnId: event.turnId, lastTurnStatus: "running" });
4685
4810
  } else if (event.type === "turn_completed" || event.type === "turn_cancelled" || event.type === "turn_failed") {
4811
+ const attempt = this.store.findIncompleteTurnAttemptByTurnId(event.turnId);
4812
+ if (attempt) {
4813
+ clearTimeout(this.recoveryRetryTimers.get(attempt.attemptId));
4814
+ this.recoveryRetryTimers.delete(attempt.attemptId);
4815
+ this.recoveryRetryStartedAt.delete(attempt.attemptId);
4816
+ this.recoveredLiveAttempts.delete(attempt.attemptId);
4817
+ }
4686
4818
  this.store.markTurnAttemptTerminal(
4687
4819
  event.turnId,
4688
4820
  event.type === "turn_completed" ? "completed" : event.type === "turn_cancelled" ? "cancelled" : "failed",
@@ -4698,7 +4830,7 @@ export class ProxySessionController {
4698
4830
  ...event,
4699
4831
  turnCount: event.turnCount ?? this.store.countTaskTurns(event.sessionId),
4700
4832
  }
4701
- : event;
4833
+ : event.type === "turn_completed" && event.approvedPlan ? { ...event, approvedPlan: false } : event;
4702
4834
  const presentationEvent = event.type === "turn_failed"
4703
4835
  && isRetryableLlmTurnFailure(event.message)
4704
4836
  && (failedAttempt?.retryCount ?? 0) >= MAX_LLM_TURN_RETRIES
@@ -4736,6 +4868,7 @@ export class ProxySessionController {
4736
4868
  : undefined;
4737
4869
  if (latest?.lastTurnId === event.turnId && !activeTurnId) {
4738
4870
  this.store.updateSession(event.sessionId, { status: event.type === "turn_failed" ? "failed" : "ready" });
4871
+ await this.applyPendingProviderWhenIdle(event.sessionId);
4739
4872
  await this.scheduleNextQueuedPrompt(event.sessionId);
4740
4873
  }
4741
4874
  }
@@ -4743,6 +4876,80 @@ export class ProxySessionController {
4743
4876
  if (presentationError) throw presentationError;
4744
4877
  }
4745
4878
 
4879
+ private async continueApprovedPlan(
4880
+ event: Extract<RuntimeEvent, { type: "turn_completed" }>,
4881
+ attempt: TurnAttemptRecord,
4882
+ ): Promise<void> {
4883
+ const { sessionId, turnId } = event;
4884
+ const source = this.store.getSession(sessionId);
4885
+ if (!source || source.status === "closed" || source.lastTurnId !== turnId) return;
4886
+ this.planHandoffs.set(sessionId, turnId);
4887
+ this.store.markTurnAttemptTerminal(turnId, "completed");
4888
+ this.store.updateRuntimeSession(sessionId, { lastTurnId: turnId, lastTurnStatus: "completed" });
4889
+ let continued = false;
4890
+ let failed = false;
4891
+ try {
4892
+ try {
4893
+ await this.outbound.onEvent(event);
4894
+ } catch (error) {
4895
+ this.logger.warn({ error, sessionId, turnId }, "Failed to finalize the approved plan card.");
4896
+ }
4897
+ await this.settingsOperations.run(sessionId, async () => {
4898
+ const current = this.store.getSessionForContext(sessionId, attempt.contextKey);
4899
+ if (this.planHandoffs.get(sessionId) !== turnId || this.sessionResets.has(sessionId)
4900
+ || !current || current.status === "closed" || current.remoteSessionId !== source.remoteSessionId) return;
4901
+ const loaded = await this.loadSession(current);
4902
+ await this.assertSessionTurnOwnership(current, loaded.runtime);
4903
+ if (this.planHandoffs.get(sessionId) !== turnId || this.sessionResets.has(sessionId)) return;
4904
+ // Another client may already have started the implementation.
4905
+ let nextTurnId = loaded.session.activeTurnId;
4906
+ if (!nextTurnId) {
4907
+ if (current.lastTurnId !== turnId) return;
4908
+ await this.applyPendingProviderSettings(loaded);
4909
+ if (this.store.getPendingProviderSettings(sessionId)) throw new Error("Provider 切换尚未完成。");
4910
+ if (this.planHandoffs.get(sessionId) !== turnId || this.sessionResets.has(sessionId)) return;
4911
+ nextTurnId = await this.startTurnNow(
4912
+ loaded,
4913
+ "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.",
4914
+ attempt.replyMessageId ? { messageId: attempt.replyMessageId, replyInThread: true } : undefined,
4915
+ undefined,
4916
+ { messageId: attempt.messageId, displayPrompt: "方案已确认,继续执行", approvedPlanTurnId: turnId },
4917
+ );
4918
+ }
4919
+ continued = true;
4920
+ this.store.rebindPendingTurnMessages(sessionId, turnId, nextTurnId);
4921
+ const latest = this.store.getSession(sessionId);
4922
+ if (latest?.lastTurnId === nextTurnId && (latest.lastTurnStatus === "completed"
4923
+ || latest.lastTurnStatus === "failed" || latest.lastTurnStatus === "cancelled")) {
4924
+ await this.finalizeTurnMessageReactions(nextTurnId, latest.lastTurnStatus);
4925
+ }
4926
+ this.store.audit(attempt.contextKey, "approved_plan_continued", { sessionId, turnId, nextTurnId });
4927
+ });
4928
+ } catch (error) {
4929
+ failed = true;
4930
+ this.logger.error({ error, sessionId, turnId }, "Failed to continue an approved plan.");
4931
+ await this.outbound.sendText(attempt.contextKey,
4932
+ "方案已确认,但后续执行启动失败:" + runtimeErrorMessage(error) + "。请发送消息继续。");
4933
+ } finally {
4934
+ if (this.planHandoffs.get(sessionId) === turnId) this.planHandoffs.delete(sessionId);
4935
+ if (!continued) {
4936
+ try {
4937
+ await this.outbound.onEvent(failed
4938
+ ? { type: "turn_failed", sessionId, turnId, message: "方案已确认,但后续执行未能启动。" }
4939
+ : { type: "turn_cancelled", sessionId, turnId });
4940
+ } catch (error) {
4941
+ this.logger.warn({ error, sessionId, turnId }, "Failed to finish the plan handoff card.");
4942
+ }
4943
+ await this.finalizeTurnMessageReactions(turnId, failed ? "failed" : "cancelled");
4944
+ }
4945
+ const latest = this.store.getSession(sessionId);
4946
+ if (latest?.lastTurnId === turnId && latest.status !== "closed") {
4947
+ this.store.updateSession(sessionId, { status: failed ? "failed" : "ready" });
4948
+ }
4949
+ await this.scheduleNextQueuedPrompt(sessionId);
4950
+ }
4951
+ }
4952
+
4746
4953
  private async retryFailedLlmTurn(
4747
4954
  event: Extract<RuntimeEvent, { type: "turn_failed" }>,
4748
4955
  attempt: TurnAttemptRecord,
@@ -4784,7 +4991,7 @@ export class ProxySessionController {
4784
4991
  : undefined;
4785
4992
  try {
4786
4993
  const current = this.store.getSession(session.localSessionId) ?? session;
4787
- const loaded = await this.loadSession(current);
4994
+ const loaded = await this.loadSession(current, Boolean(this.store.getPendingProviderSettings(current.localSessionId)));
4788
4995
  const retryTurnId = await this.startTurn(
4789
4996
  loaded,
4790
4997
  llmRetryRuntimePrompt(attempt.promptText, retryNumber),
@@ -4832,6 +5039,7 @@ export class ProxySessionController {
4832
5039
  }
4833
5040
 
4834
5041
  private scheduleQueuedPromptRetry(sessionId: string): void {
5042
+ if (this.store.getPendingProviderSettings(sessionId)?.error) return;
4835
5043
  if (this.queuedPromptRetryTimers.has(sessionId)) return;
4836
5044
  if (this.store.countQueuedPrompts(sessionId) === 0) return;
4837
5045
  const timer = setTimeout(() => {
@@ -4849,24 +5057,31 @@ export class ProxySessionController {
4849
5057
  }
4850
5058
 
4851
5059
  private async startNextQueuedPromptIfIdle(sessionId: string): Promise<void> {
5060
+ return this.settingsOperations.run(sessionId, () => this.startNextQueuedPromptIfIdleNow(sessionId));
5061
+ }
5062
+
5063
+ private async startNextQueuedPromptIfIdleNow(sessionId: string): Promise<void> {
5064
+ if (this.planHandoffs.has(sessionId) || this.sessionResets.has(sessionId)) return;
4852
5065
  if (this.store.countQueuedPrompts(sessionId) === 0) return;
4853
5066
  const baseRecord = this.store.getSession(sessionId);
4854
5067
  if (!baseRecord || baseRecord.status === "closed") return;
4855
5068
  let prompt: QueuedPromptRecord | undefined;
4856
5069
  try {
4857
- let loaded = await this.loadSession(baseRecord);
5070
+ let loaded = await this.loadSession(baseRecord, Boolean(this.store.getPendingProviderSettings(sessionId)));
4858
5071
  const remoteActivity = await this.assertSessionTurnOwnership(loaded.record, loaded.runtime);
4859
5072
  if (needsFullSessionSynchronization(loaded.record, loaded.session, remoteActivity)) {
4860
5073
  loaded = { ...loaded, session: await loaded.runtime.synchronizeSession(sessionId) };
4861
5074
  }
4862
5075
  if (loaded.session.activeTurnId) return;
4863
5076
 
5077
+ await this.applyPendingProviderSettings(loaded);
5078
+ if (this.store.getPendingProviderSettings(sessionId)) return;
4864
5079
  prompt = this.store.takeNextQueuedPrompt(sessionId);
4865
5080
  if (!prompt) return;
4866
5081
  const record = this.store.getSessionForContext(sessionId, prompt.contextKey) ?? baseRecord;
4867
5082
  loaded = { ...loaded, record };
4868
5083
  await this.refreshPromptQueueCards(sessionId);
4869
- const turnId = await this.startTurn(
5084
+ const turnId = await this.startTurnNow(
4870
5085
  loaded,
4871
5086
  prompt.text,
4872
5087
  prompt.replyMessageId ? { messageId: prompt.replyMessageId, replyInThread: true } : undefined,
@@ -4937,6 +5152,7 @@ export class ProxySessionController {
4937
5152
  });
4938
5153
  const pending = {
4939
5154
  contextKey: message.contextKey,
5155
+ userId: message.userId,
4940
5156
  attachmentPromise,
4941
5157
  resolveAttachment,
4942
5158
  };
@@ -4945,14 +5161,15 @@ export class ProxySessionController {
4945
5161
  }
4946
5162
 
4947
5163
  private reserveForwardAttachment(message: IncomingMessage): ForwardAttachmentReservation | undefined {
4948
- if (!message.parentMessageId) return undefined;
5164
+ if (!message.parentMessageId || isCommandMessage(message)) return undefined;
4949
5165
  const candidates = [
4950
5166
  { sourceKind: "merged_forward" as const, registry: this.pendingMergedForwards },
4951
5167
  { sourceKind: "resource" as const, registry: this.pendingResourceForwards },
4952
5168
  ];
4953
5169
  for (const candidate of candidates) {
4954
5170
  const pending = candidate.registry.get(message.parentMessageId);
4955
- if (!pending || pending.contextKey !== message.contextKey || pending.attachmentMessageId) continue;
5171
+ if (!pending || pending.contextKey !== message.contextKey
5172
+ || pending.userId !== message.userId || pending.attachmentMessageId) continue;
4956
5173
  pending.attachmentMessageId = message.messageId;
4957
5174
  return {
4958
5175
  sourceMessageId: message.parentMessageId,
@@ -4981,15 +5198,17 @@ export class ProxySessionController {
4981
5198
  pending: PendingForwardAttachment,
4982
5199
  registry: Map<string, PendingForwardAttachment>,
4983
5200
  ): Promise<IncomingMessage | undefined> {
4984
- let timer: NodeJS.Timeout | undefined;
4985
- const timeout = new Promise<undefined>((resolve) => {
4986
- timer = setTimeout(() => resolve(undefined), FORWARD_ATTACHMENT_WINDOW_MS);
4987
- timer.unref?.();
4988
- });
5201
+ const timer = setTimeout(() => {
5202
+ // Arrival reserves the pair before acknowledgement. A slow Reaction must not split it.
5203
+ if (pending.attachmentMessageId) return;
5204
+ if (registry.get(messageId) === pending) registry.delete(messageId);
5205
+ pending.resolveAttachment(undefined);
5206
+ }, FORWARD_ATTACHMENT_WINDOW_MS);
5207
+ timer.unref?.();
4989
5208
  try {
4990
- return await Promise.race([pending.attachmentPromise, timeout]);
5209
+ return await pending.attachmentPromise;
4991
5210
  } finally {
4992
- if (timer) clearTimeout(timer);
5211
+ clearTimeout(timer);
4993
5212
  if (registry.get(messageId) === pending) {
4994
5213
  registry.delete(messageId);
4995
5214
  }
@@ -5068,6 +5287,7 @@ export class ProxySessionController {
5068
5287
  }
5069
5288
 
5070
5289
  private async cancelSession(record: SessionRecord): Promise<void> {
5290
+ this.planHandoffs.delete(record.localSessionId);
5071
5291
  const runtime = this.runtimes.forAgent(record.agentName);
5072
5292
  if (runtime.kind === "codex" && record.remoteSessionId && runtime.interruptRemoteTurn) {
5073
5293
  if (runtime.getGoal && runtime.setGoal) {
@@ -5146,6 +5366,7 @@ export class ProxySessionController {
5146
5366
  }
5147
5367
 
5148
5368
  private async runSessionReset(sessionId: string, operation: () => Promise<void>): Promise<void> {
5369
+ this.planHandoffs.delete(sessionId);
5149
5370
  const previous = this.sessionResets.get(sessionId) ?? Promise.resolve();
5150
5371
  const reset = previous.catch(() => undefined).then(operation);
5151
5372
  this.sessionResets.set(sessionId, reset);
@@ -5329,6 +5550,20 @@ export class ProxySessionController {
5329
5550
  contextKey: string,
5330
5551
  activeTab: ExecutionSettingsTab,
5331
5552
  options: ExecutionSettingsCardOptions = {},
5553
+ ): Promise<void> {
5554
+ const record = options.sessionId
5555
+ ? this.requireSession(contextKey, options.sessionId)
5556
+ : this.currentSession(contextKey);
5557
+ if (!record) return this.openExecutionSettingsNow(contextKey, activeTab, options);
5558
+ return this.settingsOperations.run(record.localSessionId, () => this.openExecutionSettingsNow(
5559
+ contextKey, activeTab, { ...options, sessionId: record.localSessionId },
5560
+ ));
5561
+ }
5562
+
5563
+ private async openExecutionSettingsNow(
5564
+ contextKey: string,
5565
+ activeTab: ExecutionSettingsTab,
5566
+ options: ExecutionSettingsCardOptions = {},
5332
5567
  ): Promise<void> {
5333
5568
  const context = this.store.getOrCreateUserContext(contextKey, this.config.defaults.agent!);
5334
5569
  const record = options.sessionId
@@ -5349,24 +5584,33 @@ export class ProxySessionController {
5349
5584
  reasoningOptions: [],
5350
5585
  };
5351
5586
  if (record) {
5352
- const loaded = await this.loadSession(record);
5587
+ const loaded = await this.loadSession(record, true);
5588
+ const pending = this.store.getPendingProviderSettings(record.localSessionId);
5589
+ const selected = pending?.settings ?? loaded.session;
5590
+ if (pending && options.updateMessageId) {
5591
+ this.store.savePendingProviderSettings({
5592
+ ...pending, contextKey, messageId: options.updateMessageId, replyMessageId: options.updateMessageId, activeTab,
5593
+ });
5594
+ }
5353
5595
  const providerSupported = loaded.runtime.kind === "codex" && Boolean(loaded.runtime.listModelProviders);
5354
5596
  const providers = providerSupported ? await this.modelProviderOptions(loaded) : [];
5355
- const currentProvider = loaded.session.modelProvider ?? providers.find((provider) => provider.isDefault)?.id;
5356
- const { models, warning } = await this.modelOptions(loaded.runtime, currentProvider, loaded.session.model);
5357
- const currentModel = models.find((model) => model.id === loaded.session.model)
5597
+ const currentProvider = selected.modelProvider ?? providers.find((provider) => provider.isDefault)?.id;
5598
+ const { models, warning } = await this.modelOptions(loaded.runtime, currentProvider, selected.model);
5599
+ const currentModel = models.find((model) => model.id === selected.model)
5358
5600
  ?? models.find((model) => model.isDefault)
5359
5601
  ?? models[0];
5360
5602
  const currentEffort = currentModel?.supportedReasoningEfforts.some(
5361
- (option) => option.value === loaded.session.reasoningEffort,
5603
+ (option) => option.value === selected.reasoningEffort,
5362
5604
  )
5363
- ? loaded.session.reasoningEffort
5364
- : currentModel?.defaultReasoningEffort ?? loaded.session.reasoningEffort;
5605
+ ? selected.reasoningEffort
5606
+ : currentModel?.defaultReasoningEffort ?? selected.reasoningEffort;
5365
5607
  runtimeSettings = {
5608
+ pendingNotice: pending ? this.pendingProviderNotice(pending) : undefined,
5609
+ pendingFailed: Boolean(pending?.error),
5366
5610
  currentProvider,
5367
- currentModel: currentModel?.id ?? loaded.session.model,
5611
+ currentModel: pending?.settings.model ?? currentModel?.id ?? selected.model,
5368
5612
  currentEffort,
5369
- currentPermissionMode: loaded.session.permissionMode,
5613
+ currentPermissionMode: selected.permissionMode,
5370
5614
  providers,
5371
5615
  providerSupported,
5372
5616
  models,
@@ -5382,7 +5626,10 @@ export class ProxySessionController {
5382
5626
  agents,
5383
5627
  runtimeSettingsAvailable: Boolean(record),
5384
5628
  ...runtimeSettings,
5629
+ modelPage: options.expectedProvider !== undefined && options.expectedProvider !== (runtimeSettings.currentProvider ?? "")
5630
+ ? undefined : options.modelPage,
5385
5631
  notice: options.notice,
5632
+ warning: [runtimeSettings.warning, options.warning].filter(Boolean).join("\n") || undefined,
5386
5633
  });
5387
5634
  if (options.updateMessageId) {
5388
5635
  await this.outbound.updateInteractiveCard(contextKey, options.updateMessageId, card);
@@ -5400,7 +5647,12 @@ export class ProxySessionController {
5400
5647
  await this.openExecutionSettings(contextKey, "agent");
5401
5648
  }
5402
5649
 
5403
- private async selectProvider(
5650
+ private async selectProvider(contextKey: string, modelProvider: string, options: ModelCardOptions = {}): Promise<void> {
5651
+ const record = options.sessionId ? this.requireSession(contextKey, options.sessionId) : this.requireCurrentSession(contextKey);
5652
+ return this.settingsOperations.run(record.localSessionId, () => this.selectProviderNow(contextKey, modelProvider, { ...options, sessionId: record.localSessionId }));
5653
+ }
5654
+
5655
+ private async selectProviderNow(
5404
5656
  contextKey: string,
5405
5657
  modelProvider: string,
5406
5658
  options: ModelCardOptions = {},
@@ -5408,19 +5660,19 @@ export class ProxySessionController {
5408
5660
  const record = options.sessionId
5409
5661
  ? this.requireSession(contextKey, options.sessionId)
5410
5662
  : this.requireCurrentSession(contextKey);
5411
- await this.outbound.sendText(contextKey, `正在切换到 Provider ${modelProvider},请稍后。`);
5412
- const loaded = await this.loadSession(record);
5663
+ const loaded = await this.loadSession(record, true);
5664
+ const selected = this.store.getPendingProviderSettings(record.localSessionId)?.settings ?? loaded.session;
5413
5665
  await this.assertModelProvider(loaded, modelProvider);
5414
- const models = await loaded.runtime.listModels(modelProvider, loaded.session.model);
5415
- const model = selectProviderModel(models, loaded.session.model);
5666
+ const models = await loaded.runtime.listModels(modelProvider, selected.model);
5667
+ const model = selectProviderModel(models, selected.model);
5416
5668
  if (!model) throw new Error("当前运行时没有可用于 Provider 切换的模型。");
5417
- const effort = selectProviderReasoningEffort(model, loaded.session.reasoningEffort);
5418
- await this.applyProviderSettings(contextKey, {
5669
+ const effort = selectProviderReasoningEffort(model, selected.reasoningEffort);
5670
+ await this.applyProviderSettingsNow(contextKey, {
5419
5671
  provider: modelProvider,
5420
5672
  model: model.id,
5421
5673
  effort,
5422
- mode: loaded.session.permissionMode,
5423
- }, options, false);
5674
+ mode: selected.permissionMode,
5675
+ }, options);
5424
5676
  }
5425
5677
 
5426
5678
  private async openProviderSelector(
@@ -5436,14 +5688,16 @@ export class ProxySessionController {
5436
5688
  permissionMode: PermissionMode,
5437
5689
  sessionId: string,
5438
5690
  updateMessageId: string,
5691
+ modelPage?: number,
5439
5692
  ): Promise<void> {
5440
- const loaded = await this.loadSession(this.requireSession(contextKey, sessionId));
5693
+ const loaded = await this.loadSession(this.requireSession(contextKey, sessionId), true);
5441
5694
  await this.assertModelProvider(loaded, modelProvider);
5442
5695
  const { models, warning } = await this.modelOptions(loaded.runtime, modelProvider, loaded.session.model);
5443
5696
  const card = this.cardRenderer.renderModelSelector({
5444
5697
  sessionId,
5445
5698
  contextKey,
5446
5699
  currentModel: selectProviderModel(models, loaded.session.model)?.id,
5700
+ modelPage,
5447
5701
  reasoningEffort: loaded.session.reasoningEffort,
5448
5702
  models,
5449
5703
  warning,
@@ -5462,7 +5716,7 @@ export class ProxySessionController {
5462
5716
  sessionId: string,
5463
5717
  updateMessageId: string,
5464
5718
  ): Promise<void> {
5465
- const loaded = await this.loadSession(this.requireSession(contextKey, sessionId));
5719
+ const loaded = await this.loadSession(this.requireSession(contextKey, sessionId), true);
5466
5720
  await this.assertModelProvider(loaded, modelProvider);
5467
5721
  const { models, warning } = await this.modelOptions(loaded.runtime, modelProvider, model);
5468
5722
  const selected = models.find((candidate) => candidate.id === model);
@@ -5498,7 +5752,7 @@ export class ProxySessionController {
5498
5752
  sessionId: string,
5499
5753
  updateMessageId: string,
5500
5754
  ): Promise<void> {
5501
- const loaded = await this.loadSession(this.requireSession(contextKey, sessionId));
5755
+ const loaded = await this.loadSession(this.requireSession(contextKey, sessionId), true);
5502
5756
  await this.assertProviderModelSettings(loaded, modelProvider, model, effort);
5503
5757
  await this.outbound.updateInteractiveCard(
5504
5758
  contextKey,
@@ -5514,7 +5768,12 @@ export class ProxySessionController {
5514
5768
  );
5515
5769
  }
5516
5770
 
5517
- private async applyProviderSettings(
5771
+ private async applyProviderSettings(contextKey: string, settings: { provider: string; model: string; effort: string; mode: PermissionMode }, options: ModelCardOptions = {}, announceStart = true): Promise<void> {
5772
+ const record = options.sessionId ? this.requireSession(contextKey, options.sessionId) : this.requireCurrentSession(contextKey);
5773
+ return this.settingsOperations.run(record.localSessionId, () => this.applyProviderSettingsNow(contextKey, settings, { ...options, sessionId: record.localSessionId }, announceStart));
5774
+ }
5775
+
5776
+ private async applyProviderSettingsNow(
5518
5777
  contextKey: string,
5519
5778
  settings: { provider: string; model: string; effort: string; mode: PermissionMode },
5520
5779
  options: ModelCardOptions = {},
@@ -5523,17 +5782,38 @@ export class ProxySessionController {
5523
5782
  const record = options.sessionId
5524
5783
  ? this.requireSession(contextKey, options.sessionId)
5525
5784
  : this.requireCurrentSession(contextKey);
5526
- if (announceStart) {
5527
- await this.outbound.sendText(contextKey, `正在切换到 Provider ${settings.provider},请稍后。`);
5528
- }
5529
- const loaded = await this.loadSession(record);
5785
+ const loaded = await this.loadSession(record, true);
5530
5786
  const warning = await this.assertProviderModelSettings(loaded, settings.provider, settings.model, settings.effort);
5531
- const session = await this.changeProviderSettings(loaded, {
5532
- modelProvider: settings.provider,
5533
- model: settings.model,
5534
- reasoningEffort: settings.effort,
5535
- permissionMode: settings.mode,
5536
- });
5787
+ const next: RuntimeExecutionSettings = {
5788
+ modelProvider: settings.provider, model: settings.model,
5789
+ reasoningEffort: settings.effort, permissionMode: settings.mode,
5790
+ };
5791
+ await this.reconcilePreparedSettingsSession(loaded);
5792
+ if (loaded.session.activeTurnId || this.store.getPendingProviderSettings(record.localSessionId)) {
5793
+ await this.savePendingProviderSelection(loaded, next, contextKey, options, "provider");
5794
+ return;
5795
+ }
5796
+ if (announceStart) await this.outbound.sendText(contextKey, `正在切换到 Provider ${settings.provider},请稍后。`);
5797
+ const previous = { ...loaded.session };
5798
+ let session: RuntimeSession;
5799
+ try {
5800
+ session = await this.changeProviderSettings(loaded, next);
5801
+ } catch (error) {
5802
+ if (!this.providerSettingsRestored(loaded, previous, error)) {
5803
+ this.store.savePendingProviderSettings({
5804
+ localSessionId: record.localSessionId, contextKey, settings: next, activeTab: "provider",
5805
+ messageId: options.updateMessageId,
5806
+ replyMessageId: options.updateMessageId ?? this.controlSessionReplyTarget(record)?.messageId,
5807
+ error: runtimeErrorMessage(error),
5808
+ });
5809
+ if (options.updateMessageId) {
5810
+ await this.openExecutionSettingsNow(contextKey, "provider", options).catch((presentationError: unknown) => {
5811
+ this.logger.warn({ error: presentationError, sessionId: record.localSessionId }, "Failed to refresh rejected Provider selection.");
5812
+ });
5813
+ }
5814
+ }
5815
+ throw error;
5816
+ }
5537
5817
  const notice = [
5538
5818
  `Provider 已切换为 ${cardCode(session.modelProvider ?? settings.provider)}`,
5539
5819
  `模型 ${cardCode(session.model ?? settings.model)}`,
@@ -5542,7 +5822,7 @@ export class ProxySessionController {
5542
5822
  `并已保存为 ${cardCode(loaded.record.agentName)} Agent 的默认设置,从下一次请求生效。`,
5543
5823
  ].join(",");
5544
5824
  if (options.updateMessageId) {
5545
- await this.openProviderSelector(contextKey, {
5825
+ await this.openExecutionSettingsNow(contextKey, "provider", {
5546
5826
  sessionId: loaded.record.localSessionId,
5547
5827
  updateMessageId: options.updateMessageId,
5548
5828
  notice,
@@ -5553,15 +5833,23 @@ export class ProxySessionController {
5553
5833
  }
5554
5834
 
5555
5835
  private async changeProviderSettings(loaded: LoadedSession, settings: RuntimeExecutionSettings): Promise<RuntimeSession> {
5836
+ return this.sessionHydrationOperations.run(loaded.record.localSessionId, () => this.changeProviderSettingsNow(loaded, settings));
5837
+ }
5838
+
5839
+ private async changeProviderSettingsNow(loaded: LoadedSession, settings: RuntimeExecutionSettings): Promise<RuntimeSession> {
5556
5840
  if (!loaded.runtime.setExecutionSettings) throw new Error("当前运行时不支持 Provider 设置。");
5557
- const persist = async (candidate: RuntimeSession): Promise<void> => {
5841
+ loaded.session = loaded.runtime.getSession(loaded.record.localSessionId) ?? loaded.session;
5842
+ const remoteSessionId = loaded.session.remoteSessionId;
5843
+ const persist = (candidate: RuntimeSession): Promise<void> => this.agentDefaultsOperations.run(loaded.record.agentName, async () => {
5844
+ if (candidate.remoteSessionId !== remoteSessionId) {
5845
+ throw new Error("设置切换返回了不同的任务 ID,未保存设置;不允许替换任务或更改历史。");
5846
+ }
5558
5847
  assertProviderSettingsApplied(candidate, settings);
5559
5848
  const agent = this.ensureAgent(loaded.record.agentName);
5560
5849
  const previousDefaults = agent.defaults;
5561
- await this.persistAgentExecutionDefaults(loaded.record.localSessionId, candidate);
5850
+ await this.persistAgentExecutionDefaultsNow(loaded.record.localSessionId, candidate);
5562
5851
  try {
5563
5852
  this.store.updateRuntimeSession(loaded.record.localSessionId, {
5564
- remoteSessionId: candidate.remoteSessionId,
5565
5853
  modelProvider: candidate.modelProvider,
5566
5854
  model: candidate.model,
5567
5855
  reasoningEffort: candidate.reasoningEffort,
@@ -5572,12 +5860,145 @@ export class ProxySessionController {
5572
5860
  agent.defaults = previousDefaults;
5573
5861
  throw error;
5574
5862
  }
5575
- };
5863
+ });
5576
5864
  const session = await loaded.runtime.setExecutionSettings(loaded.record.localSessionId, settings, persist);
5865
+ this.settingsOnlySessions.delete(loaded.record.localSessionId);
5577
5866
  this.outbound.updateSessionModel(loaded.record.localSessionId, session.model, session.modelProvider);
5578
5867
  return session;
5579
5868
  }
5580
5869
 
5870
+ private async reconcilePreparedSettingsSession(loaded: LoadedSession): Promise<void> {
5871
+ if (!this.settingsOnlySessions.has(loaded.record.localSessionId) || !loaded.session.activeTurnId) return;
5872
+ const activity = await this.assertSessionTurnOwnership(loaded.record, loaded.runtime);
5873
+ if (activity && !activity.active) {
5874
+ loaded.session = await loaded.runtime.synchronizeSession(loaded.record.localSessionId);
5875
+ }
5876
+ }
5877
+
5878
+ private providerSettingsRestored(loaded: LoadedSession, previous: RuntimeSession, error: unknown): boolean {
5879
+ const restored = loaded.runtime.getSession(loaded.record.localSessionId);
5880
+ const saved = this.store.getSession(loaded.record.localSessionId);
5881
+ const keys = ["remoteSessionId", "modelProvider", "model", "reasoningEffort", "permissionMode"] as const;
5882
+ return error instanceof ProviderSwitchError && error.previousSettingsRestored
5883
+ && Boolean(restored && saved && keys.every((key) => restored[key] === previous[key] && saved[key] === previous[key]));
5884
+ }
5885
+
5886
+ private pendingProviderNotice(pending: PendingProviderSettings): string {
5887
+ const target = `Provider ${cardCode(pending.settings.modelProvider)},模型 ${cardCode(pending.settings.model)}`;
5888
+ return pending.error
5889
+ ? `切换失败:${pending.error}。目标为 ${target};后续轮次已暂停,请重新选择 Provider 或模型重试。`
5890
+ : `待生效:当前轮次不变,结束后切换到 ${target}。可继续选择模型,以最后选择为准。`;
5891
+ }
5892
+
5893
+ private async savePendingProviderSelection(
5894
+ loaded: LoadedSession, settings: RuntimeExecutionSettings, contextKey: string,
5895
+ options: ModelCardOptions, activeTab: ExecutionSettingsTab,
5896
+ ): Promise<void> {
5897
+ const previous = this.store.getPendingProviderSettings(loaded.record.localSessionId);
5898
+ const pending: PendingProviderSettings = {
5899
+ localSessionId: loaded.record.localSessionId, contextKey, settings, activeTab,
5900
+ messageId: options.updateMessageId ?? previous?.messageId,
5901
+ replyMessageId: options.updateMessageId ?? previous?.replyMessageId
5902
+ ?? this.controlSessionReplyTarget(loaded.record)?.messageId,
5903
+ };
5904
+ this.store.savePendingProviderSettings(pending);
5905
+ await this.reconcilePreparedSettingsSession(loaded);
5906
+ if (!loaded.session.activeTurnId) {
5907
+ await this.applyPendingProviderSettings(loaded);
5908
+ void this.scheduleNextQueuedPrompt(loaded.record.localSessionId).catch((error: unknown) => {
5909
+ this.logger.warn({ error }, "Failed to resume queue after Provider selection.");
5910
+ });
5911
+ return;
5912
+ }
5913
+ if (options.updateMessageId) {
5914
+ await this.openExecutionSettingsNow(contextKey, activeTab, options);
5915
+ } else {
5916
+ await this.outbound.sendText(contextKey, this.pendingProviderNotice(pending).replaceAll("`", ""));
5917
+ }
5918
+ }
5919
+
5920
+ // Caller holds settingsOperations; only a verified rollback can unblock the next turn.
5921
+ private async applyPendingProviderSettings(loaded: LoadedSession): Promise<void> {
5922
+ const pending = this.store.getPendingProviderSettings(loaded.record.localSessionId);
5923
+ if (!pending) return;
5924
+ await this.reconcilePreparedSettingsSession(loaded);
5925
+ if (loaded.runtime.getSession(loaded.record.localSessionId)?.activeTurnId) return;
5926
+ if (pending.error) throw new Error(this.pendingProviderNotice(pending));
5927
+ const previous = { ...(loaded.runtime.getSession(loaded.record.localSessionId) ?? loaded.session) };
5928
+ let notice: string;
5929
+ let failure: unknown;
5930
+ let rolledBack = false;
5931
+ try {
5932
+ loaded.session = await this.changeProviderSettings(loaded, pending.settings);
5933
+ this.store.deletePendingProviderSettings(pending.localSessionId);
5934
+ notice = `Provider 已切换为 ${cardCode(pending.settings.modelProvider)},模型 ${cardCode(pending.settings.model)},从下一轮生效。`;
5935
+ } catch (error) {
5936
+ const restored = loaded.runtime.getSession(pending.localSessionId);
5937
+ rolledBack = this.providerSettingsRestored(loaded, previous, error);
5938
+ if (rolledBack) {
5939
+ loaded.session = restored!;
5940
+ this.store.deletePendingProviderSettings(pending.localSessionId);
5941
+ this.outbound.updateSessionModel(pending.localSessionId, previous.model, previous.modelProvider);
5942
+ notice = `切换到 Provider ${cardCode(pending.settings.modelProvider)}、模型 ${cardCode(pending.settings.model)} 失败:${runtimeErrorMessage(error)} 已回退到 Provider ${cardCode(previous.modelProvider ?? "Agent 默认")}、模型 ${cardCode(previous.model ?? "默认")},后续轮次继续使用原设置。`;
5943
+ } else {
5944
+ failure = error;
5945
+ const failed = { ...pending, error: runtimeErrorMessage(error) };
5946
+ this.store.savePendingProviderSettings(failed);
5947
+ notice = this.pendingProviderNotice(failed);
5948
+ }
5949
+ }
5950
+ try {
5951
+ await this.outbound.withReplyTarget(pending.contextKey, pending.replyMessageId
5952
+ ? { messageId: pending.replyMessageId, replyInThread: true } : undefined, async () => {
5953
+ if (pending.messageId) {
5954
+ try {
5955
+ await this.openExecutionSettingsNow(pending.contextKey, pending.activeTab ?? "provider", {
5956
+ sessionId: pending.localSessionId, updateMessageId: pending.messageId,
5957
+ notice: failure || rolledBack ? undefined : notice,
5958
+ warning: rolledBack ? notice.replaceAll("`", "") : undefined,
5959
+ });
5960
+ return;
5961
+ } catch (error) {
5962
+ this.logger.warn({ error, sessionId: pending.localSessionId }, "Failed to refresh Provider settings card.");
5963
+ }
5964
+ }
5965
+ await this.outbound.sendText(pending.contextKey, notice.replaceAll("`", ""));
5966
+ });
5967
+ } catch (error) {
5968
+ this.logger.warn({ error, sessionId: pending.localSessionId }, "Failed to notify Provider settings result.");
5969
+ }
5970
+ if (failure) throw failure;
5971
+ }
5972
+
5973
+ private async applyPendingProviderWhenIdle(sessionId: string): Promise<void> {
5974
+ await this.settingsOperations.run(sessionId, async () => {
5975
+ if (this.recoveryClosed) return;
5976
+ const pending = this.store.getPendingProviderSettings(sessionId);
5977
+ if (!pending || pending.error) return;
5978
+ const record = this.store.getSession(sessionId);
5979
+ if (!record || record.status === "closed") {
5980
+ this.store.deletePendingProviderSettings(sessionId);
5981
+ return;
5982
+ }
5983
+ try {
5984
+ const loaded = await this.loadSession(record, true);
5985
+ await this.applyPendingProviderSettings(loaded);
5986
+ } catch (error) {
5987
+ this.logger.warn({ error, sessionId }, "Deferred Provider switch could not be applied.");
5988
+ if (!this.store.getPendingProviderSettings(sessionId)?.error) {
5989
+ const failed = { ...pending, error: runtimeErrorMessage(error) };
5990
+ this.store.savePendingProviderSettings(failed);
5991
+ await this.outbound.withReplyTarget(pending.contextKey, pending.replyMessageId
5992
+ ? { messageId: pending.replyMessageId, replyInThread: true } : undefined,
5993
+ () => this.outbound.sendText(pending.contextKey, this.pendingProviderNotice(failed).replaceAll("`", "")))
5994
+ .catch((notificationError: unknown) => {
5995
+ this.logger.warn({ error: notificationError, sessionId }, "Failed to notify Provider recovery failure.");
5996
+ });
5997
+ }
5998
+ }
5999
+ });
6000
+ }
6001
+
5581
6002
  private async assertProviderModelSettings(
5582
6003
  loaded: LoadedSession,
5583
6004
  modelProvider: string,
@@ -5622,36 +6043,52 @@ export class ProxySessionController {
5622
6043
  }
5623
6044
 
5624
6045
  private async model(contextKey: string, model?: string, options: ModelCardOptions = {}): Promise<void> {
6046
+ const record = options.sessionId ? this.requireSession(contextKey, options.sessionId) : this.requireCurrentSession(contextKey);
6047
+ return this.settingsOperations.run(record.localSessionId, () => this.modelNow(contextKey, model, { ...options, sessionId: record.localSessionId }));
6048
+ }
6049
+
6050
+ private async modelNow(contextKey: string, model?: string, options: ModelCardOptions = {}): Promise<void> {
5625
6051
  if (!model) {
5626
- await this.openExecutionSettings(contextKey, "model", options);
6052
+ await this.openExecutionSettingsNow(contextKey, "model", options);
5627
6053
  return;
5628
6054
  }
5629
6055
  const record = options.sessionId
5630
6056
  ? this.requireSession(contextKey, options.sessionId)
5631
6057
  : this.requireCurrentSession(contextKey);
5632
- const loaded = await this.loadSession(record);
5633
- const models = await loaded.runtime.listModels(loaded.session.modelProvider, loaded.session.model);
6058
+ const loaded = await this.loadSession(record, true);
6059
+ const pending = this.store.getPendingProviderSettings(record.localSessionId);
6060
+ const selectedSettings = pending?.settings ?? loaded.session;
6061
+ if (options.expectedProvider && options.expectedProvider !== selectedSettings.modelProvider) {
6062
+ throw new Error("Provider 已发生变化,请重新打开运行设置后选择。");
6063
+ }
6064
+ const models = await loaded.runtime.listModels(selectedSettings.modelProvider, selectedSettings.model);
5634
6065
  const selected = models.find((item) => item.id === model);
5635
6066
  if (!selected) throw new Error(`未知模型:${model}`);
5636
- const currentEffort = loaded.session.reasoningEffort;
6067
+ const currentEffort = selectedSettings.reasoningEffort;
5637
6068
  const compatible = currentEffort
5638
6069
  ? selected.supportedReasoningEfforts.length === 0
5639
6070
  || selected.supportedReasoningEfforts.some((option) => option.value === currentEffort)
5640
6071
  : false;
5641
6072
  const nextEffort = compatible ? currentEffort : selected.defaultReasoningEffort;
5642
6073
 
5643
- await loaded.runtime.setModel(loaded.record.localSessionId, model);
5644
- if (nextEffort && nextEffort !== currentEffort) {
5645
- await loaded.runtime.setReasoningEffort(loaded.record.localSessionId, nextEffort);
6074
+ if (pending) {
6075
+ await this.savePendingProviderSelection(loaded, { ...pending.settings, model, reasoningEffort: selectProviderReasoningEffort(selected, nextEffort) }, contextKey, options, "model");
6076
+ return;
5646
6077
  }
5647
- this.store.updateRuntimeSession(loaded.record.localSessionId, { model, reasoningEffort: nextEffort });
5648
- this.outbound.updateSessionModel(loaded.record.localSessionId, model, loaded.session.modelProvider);
5649
- await this.persistAgentExecutionDefaults(loaded.record.localSessionId);
6078
+ await this.sessionHydrationOperations.run(loaded.record.localSessionId, async () => {
6079
+ await loaded.runtime.setModel(loaded.record.localSessionId, model);
6080
+ if (nextEffort && nextEffort !== currentEffort) {
6081
+ await loaded.runtime.setReasoningEffort(loaded.record.localSessionId, nextEffort);
6082
+ }
6083
+ this.store.updateRuntimeSession(loaded.record.localSessionId, { model, reasoningEffort: nextEffort });
6084
+ this.outbound.updateSessionModel(loaded.record.localSessionId, model, loaded.session.modelProvider);
6085
+ await this.persistAgentExecutionDefaults(loaded.record.localSessionId);
6086
+ });
5650
6087
  const effortMessage = nextEffort && nextEffort !== currentEffort
5651
6088
  ? `,思考强度已自动调整为 ${nextEffort}`
5652
6089
  : "";
5653
6090
  if (options.updateMessageId) {
5654
- await this.openExecutionSettings(contextKey, "model", {
6091
+ await this.openExecutionSettingsNow(contextKey, "model", {
5655
6092
  sessionId: loaded.record.localSessionId,
5656
6093
  updateMessageId: options.updateMessageId,
5657
6094
  notice: `模型已切换为 ${cardCode(model)}${effortMessage},并已保存为 ${cardCode(loaded.record.agentName)} Agent 的默认设置,从下一次请求生效。`,
@@ -5827,6 +6264,7 @@ export class ProxySessionController {
5827
6264
  const created = await this.shellCommandJobs.createJob({ contextKey, sourceMessageId, command, cwd });
5828
6265
  let cardMessageId: string | undefined;
5829
6266
  const initialState: ShellCommandCardView = {
6267
+ terminalUrl: this.lifecycle?.terminalUrl?.(created.id),
5830
6268
  jobId: created.id,
5831
6269
  contextKey,
5832
6270
  command,
@@ -5853,7 +6291,7 @@ export class ProxySessionController {
5853
6291
  await this.outbound.updateInteractiveCard(
5854
6292
  contextKey,
5855
6293
  cardMessageId,
5856
- this.cardRenderer.renderShellCommandCard(shellCommandJobCardView(failed)),
6294
+ this.cardRenderer.renderShellCommandCard({ ...shellCommandJobCardView(failed), terminalUrl: this.lifecycle?.terminalUrl?.(failed.id) }),
5857
6295
  );
5858
6296
  await this.shellCommandJobs.markPresented(created.id);
5859
6297
  } catch (cardError) {
@@ -5911,7 +6349,7 @@ export class ProxySessionController {
5911
6349
  ): void {
5912
6350
  if (!job.cardMessageId || this.shellCommandJobMonitors.has(job.id)) return;
5913
6351
  const scheduler = new CardUpdateScheduler<ShellCommandCardView>({
5914
- render: (state) => this.cardRenderer.renderShellCommandCard(state),
6352
+ render: (state) => this.cardRenderer.renderShellCommandCard({ ...state, terminalUrl: this.lifecycle?.terminalUrl?.(job.id) }),
5915
6353
  write: (card) => this.outbound.updateInteractiveCard(job.contextKey, job.cardMessageId!, card),
5916
6354
  onError: (error) => this.logger.warn(
5917
6355
  { error, jobId: job.id, contextKey: job.contextKey, messageId: job.cardMessageId },
@@ -6062,16 +6500,26 @@ export class ProxySessionController {
6062
6500
  }
6063
6501
 
6064
6502
  private async thinking(contextKey: string, effort?: string, options: ThinkingCardOptions = {}): Promise<void> {
6503
+ const record = options.sessionId ? this.requireSession(contextKey, options.sessionId) : this.requireCurrentSession(contextKey);
6504
+ return this.settingsOperations.run(record.localSessionId, () => this.thinkingNow(contextKey, effort, { ...options, sessionId: record.localSessionId }));
6505
+ }
6506
+
6507
+ private async thinkingNow(contextKey: string, effort?: string, options: ThinkingCardOptions = {}): Promise<void> {
6065
6508
  if (!effort) {
6066
- await this.openExecutionSettings(contextKey, "thinking", options);
6509
+ await this.openExecutionSettingsNow(contextKey, "thinking", options);
6067
6510
  return;
6068
6511
  }
6069
6512
  const record = options.sessionId
6070
6513
  ? this.requireSession(contextKey, options.sessionId)
6071
6514
  : this.requireCurrentSession(contextKey);
6072
- const loaded = await this.loadSession(record);
6073
- const models = await loaded.runtime.listModels(loaded.session.modelProvider, loaded.session.model);
6074
- const currentModel = models.find((item) => item.id === loaded.session.model)
6515
+ const loaded = await this.loadSession(record, true);
6516
+ const pending = this.store.getPendingProviderSettings(record.localSessionId);
6517
+ const selectedSettings = pending?.settings ?? loaded.session;
6518
+ if (options.expectedProvider && options.expectedProvider !== selectedSettings.modelProvider) {
6519
+ throw new Error("Provider 已发生变化,请重新打开运行设置后选择。");
6520
+ }
6521
+ const models = await loaded.runtime.listModels(selectedSettings.modelProvider, selectedSettings.model);
6522
+ const currentModel = models.find((item) => item.id === selectedSettings.model)
6075
6523
  ?? models.find((item) => item.isDefault);
6076
6524
  if (!currentModel) throw new Error("当前运行时没有可配置思考强度的模型。");
6077
6525
  if (options.expectedModel && currentModel.id !== options.expectedModel) {
@@ -6083,11 +6531,17 @@ export class ProxySessionController {
6083
6531
  const options = supported.map((option) => option.value).join("、") || "无";
6084
6532
  throw new Error(`不支持的思考强度:${effort}。支持的强度:${options}`);
6085
6533
  }
6086
- await loaded.runtime.setReasoningEffort(loaded.record.localSessionId, effort);
6087
- this.store.updateRuntimeSession(loaded.record.localSessionId, { reasoningEffort: effort });
6088
- await this.persistAgentExecutionDefaults(loaded.record.localSessionId);
6534
+ if (pending) {
6535
+ await this.savePendingProviderSelection(loaded, { ...pending.settings, reasoningEffort: effort }, contextKey, options, "thinking");
6536
+ return;
6537
+ }
6538
+ await this.sessionHydrationOperations.run(loaded.record.localSessionId, async () => {
6539
+ await loaded.runtime.setReasoningEffort(loaded.record.localSessionId, effort);
6540
+ this.store.updateRuntimeSession(loaded.record.localSessionId, { reasoningEffort: effort });
6541
+ await this.persistAgentExecutionDefaults(loaded.record.localSessionId);
6542
+ });
6089
6543
  if (options.updateMessageId) {
6090
- await this.openExecutionSettings(contextKey, "thinking", {
6544
+ await this.openExecutionSettingsNow(contextKey, "thinking", {
6091
6545
  sessionId: loaded.record.localSessionId,
6092
6546
  updateMessageId: options.updateMessageId,
6093
6547
  notice: `思考强度已切换为 ${cardCode(effort)},并已保存为 ${cardCode(loaded.record.agentName)} Agent 的默认设置,从下一次请求生效。`,
@@ -6100,25 +6554,38 @@ export class ProxySessionController {
6100
6554
  );
6101
6555
  }
6102
6556
 
6103
- private async permissions(
6557
+ private async permissions(contextKey: string, mode?: PermissionMode, options: ModelCardOptions = {}): Promise<void> {
6558
+ const record = options.sessionId ? this.requireSession(contextKey, options.sessionId) : this.requireCurrentSession(contextKey);
6559
+ return this.settingsOperations.run(record.localSessionId, () => this.permissionsNow(contextKey, mode, { ...options, sessionId: record.localSessionId }));
6560
+ }
6561
+
6562
+ private async permissionsNow(
6104
6563
  contextKey: string,
6105
6564
  mode?: PermissionMode,
6106
6565
  options: ModelCardOptions = {},
6107
6566
  ): Promise<void> {
6108
6567
  if (!mode) {
6109
- await this.openExecutionSettings(contextKey, "permission", options);
6568
+ await this.openExecutionSettingsNow(contextKey, "permission", options);
6110
6569
  return;
6111
6570
  }
6112
6571
  const record = options.sessionId
6113
6572
  ? this.requireSession(contextKey, options.sessionId)
6114
6573
  : this.requireCurrentSession(contextKey);
6115
- const loaded = await this.loadSession(record);
6116
- await loaded.runtime.setPermissionMode(record.localSessionId, mode);
6117
- loaded.session.permissionMode = mode;
6118
- this.store.updateRuntimeSession(record.localSessionId, { permissionMode: mode });
6119
- await this.persistAgentExecutionDefaults(record.localSessionId);
6574
+ const loaded = await this.loadSession(record, true);
6575
+ const pending = this.store.getPendingProviderSettings(record.localSessionId);
6576
+ if (pending) {
6577
+ await this.savePendingProviderSelection(loaded, { ...pending.settings, permissionMode: mode }, contextKey, options, "permission");
6578
+ return;
6579
+ }
6580
+ await this.sessionHydrationOperations.run(record.localSessionId, async () => {
6581
+ await loaded.runtime.setPermissionMode(record.localSessionId, mode);
6582
+ loaded.session = loaded.runtime.getSession(record.localSessionId) ?? loaded.session;
6583
+ loaded.session.permissionMode = mode;
6584
+ this.store.updateRuntimeSession(record.localSessionId, { permissionMode: mode });
6585
+ await this.persistAgentExecutionDefaults(record.localSessionId);
6586
+ });
6120
6587
  if (options.updateMessageId) {
6121
- await this.openExecutionSettings(contextKey, "permission", {
6588
+ await this.openExecutionSettingsNow(contextKey, "permission", {
6122
6589
  sessionId: loaded.record.localSessionId,
6123
6590
  updateMessageId: options.updateMessageId,
6124
6591
  notice: mode === "auto"
@@ -6163,7 +6630,8 @@ export class ProxySessionController {
6163
6630
  const existing = this.turnDetailHydrations.get(turnId);
6164
6631
  if (existing) return existing;
6165
6632
  const snapshot = turnViewSnapshot(this.store.getTurnSnapshot(turnId));
6166
- if ((!snapshot || snapshot.previewJournal) && this.store.previews.has(turnId)) return this.store.previews.load(turnId);
6633
+ const previewTurnId = snapshot?.previewTurnId ?? turnId;
6634
+ if ((!snapshot || snapshot.previewJournal) && this.store.previews.has(previewTurnId)) return this.store.previews.load(previewTurnId);
6167
6635
  if (snapshot && !needsHistoricalDetails(snapshot)) return snapshot;
6168
6636
  const origin = !snapshot ? this.store.getTurnRuntimeOrigin(turnId) : undefined;
6169
6637
  if (!snapshot && (!origin || !this.runtimes.forAgent(origin.agentName).readRemoteTurn)) return undefined;
@@ -6189,7 +6657,7 @@ export class ProxySessionController {
6189
6657
  }
6190
6658
  // An Agent Bot turn may have arrived while the read-only request was in flight.
6191
6659
  const latest = turnViewSnapshot(this.store.getTurnSnapshot(origin.turnId));
6192
- if (latest) return latest.previewJournal ? this.store.previews.load(origin.turnId) ?? latest : latest;
6660
+ if (latest) return latest.previewJournal ? this.store.previews.load(latest.previewTurnId ?? origin.turnId) ?? latest : latest;
6193
6661
  const state = hydrateTurnViewState(createTurnViewState(
6194
6662
  origin.localSessionId, origin.turnId, details.startedAt ?? 0,
6195
6663
  remote?.title, undefined, remote?.cwd, undefined, this.agentLabel(origin.agentName),
@@ -6754,20 +7222,28 @@ export class ProxySessionController {
6754
7222
  const project = taskProjectInfo(entry);
6755
7223
  const projectActionSource = projectActionSources.get(project.key) ?? entry;
6756
7224
  const marker = entry.current ? "✅ " : entry.active ? "🟢 " : "";
6757
- const showStop = entry.status === "外部执行中"
6758
- && entry.reference !== options.forceSwitchTaskId
6759
- && entry.id !== options.forceSwitchTaskId;
6760
- const actions: TaskListCardAction[] = entry.current ? [] : [bindCardAction({
6761
- text: showStop ? "Stop" : "Switch",
6762
- type: showStop ? "danger" as const : "default" as const,
7225
+ const stopAction = entry.active ? bindCardAction({
7226
+ text: "Stop",
7227
+ type: "danger",
6763
7228
  value: {
6764
- action: showStop ? "session_stop" : "session_switch",
7229
+ action: "session_stop",
6765
7230
  sessionId: entry.reference,
6766
7231
  ...(searchTerm ? { searchTerm } : {}),
6767
7232
  page: String(page),
6768
7233
  contextKey,
6769
7234
  },
6770
- })];
7235
+ }) : undefined;
7236
+ const actions: TaskListCardAction[] = [];
7237
+ if (!entry.current && entry.status !== "外部执行中") actions.push(bindCardAction({
7238
+ text: "Switch",
7239
+ value: {
7240
+ action: "session_switch",
7241
+ sessionId: entry.reference,
7242
+ ...(searchTerm ? { searchTerm } : {}),
7243
+ page: String(page),
7244
+ contextKey,
7245
+ },
7246
+ }));
6771
7247
  actions.push(bindCardAction({
6772
7248
  text: "SwitchGroup",
6773
7249
  value: {
@@ -6833,7 +7309,7 @@ export class ProxySessionController {
6833
7309
  contextKey,
6834
7310
  },
6835
7311
  }));
6836
- actions.push(bindCardAction({
7312
+ if (!entry.active) actions.push(bindCardAction({
6837
7313
  text: "Archive",
6838
7314
  value: {
6839
7315
  action: "session_archive",
@@ -6855,6 +7331,7 @@ export class ProxySessionController {
6855
7331
  `**最新 Prompt**:${cardText(lastUserPrompt)}`,
6856
7332
  `<font color='grey'>最近更新:${cardText(entry.updatedLabel)}</font>`,
6857
7333
  ],
7334
+ stopAction,
6858
7335
  actions,
6859
7336
  current: entry.current,
6860
7337
  },
@@ -6884,7 +7361,7 @@ export class ProxySessionController {
6884
7361
  `第 ${page + 1} 页 · 每页 ${SESSION_PAGE_SIZE} 个任务${hasNext ? "" : ` · 当前共 ${entries.length} 个任务`}`,
6885
7362
  "",
6886
7363
  "> 项目菜单:**New** 新建任务,**NewGroup** 新建群。",
6887
- "> 任务详情:**Switch** 切换,**Stop** 停止,**Fork** / **ForkGroup** 创建分支,**Status** 查看状态,**Archive** 归档。",
7364
+ "> 任务详情:**Switch** 切换,运行中任务可用 **Stop** 发送停止信号,**Fork** / **ForkGroup** 创建分支,**Status** 查看状态,**Archive** 归档。",
6888
7365
  ],
6889
7366
  [
6890
7367
  ...(hasPrevious ? [bindCardAction({
@@ -6959,7 +7436,7 @@ export class ProxySessionController {
6959
7436
 
6960
7437
  private async refreshSessionsCardFromAction(
6961
7438
  action: CardAction,
6962
- options: { forceSwitchTaskId?: string; page?: number } = {},
7439
+ options: { page?: number } = {},
6963
7440
  ): Promise<void> {
6964
7441
  if (!action.messageId) return;
6965
7442
  const searchTerm = typeof action.value.searchTerm === "string" && action.value.searchTerm.trim()
@@ -6967,7 +7444,6 @@ export class ProxySessionController {
6967
7444
  : undefined;
6968
7445
  await this.listSessions(action.contextKey, searchTerm, {
6969
7446
  updateMessageId: action.messageId,
6970
- forceSwitchTaskId: options.forceSwitchTaskId,
6971
7447
  page: options.page ?? parseSessionPage(action.value.page),
6972
7448
  });
6973
7449
  }
@@ -7156,7 +7632,7 @@ export class ProxySessionController {
7156
7632
  const remoteReference = local
7157
7633
  ? remoteSessionReference(local.agentName, local.remoteSessionId!)
7158
7634
  : taskId;
7159
- const { agentName, runtime, remote } = await this.resolveRemoteCodexSession(remoteReference);
7635
+ const { agentName, runtime, remote } = await this.resolveRemoteCodexSession(remoteReference, { view: "metadata" });
7160
7636
  if (isRemoteSessionActive(remote)) {
7161
7637
  throw new Error("任务正在执行,无法归档。请先停止任务或等待本轮完成。");
7162
7638
  }
@@ -7165,7 +7641,11 @@ export class ProxySessionController {
7165
7641
  }
7166
7642
 
7167
7643
  const wasCurrent = Boolean(local && this.store.getUserContext(contextKey)?.currentSessionId === local.localSessionId);
7168
- await runtime.archiveRemoteSession(remote.id);
7644
+ const allowUnmaterialized = Boolean(local && !local.lastTurnId && !local.lastTurnStatus
7645
+ && !this.store.hasTurnAttemptsForSession(local.localSessionId)
7646
+ && !this.store.getForkHistorySource(local.localSessionId)
7647
+ && this.store.listTaskTurnGraphIndex(local.localSessionId).length === 0);
7648
+ await runtime.archiveRemoteSession(remote.id, { allowUnmaterialized });
7169
7649
  const title = local?.title ?? remote.title ?? remote.preview ?? remote.id;
7170
7650
  if (local) {
7171
7651
  this.store.archiveSession(local.localSessionId);
@@ -7318,6 +7798,7 @@ export class ProxySessionController {
7318
7798
  }
7319
7799
  const localActiveTurnId = runtime.getSession(record.localSessionId)?.activeTurnId;
7320
7800
  const runtimeOwnsActiveTurn = Boolean(localActiveTurnId)
7801
+ && (!this.settingsOnlySessions.has(record.localSessionId) || Boolean(activity.activeTurnId))
7321
7802
  && (!activity.activeTurnId || activity.activeTurnId === localActiveTurnId);
7322
7803
  const persistedTurnMatches = record.status === "running"
7323
7804
  && record.lastTurnStatus === "running"
@@ -7719,6 +8200,12 @@ export class ProxySessionController {
7719
8200
  }
7720
8201
 
7721
8202
  private async persistAgentExecutionDefaults(localSessionId: string, candidate?: RuntimeSession): Promise<void> {
8203
+ const record = this.store.getSession(localSessionId);
8204
+ if (!record) throw new Error(`找不到任务:${localSessionId}`);
8205
+ return this.agentDefaultsOperations.run(record.agentName, () => this.persistAgentExecutionDefaultsNow(localSessionId, candidate));
8206
+ }
8207
+
8208
+ private async persistAgentExecutionDefaultsNow(localSessionId: string, candidate?: RuntimeSession): Promise<void> {
7722
8209
  const record = this.store.getSession(localSessionId);
7723
8210
  if (!record) throw new Error(`找不到任务:${localSessionId}`);
7724
8211
  const session = candidate ?? record;
@@ -8848,6 +9335,13 @@ function executionSettingsTabValue(value: unknown): ExecutionSettingsTab {
8848
9335
  throw new Error("设置卡片的 tab 无效,请重新发送设置命令。");
8849
9336
  }
8850
9337
 
9338
+ function modelCardPageValue(value: unknown): number | undefined {
9339
+ if (value === undefined) return undefined;
9340
+ const page = typeof value === "number" || (typeof value === "string" && value.trim()) ? Number(value) : NaN;
9341
+ if (!Number.isSafeInteger(page) || page < 0) throw new Error("模型卡片页码无效,请重新发送 /model。");
9342
+ return page;
9343
+ }
9344
+
8851
9345
  function resetHistoryPageValue(value: unknown): number {
8852
9346
  const page = Number(value);
8853
9347
  if (!Number.isSafeInteger(page) || page < 0) throw new Error("历史轮次卡片页码无效,请重新发送 /turns。");
@@ -9255,10 +9749,18 @@ function defaultResourcePromptFromCounts(imageCount: number, fileCount: number):
9255
9749
  return imageCount === 1 ? "请查看这张图片" : "请查看这些图片";
9256
9750
  }
9257
9751
 
9258
- function isStandaloneResourceMessage(message: IncomingMessage): boolean {
9752
+ function isCommandMessage(message: IncomingMessage): boolean {
9753
+ const text = normalizeFeishuPostText(message.text).trimStart();
9754
+ return text.startsWith("/") || text.startsWith("!");
9755
+ }
9756
+
9757
+ function isForwardAttachmentSource(message: IncomingMessage): boolean {
9259
9758
  return !message.mergedForwardMessageId
9260
- && message.text.trim().length === 0
9261
- && ((message.images?.length ?? 0) > 0 || (message.files?.length ?? 0) > 0);
9759
+ && !message.parentMessageId
9760
+ && !message.contextKey.startsWith("console:")
9761
+ && !isCommandMessage(message)
9762
+ && (message.messageType === "post"
9763
+ || (message.images?.length ?? 0) > 0 || (message.files?.length ?? 0) > 0);
9262
9764
  }
9263
9765
 
9264
9766
  function deduplicateImageReferences(
@@ -9335,7 +9837,8 @@ function isRetryableLlmTurnFailure(message: string): boolean {
9335
9837
  const normalized = message.trim().toLowerCase();
9336
9838
  if (!normalized) return false;
9337
9839
  const permanentFailure = [
9338
- /auth(?:entication|orization)?\b|unauthorized|forbidden|invalid api key|incorrect api key/u,
9840
+ /\b(?:auth|authenticat\w*|unauthenticat\w*|authoriz\w*|unauthorized|forbidden)\b|invalid api key|incorrect api key/u,
9841
+ /(?:^|\D)(?:401|403)(?:\D|$)/u,
9339
9842
  /insufficient[_ -]?quota|quota (?:exceeded|exhausted)|usage limit|billing|credit balance/u,
9340
9843
  /context (?:length|window)|maximum context|too many tokens|token limit/u,
9341
9844
  /invalid request|bad request|invalid argument|malformed|unsupported|not supported/u,