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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +27 -3
  3. package/README.zh.md +27 -3
  4. package/SECURITY.md +12 -0
  5. package/config.example.yaml +5 -0
  6. package/dist/cli/controlProtocol.js.map +1 -1
  7. package/dist/cli/serverStatus.js +1 -0
  8. package/dist/cli/serverStatus.js.map +1 -1
  9. package/dist/cli.js +6 -1
  10. package/dist/cli.js.map +1 -1
  11. package/dist/codex/CodexRuntime.js +202 -115
  12. package/dist/codex/CodexRuntime.js.map +1 -1
  13. package/dist/codex/ProviderSwitch.js +12 -13
  14. package/dist/codex/ProviderSwitch.js.map +1 -1
  15. package/dist/config/AgentExecutionDefaultsStore.js +4 -0
  16. package/dist/config/AgentExecutionDefaultsStore.js.map +1 -1
  17. package/dist/config/loadConfig.js +4 -0
  18. package/dist/config/loadConfig.js.map +1 -1
  19. package/dist/config/schema.js +3 -0
  20. package/dist/config/schema.js.map +1 -1
  21. package/dist/feishu/CardRenderer.js +66 -8
  22. package/dist/feishu/CardRenderer.js.map +1 -1
  23. package/dist/feishu/FeishuConnector.js +1 -0
  24. package/dist/feishu/FeishuConnector.js.map +1 -1
  25. package/dist/feishu/FeishuTurnPresenter.js +121 -17
  26. package/dist/feishu/FeishuTurnPresenter.js.map +1 -1
  27. package/dist/index.js +43 -6
  28. package/dist/index.js.map +1 -1
  29. package/dist/local-files/LocalFileViewerServer.js +51 -4
  30. package/dist/local-files/LocalFileViewerServer.js.map +1 -1
  31. package/dist/local-files/TurnPreviewPage.js +7 -2
  32. package/dist/local-files/TurnPreviewPage.js.map +1 -1
  33. package/dist/presentation/TurnStateReducer.js +41 -6
  34. package/dist/presentation/TurnStateReducer.js.map +1 -1
  35. package/dist/proxy/ProxySessionController.js +615 -214
  36. package/dist/proxy/ProxySessionController.js.map +1 -1
  37. package/dist/proxy/SessionOperationQueue.js +14 -0
  38. package/dist/proxy/SessionOperationQueue.js.map +1 -0
  39. package/dist/runtime/AcpRuntimeAdapter.js +4 -2
  40. package/dist/runtime/AcpRuntimeAdapter.js.map +1 -1
  41. package/dist/state/StateStore.js +22 -0
  42. package/dist/state/StateStore.js.map +1 -1
  43. package/dist/state/TurnPreviewJournal.js +19 -7
  44. package/dist/state/TurnPreviewJournal.js.map +1 -1
  45. package/dist/state/migrations.js +6 -0
  46. package/dist/state/migrations.js.map +1 -1
  47. package/dist/supervision/SafeRestartNotifier.js +1 -0
  48. package/dist/supervision/SafeRestartNotifier.js.map +1 -1
  49. package/dist/supervision/SafeRestartScheduler.js +19 -6
  50. package/dist/supervision/SafeRestartScheduler.js.map +1 -1
  51. package/dist/terminal/TerminalCards.js +13 -0
  52. package/dist/terminal/TerminalCards.js.map +1 -0
  53. package/dist/terminal/TerminalLinks.js +76 -0
  54. package/dist/terminal/TerminalLinks.js.map +1 -0
  55. package/dist/terminal/TerminalPage.js +212 -0
  56. package/dist/terminal/TerminalPage.js.map +1 -0
  57. package/dist/terminal/TerminalProcess.js +71 -0
  58. package/dist/terminal/TerminalProcess.js.map +1 -0
  59. package/dist/terminal/TerminalServer.js +639 -0
  60. package/dist/terminal/TerminalServer.js.map +1 -0
  61. package/docs/technical-reference.md +48 -0
  62. package/docs/technical-reference.zh.md +48 -0
  63. package/npm-shrinkwrap.json +55 -5
  64. package/package.json +11 -4
  65. package/skills/agent-bot/SKILL.md +9 -2
  66. package/src/cli/controlProtocol.ts +2 -1
  67. package/src/cli/serverStatus.ts +1 -0
  68. package/src/cli.ts +6 -1
  69. package/src/codex/CodexRuntime.ts +200 -123
  70. package/src/codex/ProviderSwitch.ts +11 -12
  71. package/src/config/AgentExecutionDefaultsStore.ts +2 -0
  72. package/src/config/loadConfig.ts +4 -0
  73. package/src/config/schema.ts +5 -1
  74. package/src/feishu/CardRenderer.ts +81 -8
  75. package/src/feishu/FeishuConnector.ts +1 -0
  76. package/src/feishu/FeishuTurnPresenter.ts +108 -16
  77. package/src/feishu/types.ts +1 -0
  78. package/src/index.ts +44 -7
  79. package/src/local-files/LocalFileViewerServer.ts +48 -4
  80. package/src/local-files/TurnPreviewPage.ts +7 -2
  81. package/src/presentation/TurnStateReducer.ts +46 -7
  82. package/src/presentation/turnViewTypes.ts +5 -0
  83. package/src/proxy/ProxySessionController.ts +625 -211
  84. package/src/proxy/SessionOperationQueue.ts +13 -0
  85. package/src/runtime/AcpRuntimeAdapter.ts +5 -3
  86. package/src/runtime/types.ts +8 -3
  87. package/src/state/StateStore.ts +38 -1
  88. package/src/state/TurnPreviewJournal.ts +19 -7
  89. package/src/state/migrations.ts +6 -0
  90. package/src/supervision/SafeRestartNotifier.ts +1 -0
  91. package/src/supervision/SafeRestartScheduler.ts +18 -6
  92. package/src/terminal/TerminalCards.ts +18 -0
  93. package/src/terminal/TerminalLinks.ts +61 -0
  94. package/src/terminal/TerminalPage.ts +213 -0
  95. package/src/terminal/TerminalProcess.ts +71 -0
  96. package/src/terminal/TerminalServer.ts +513 -0
@@ -4,7 +4,7 @@ import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { createProjectlessWorkspace, detectProjectlessWorkspace } from "../codex/ProjectlessWorkspace.js";
6
6
  import { CodexVersionError } from "../codex/CodexVersion.js";
7
- import { assertProviderSettingsApplied, isMissingRolloutError } from "../codex/ProviderSwitch.js";
7
+ import { assertProviderSettingsApplied, ProviderSwitchError } from "../codex/ProviderSwitch.js";
8
8
  import { SystemThreadWriterProcessController, } from "../codex/ThreadWriterProcess.js";
9
9
  import { resolveUserPath } from "../config/paths.js";
10
10
  import { DEFAULT_GROUP_NAME_FORMAT, } from "../config/schema.js";
@@ -22,6 +22,7 @@ import { errorLogValue } from "../logging/errorLogValue.js";
22
22
  import { createTurnViewState, hydrateTurnViewState } from "../presentation/TurnStateReducer.js";
23
23
  import { buildTurnGraphRows } from "../presentation/turnGraph.js";
24
24
  import { isActiveShellCommandJob, ShellCommandJobManager, } from "../shell/ShellCommandJobManager.js";
25
+ import { SessionOperationQueue } from "./SessionOperationQueue.js";
25
26
  import { createId } from "../utils/id.js";
26
27
  import { formatStorageSize } from "../utils/formatStorageSize.js";
27
28
  import { truncateMiddle, truncateText } from "../utils/markdown.js";
@@ -53,7 +54,7 @@ const RECOVERY_HEARTBEAT_INTERVAL_MS = 60 * 1_000;
53
54
  const SHELL_COMMAND_JOB_POLL_INTERVAL_MS = 1_000;
54
55
  const SHELL_COMMAND_JOB_RELAUNCH_DELAY_MS = 5_000;
55
56
  const APP_SERVER_RELEASE_POLL_INTERVAL_MS = 500;
56
- const FORWARD_ATTACHMENT_WINDOW_MS = 800;
57
+ const FORWARD_ATTACHMENT_WINDOW_MS = 1_500;
57
58
  const DEFAULT_MERGED_FORWARD_INSTRUCTION = "请参考以下内容回复用户";
58
59
  const DEFAULT_REFERENCED_MESSAGE_INSTRUCTION = "请参考引用消息回复用户";
59
60
  const TURN_STOP_REQUESTED_MESSAGE = "已发送停止信号,任务会自动停止。";
@@ -227,9 +228,14 @@ export class ProxySessionController {
227
228
  messageQueues = new Map();
228
229
  switchGroupOperations = new Set();
229
230
  sessionLoads = new Map();
231
+ settingsOnlySessions = new Set();
230
232
  queuedPromptStarts = new Map();
231
233
  queuedPromptCards = new Map();
232
234
  queuedPromptCardWrites = new Map();
235
+ settingsOperations = new SessionOperationQueue();
236
+ // Loads may run inside settingsOperations; never acquire that queue while hydrating.
237
+ sessionHydrationOperations = new SessionOperationQueue();
238
+ agentDefaultsOperations = new SessionOperationQueue();
233
239
  sessionResets = new Map();
234
240
  resetHistoryOperations = new Map();
235
241
  remoteTurnHistoryRefreshes = new Map();
@@ -239,6 +245,7 @@ export class ProxySessionController {
239
245
  lastSessionListings = new Map();
240
246
  threadInitializations = new Map();
241
247
  llmRetryingSessions = new Set();
248
+ planHandoffs = new Map();
242
249
  retriedFailureTurnIds = new Set();
243
250
  queuedPromptRetryTimers = new Map();
244
251
  recoveryRetryTimers = new Map();
@@ -304,7 +311,7 @@ export class ProxySessionController {
304
311
  const pendingMergedForward = message.mergedForwardMessageId
305
312
  ? this.registerPendingForwardAttachment(message, this.pendingMergedForwards)
306
313
  : undefined;
307
- const pendingResourceForward = !attachmentReservation && isStandaloneResourceMessage(message)
314
+ const pendingResourceForward = !attachmentReservation && isForwardAttachmentSource(message)
308
315
  ? this.registerPendingForwardAttachment(message, this.pendingResourceForwards)
309
316
  : undefined;
310
317
  try {
@@ -342,7 +349,9 @@ export class ProxySessionController {
342
349
  const attachment = await this.waitForForwardAttachment(message.messageId, pendingResourceForward, this.pendingResourceForwards);
343
350
  if (attachment) {
344
351
  coalescedForwardAttachment = true;
345
- const instruction = normalizeFeishuPostText(attachment.text) || defaultResourcePrompt(resourceMessage);
352
+ const sourceText = resourceMessage.text.trim();
353
+ const instruction = normalizeFeishuPostText(attachment.text)
354
+ || (sourceText ? DEFAULT_REFERENCED_MESSAGE_INSTRUCTION : defaultResourcePrompt(resourceMessage));
346
355
  forwardedImages = (resourceMessage.images ?? []).map((image) => ({
347
356
  messageId: resourceMessage.messageId,
348
357
  imageKey: image.imageKey,
@@ -354,7 +363,7 @@ export class ProxySessionController {
354
363
  }));
355
364
  message = {
356
365
  ...attachment,
357
- text: instruction,
366
+ text: sourceText ? referencedMessagePrompt(instruction, sourceText) : instruction,
358
367
  displayText: instruction,
359
368
  };
360
369
  this.relatedReactionMessageIds.set(message.messageId, [resourceMessage.messageId]);
@@ -623,7 +632,12 @@ export class ProxySessionController {
623
632
  await this.outbound.withReplyTarget(contextKey, replyTarget, async () => {
624
633
  try {
625
634
  const kind = String(scopedAction.value.action ?? "");
626
- if (kind === "agentbot_update_cancel") {
635
+ if (kind === "terminal_authorize") {
636
+ if (!this.lifecycle?.authorizeTerminal)
637
+ throw new Error("网页终端未启用。");
638
+ await this.outbound.sendText(contextKey, await this.lifecycle.authorizeTerminal(scopedAction));
639
+ }
640
+ else if (kind === "agentbot_update_cancel") {
627
641
  if (!this.lifecycle?.cancelAutomaticUpdate)
628
642
  throw new Error("自动更新功能未启用。");
629
643
  await this.lifecycle.cancelAutomaticUpdate(scopedAction);
@@ -842,9 +856,11 @@ export class ProxySessionController {
842
856
  await this.refreshStatusCardFromAction(scopedAction);
843
857
  }
844
858
  else if (kind === "settings_tab_open") {
859
+ const sessionId = String(scopedAction.value.sessionId ?? "");
845
860
  await this.openExecutionSettings(contextKey, executionSettingsTabValue(scopedAction.value.tab), {
846
- sessionId: String(scopedAction.value.sessionId ?? ""),
847
- updateMessageId: scopedAction.messageId,
861
+ sessionId, updateMessageId: scopedAction.messageId,
862
+ modelPage: modelCardPageValue(scopedAction.value.page),
863
+ expectedProvider: typeof scopedAction.value.provider === "string" ? scopedAction.value.provider : undefined,
848
864
  });
849
865
  }
850
866
  else if (kind === "settings_agent_select") {
@@ -861,12 +877,14 @@ export class ProxySessionController {
861
877
  }
862
878
  else if (kind === "settings_model_select") {
863
879
  await this.model(contextKey, String(scopedAction.value.model ?? ""), {
880
+ expectedProvider: String(scopedAction.value.provider ?? ""),
864
881
  sessionId: String(scopedAction.value.sessionId ?? ""),
865
882
  updateMessageId: scopedAction.messageId,
866
883
  });
867
884
  }
868
885
  else if (kind === "settings_thinking_select") {
869
886
  await this.thinking(contextKey, String(scopedAction.value.effort ?? ""), {
887
+ expectedProvider: String(scopedAction.value.provider ?? ""),
870
888
  sessionId: String(scopedAction.value.sessionId ?? ""),
871
889
  updateMessageId: scopedAction.messageId,
872
890
  expectedModel: String(scopedAction.value.model ?? ""),
@@ -891,6 +909,7 @@ export class ProxySessionController {
891
909
  await this.model(contextKey, undefined, {
892
910
  sessionId,
893
911
  updateMessageId: scopedAction.messageId,
912
+ modelPage: modelCardPageValue(scopedAction.value.page),
894
913
  });
895
914
  }
896
915
  else if (kind === "reasoning_select") {
@@ -910,7 +929,7 @@ export class ProxySessionController {
910
929
  });
911
930
  }
912
931
  else if (kind === "provider_select" || kind === "provider_model_open") {
913
- await this.openProviderModelSelector(contextKey, String(scopedAction.value.provider ?? ""), permissionModeValue(scopedAction.value.permissionMode), String(scopedAction.value.sessionId ?? ""), requiredCardMessageId(scopedAction.messageId));
932
+ await this.openProviderModelSelector(contextKey, String(scopedAction.value.provider ?? ""), permissionModeValue(scopedAction.value.permissionMode), String(scopedAction.value.sessionId ?? ""), requiredCardMessageId(scopedAction.messageId), modelCardPageValue(scopedAction.value.page));
914
933
  }
915
934
  else if (kind === "provider_model_select" || kind === "provider_reasoning_open") {
916
935
  await this.openProviderReasoningSelector(contextKey, String(scopedAction.value.provider ?? ""), String(scopedAction.value.model ?? ""), permissionModeValue(scopedAction.value.permissionMode), String(scopedAction.value.sessionId ?? ""), requiredCardMessageId(scopedAction.messageId));
@@ -965,6 +984,7 @@ export class ProxySessionController {
965
984
  this.queuedPromptCards.clear();
966
985
  this.queuedPromptCardWrites.clear();
967
986
  this.sessionResets.clear();
987
+ this.planHandoffs.clear();
968
988
  this.resetHistoryOperations.clear();
969
989
  this.remoteTurnHistoryRefreshes.clear();
970
990
  this.forkHistoryCursors.clear();
@@ -1270,78 +1290,43 @@ export class ProxySessionController {
1270
1290
  if (setting && !value?.trim())
1271
1291
  throw new Error(`task ${setting} requires a value.`);
1272
1292
  const nextValue = value?.trim();
1273
- if (setting === "provider" && nextValue) {
1293
+ if (setting && nextValue) {
1274
1294
  const contextKey = this.controlSessionContextKey(record);
1275
- await this.outbound.withReplyTarget(contextKey, this.controlSessionReplyTarget(record), () => this.outbound.sendText(contextKey, `正在切换到 Provider ${nextValue},请稍后。`));
1276
- }
1277
- const loaded = await this.loadSession(record);
1278
- if (setting === "provider" && nextValue) {
1279
- await this.assertModelProvider(loaded, nextValue);
1280
- const models = await loaded.runtime.listModels(nextValue, loaded.session.model);
1281
- const model = selectProviderModel(models, loaded.session.model);
1282
- if (!model)
1283
- throw new Error("The current runtime has no model available for a Provider change.");
1284
- const effort = selectProviderReasoningEffort(model, loaded.session.reasoningEffort);
1285
- await this.changeProviderSettings(loaded, {
1286
- modelProvider: nextValue,
1287
- model: model.id,
1288
- reasoningEffort: effort,
1289
- permissionMode: loaded.session.permissionMode,
1295
+ await this.outbound.withReplyTarget(contextKey, this.controlSessionReplyTarget(record), async () => {
1296
+ const options = { sessionId: record.localSessionId };
1297
+ if (setting === "provider")
1298
+ await this.selectProvider(contextKey, nextValue, options);
1299
+ else if (setting === "model")
1300
+ await this.model(contextKey, nextValue, options);
1301
+ else if (setting === "thinking")
1302
+ await this.thinking(contextKey, nextValue, options);
1303
+ else {
1304
+ if (nextValue !== "auto" && nextValue !== "confirm")
1305
+ throw new Error("task permissions accepts auto or confirm.");
1306
+ await this.permissions(contextKey, nextValue, options);
1307
+ }
1290
1308
  });
1291
1309
  }
1292
- else if (setting === "model" && nextValue) {
1293
- const models = await loaded.runtime.listModels(loaded.session.modelProvider, loaded.session.model);
1294
- const selected = models.find((candidate) => candidate.id === nextValue);
1295
- if (!selected)
1296
- throw new Error(`Unknown model: ${nextValue}`);
1297
- const currentEffort = loaded.session.reasoningEffort;
1298
- const nextEffort = currentEffort && (selected.supportedReasoningEfforts.length === 0
1299
- || selected.supportedReasoningEfforts.some((candidate) => candidate.value === currentEffort))
1300
- ? currentEffort
1301
- : selected.defaultReasoningEffort;
1302
- await loaded.runtime.setModel(record.localSessionId, nextValue);
1303
- if (nextEffort && nextEffort !== currentEffort) {
1304
- await loaded.runtime.setReasoningEffort(record.localSessionId, nextEffort);
1305
- }
1306
- this.store.updateRuntimeSession(record.localSessionId, { model: nextValue, reasoningEffort: nextEffort });
1307
- this.outbound.updateSessionModel(record.localSessionId, nextValue, loaded.session.modelProvider);
1308
- }
1309
- else if (setting === "thinking" && nextValue) {
1310
- const models = await loaded.runtime.listModels(loaded.session.modelProvider, loaded.session.model);
1311
- const currentModel = models.find((candidate) => candidate.id === loaded.session.model)
1310
+ return this.settingsOperations.run(localSessionId, async () => {
1311
+ const loaded = await this.loadSession(this.store.getSession(localSessionId) ?? record, true);
1312
+ const session = this.store.getSession(record.localSessionId) ?? record;
1313
+ const pending = this.store.getPendingProviderSettings(record.localSessionId);
1314
+ const selected = pending?.settings ?? session;
1315
+ const models = await loaded.runtime.listModels(selected.modelProvider, selected.model);
1316
+ const currentModel = models.find((candidate) => candidate.id === selected.model)
1312
1317
  ?? models.find((candidate) => candidate.isDefault);
1313
- if (!currentModel)
1314
- throw new Error("The current runtime has no model with configurable reasoning effort.");
1315
- if (!currentModel.supportedReasoningEfforts.some((candidate) => candidate.value === nextValue)) {
1316
- const supported = currentModel.supportedReasoningEfforts.map((candidate) => candidate.value).join(", ") || "none";
1317
- throw new Error(`Unsupported reasoning effort: ${nextValue}. Supported values: ${supported}.`);
1318
- }
1319
- await loaded.runtime.setReasoningEffort(record.localSessionId, nextValue);
1320
- this.store.updateRuntimeSession(record.localSessionId, { reasoningEffort: nextValue });
1321
- }
1322
- else if (setting === "permissions" && nextValue) {
1323
- if (nextValue !== "auto" && nextValue !== "confirm") {
1324
- throw new Error("task permissions accepts auto or confirm.");
1325
- }
1326
- await loaded.runtime.setPermissionMode(record.localSessionId, nextValue);
1327
- this.store.updateRuntimeSession(record.localSessionId, { permissionMode: nextValue });
1328
- }
1329
- const session = this.store.getSession(record.localSessionId) ?? record;
1330
- if (setting && setting !== "provider")
1331
- await this.persistAgentExecutionDefaults(session.localSessionId);
1332
- const models = await loaded.runtime.listModels(session.modelProvider, session.model);
1333
- const currentModel = models.find((candidate) => candidate.id === session.model)
1334
- ?? models.find((candidate) => candidate.isDefault);
1335
- const providers = loaded.runtime.listModelProviders
1336
- ? await this.modelProviderOptions(loaded)
1337
- : [];
1338
- return {
1339
- session,
1340
- providers,
1341
- models,
1342
- reasoningOptions: currentModel?.supportedReasoningEfforts ?? [],
1343
- permissionModes: ["auto", "confirm"],
1344
- };
1318
+ const providers = loaded.runtime.listModelProviders
1319
+ ? await this.modelProviderOptions(loaded)
1320
+ : [];
1321
+ return {
1322
+ session,
1323
+ pendingProvider: pending,
1324
+ providers,
1325
+ models,
1326
+ reasoningOptions: currentModel?.supportedReasoningEfforts ?? [],
1327
+ permissionModes: ["auto", "confirm"],
1328
+ };
1329
+ });
1345
1330
  }
1346
1331
  async controlTaskGoal(localSessionId, action, objective) {
1347
1332
  const record = this.requireControlSession(localSessionId);
@@ -1746,6 +1731,9 @@ export class ProxySessionController {
1746
1731
  this.recoveryRetryStartedAt.delete(attempt.attemptId);
1747
1732
  }
1748
1733
  }
1734
+ for (const pending of this.store.listPendingProviderSettings()) {
1735
+ await this.applyPendingProviderWhenIdle(pending.localSessionId);
1736
+ }
1749
1737
  this.restorePersistedQueuedPrompts();
1750
1738
  }
1751
1739
  async finishPersistedCancellationRequests() {
@@ -2051,6 +2039,17 @@ export class ProxySessionController {
2051
2039
  const pendingAttempt = this.store.getTurnAttempt(attempt.attemptId);
2052
2040
  if (!pendingAttempt || !isIncompleteTurnAttemptStatus(pendingAttempt.status))
2053
2041
  return;
2042
+ const continued = turnViewSnapshot(this.store.getTurnSnapshot(turnId));
2043
+ if (continued?.continuedFromTurnId && this.store.getTurnDelivery(turnId)?.progressMessageId) {
2044
+ this.store.updateTurnAttempt(attempt.attemptId, { pendingTurnId: null, turnId, status: "running" });
2045
+ await this.outbound.onEvent({ type: "turn_started", sessionId: session.localSessionId,
2046
+ turnId, startedAt: continued.startedAt });
2047
+ const synchronized = await loaded.runtime.synchronizeSession(session.localSessionId);
2048
+ this.recoveryRetryStartedAt.delete(attempt.attemptId);
2049
+ if (synchronized.activeTurnId)
2050
+ this.recoveredLiveAttempts.add(attempt.attemptId);
2051
+ return;
2052
+ }
2054
2053
  const oldCardTurnId = attempt.pendingTurnId ?? attempt.turnId;
2055
2054
  if (oldCardTurnId) {
2056
2055
  await this.outbound.interruptTurnForRecovery(session.localSessionId, attempt.contextKey, oldCardTurnId, "Agent Bot 已重启,执行仍在继续;进度已转移到新的思考卡片。");
@@ -2091,7 +2090,7 @@ export class ProxySessionController {
2091
2090
  });
2092
2091
  }
2093
2092
  const current = this.store.getSession(session.localSessionId) ?? session;
2094
- const loaded = await this.loadSession({ ...current, contextKey: attempt.contextKey });
2093
+ const loaded = await this.loadSession({ ...current, contextKey: attempt.contextKey }, Boolean(this.store.getPendingProviderSettings(current.localSessionId)));
2095
2094
  const recoveryText = recoveryRuntimePrompt(attempt.promptText);
2096
2095
  const turnId = await this.startTurn(loaded, recoveryText, replyTarget, attempt.localImagePaths, {
2097
2096
  attemptId: attempt.attemptId,
@@ -2130,7 +2129,7 @@ export class ProxySessionController {
2130
2129
  await this.promptSession(record, contextKey, text, messageId, replyTarget, localImagePaths, displayPrompt);
2131
2130
  }
2132
2131
  async promptSession(record, contextKey, text, messageId, replyTarget, localImagePaths, displayPrompt) {
2133
- if (this.llmRetryingSessions.has(record.localSessionId)) {
2132
+ if (this.llmRetryingSessions.has(record.localSessionId) || this.planHandoffs.has(record.localSessionId)) {
2134
2133
  const queued = this.persistQueuedPrompt(record.localSessionId, contextKey, text, {
2135
2134
  localImagePaths,
2136
2135
  messageId,
@@ -2151,7 +2150,7 @@ export class ProxySessionController {
2151
2150
  }
2152
2151
  let loaded;
2153
2152
  try {
2154
- loaded = await this.loadSession(record);
2153
+ loaded = await this.loadSession(record, Boolean(this.store.getPendingProviderSettings(record.localSessionId)));
2155
2154
  }
2156
2155
  catch (error) {
2157
2156
  if (await this.presentThreadWriterConflict(record, contextKey, error, true)) {
@@ -3127,6 +3126,22 @@ export class ProxySessionController {
3127
3126
  };
3128
3127
  }
3129
3128
  async startTurn(loaded, text, replyTarget, localImagePaths, options = {}) {
3129
+ return this.settingsOperations.run(loaded.record.localSessionId, async () => {
3130
+ try {
3131
+ await this.applyPendingProviderSettings(loaded);
3132
+ }
3133
+ catch (error) {
3134
+ await this.outbound.failPendingTurn(loaded.record.localSessionId, runtimeErrorMessage(error));
3135
+ throw error;
3136
+ }
3137
+ if (this.store.getPendingProviderSettings(loaded.record.localSessionId)) {
3138
+ throw new Error("Provider 切换尚未完成,请稍后重试。");
3139
+ }
3140
+ loaded.session = loaded.runtime.getSession(loaded.record.localSessionId) ?? loaded.session;
3141
+ return this.startTurnNow(loaded, text, replyTarget, localImagePaths, options);
3142
+ });
3143
+ }
3144
+ async startTurnNow(loaded, text, replyTarget, localImagePaths, options = {}) {
3130
3145
  const currentRecord = this.store.getSession(loaded.record.localSessionId);
3131
3146
  const title = currentRecord?.title ?? normalizeTaskTitle(options.displayPrompt ?? text);
3132
3147
  if (!currentRecord?.title && title)
@@ -3172,7 +3187,13 @@ export class ProxySessionController {
3172
3187
  }
3173
3188
  this.store.updateTurnAttempt(attemptId, { pendingTurnId: pendingTurnId ?? null });
3174
3189
  try {
3175
- turnId = await loaded.runtime.startTurn(loaded.record.localSessionId, runtimePrompt(runtimeText, localImagePaths));
3190
+ if (options.approvedPlanTurnId && (this.planHandoffs.get(loaded.record.localSessionId) !== options.approvedPlanTurnId
3191
+ || this.sessionResets.has(loaded.record.localSessionId))) {
3192
+ throw new Error("方案执行已取消。");
3193
+ }
3194
+ turnId = await loaded.runtime.startTurn(loaded.record.localSessionId, options.approvedPlanTurnId
3195
+ ? { text: runtimeText, collaborationMode: "default" }
3196
+ : runtimePrompt(runtimeText, localImagePaths));
3176
3197
  break;
3177
3198
  }
3178
3199
  catch (error) {
@@ -3587,22 +3608,44 @@ export class ProxySessionController {
3587
3608
  this.store.setBoundProjectCwd(groupContextKey, boundProjectCwd);
3588
3609
  return { chatId: group.chatId, contextKey: groupContextKey, name: group.name };
3589
3610
  }
3590
- async loadSession(record) {
3611
+ async loadSession(record, forSettings = false) {
3591
3612
  const agent = this.ensureAgent(record.agentName);
3592
3613
  const runtime = this.runtimes.forAgent(record.agentName);
3593
3614
  const existing = runtime.getSession(record.localSessionId);
3594
- if (existing)
3615
+ if (existing && (forSettings || !this.settingsOnlySessions.has(record.localSessionId))) {
3595
3616
  return { record, runtime, session: existing };
3617
+ }
3596
3618
  const pending = this.sessionLoads.get(record.localSessionId);
3597
3619
  if (pending) {
3598
- const loaded = await pending;
3599
- return {
3600
- ...loaded,
3601
- record: { ...loaded.record, contextKey: record.contextKey },
3602
- };
3620
+ try {
3621
+ const loaded = await pending;
3622
+ if (!forSettings && this.settingsOnlySessions.has(record.localSessionId)) {
3623
+ if (this.sessionLoads.get(record.localSessionId) === pending)
3624
+ this.sessionLoads.delete(record.localSessionId);
3625
+ return this.loadSession(this.store.getSession(record.localSessionId) ?? record);
3626
+ }
3627
+ return { ...loaded, record: { ...loaded.record, contextKey: record.contextKey } };
3628
+ }
3629
+ catch (error) {
3630
+ if (!forSettings || !runtime.prepareSessionForSettings)
3631
+ throw error;
3632
+ if (this.sessionLoads.get(record.localSessionId) === pending)
3633
+ this.sessionLoads.delete(record.localSessionId);
3634
+ return this.loadSession(record, forSettings);
3635
+ }
3603
3636
  }
3604
3637
  this.outbound.registerSession(record.localSessionId, record.contextKey, record.title, record.cwd, this.agentLabel(record.agentName), record.model, record.modelProvider);
3605
- const loading = (async () => {
3638
+ const loading = this.sessionHydrationOperations.run(record.localSessionId, async () => {
3639
+ const latest = this.store.getSession(record.localSessionId);
3640
+ if (latest)
3641
+ record = {
3642
+ ...record, modelProvider: latest.modelProvider, model: latest.model,
3643
+ reasoningEffort: latest.reasoningEffort, permissionMode: latest.permissionMode,
3644
+ };
3645
+ const current = runtime.getSession(record.localSessionId);
3646
+ if (current && (forSettings || !this.settingsOnlySessions.has(record.localSessionId))) {
3647
+ return { record, runtime, session: current };
3648
+ }
3606
3649
  if (record.lastTurnId) {
3607
3650
  try {
3608
3651
  await this.outbound.resumeDelivery(record.localSessionId, record.contextKey, record.lastTurnId);
@@ -3612,45 +3655,32 @@ export class ProxySessionController {
3612
3655
  }
3613
3656
  }
3614
3657
  const permissionMode = record.permissionMode ?? "auto";
3615
- if (record.remoteSessionId)
3658
+ const prepare = forSettings && record.remoteSessionId && runtime.prepareSessionForSettings;
3659
+ if (record.remoteSessionId && !prepare)
3616
3660
  await this.assertSessionTurnOwnership(record, runtime);
3617
3661
  let session;
3618
3662
  if (record.remoteSessionId) {
3619
- try {
3620
- session = await runtime.resumeSession({
3621
- localSessionId: record.localSessionId,
3622
- remoteSessionId: record.remoteSessionId,
3623
- agentName: record.agentName,
3624
- cwd: record.cwd,
3625
- title: record.title,
3626
- modelProvider: record.modelProvider,
3627
- model: record.model,
3628
- reasoningEffort: record.reasoningEffort,
3629
- permissionMode,
3630
- activeTurnId: agent.kind === "app-server" && record.status === "running" && record.lastTurnStatus === "running"
3631
- ? record.lastTurnId
3632
- : undefined,
3633
- lastTurnId: record.lastTurnId,
3634
- lastTurnStatus: record.lastTurnStatus,
3635
- });
3636
- }
3637
- catch (error) {
3638
- if (activeWriterThreadId(error))
3639
- throw error;
3640
- if (!(agent.kind === "app-server" && !record.lastTurnId
3641
- && !this.store.getForkHistorySource(record.localSessionId) && isMissingRolloutError(error)))
3642
- throw error;
3643
- this.logger.warn({ error, sessionId: record.localSessionId }, "App Server task has no rollout; creating a replacement task.");
3644
- session = await runtime.createSession({
3645
- localSessionId: record.localSessionId,
3646
- agentName: record.agentName,
3647
- cwd: record.cwd,
3648
- title: record.title,
3649
- modelProvider: record.modelProvider,
3650
- model: record.model,
3651
- reasoningEffort: record.reasoningEffort,
3652
- permissionMode,
3653
- });
3663
+ const input = {
3664
+ localSessionId: record.localSessionId,
3665
+ remoteSessionId: record.remoteSessionId,
3666
+ agentName: record.agentName,
3667
+ cwd: record.cwd,
3668
+ title: record.title,
3669
+ modelProvider: record.modelProvider,
3670
+ model: record.model,
3671
+ reasoningEffort: record.reasoningEffort,
3672
+ permissionMode,
3673
+ activeTurnId: agent.kind === "app-server" && record.status === "running" && record.lastTurnStatus === "running"
3674
+ ? record.lastTurnId
3675
+ : undefined,
3676
+ lastTurnId: record.lastTurnId,
3677
+ lastTurnStatus: record.lastTurnStatus,
3678
+ };
3679
+ if (prepare)
3680
+ this.settingsOnlySessions.add(record.localSessionId);
3681
+ session = prepare ? await prepare.call(runtime, input) : await runtime.resumeSession(input);
3682
+ if (session.remoteSessionId !== record.remoteSessionId) {
3683
+ throw new Error("加载设置返回了不同的任务 ID,不允许替换任务或更改历史。");
3654
3684
  }
3655
3685
  }
3656
3686
  else {
@@ -3665,20 +3695,24 @@ export class ProxySessionController {
3665
3695
  permissionMode,
3666
3696
  });
3667
3697
  }
3668
- this.persistRuntimeSession(record, session, session.activeTurnId ? "running" : "ready");
3698
+ if (!prepare) {
3699
+ this.settingsOnlySessions.delete(record.localSessionId);
3700
+ this.persistRuntimeSession(record, session, session.activeTurnId ? "running" : "ready");
3701
+ }
3669
3702
  const saved = this.store.getSession(record.localSessionId) ?? record;
3670
3703
  return {
3671
3704
  record: { ...saved, contextKey: record.contextKey },
3672
3705
  runtime,
3673
3706
  session,
3674
3707
  };
3675
- })();
3708
+ });
3676
3709
  this.sessionLoads.set(record.localSessionId, loading);
3677
3710
  try {
3678
3711
  return await loading;
3679
3712
  }
3680
3713
  finally {
3681
- this.sessionLoads.delete(record.localSessionId);
3714
+ if (this.sessionLoads.get(record.localSessionId) === loading)
3715
+ this.sessionLoads.delete(record.localSessionId);
3682
3716
  }
3683
3717
  }
3684
3718
  persistRuntimeSession(record, session, status) {
@@ -3716,6 +3750,15 @@ export class ProxySessionController {
3716
3750
  this.store.saveTurnParent(event.turnId, event.sessionId, this.completedParentTurnId(source));
3717
3751
  }
3718
3752
  }
3753
+ if (event.type === "turn_completed" && event.approvedPlan) {
3754
+ if (this.planHandoffs.get(event.sessionId) === event.turnId)
3755
+ return;
3756
+ const attempt = this.store.findIncompleteTurnAttemptByTurnId(event.turnId);
3757
+ if (attempt) {
3758
+ await this.continueApprovedPlan(event, attempt);
3759
+ return;
3760
+ }
3761
+ }
3719
3762
  const failedAttempt = event.type === "turn_failed"
3720
3763
  ? this.store.findIncompleteTurnAttemptByTurnId(event.turnId)
3721
3764
  : undefined;
@@ -3759,7 +3802,7 @@ export class ProxySessionController {
3759
3802
  ...event,
3760
3803
  turnCount: event.turnCount ?? this.store.countTaskTurns(event.sessionId),
3761
3804
  }
3762
- : event;
3805
+ : event.type === "turn_completed" && event.approvedPlan ? { ...event, approvedPlan: false } : event;
3763
3806
  const presentationEvent = event.type === "turn_failed"
3764
3807
  && isRetryableLlmTurnFailure(event.message)
3765
3808
  && (failedAttempt?.retryCount ?? 0) >= MAX_LLM_TURN_RETRIES
@@ -3794,12 +3837,87 @@ export class ProxySessionController {
3794
3837
  : undefined;
3795
3838
  if (latest?.lastTurnId === event.turnId && !activeTurnId) {
3796
3839
  this.store.updateSession(event.sessionId, { status: event.type === "turn_failed" ? "failed" : "ready" });
3840
+ await this.applyPendingProviderWhenIdle(event.sessionId);
3797
3841
  await this.scheduleNextQueuedPrompt(event.sessionId);
3798
3842
  }
3799
3843
  }
3800
3844
  if (presentationError)
3801
3845
  throw presentationError;
3802
3846
  }
3847
+ async continueApprovedPlan(event, attempt) {
3848
+ const { sessionId, turnId } = event;
3849
+ const source = this.store.getSession(sessionId);
3850
+ if (!source || source.status === "closed" || source.lastTurnId !== turnId)
3851
+ return;
3852
+ this.planHandoffs.set(sessionId, turnId);
3853
+ this.store.markTurnAttemptTerminal(turnId, "completed");
3854
+ this.store.updateRuntimeSession(sessionId, { lastTurnId: turnId, lastTurnStatus: "completed" });
3855
+ let continued = false;
3856
+ let failed = false;
3857
+ try {
3858
+ try {
3859
+ await this.outbound.onEvent(event);
3860
+ }
3861
+ catch (error) {
3862
+ this.logger.warn({ error, sessionId, turnId }, "Failed to finalize the approved plan card.");
3863
+ }
3864
+ await this.settingsOperations.run(sessionId, async () => {
3865
+ const current = this.store.getSessionForContext(sessionId, attempt.contextKey);
3866
+ if (this.planHandoffs.get(sessionId) !== turnId || this.sessionResets.has(sessionId)
3867
+ || !current || current.status === "closed" || current.remoteSessionId !== source.remoteSessionId)
3868
+ return;
3869
+ const loaded = await this.loadSession(current);
3870
+ await this.assertSessionTurnOwnership(current, loaded.runtime);
3871
+ if (this.planHandoffs.get(sessionId) !== turnId || this.sessionResets.has(sessionId))
3872
+ return;
3873
+ // Another client may already have started the implementation.
3874
+ let nextTurnId = loaded.session.activeTurnId;
3875
+ if (!nextTurnId) {
3876
+ if (current.lastTurnId !== turnId)
3877
+ return;
3878
+ await this.applyPendingProviderSettings(loaded);
3879
+ if (this.store.getPendingProviderSettings(sessionId))
3880
+ throw new Error("Provider 切换尚未完成。");
3881
+ if (this.planHandoffs.get(sessionId) !== turnId || this.sessionResets.has(sessionId))
3882
+ return;
3883
+ nextTurnId = await this.startTurnNow(loaded, "The proposed plan has been approved. Continue in the current task and workspace without asking for plan approval again; preserve the existing conversation and completed work. Implement the plan unless the user explicitly requested discussion or planning only, or prohibited implementation; those constraints still apply.", attempt.replyMessageId ? { messageId: attempt.replyMessageId, replyInThread: true } : undefined, undefined, { messageId: attempt.messageId, displayPrompt: "方案已确认,继续执行", approvedPlanTurnId: turnId });
3884
+ }
3885
+ continued = true;
3886
+ this.store.rebindPendingTurnMessages(sessionId, turnId, nextTurnId);
3887
+ const latest = this.store.getSession(sessionId);
3888
+ if (latest?.lastTurnId === nextTurnId && (latest.lastTurnStatus === "completed"
3889
+ || latest.lastTurnStatus === "failed" || latest.lastTurnStatus === "cancelled")) {
3890
+ await this.finalizeTurnMessageReactions(nextTurnId, latest.lastTurnStatus);
3891
+ }
3892
+ this.store.audit(attempt.contextKey, "approved_plan_continued", { sessionId, turnId, nextTurnId });
3893
+ });
3894
+ }
3895
+ catch (error) {
3896
+ failed = true;
3897
+ this.logger.error({ error, sessionId, turnId }, "Failed to continue an approved plan.");
3898
+ await this.outbound.sendText(attempt.contextKey, "方案已确认,但后续执行启动失败:" + runtimeErrorMessage(error) + "。请发送消息继续。");
3899
+ }
3900
+ finally {
3901
+ if (this.planHandoffs.get(sessionId) === turnId)
3902
+ this.planHandoffs.delete(sessionId);
3903
+ if (!continued) {
3904
+ try {
3905
+ await this.outbound.onEvent(failed
3906
+ ? { type: "turn_failed", sessionId, turnId, message: "方案已确认,但后续执行未能启动。" }
3907
+ : { type: "turn_cancelled", sessionId, turnId });
3908
+ }
3909
+ catch (error) {
3910
+ this.logger.warn({ error, sessionId, turnId }, "Failed to finish the plan handoff card.");
3911
+ }
3912
+ await this.finalizeTurnMessageReactions(turnId, failed ? "failed" : "cancelled");
3913
+ }
3914
+ const latest = this.store.getSession(sessionId);
3915
+ if (latest?.lastTurnId === turnId && latest.status !== "closed") {
3916
+ this.store.updateSession(sessionId, { status: failed ? "failed" : "ready" });
3917
+ }
3918
+ await this.scheduleNextQueuedPrompt(sessionId);
3919
+ }
3920
+ }
3803
3921
  async retryFailedLlmTurn(event, attempt) {
3804
3922
  const retryNumber = attempt.retryCount;
3805
3923
  const session = this.store.getSession(event.sessionId);
@@ -3833,7 +3951,7 @@ export class ProxySessionController {
3833
3951
  : undefined;
3834
3952
  try {
3835
3953
  const current = this.store.getSession(session.localSessionId) ?? session;
3836
- const loaded = await this.loadSession(current);
3954
+ const loaded = await this.loadSession(current, Boolean(this.store.getPendingProviderSettings(current.localSessionId)));
3837
3955
  const retryTurnId = await this.startTurn(loaded, llmRetryRuntimePrompt(attempt.promptText, retryNumber), replyTarget, attempt.localImagePaths, {
3838
3956
  attemptId: attempt.attemptId,
3839
3957
  messageId: attempt.messageId,
@@ -3870,6 +3988,8 @@ export class ProxySessionController {
3870
3988
  });
3871
3989
  }
3872
3990
  scheduleQueuedPromptRetry(sessionId) {
3991
+ if (this.store.getPendingProviderSettings(sessionId)?.error)
3992
+ return;
3873
3993
  if (this.queuedPromptRetryTimers.has(sessionId))
3874
3994
  return;
3875
3995
  if (this.store.countQueuedPrompts(sessionId) === 0)
@@ -3885,6 +4005,11 @@ export class ProxySessionController {
3885
4005
  this.queuedPromptRetryTimers.set(sessionId, timer);
3886
4006
  }
3887
4007
  async startNextQueuedPromptIfIdle(sessionId) {
4008
+ return this.settingsOperations.run(sessionId, () => this.startNextQueuedPromptIfIdleNow(sessionId));
4009
+ }
4010
+ async startNextQueuedPromptIfIdleNow(sessionId) {
4011
+ if (this.planHandoffs.has(sessionId) || this.sessionResets.has(sessionId))
4012
+ return;
3888
4013
  if (this.store.countQueuedPrompts(sessionId) === 0)
3889
4014
  return;
3890
4015
  const baseRecord = this.store.getSession(sessionId);
@@ -3892,20 +4017,23 @@ export class ProxySessionController {
3892
4017
  return;
3893
4018
  let prompt;
3894
4019
  try {
3895
- let loaded = await this.loadSession(baseRecord);
4020
+ let loaded = await this.loadSession(baseRecord, Boolean(this.store.getPendingProviderSettings(sessionId)));
3896
4021
  const remoteActivity = await this.assertSessionTurnOwnership(loaded.record, loaded.runtime);
3897
4022
  if (needsFullSessionSynchronization(loaded.record, loaded.session, remoteActivity)) {
3898
4023
  loaded = { ...loaded, session: await loaded.runtime.synchronizeSession(sessionId) };
3899
4024
  }
3900
4025
  if (loaded.session.activeTurnId)
3901
4026
  return;
4027
+ await this.applyPendingProviderSettings(loaded);
4028
+ if (this.store.getPendingProviderSettings(sessionId))
4029
+ return;
3902
4030
  prompt = this.store.takeNextQueuedPrompt(sessionId);
3903
4031
  if (!prompt)
3904
4032
  return;
3905
4033
  const record = this.store.getSessionForContext(sessionId, prompt.contextKey) ?? baseRecord;
3906
4034
  loaded = { ...loaded, record };
3907
4035
  await this.refreshPromptQueueCards(sessionId);
3908
- const turnId = await this.startTurn(loaded, prompt.text, prompt.replyMessageId ? { messageId: prompt.replyMessageId, replyInThread: true } : undefined, prompt.localImagePaths, { messageId: prompt.messageId, displayPrompt: prompt.displayPrompt });
4036
+ const turnId = await this.startTurnNow(loaded, prompt.text, prompt.replyMessageId ? { messageId: prompt.replyMessageId, replyInThread: true } : undefined, prompt.localImagePaths, { messageId: prompt.messageId, displayPrompt: prompt.displayPrompt });
3909
4037
  if (prompt.messageId)
3910
4038
  await this.bindMessageReactionToTurn(prompt.messageId, sessionId, turnId);
3911
4039
  }
@@ -3964,6 +4092,7 @@ export class ProxySessionController {
3964
4092
  });
3965
4093
  const pending = {
3966
4094
  contextKey: message.contextKey,
4095
+ userId: message.userId,
3967
4096
  attachmentPromise,
3968
4097
  resolveAttachment,
3969
4098
  };
@@ -3971,7 +4100,7 @@ export class ProxySessionController {
3971
4100
  return pending;
3972
4101
  }
3973
4102
  reserveForwardAttachment(message) {
3974
- if (!message.parentMessageId)
4103
+ if (!message.parentMessageId || isCommandMessage(message))
3975
4104
  return undefined;
3976
4105
  const candidates = [
3977
4106
  { sourceKind: "merged_forward", registry: this.pendingMergedForwards },
@@ -3979,7 +4108,8 @@ export class ProxySessionController {
3979
4108
  ];
3980
4109
  for (const candidate of candidates) {
3981
4110
  const pending = candidate.registry.get(message.parentMessageId);
3982
- if (!pending || pending.contextKey !== message.contextKey || pending.attachmentMessageId)
4111
+ if (!pending || pending.contextKey !== message.contextKey
4112
+ || pending.userId !== message.userId || pending.attachmentMessageId)
3983
4113
  continue;
3984
4114
  pending.attachmentMessageId = message.messageId;
3985
4115
  return {
@@ -4004,17 +4134,20 @@ export class ProxySessionController {
4004
4134
  return true;
4005
4135
  }
4006
4136
  async waitForForwardAttachment(messageId, pending, registry) {
4007
- let timer;
4008
- const timeout = new Promise((resolve) => {
4009
- timer = setTimeout(() => resolve(undefined), FORWARD_ATTACHMENT_WINDOW_MS);
4010
- timer.unref?.();
4011
- });
4137
+ const timer = setTimeout(() => {
4138
+ // Arrival reserves the pair before acknowledgement. A slow Reaction must not split it.
4139
+ if (pending.attachmentMessageId)
4140
+ return;
4141
+ if (registry.get(messageId) === pending)
4142
+ registry.delete(messageId);
4143
+ pending.resolveAttachment(undefined);
4144
+ }, FORWARD_ATTACHMENT_WINDOW_MS);
4145
+ timer.unref?.();
4012
4146
  try {
4013
- return await Promise.race([pending.attachmentPromise, timeout]);
4147
+ return await pending.attachmentPromise;
4014
4148
  }
4015
4149
  finally {
4016
- if (timer)
4017
- clearTimeout(timer);
4150
+ clearTimeout(timer);
4018
4151
  if (registry.get(messageId) === pending) {
4019
4152
  registry.delete(messageId);
4020
4153
  }
@@ -4075,6 +4208,7 @@ export class ProxySessionController {
4075
4208
  await this.cancelSession(record);
4076
4209
  }
4077
4210
  async cancelSession(record) {
4211
+ this.planHandoffs.delete(record.localSessionId);
4078
4212
  const runtime = this.runtimes.forAgent(record.agentName);
4079
4213
  if (runtime.kind === "codex" && record.remoteSessionId && runtime.interruptRemoteTurn) {
4080
4214
  if (runtime.getGoal && runtime.setGoal) {
@@ -4151,6 +4285,7 @@ export class ProxySessionController {
4151
4285
  });
4152
4286
  }
4153
4287
  async runSessionReset(sessionId, operation) {
4288
+ this.planHandoffs.delete(sessionId);
4154
4289
  const previous = this.sessionResets.get(sessionId) ?? Promise.resolve();
4155
4290
  const reset = previous.catch(() => undefined).then(operation);
4156
4291
  this.sessionResets.set(sessionId, reset);
@@ -4295,6 +4430,14 @@ export class ProxySessionController {
4295
4430
  await loaded.runtime.respondToApproval(sessionId, requestId, decision);
4296
4431
  }
4297
4432
  async openExecutionSettings(contextKey, activeTab, options = {}) {
4433
+ const record = options.sessionId
4434
+ ? this.requireSession(contextKey, options.sessionId)
4435
+ : this.currentSession(contextKey);
4436
+ if (!record)
4437
+ return this.openExecutionSettingsNow(contextKey, activeTab, options);
4438
+ return this.settingsOperations.run(record.localSessionId, () => this.openExecutionSettingsNow(contextKey, activeTab, { ...options, sessionId: record.localSessionId }));
4439
+ }
4440
+ async openExecutionSettingsNow(contextKey, activeTab, options = {}) {
4298
4441
  const context = this.store.getOrCreateUserContext(contextKey, this.config.defaults.agent);
4299
4442
  const record = options.sessionId
4300
4443
  ? this.requireSession(contextKey, options.sessionId)
@@ -4313,22 +4456,31 @@ export class ProxySessionController {
4313
4456
  reasoningOptions: [],
4314
4457
  };
4315
4458
  if (record) {
4316
- const loaded = await this.loadSession(record);
4459
+ const loaded = await this.loadSession(record, true);
4460
+ const pending = this.store.getPendingProviderSettings(record.localSessionId);
4461
+ const selected = pending?.settings ?? loaded.session;
4462
+ if (pending && options.updateMessageId) {
4463
+ this.store.savePendingProviderSettings({
4464
+ ...pending, contextKey, messageId: options.updateMessageId, replyMessageId: options.updateMessageId, activeTab,
4465
+ });
4466
+ }
4317
4467
  const providerSupported = loaded.runtime.kind === "codex" && Boolean(loaded.runtime.listModelProviders);
4318
4468
  const providers = providerSupported ? await this.modelProviderOptions(loaded) : [];
4319
- const currentProvider = loaded.session.modelProvider ?? providers.find((provider) => provider.isDefault)?.id;
4320
- const { models, warning } = await this.modelOptions(loaded.runtime, currentProvider, loaded.session.model);
4321
- const currentModel = models.find((model) => model.id === loaded.session.model)
4469
+ const currentProvider = selected.modelProvider ?? providers.find((provider) => provider.isDefault)?.id;
4470
+ const { models, warning } = await this.modelOptions(loaded.runtime, currentProvider, selected.model);
4471
+ const currentModel = models.find((model) => model.id === selected.model)
4322
4472
  ?? models.find((model) => model.isDefault)
4323
4473
  ?? models[0];
4324
- const currentEffort = currentModel?.supportedReasoningEfforts.some((option) => option.value === loaded.session.reasoningEffort)
4325
- ? loaded.session.reasoningEffort
4326
- : currentModel?.defaultReasoningEffort ?? loaded.session.reasoningEffort;
4474
+ const currentEffort = currentModel?.supportedReasoningEfforts.some((option) => option.value === selected.reasoningEffort)
4475
+ ? selected.reasoningEffort
4476
+ : currentModel?.defaultReasoningEffort ?? selected.reasoningEffort;
4327
4477
  runtimeSettings = {
4478
+ pendingNotice: pending ? this.pendingProviderNotice(pending) : undefined,
4479
+ pendingFailed: Boolean(pending?.error),
4328
4480
  currentProvider,
4329
- currentModel: currentModel?.id ?? loaded.session.model,
4481
+ currentModel: pending?.settings.model ?? currentModel?.id ?? selected.model,
4330
4482
  currentEffort,
4331
- currentPermissionMode: loaded.session.permissionMode,
4483
+ currentPermissionMode: selected.permissionMode,
4332
4484
  providers,
4333
4485
  providerSupported,
4334
4486
  models,
@@ -4344,7 +4496,10 @@ export class ProxySessionController {
4344
4496
  agents,
4345
4497
  runtimeSettingsAvailable: Boolean(record),
4346
4498
  ...runtimeSettings,
4499
+ modelPage: options.expectedProvider !== undefined && options.expectedProvider !== (runtimeSettings.currentProvider ?? "")
4500
+ ? undefined : options.modelPage,
4347
4501
  notice: options.notice,
4502
+ warning: [runtimeSettings.warning, options.warning].filter(Boolean).join("\n") || undefined,
4348
4503
  });
4349
4504
  if (options.updateMessageId) {
4350
4505
  await this.outbound.updateInteractiveCard(contextKey, options.updateMessageId, card);
@@ -4362,35 +4517,40 @@ export class ProxySessionController {
4362
4517
  await this.openExecutionSettings(contextKey, "agent");
4363
4518
  }
4364
4519
  async selectProvider(contextKey, modelProvider, options = {}) {
4520
+ const record = options.sessionId ? this.requireSession(contextKey, options.sessionId) : this.requireCurrentSession(contextKey);
4521
+ return this.settingsOperations.run(record.localSessionId, () => this.selectProviderNow(contextKey, modelProvider, { ...options, sessionId: record.localSessionId }));
4522
+ }
4523
+ async selectProviderNow(contextKey, modelProvider, options = {}) {
4365
4524
  const record = options.sessionId
4366
4525
  ? this.requireSession(contextKey, options.sessionId)
4367
4526
  : this.requireCurrentSession(contextKey);
4368
- await this.outbound.sendText(contextKey, `正在切换到 Provider ${modelProvider},请稍后。`);
4369
- const loaded = await this.loadSession(record);
4527
+ const loaded = await this.loadSession(record, true);
4528
+ const selected = this.store.getPendingProviderSettings(record.localSessionId)?.settings ?? loaded.session;
4370
4529
  await this.assertModelProvider(loaded, modelProvider);
4371
- const models = await loaded.runtime.listModels(modelProvider, loaded.session.model);
4372
- const model = selectProviderModel(models, loaded.session.model);
4530
+ const models = await loaded.runtime.listModels(modelProvider, selected.model);
4531
+ const model = selectProviderModel(models, selected.model);
4373
4532
  if (!model)
4374
4533
  throw new Error("当前运行时没有可用于 Provider 切换的模型。");
4375
- const effort = selectProviderReasoningEffort(model, loaded.session.reasoningEffort);
4376
- await this.applyProviderSettings(contextKey, {
4534
+ const effort = selectProviderReasoningEffort(model, selected.reasoningEffort);
4535
+ await this.applyProviderSettingsNow(contextKey, {
4377
4536
  provider: modelProvider,
4378
4537
  model: model.id,
4379
4538
  effort,
4380
- mode: loaded.session.permissionMode,
4381
- }, options, false);
4539
+ mode: selected.permissionMode,
4540
+ }, options);
4382
4541
  }
4383
4542
  async openProviderSelector(contextKey, options = {}) {
4384
4543
  await this.openExecutionSettings(contextKey, "provider", options);
4385
4544
  }
4386
- async openProviderModelSelector(contextKey, modelProvider, permissionMode, sessionId, updateMessageId) {
4387
- const loaded = await this.loadSession(this.requireSession(contextKey, sessionId));
4545
+ async openProviderModelSelector(contextKey, modelProvider, permissionMode, sessionId, updateMessageId, modelPage) {
4546
+ const loaded = await this.loadSession(this.requireSession(contextKey, sessionId), true);
4388
4547
  await this.assertModelProvider(loaded, modelProvider);
4389
4548
  const { models, warning } = await this.modelOptions(loaded.runtime, modelProvider, loaded.session.model);
4390
4549
  const card = this.cardRenderer.renderModelSelector({
4391
4550
  sessionId,
4392
4551
  contextKey,
4393
4552
  currentModel: selectProviderModel(models, loaded.session.model)?.id,
4553
+ modelPage,
4394
4554
  reasoningEffort: loaded.session.reasoningEffort,
4395
4555
  models,
4396
4556
  warning,
@@ -4401,7 +4561,7 @@ export class ProxySessionController {
4401
4561
  await this.outbound.updateInteractiveCard(contextKey, updateMessageId, card);
4402
4562
  }
4403
4563
  async openProviderReasoningSelector(contextKey, modelProvider, model, permissionMode, sessionId, updateMessageId) {
4404
- const loaded = await this.loadSession(this.requireSession(contextKey, sessionId));
4564
+ const loaded = await this.loadSession(this.requireSession(contextKey, sessionId), true);
4405
4565
  await this.assertModelProvider(loaded, modelProvider);
4406
4566
  const { models, warning } = await this.modelOptions(loaded.runtime, modelProvider, model);
4407
4567
  const selected = models.find((candidate) => candidate.id === model);
@@ -4427,7 +4587,7 @@ export class ProxySessionController {
4427
4587
  await this.outbound.updateInteractiveCard(contextKey, updateMessageId, card);
4428
4588
  }
4429
4589
  async openProviderPermissionSelector(contextKey, modelProvider, model, effort, permissionMode, sessionId, updateMessageId) {
4430
- const loaded = await this.loadSession(this.requireSession(contextKey, sessionId));
4590
+ const loaded = await this.loadSession(this.requireSession(contextKey, sessionId), true);
4431
4591
  await this.assertProviderModelSettings(loaded, modelProvider, model, effort);
4432
4592
  await this.outbound.updateInteractiveCard(contextKey, updateMessageId, this.cardRenderer.renderPermissionSelector({
4433
4593
  sessionId,
@@ -4439,20 +4599,47 @@ export class ProxySessionController {
4439
4599
  }));
4440
4600
  }
4441
4601
  async applyProviderSettings(contextKey, settings, options = {}, announceStart = true) {
4602
+ const record = options.sessionId ? this.requireSession(contextKey, options.sessionId) : this.requireCurrentSession(contextKey);
4603
+ return this.settingsOperations.run(record.localSessionId, () => this.applyProviderSettingsNow(contextKey, settings, { ...options, sessionId: record.localSessionId }, announceStart));
4604
+ }
4605
+ async applyProviderSettingsNow(contextKey, settings, options = {}, announceStart = true) {
4442
4606
  const record = options.sessionId
4443
4607
  ? this.requireSession(contextKey, options.sessionId)
4444
4608
  : this.requireCurrentSession(contextKey);
4445
- if (announceStart) {
4609
+ const loaded = await this.loadSession(record, true);
4610
+ const warning = await this.assertProviderModelSettings(loaded, settings.provider, settings.model, settings.effort);
4611
+ const next = {
4612
+ modelProvider: settings.provider, model: settings.model,
4613
+ reasoningEffort: settings.effort, permissionMode: settings.mode,
4614
+ };
4615
+ await this.reconcilePreparedSettingsSession(loaded);
4616
+ if (loaded.session.activeTurnId || this.store.getPendingProviderSettings(record.localSessionId)) {
4617
+ await this.savePendingProviderSelection(loaded, next, contextKey, options, "provider");
4618
+ return;
4619
+ }
4620
+ if (announceStart)
4446
4621
  await this.outbound.sendText(contextKey, `正在切换到 Provider ${settings.provider},请稍后。`);
4622
+ const previous = { ...loaded.session };
4623
+ let session;
4624
+ try {
4625
+ session = await this.changeProviderSettings(loaded, next);
4626
+ }
4627
+ catch (error) {
4628
+ if (!this.providerSettingsRestored(loaded, previous, error)) {
4629
+ this.store.savePendingProviderSettings({
4630
+ localSessionId: record.localSessionId, contextKey, settings: next, activeTab: "provider",
4631
+ messageId: options.updateMessageId,
4632
+ replyMessageId: options.updateMessageId ?? this.controlSessionReplyTarget(record)?.messageId,
4633
+ error: runtimeErrorMessage(error),
4634
+ });
4635
+ if (options.updateMessageId) {
4636
+ await this.openExecutionSettingsNow(contextKey, "provider", options).catch((presentationError) => {
4637
+ this.logger.warn({ error: presentationError, sessionId: record.localSessionId }, "Failed to refresh rejected Provider selection.");
4638
+ });
4639
+ }
4640
+ }
4641
+ throw error;
4447
4642
  }
4448
- const loaded = await this.loadSession(record);
4449
- const warning = await this.assertProviderModelSettings(loaded, settings.provider, settings.model, settings.effort);
4450
- const session = await this.changeProviderSettings(loaded, {
4451
- modelProvider: settings.provider,
4452
- model: settings.model,
4453
- reasoningEffort: settings.effort,
4454
- permissionMode: settings.mode,
4455
- });
4456
4643
  const notice = [
4457
4644
  `Provider 已切换为 ${cardCode(session.modelProvider ?? settings.provider)}`,
4458
4645
  `模型 ${cardCode(session.model ?? settings.model)}`,
@@ -4461,7 +4648,7 @@ export class ProxySessionController {
4461
4648
  `并已保存为 ${cardCode(loaded.record.agentName)} Agent 的默认设置,从下一次请求生效。`,
4462
4649
  ].join(",");
4463
4650
  if (options.updateMessageId) {
4464
- await this.openProviderSelector(contextKey, {
4651
+ await this.openExecutionSettingsNow(contextKey, "provider", {
4465
4652
  sessionId: loaded.record.localSessionId,
4466
4653
  updateMessageId: options.updateMessageId,
4467
4654
  notice,
@@ -4472,16 +4659,23 @@ export class ProxySessionController {
4472
4659
  }
4473
4660
  }
4474
4661
  async changeProviderSettings(loaded, settings) {
4662
+ return this.sessionHydrationOperations.run(loaded.record.localSessionId, () => this.changeProviderSettingsNow(loaded, settings));
4663
+ }
4664
+ async changeProviderSettingsNow(loaded, settings) {
4475
4665
  if (!loaded.runtime.setExecutionSettings)
4476
4666
  throw new Error("当前运行时不支持 Provider 设置。");
4477
- const persist = async (candidate) => {
4667
+ loaded.session = loaded.runtime.getSession(loaded.record.localSessionId) ?? loaded.session;
4668
+ const remoteSessionId = loaded.session.remoteSessionId;
4669
+ const persist = (candidate) => this.agentDefaultsOperations.run(loaded.record.agentName, async () => {
4670
+ if (candidate.remoteSessionId !== remoteSessionId) {
4671
+ throw new Error("设置切换返回了不同的任务 ID,未保存设置;不允许替换任务或更改历史。");
4672
+ }
4478
4673
  assertProviderSettingsApplied(candidate, settings);
4479
4674
  const agent = this.ensureAgent(loaded.record.agentName);
4480
4675
  const previousDefaults = agent.defaults;
4481
- await this.persistAgentExecutionDefaults(loaded.record.localSessionId, candidate);
4676
+ await this.persistAgentExecutionDefaultsNow(loaded.record.localSessionId, candidate);
4482
4677
  try {
4483
4678
  this.store.updateRuntimeSession(loaded.record.localSessionId, {
4484
- remoteSessionId: candidate.remoteSessionId,
4485
4679
  modelProvider: candidate.modelProvider,
4486
4680
  model: candidate.model,
4487
4681
  reasoningEffort: candidate.reasoningEffort,
@@ -4493,11 +4687,147 @@ export class ProxySessionController {
4493
4687
  agent.defaults = previousDefaults;
4494
4688
  throw error;
4495
4689
  }
4496
- };
4690
+ });
4497
4691
  const session = await loaded.runtime.setExecutionSettings(loaded.record.localSessionId, settings, persist);
4692
+ this.settingsOnlySessions.delete(loaded.record.localSessionId);
4498
4693
  this.outbound.updateSessionModel(loaded.record.localSessionId, session.model, session.modelProvider);
4499
4694
  return session;
4500
4695
  }
4696
+ async reconcilePreparedSettingsSession(loaded) {
4697
+ if (!this.settingsOnlySessions.has(loaded.record.localSessionId) || !loaded.session.activeTurnId)
4698
+ return;
4699
+ const activity = await this.assertSessionTurnOwnership(loaded.record, loaded.runtime);
4700
+ if (activity && !activity.active) {
4701
+ loaded.session = await loaded.runtime.synchronizeSession(loaded.record.localSessionId);
4702
+ }
4703
+ }
4704
+ providerSettingsRestored(loaded, previous, error) {
4705
+ const restored = loaded.runtime.getSession(loaded.record.localSessionId);
4706
+ const saved = this.store.getSession(loaded.record.localSessionId);
4707
+ const keys = ["remoteSessionId", "modelProvider", "model", "reasoningEffort", "permissionMode"];
4708
+ return error instanceof ProviderSwitchError && error.previousSettingsRestored
4709
+ && Boolean(restored && saved && keys.every((key) => restored[key] === previous[key] && saved[key] === previous[key]));
4710
+ }
4711
+ pendingProviderNotice(pending) {
4712
+ const target = `Provider ${cardCode(pending.settings.modelProvider)},模型 ${cardCode(pending.settings.model)}`;
4713
+ return pending.error
4714
+ ? `切换失败:${pending.error}。目标为 ${target};后续轮次已暂停,请重新选择 Provider 或模型重试。`
4715
+ : `待生效:当前轮次不变,结束后切换到 ${target}。可继续选择模型,以最后选择为准。`;
4716
+ }
4717
+ async savePendingProviderSelection(loaded, settings, contextKey, options, activeTab) {
4718
+ const previous = this.store.getPendingProviderSettings(loaded.record.localSessionId);
4719
+ const pending = {
4720
+ localSessionId: loaded.record.localSessionId, contextKey, settings, activeTab,
4721
+ messageId: options.updateMessageId ?? previous?.messageId,
4722
+ replyMessageId: options.updateMessageId ?? previous?.replyMessageId
4723
+ ?? this.controlSessionReplyTarget(loaded.record)?.messageId,
4724
+ };
4725
+ this.store.savePendingProviderSettings(pending);
4726
+ await this.reconcilePreparedSettingsSession(loaded);
4727
+ if (!loaded.session.activeTurnId) {
4728
+ await this.applyPendingProviderSettings(loaded);
4729
+ void this.scheduleNextQueuedPrompt(loaded.record.localSessionId).catch((error) => {
4730
+ this.logger.warn({ error }, "Failed to resume queue after Provider selection.");
4731
+ });
4732
+ return;
4733
+ }
4734
+ if (options.updateMessageId) {
4735
+ await this.openExecutionSettingsNow(contextKey, activeTab, options);
4736
+ }
4737
+ else {
4738
+ await this.outbound.sendText(contextKey, this.pendingProviderNotice(pending).replaceAll("`", ""));
4739
+ }
4740
+ }
4741
+ // Caller holds settingsOperations; only a verified rollback can unblock the next turn.
4742
+ async applyPendingProviderSettings(loaded) {
4743
+ const pending = this.store.getPendingProviderSettings(loaded.record.localSessionId);
4744
+ if (!pending)
4745
+ return;
4746
+ await this.reconcilePreparedSettingsSession(loaded);
4747
+ if (loaded.runtime.getSession(loaded.record.localSessionId)?.activeTurnId)
4748
+ return;
4749
+ if (pending.error)
4750
+ throw new Error(this.pendingProviderNotice(pending));
4751
+ const previous = { ...(loaded.runtime.getSession(loaded.record.localSessionId) ?? loaded.session) };
4752
+ let notice;
4753
+ let failure;
4754
+ let rolledBack = false;
4755
+ try {
4756
+ loaded.session = await this.changeProviderSettings(loaded, pending.settings);
4757
+ this.store.deletePendingProviderSettings(pending.localSessionId);
4758
+ notice = `Provider 已切换为 ${cardCode(pending.settings.modelProvider)},模型 ${cardCode(pending.settings.model)},从下一轮生效。`;
4759
+ }
4760
+ catch (error) {
4761
+ const restored = loaded.runtime.getSession(pending.localSessionId);
4762
+ rolledBack = this.providerSettingsRestored(loaded, previous, error);
4763
+ if (rolledBack) {
4764
+ loaded.session = restored;
4765
+ this.store.deletePendingProviderSettings(pending.localSessionId);
4766
+ this.outbound.updateSessionModel(pending.localSessionId, previous.model, previous.modelProvider);
4767
+ notice = `切换到 Provider ${cardCode(pending.settings.modelProvider)}、模型 ${cardCode(pending.settings.model)} 失败:${runtimeErrorMessage(error)} 已回退到 Provider ${cardCode(previous.modelProvider ?? "Agent 默认")}、模型 ${cardCode(previous.model ?? "默认")},后续轮次继续使用原设置。`;
4768
+ }
4769
+ else {
4770
+ failure = error;
4771
+ const failed = { ...pending, error: runtimeErrorMessage(error) };
4772
+ this.store.savePendingProviderSettings(failed);
4773
+ notice = this.pendingProviderNotice(failed);
4774
+ }
4775
+ }
4776
+ try {
4777
+ await this.outbound.withReplyTarget(pending.contextKey, pending.replyMessageId
4778
+ ? { messageId: pending.replyMessageId, replyInThread: true } : undefined, async () => {
4779
+ if (pending.messageId) {
4780
+ try {
4781
+ await this.openExecutionSettingsNow(pending.contextKey, pending.activeTab ?? "provider", {
4782
+ sessionId: pending.localSessionId, updateMessageId: pending.messageId,
4783
+ notice: failure || rolledBack ? undefined : notice,
4784
+ warning: rolledBack ? notice.replaceAll("`", "") : undefined,
4785
+ });
4786
+ return;
4787
+ }
4788
+ catch (error) {
4789
+ this.logger.warn({ error, sessionId: pending.localSessionId }, "Failed to refresh Provider settings card.");
4790
+ }
4791
+ }
4792
+ await this.outbound.sendText(pending.contextKey, notice.replaceAll("`", ""));
4793
+ });
4794
+ }
4795
+ catch (error) {
4796
+ this.logger.warn({ error, sessionId: pending.localSessionId }, "Failed to notify Provider settings result.");
4797
+ }
4798
+ if (failure)
4799
+ throw failure;
4800
+ }
4801
+ async applyPendingProviderWhenIdle(sessionId) {
4802
+ await this.settingsOperations.run(sessionId, async () => {
4803
+ if (this.recoveryClosed)
4804
+ return;
4805
+ const pending = this.store.getPendingProviderSettings(sessionId);
4806
+ if (!pending || pending.error)
4807
+ return;
4808
+ const record = this.store.getSession(sessionId);
4809
+ if (!record || record.status === "closed") {
4810
+ this.store.deletePendingProviderSettings(sessionId);
4811
+ return;
4812
+ }
4813
+ try {
4814
+ const loaded = await this.loadSession(record, true);
4815
+ await this.applyPendingProviderSettings(loaded);
4816
+ }
4817
+ catch (error) {
4818
+ this.logger.warn({ error, sessionId }, "Deferred Provider switch could not be applied.");
4819
+ if (!this.store.getPendingProviderSettings(sessionId)?.error) {
4820
+ const failed = { ...pending, error: runtimeErrorMessage(error) };
4821
+ this.store.savePendingProviderSettings(failed);
4822
+ await this.outbound.withReplyTarget(pending.contextKey, pending.replyMessageId
4823
+ ? { messageId: pending.replyMessageId, replyInThread: true } : undefined, () => this.outbound.sendText(pending.contextKey, this.pendingProviderNotice(failed).replaceAll("`", "")))
4824
+ .catch((notificationError) => {
4825
+ this.logger.warn({ error: notificationError, sessionId }, "Failed to notify Provider recovery failure.");
4826
+ });
4827
+ }
4828
+ }
4829
+ });
4830
+ }
4501
4831
  async assertProviderModelSettings(loaded, modelProvider, model, effort) {
4502
4832
  await this.assertModelProvider(loaded, modelProvider);
4503
4833
  const { models, warning } = await this.modelOptions(loaded.runtime, modelProvider, model);
@@ -4534,36 +4864,51 @@ export class ProxySessionController {
4534
4864
  return providers;
4535
4865
  }
4536
4866
  async model(contextKey, model, options = {}) {
4867
+ const record = options.sessionId ? this.requireSession(contextKey, options.sessionId) : this.requireCurrentSession(contextKey);
4868
+ return this.settingsOperations.run(record.localSessionId, () => this.modelNow(contextKey, model, { ...options, sessionId: record.localSessionId }));
4869
+ }
4870
+ async modelNow(contextKey, model, options = {}) {
4537
4871
  if (!model) {
4538
- await this.openExecutionSettings(contextKey, "model", options);
4872
+ await this.openExecutionSettingsNow(contextKey, "model", options);
4539
4873
  return;
4540
4874
  }
4541
4875
  const record = options.sessionId
4542
4876
  ? this.requireSession(contextKey, options.sessionId)
4543
4877
  : this.requireCurrentSession(contextKey);
4544
- const loaded = await this.loadSession(record);
4545
- const models = await loaded.runtime.listModels(loaded.session.modelProvider, loaded.session.model);
4878
+ const loaded = await this.loadSession(record, true);
4879
+ const pending = this.store.getPendingProviderSettings(record.localSessionId);
4880
+ const selectedSettings = pending?.settings ?? loaded.session;
4881
+ if (options.expectedProvider && options.expectedProvider !== selectedSettings.modelProvider) {
4882
+ throw new Error("Provider 已发生变化,请重新打开运行设置后选择。");
4883
+ }
4884
+ const models = await loaded.runtime.listModels(selectedSettings.modelProvider, selectedSettings.model);
4546
4885
  const selected = models.find((item) => item.id === model);
4547
4886
  if (!selected)
4548
4887
  throw new Error(`未知模型:${model}`);
4549
- const currentEffort = loaded.session.reasoningEffort;
4888
+ const currentEffort = selectedSettings.reasoningEffort;
4550
4889
  const compatible = currentEffort
4551
4890
  ? selected.supportedReasoningEfforts.length === 0
4552
4891
  || selected.supportedReasoningEfforts.some((option) => option.value === currentEffort)
4553
4892
  : false;
4554
4893
  const nextEffort = compatible ? currentEffort : selected.defaultReasoningEffort;
4555
- await loaded.runtime.setModel(loaded.record.localSessionId, model);
4556
- if (nextEffort && nextEffort !== currentEffort) {
4557
- await loaded.runtime.setReasoningEffort(loaded.record.localSessionId, nextEffort);
4894
+ if (pending) {
4895
+ await this.savePendingProviderSelection(loaded, { ...pending.settings, model, reasoningEffort: selectProviderReasoningEffort(selected, nextEffort) }, contextKey, options, "model");
4896
+ return;
4558
4897
  }
4559
- this.store.updateRuntimeSession(loaded.record.localSessionId, { model, reasoningEffort: nextEffort });
4560
- this.outbound.updateSessionModel(loaded.record.localSessionId, model, loaded.session.modelProvider);
4561
- await this.persistAgentExecutionDefaults(loaded.record.localSessionId);
4898
+ await this.sessionHydrationOperations.run(loaded.record.localSessionId, async () => {
4899
+ await loaded.runtime.setModel(loaded.record.localSessionId, model);
4900
+ if (nextEffort && nextEffort !== currentEffort) {
4901
+ await loaded.runtime.setReasoningEffort(loaded.record.localSessionId, nextEffort);
4902
+ }
4903
+ this.store.updateRuntimeSession(loaded.record.localSessionId, { model, reasoningEffort: nextEffort });
4904
+ this.outbound.updateSessionModel(loaded.record.localSessionId, model, loaded.session.modelProvider);
4905
+ await this.persistAgentExecutionDefaults(loaded.record.localSessionId);
4906
+ });
4562
4907
  const effortMessage = nextEffort && nextEffort !== currentEffort
4563
4908
  ? `,思考强度已自动调整为 ${nextEffort}`
4564
4909
  : "";
4565
4910
  if (options.updateMessageId) {
4566
- await this.openExecutionSettings(contextKey, "model", {
4911
+ await this.openExecutionSettingsNow(contextKey, "model", {
4567
4912
  sessionId: loaded.record.localSessionId,
4568
4913
  updateMessageId: options.updateMessageId,
4569
4914
  notice: `模型已切换为 ${cardCode(model)}${effortMessage},并已保存为 ${cardCode(loaded.record.agentName)} Agent 的默认设置,从下一次请求生效。`,
@@ -4703,6 +5048,7 @@ export class ProxySessionController {
4703
5048
  const created = await this.shellCommandJobs.createJob({ contextKey, sourceMessageId, command, cwd });
4704
5049
  let cardMessageId;
4705
5050
  const initialState = {
5051
+ terminalUrl: this.lifecycle?.terminalUrl?.(created.id),
4706
5052
  jobId: created.id,
4707
5053
  contextKey,
4708
5054
  command,
@@ -4725,7 +5071,7 @@ export class ProxySessionController {
4725
5071
  if (cardMessageId) {
4726
5072
  try {
4727
5073
  const failed = await this.shellCommandJobs.readJob(created.id);
4728
- await this.outbound.updateInteractiveCard(contextKey, cardMessageId, this.cardRenderer.renderShellCommandCard(shellCommandJobCardView(failed)));
5074
+ await this.outbound.updateInteractiveCard(contextKey, cardMessageId, this.cardRenderer.renderShellCommandCard({ ...shellCommandJobCardView(failed), terminalUrl: this.lifecycle?.terminalUrl?.(failed.id) }));
4729
5075
  await this.shellCommandJobs.markPresented(created.id);
4730
5076
  }
4731
5077
  catch (cardError) {
@@ -4776,7 +5122,7 @@ export class ProxySessionController {
4776
5122
  if (!job.cardMessageId || this.shellCommandJobMonitors.has(job.id))
4777
5123
  return;
4778
5124
  const scheduler = new CardUpdateScheduler({
4779
- render: (state) => this.cardRenderer.renderShellCommandCard(state),
5125
+ render: (state) => this.cardRenderer.renderShellCommandCard({ ...state, terminalUrl: this.lifecycle?.terminalUrl?.(job.id) }),
4780
5126
  write: (card) => this.outbound.updateInteractiveCard(job.contextKey, job.cardMessageId, card),
4781
5127
  onError: (error) => this.logger.warn({ error, jobId: job.id, contextKey: job.contextKey, messageId: job.cardMessageId }, "Failed to update a background shell command card."),
4782
5128
  });
@@ -4925,16 +5271,25 @@ export class ProxySessionController {
4925
5271
  });
4926
5272
  }
4927
5273
  async thinking(contextKey, effort, options = {}) {
5274
+ const record = options.sessionId ? this.requireSession(contextKey, options.sessionId) : this.requireCurrentSession(contextKey);
5275
+ return this.settingsOperations.run(record.localSessionId, () => this.thinkingNow(contextKey, effort, { ...options, sessionId: record.localSessionId }));
5276
+ }
5277
+ async thinkingNow(contextKey, effort, options = {}) {
4928
5278
  if (!effort) {
4929
- await this.openExecutionSettings(contextKey, "thinking", options);
5279
+ await this.openExecutionSettingsNow(contextKey, "thinking", options);
4930
5280
  return;
4931
5281
  }
4932
5282
  const record = options.sessionId
4933
5283
  ? this.requireSession(contextKey, options.sessionId)
4934
5284
  : this.requireCurrentSession(contextKey);
4935
- const loaded = await this.loadSession(record);
4936
- const models = await loaded.runtime.listModels(loaded.session.modelProvider, loaded.session.model);
4937
- const currentModel = models.find((item) => item.id === loaded.session.model)
5285
+ const loaded = await this.loadSession(record, true);
5286
+ const pending = this.store.getPendingProviderSettings(record.localSessionId);
5287
+ const selectedSettings = pending?.settings ?? loaded.session;
5288
+ if (options.expectedProvider && options.expectedProvider !== selectedSettings.modelProvider) {
5289
+ throw new Error("Provider 已发生变化,请重新打开运行设置后选择。");
5290
+ }
5291
+ const models = await loaded.runtime.listModels(selectedSettings.modelProvider, selectedSettings.model);
5292
+ const currentModel = models.find((item) => item.id === selectedSettings.model)
4938
5293
  ?? models.find((item) => item.isDefault);
4939
5294
  if (!currentModel)
4940
5295
  throw new Error("当前运行时没有可配置思考强度的模型。");
@@ -4946,11 +5301,17 @@ export class ProxySessionController {
4946
5301
  const options = supported.map((option) => option.value).join("、") || "无";
4947
5302
  throw new Error(`不支持的思考强度:${effort}。支持的强度:${options}`);
4948
5303
  }
4949
- await loaded.runtime.setReasoningEffort(loaded.record.localSessionId, effort);
4950
- this.store.updateRuntimeSession(loaded.record.localSessionId, { reasoningEffort: effort });
4951
- await this.persistAgentExecutionDefaults(loaded.record.localSessionId);
5304
+ if (pending) {
5305
+ await this.savePendingProviderSelection(loaded, { ...pending.settings, reasoningEffort: effort }, contextKey, options, "thinking");
5306
+ return;
5307
+ }
5308
+ await this.sessionHydrationOperations.run(loaded.record.localSessionId, async () => {
5309
+ await loaded.runtime.setReasoningEffort(loaded.record.localSessionId, effort);
5310
+ this.store.updateRuntimeSession(loaded.record.localSessionId, { reasoningEffort: effort });
5311
+ await this.persistAgentExecutionDefaults(loaded.record.localSessionId);
5312
+ });
4952
5313
  if (options.updateMessageId) {
4953
- await this.openExecutionSettings(contextKey, "thinking", {
5314
+ await this.openExecutionSettingsNow(contextKey, "thinking", {
4954
5315
  sessionId: loaded.record.localSessionId,
4955
5316
  updateMessageId: options.updateMessageId,
4956
5317
  notice: `思考强度已切换为 ${cardCode(effort)},并已保存为 ${cardCode(loaded.record.agentName)} Agent 的默认设置,从下一次请求生效。`,
@@ -4960,20 +5321,32 @@ export class ProxySessionController {
4960
5321
  await this.outbound.sendText(contextKey, `思考强度已切换为 ${effort},并已保存为 ${loaded.record.agentName} Agent 的默认设置,从下一次请求生效。`);
4961
5322
  }
4962
5323
  async permissions(contextKey, mode, options = {}) {
5324
+ const record = options.sessionId ? this.requireSession(contextKey, options.sessionId) : this.requireCurrentSession(contextKey);
5325
+ return this.settingsOperations.run(record.localSessionId, () => this.permissionsNow(contextKey, mode, { ...options, sessionId: record.localSessionId }));
5326
+ }
5327
+ async permissionsNow(contextKey, mode, options = {}) {
4963
5328
  if (!mode) {
4964
- await this.openExecutionSettings(contextKey, "permission", options);
5329
+ await this.openExecutionSettingsNow(contextKey, "permission", options);
4965
5330
  return;
4966
5331
  }
4967
5332
  const record = options.sessionId
4968
5333
  ? this.requireSession(contextKey, options.sessionId)
4969
5334
  : this.requireCurrentSession(contextKey);
4970
- const loaded = await this.loadSession(record);
4971
- await loaded.runtime.setPermissionMode(record.localSessionId, mode);
4972
- loaded.session.permissionMode = mode;
4973
- this.store.updateRuntimeSession(record.localSessionId, { permissionMode: mode });
4974
- await this.persistAgentExecutionDefaults(record.localSessionId);
5335
+ const loaded = await this.loadSession(record, true);
5336
+ const pending = this.store.getPendingProviderSettings(record.localSessionId);
5337
+ if (pending) {
5338
+ await this.savePendingProviderSelection(loaded, { ...pending.settings, permissionMode: mode }, contextKey, options, "permission");
5339
+ return;
5340
+ }
5341
+ await this.sessionHydrationOperations.run(record.localSessionId, async () => {
5342
+ await loaded.runtime.setPermissionMode(record.localSessionId, mode);
5343
+ loaded.session = loaded.runtime.getSession(record.localSessionId) ?? loaded.session;
5344
+ loaded.session.permissionMode = mode;
5345
+ this.store.updateRuntimeSession(record.localSessionId, { permissionMode: mode });
5346
+ await this.persistAgentExecutionDefaults(record.localSessionId);
5347
+ });
4975
5348
  if (options.updateMessageId) {
4976
- await this.openExecutionSettings(contextKey, "permission", {
5349
+ await this.openExecutionSettingsNow(contextKey, "permission", {
4977
5350
  sessionId: loaded.record.localSessionId,
4978
5351
  updateMessageId: options.updateMessageId,
4979
5352
  notice: mode === "auto"
@@ -5014,8 +5387,9 @@ export class ProxySessionController {
5014
5387
  if (existing)
5015
5388
  return existing;
5016
5389
  const snapshot = turnViewSnapshot(this.store.getTurnSnapshot(turnId));
5017
- if ((!snapshot || snapshot.previewJournal) && this.store.previews.has(turnId))
5018
- return this.store.previews.load(turnId);
5390
+ const previewTurnId = snapshot?.previewTurnId ?? turnId;
5391
+ if ((!snapshot || snapshot.previewJournal) && this.store.previews.has(previewTurnId))
5392
+ return this.store.previews.load(previewTurnId);
5019
5393
  if (snapshot && !needsHistoricalDetails(snapshot))
5020
5394
  return snapshot;
5021
5395
  const origin = !snapshot ? this.store.getTurnRuntimeOrigin(turnId) : undefined;
@@ -5048,7 +5422,7 @@ export class ProxySessionController {
5048
5422
  // An Agent Bot turn may have arrived while the read-only request was in flight.
5049
5423
  const latest = turnViewSnapshot(this.store.getTurnSnapshot(origin.turnId));
5050
5424
  if (latest)
5051
- return latest.previewJournal ? this.store.previews.load(origin.turnId) ?? latest : latest;
5425
+ return latest.previewJournal ? this.store.previews.load(latest.previewTurnId ?? origin.turnId) ?? latest : latest;
5052
5426
  const state = hydrateTurnViewState(createTurnViewState(origin.localSessionId, origin.turnId, details.startedAt ?? 0, remote?.title, undefined, remote?.cwd, undefined, this.agentLabel(origin.agentName)), details);
5053
5427
  // Viewing external work must not create a task, a chat binding or a pending final reply.
5054
5428
  this.store.previews.seed(state);
@@ -5951,7 +6325,7 @@ export class ProxySessionController {
5951
6325
  const remoteReference = local
5952
6326
  ? remoteSessionReference(local.agentName, local.remoteSessionId)
5953
6327
  : taskId;
5954
- const { agentName, runtime, remote } = await this.resolveRemoteCodexSession(remoteReference);
6328
+ const { agentName, runtime, remote } = await this.resolveRemoteCodexSession(remoteReference, { view: "metadata" });
5955
6329
  if (isRemoteSessionActive(remote)) {
5956
6330
  throw new Error("任务正在执行,无法归档。请先停止任务或等待本轮完成。");
5957
6331
  }
@@ -5959,7 +6333,11 @@ export class ProxySessionController {
5959
6333
  throw new Error(`Agent ${agentName} 不支持归档任务。`);
5960
6334
  }
5961
6335
  const wasCurrent = Boolean(local && this.store.getUserContext(contextKey)?.currentSessionId === local.localSessionId);
5962
- await runtime.archiveRemoteSession(remote.id);
6336
+ const allowUnmaterialized = Boolean(local && !local.lastTurnId && !local.lastTurnStatus
6337
+ && !this.store.hasTurnAttemptsForSession(local.localSessionId)
6338
+ && !this.store.getForkHistorySource(local.localSessionId)
6339
+ && this.store.listTaskTurnGraphIndex(local.localSessionId).length === 0);
6340
+ await runtime.archiveRemoteSession(remote.id, { allowUnmaterialized });
5963
6341
  const title = local?.title ?? remote.title ?? remote.preview ?? remote.id;
5964
6342
  if (local) {
5965
6343
  this.store.archiveSession(local.localSessionId);
@@ -6097,6 +6475,7 @@ export class ProxySessionController {
6097
6475
  }
6098
6476
  const localActiveTurnId = runtime.getSession(record.localSessionId)?.activeTurnId;
6099
6477
  const runtimeOwnsActiveTurn = Boolean(localActiveTurnId)
6478
+ && (!this.settingsOnlySessions.has(record.localSessionId) || Boolean(activity.activeTurnId))
6100
6479
  && (!activity.activeTurnId || activity.activeTurnId === localActiveTurnId);
6101
6480
  const persistedTurnMatches = record.status === "running"
6102
6481
  && record.lastTurnStatus === "running"
@@ -6443,6 +6822,12 @@ export class ProxySessionController {
6443
6822
  return agent;
6444
6823
  }
6445
6824
  async persistAgentExecutionDefaults(localSessionId, candidate) {
6825
+ const record = this.store.getSession(localSessionId);
6826
+ if (!record)
6827
+ throw new Error(`找不到任务:${localSessionId}`);
6828
+ return this.agentDefaultsOperations.run(record.agentName, () => this.persistAgentExecutionDefaultsNow(localSessionId, candidate));
6829
+ }
6830
+ async persistAgentExecutionDefaultsNow(localSessionId, candidate) {
6446
6831
  const record = this.store.getSession(localSessionId);
6447
6832
  if (!record)
6448
6833
  throw new Error(`找不到任务:${localSessionId}`);
@@ -7367,6 +7752,14 @@ function executionSettingsTabValue(value) {
7367
7752
  return value;
7368
7753
  throw new Error("设置卡片的 tab 无效,请重新发送设置命令。");
7369
7754
  }
7755
+ function modelCardPageValue(value) {
7756
+ if (value === undefined)
7757
+ return undefined;
7758
+ const page = typeof value === "number" || (typeof value === "string" && value.trim()) ? Number(value) : NaN;
7759
+ if (!Number.isSafeInteger(page) || page < 0)
7760
+ throw new Error("模型卡片页码无效,请重新发送 /model。");
7761
+ return page;
7762
+ }
7370
7763
  function resetHistoryPageValue(value) {
7371
7764
  const page = Number(value);
7372
7765
  if (!Number.isSafeInteger(page) || page < 0)
@@ -7743,10 +8136,17 @@ function defaultResourcePromptFromCounts(imageCount, fileCount) {
7743
8136
  return fileCount === 1 ? "请查看这个文件" : "请查看这些文件";
7744
8137
  return imageCount === 1 ? "请查看这张图片" : "请查看这些图片";
7745
8138
  }
7746
- function isStandaloneResourceMessage(message) {
8139
+ function isCommandMessage(message) {
8140
+ const text = normalizeFeishuPostText(message.text).trimStart();
8141
+ return text.startsWith("/") || text.startsWith("!");
8142
+ }
8143
+ function isForwardAttachmentSource(message) {
7747
8144
  return !message.mergedForwardMessageId
7748
- && message.text.trim().length === 0
7749
- && ((message.images?.length ?? 0) > 0 || (message.files?.length ?? 0) > 0);
8145
+ && !message.parentMessageId
8146
+ && !message.contextKey.startsWith("console:")
8147
+ && !isCommandMessage(message)
8148
+ && (message.messageType === "post"
8149
+ || (message.images?.length ?? 0) > 0 || (message.files?.length ?? 0) > 0);
7750
8150
  }
7751
8151
  function deduplicateImageReferences(images) {
7752
8152
  const seen = new Set();
@@ -7813,7 +8213,8 @@ function isRetryableLlmTurnFailure(message) {
7813
8213
  if (!normalized)
7814
8214
  return false;
7815
8215
  const permanentFailure = [
7816
- /auth(?:entication|orization)?\b|unauthorized|forbidden|invalid api key|incorrect api key/u,
8216
+ /\b(?:auth|authenticat\w*|unauthenticat\w*|authoriz\w*|unauthorized|forbidden)\b|invalid api key|incorrect api key/u,
8217
+ /(?:^|\D)(?:401|403)(?:\D|$)/u,
7817
8218
  /insufficient[_ -]?quota|quota (?:exceeded|exhausted)|usage limit|billing|credit balance/u,
7818
8219
  /context (?:length|window)|maximum context|too many tokens|token limit/u,
7819
8220
  /invalid request|bad request|invalid argument|malformed|unsupported|not supported/u,