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

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 (105) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +29 -3
  3. package/README.zh.md +29 -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/CodexEventMapper.js +1 -0
  12. package/dist/codex/CodexEventMapper.js.map +1 -1
  13. package/dist/codex/CodexRuntime.js +247 -120
  14. package/dist/codex/CodexRuntime.js.map +1 -1
  15. package/dist/codex/ProviderSwitch.js +12 -13
  16. package/dist/codex/ProviderSwitch.js.map +1 -1
  17. package/dist/codex/TraePlanEntry.js +70 -0
  18. package/dist/codex/TraePlanEntry.js.map +1 -0
  19. package/dist/config/AgentExecutionDefaultsStore.js +4 -0
  20. package/dist/config/AgentExecutionDefaultsStore.js.map +1 -1
  21. package/dist/config/loadConfig.js +4 -0
  22. package/dist/config/loadConfig.js.map +1 -1
  23. package/dist/config/schema.js +3 -0
  24. package/dist/config/schema.js.map +1 -1
  25. package/dist/feishu/CardRenderer.js +92 -8
  26. package/dist/feishu/CardRenderer.js.map +1 -1
  27. package/dist/feishu/FeishuConnector.js +1 -0
  28. package/dist/feishu/FeishuConnector.js.map +1 -1
  29. package/dist/feishu/FeishuTurnPresenter.js +121 -17
  30. package/dist/feishu/FeishuTurnPresenter.js.map +1 -1
  31. package/dist/index.js +57 -7
  32. package/dist/index.js.map +1 -1
  33. package/dist/local-files/LocalFileViewerServer.js +51 -4
  34. package/dist/local-files/LocalFileViewerServer.js.map +1 -1
  35. package/dist/local-files/TurnPreviewPage.js +7 -2
  36. package/dist/local-files/TurnPreviewPage.js.map +1 -1
  37. package/dist/presentation/TurnStateReducer.js +41 -6
  38. package/dist/presentation/TurnStateReducer.js.map +1 -1
  39. package/dist/proxy/ProxySessionController.js +620 -214
  40. package/dist/proxy/ProxySessionController.js.map +1 -1
  41. package/dist/proxy/SessionOperationQueue.js +14 -0
  42. package/dist/proxy/SessionOperationQueue.js.map +1 -0
  43. package/dist/runtime/AcpRuntimeAdapter.js +4 -2
  44. package/dist/runtime/AcpRuntimeAdapter.js.map +1 -1
  45. package/dist/state/StateStore.js +22 -0
  46. package/dist/state/StateStore.js.map +1 -1
  47. package/dist/state/TurnPreviewJournal.js +19 -7
  48. package/dist/state/TurnPreviewJournal.js.map +1 -1
  49. package/dist/state/migrations.js +6 -0
  50. package/dist/state/migrations.js.map +1 -1
  51. package/dist/supervision/SafeRestartNotifier.js +33 -1
  52. package/dist/supervision/SafeRestartNotifier.js.map +1 -1
  53. package/dist/supervision/SafeRestartScheduler.js +22 -6
  54. package/dist/supervision/SafeRestartScheduler.js.map +1 -1
  55. package/dist/terminal/TerminalCards.js +13 -0
  56. package/dist/terminal/TerminalCards.js.map +1 -0
  57. package/dist/terminal/TerminalCommandTracker.js +74 -0
  58. package/dist/terminal/TerminalCommandTracker.js.map +1 -0
  59. package/dist/terminal/TerminalLinks.js +76 -0
  60. package/dist/terminal/TerminalLinks.js.map +1 -0
  61. package/dist/terminal/TerminalPage.js +212 -0
  62. package/dist/terminal/TerminalPage.js.map +1 -0
  63. package/dist/terminal/TerminalProcess.js +95 -0
  64. package/dist/terminal/TerminalProcess.js.map +1 -0
  65. package/dist/terminal/TerminalServer.js +676 -0
  66. package/dist/terminal/TerminalServer.js.map +1 -0
  67. package/docs/technical-reference.md +48 -0
  68. package/docs/technical-reference.zh.md +48 -0
  69. package/npm-shrinkwrap.json +55 -5
  70. package/package.json +11 -4
  71. package/skills/agent-bot/SKILL.md +11 -2
  72. package/src/cli/controlProtocol.ts +2 -1
  73. package/src/cli/serverStatus.ts +1 -0
  74. package/src/cli.ts +6 -1
  75. package/src/codex/CodexEventMapper.ts +1 -0
  76. package/src/codex/CodexRuntime.ts +244 -128
  77. package/src/codex/ProviderSwitch.ts +11 -12
  78. package/src/codex/TraePlanEntry.ts +63 -0
  79. package/src/config/AgentExecutionDefaultsStore.ts +2 -0
  80. package/src/config/loadConfig.ts +4 -0
  81. package/src/config/schema.ts +5 -1
  82. package/src/feishu/CardRenderer.ts +109 -8
  83. package/src/feishu/FeishuConnector.ts +1 -0
  84. package/src/feishu/FeishuTurnPresenter.ts +108 -16
  85. package/src/feishu/types.ts +1 -0
  86. package/src/index.ts +56 -8
  87. package/src/local-files/LocalFileViewerServer.ts +48 -4
  88. package/src/local-files/TurnPreviewPage.ts +7 -2
  89. package/src/presentation/TurnStateReducer.ts +46 -7
  90. package/src/presentation/turnViewTypes.ts +5 -0
  91. package/src/proxy/ProxySessionController.ts +629 -211
  92. package/src/proxy/SessionOperationQueue.ts +13 -0
  93. package/src/runtime/AcpRuntimeAdapter.ts +5 -3
  94. package/src/runtime/types.ts +8 -3
  95. package/src/state/StateStore.ts +38 -1
  96. package/src/state/TurnPreviewJournal.ts +19 -7
  97. package/src/state/migrations.ts +6 -0
  98. package/src/supervision/SafeRestartNotifier.ts +35 -2
  99. package/src/supervision/SafeRestartScheduler.ts +25 -6
  100. package/src/terminal/TerminalCards.ts +18 -0
  101. package/src/terminal/TerminalCommandTracker.ts +74 -0
  102. package/src/terminal/TerminalLinks.ts +61 -0
  103. package/src/terminal/TerminalPage.ts +213 -0
  104. package/src/terminal/TerminalProcess.ts +93 -0
  105. package/src/terminal/TerminalServer.ts +551 -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,17 @@ 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 === "safe_restart_terminal_stop") {
641
+ if (!this.lifecycle?.stopSafeRestartTerminal)
642
+ throw new Error("网页终端未启用。");
643
+ await this.outbound.sendText(contextKey, await this.lifecycle.stopSafeRestartTerminal(scopedAction));
644
+ }
645
+ else if (kind === "agentbot_update_cancel") {
627
646
  if (!this.lifecycle?.cancelAutomaticUpdate)
628
647
  throw new Error("自动更新功能未启用。");
629
648
  await this.lifecycle.cancelAutomaticUpdate(scopedAction);
@@ -842,9 +861,11 @@ export class ProxySessionController {
842
861
  await this.refreshStatusCardFromAction(scopedAction);
843
862
  }
844
863
  else if (kind === "settings_tab_open") {
864
+ const sessionId = String(scopedAction.value.sessionId ?? "");
845
865
  await this.openExecutionSettings(contextKey, executionSettingsTabValue(scopedAction.value.tab), {
846
- sessionId: String(scopedAction.value.sessionId ?? ""),
847
- updateMessageId: scopedAction.messageId,
866
+ sessionId, updateMessageId: scopedAction.messageId,
867
+ modelPage: modelCardPageValue(scopedAction.value.page),
868
+ expectedProvider: typeof scopedAction.value.provider === "string" ? scopedAction.value.provider : undefined,
848
869
  });
849
870
  }
850
871
  else if (kind === "settings_agent_select") {
@@ -861,12 +882,14 @@ export class ProxySessionController {
861
882
  }
862
883
  else if (kind === "settings_model_select") {
863
884
  await this.model(contextKey, String(scopedAction.value.model ?? ""), {
885
+ expectedProvider: String(scopedAction.value.provider ?? ""),
864
886
  sessionId: String(scopedAction.value.sessionId ?? ""),
865
887
  updateMessageId: scopedAction.messageId,
866
888
  });
867
889
  }
868
890
  else if (kind === "settings_thinking_select") {
869
891
  await this.thinking(contextKey, String(scopedAction.value.effort ?? ""), {
892
+ expectedProvider: String(scopedAction.value.provider ?? ""),
870
893
  sessionId: String(scopedAction.value.sessionId ?? ""),
871
894
  updateMessageId: scopedAction.messageId,
872
895
  expectedModel: String(scopedAction.value.model ?? ""),
@@ -891,6 +914,7 @@ export class ProxySessionController {
891
914
  await this.model(contextKey, undefined, {
892
915
  sessionId,
893
916
  updateMessageId: scopedAction.messageId,
917
+ modelPage: modelCardPageValue(scopedAction.value.page),
894
918
  });
895
919
  }
896
920
  else if (kind === "reasoning_select") {
@@ -910,7 +934,7 @@ export class ProxySessionController {
910
934
  });
911
935
  }
912
936
  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));
937
+ await this.openProviderModelSelector(contextKey, String(scopedAction.value.provider ?? ""), permissionModeValue(scopedAction.value.permissionMode), String(scopedAction.value.sessionId ?? ""), requiredCardMessageId(scopedAction.messageId), modelCardPageValue(scopedAction.value.page));
914
938
  }
915
939
  else if (kind === "provider_model_select" || kind === "provider_reasoning_open") {
916
940
  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 +989,7 @@ export class ProxySessionController {
965
989
  this.queuedPromptCards.clear();
966
990
  this.queuedPromptCardWrites.clear();
967
991
  this.sessionResets.clear();
992
+ this.planHandoffs.clear();
968
993
  this.resetHistoryOperations.clear();
969
994
  this.remoteTurnHistoryRefreshes.clear();
970
995
  this.forkHistoryCursors.clear();
@@ -1270,78 +1295,43 @@ export class ProxySessionController {
1270
1295
  if (setting && !value?.trim())
1271
1296
  throw new Error(`task ${setting} requires a value.`);
1272
1297
  const nextValue = value?.trim();
1273
- if (setting === "provider" && nextValue) {
1298
+ if (setting && nextValue) {
1274
1299
  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,
1300
+ await this.outbound.withReplyTarget(contextKey, this.controlSessionReplyTarget(record), async () => {
1301
+ const options = { sessionId: record.localSessionId };
1302
+ if (setting === "provider")
1303
+ await this.selectProvider(contextKey, nextValue, options);
1304
+ else if (setting === "model")
1305
+ await this.model(contextKey, nextValue, options);
1306
+ else if (setting === "thinking")
1307
+ await this.thinking(contextKey, nextValue, options);
1308
+ else {
1309
+ if (nextValue !== "auto" && nextValue !== "confirm")
1310
+ throw new Error("task permissions accepts auto or confirm.");
1311
+ await this.permissions(contextKey, nextValue, options);
1312
+ }
1290
1313
  });
1291
1314
  }
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)
1315
+ return this.settingsOperations.run(localSessionId, async () => {
1316
+ const loaded = await this.loadSession(this.store.getSession(localSessionId) ?? record, true);
1317
+ const session = this.store.getSession(record.localSessionId) ?? record;
1318
+ const pending = this.store.getPendingProviderSettings(record.localSessionId);
1319
+ const selected = pending?.settings ?? session;
1320
+ const models = await loaded.runtime.listModels(selected.modelProvider, selected.model);
1321
+ const currentModel = models.find((candidate) => candidate.id === selected.model)
1312
1322
  ?? 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
- };
1323
+ const providers = loaded.runtime.listModelProviders
1324
+ ? await this.modelProviderOptions(loaded)
1325
+ : [];
1326
+ return {
1327
+ session,
1328
+ pendingProvider: pending,
1329
+ providers,
1330
+ models,
1331
+ reasoningOptions: currentModel?.supportedReasoningEfforts ?? [],
1332
+ permissionModes: ["auto", "confirm"],
1333
+ };
1334
+ });
1345
1335
  }
1346
1336
  async controlTaskGoal(localSessionId, action, objective) {
1347
1337
  const record = this.requireControlSession(localSessionId);
@@ -1746,6 +1736,9 @@ export class ProxySessionController {
1746
1736
  this.recoveryRetryStartedAt.delete(attempt.attemptId);
1747
1737
  }
1748
1738
  }
1739
+ for (const pending of this.store.listPendingProviderSettings()) {
1740
+ await this.applyPendingProviderWhenIdle(pending.localSessionId);
1741
+ }
1749
1742
  this.restorePersistedQueuedPrompts();
1750
1743
  }
1751
1744
  async finishPersistedCancellationRequests() {
@@ -2051,6 +2044,17 @@ export class ProxySessionController {
2051
2044
  const pendingAttempt = this.store.getTurnAttempt(attempt.attemptId);
2052
2045
  if (!pendingAttempt || !isIncompleteTurnAttemptStatus(pendingAttempt.status))
2053
2046
  return;
2047
+ const continued = turnViewSnapshot(this.store.getTurnSnapshot(turnId));
2048
+ if (continued?.continuedFromTurnId && this.store.getTurnDelivery(turnId)?.progressMessageId) {
2049
+ this.store.updateTurnAttempt(attempt.attemptId, { pendingTurnId: null, turnId, status: "running" });
2050
+ await this.outbound.onEvent({ type: "turn_started", sessionId: session.localSessionId,
2051
+ turnId, startedAt: continued.startedAt });
2052
+ const synchronized = await loaded.runtime.synchronizeSession(session.localSessionId);
2053
+ this.recoveryRetryStartedAt.delete(attempt.attemptId);
2054
+ if (synchronized.activeTurnId)
2055
+ this.recoveredLiveAttempts.add(attempt.attemptId);
2056
+ return;
2057
+ }
2054
2058
  const oldCardTurnId = attempt.pendingTurnId ?? attempt.turnId;
2055
2059
  if (oldCardTurnId) {
2056
2060
  await this.outbound.interruptTurnForRecovery(session.localSessionId, attempt.contextKey, oldCardTurnId, "Agent Bot 已重启,执行仍在继续;进度已转移到新的思考卡片。");
@@ -2091,7 +2095,7 @@ export class ProxySessionController {
2091
2095
  });
2092
2096
  }
2093
2097
  const current = this.store.getSession(session.localSessionId) ?? session;
2094
- const loaded = await this.loadSession({ ...current, contextKey: attempt.contextKey });
2098
+ const loaded = await this.loadSession({ ...current, contextKey: attempt.contextKey }, Boolean(this.store.getPendingProviderSettings(current.localSessionId)));
2095
2099
  const recoveryText = recoveryRuntimePrompt(attempt.promptText);
2096
2100
  const turnId = await this.startTurn(loaded, recoveryText, replyTarget, attempt.localImagePaths, {
2097
2101
  attemptId: attempt.attemptId,
@@ -2130,7 +2134,7 @@ export class ProxySessionController {
2130
2134
  await this.promptSession(record, contextKey, text, messageId, replyTarget, localImagePaths, displayPrompt);
2131
2135
  }
2132
2136
  async promptSession(record, contextKey, text, messageId, replyTarget, localImagePaths, displayPrompt) {
2133
- if (this.llmRetryingSessions.has(record.localSessionId)) {
2137
+ if (this.llmRetryingSessions.has(record.localSessionId) || this.planHandoffs.has(record.localSessionId)) {
2134
2138
  const queued = this.persistQueuedPrompt(record.localSessionId, contextKey, text, {
2135
2139
  localImagePaths,
2136
2140
  messageId,
@@ -2151,7 +2155,7 @@ export class ProxySessionController {
2151
2155
  }
2152
2156
  let loaded;
2153
2157
  try {
2154
- loaded = await this.loadSession(record);
2158
+ loaded = await this.loadSession(record, Boolean(this.store.getPendingProviderSettings(record.localSessionId)));
2155
2159
  }
2156
2160
  catch (error) {
2157
2161
  if (await this.presentThreadWriterConflict(record, contextKey, error, true)) {
@@ -3127,6 +3131,22 @@ export class ProxySessionController {
3127
3131
  };
3128
3132
  }
3129
3133
  async startTurn(loaded, text, replyTarget, localImagePaths, options = {}) {
3134
+ return this.settingsOperations.run(loaded.record.localSessionId, async () => {
3135
+ try {
3136
+ await this.applyPendingProviderSettings(loaded);
3137
+ }
3138
+ catch (error) {
3139
+ await this.outbound.failPendingTurn(loaded.record.localSessionId, runtimeErrorMessage(error));
3140
+ throw error;
3141
+ }
3142
+ if (this.store.getPendingProviderSettings(loaded.record.localSessionId)) {
3143
+ throw new Error("Provider 切换尚未完成,请稍后重试。");
3144
+ }
3145
+ loaded.session = loaded.runtime.getSession(loaded.record.localSessionId) ?? loaded.session;
3146
+ return this.startTurnNow(loaded, text, replyTarget, localImagePaths, options);
3147
+ });
3148
+ }
3149
+ async startTurnNow(loaded, text, replyTarget, localImagePaths, options = {}) {
3130
3150
  const currentRecord = this.store.getSession(loaded.record.localSessionId);
3131
3151
  const title = currentRecord?.title ?? normalizeTaskTitle(options.displayPrompt ?? text);
3132
3152
  if (!currentRecord?.title && title)
@@ -3172,7 +3192,13 @@ export class ProxySessionController {
3172
3192
  }
3173
3193
  this.store.updateTurnAttempt(attemptId, { pendingTurnId: pendingTurnId ?? null });
3174
3194
  try {
3175
- turnId = await loaded.runtime.startTurn(loaded.record.localSessionId, runtimePrompt(runtimeText, localImagePaths));
3195
+ if (options.approvedPlanTurnId && (this.planHandoffs.get(loaded.record.localSessionId) !== options.approvedPlanTurnId
3196
+ || this.sessionResets.has(loaded.record.localSessionId))) {
3197
+ throw new Error("方案执行已取消。");
3198
+ }
3199
+ turnId = await loaded.runtime.startTurn(loaded.record.localSessionId, options.approvedPlanTurnId
3200
+ ? { text: runtimeText, collaborationMode: "default" }
3201
+ : runtimePrompt(runtimeText, localImagePaths));
3176
3202
  break;
3177
3203
  }
3178
3204
  catch (error) {
@@ -3587,22 +3613,44 @@ export class ProxySessionController {
3587
3613
  this.store.setBoundProjectCwd(groupContextKey, boundProjectCwd);
3588
3614
  return { chatId: group.chatId, contextKey: groupContextKey, name: group.name };
3589
3615
  }
3590
- async loadSession(record) {
3616
+ async loadSession(record, forSettings = false) {
3591
3617
  const agent = this.ensureAgent(record.agentName);
3592
3618
  const runtime = this.runtimes.forAgent(record.agentName);
3593
3619
  const existing = runtime.getSession(record.localSessionId);
3594
- if (existing)
3620
+ if (existing && (forSettings || !this.settingsOnlySessions.has(record.localSessionId))) {
3595
3621
  return { record, runtime, session: existing };
3622
+ }
3596
3623
  const pending = this.sessionLoads.get(record.localSessionId);
3597
3624
  if (pending) {
3598
- const loaded = await pending;
3599
- return {
3600
- ...loaded,
3601
- record: { ...loaded.record, contextKey: record.contextKey },
3602
- };
3625
+ try {
3626
+ const loaded = await pending;
3627
+ if (!forSettings && this.settingsOnlySessions.has(record.localSessionId)) {
3628
+ if (this.sessionLoads.get(record.localSessionId) === pending)
3629
+ this.sessionLoads.delete(record.localSessionId);
3630
+ return this.loadSession(this.store.getSession(record.localSessionId) ?? record);
3631
+ }
3632
+ return { ...loaded, record: { ...loaded.record, contextKey: record.contextKey } };
3633
+ }
3634
+ catch (error) {
3635
+ if (!forSettings || !runtime.prepareSessionForSettings)
3636
+ throw error;
3637
+ if (this.sessionLoads.get(record.localSessionId) === pending)
3638
+ this.sessionLoads.delete(record.localSessionId);
3639
+ return this.loadSession(record, forSettings);
3640
+ }
3603
3641
  }
3604
3642
  this.outbound.registerSession(record.localSessionId, record.contextKey, record.title, record.cwd, this.agentLabel(record.agentName), record.model, record.modelProvider);
3605
- const loading = (async () => {
3643
+ const loading = this.sessionHydrationOperations.run(record.localSessionId, async () => {
3644
+ const latest = this.store.getSession(record.localSessionId);
3645
+ if (latest)
3646
+ record = {
3647
+ ...record, modelProvider: latest.modelProvider, model: latest.model,
3648
+ reasoningEffort: latest.reasoningEffort, permissionMode: latest.permissionMode,
3649
+ };
3650
+ const current = runtime.getSession(record.localSessionId);
3651
+ if (current && (forSettings || !this.settingsOnlySessions.has(record.localSessionId))) {
3652
+ return { record, runtime, session: current };
3653
+ }
3606
3654
  if (record.lastTurnId) {
3607
3655
  try {
3608
3656
  await this.outbound.resumeDelivery(record.localSessionId, record.contextKey, record.lastTurnId);
@@ -3612,45 +3660,32 @@ export class ProxySessionController {
3612
3660
  }
3613
3661
  }
3614
3662
  const permissionMode = record.permissionMode ?? "auto";
3615
- if (record.remoteSessionId)
3663
+ const prepare = forSettings && record.remoteSessionId && runtime.prepareSessionForSettings;
3664
+ if (record.remoteSessionId && !prepare)
3616
3665
  await this.assertSessionTurnOwnership(record, runtime);
3617
3666
  let session;
3618
3667
  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
- });
3668
+ const input = {
3669
+ localSessionId: record.localSessionId,
3670
+ remoteSessionId: record.remoteSessionId,
3671
+ agentName: record.agentName,
3672
+ cwd: record.cwd,
3673
+ title: record.title,
3674
+ modelProvider: record.modelProvider,
3675
+ model: record.model,
3676
+ reasoningEffort: record.reasoningEffort,
3677
+ permissionMode,
3678
+ activeTurnId: agent.kind === "app-server" && record.status === "running" && record.lastTurnStatus === "running"
3679
+ ? record.lastTurnId
3680
+ : undefined,
3681
+ lastTurnId: record.lastTurnId,
3682
+ lastTurnStatus: record.lastTurnStatus,
3683
+ };
3684
+ if (prepare)
3685
+ this.settingsOnlySessions.add(record.localSessionId);
3686
+ session = prepare ? await prepare.call(runtime, input) : await runtime.resumeSession(input);
3687
+ if (session.remoteSessionId !== record.remoteSessionId) {
3688
+ throw new Error("加载设置返回了不同的任务 ID,不允许替换任务或更改历史。");
3654
3689
  }
3655
3690
  }
3656
3691
  else {
@@ -3665,20 +3700,24 @@ export class ProxySessionController {
3665
3700
  permissionMode,
3666
3701
  });
3667
3702
  }
3668
- this.persistRuntimeSession(record, session, session.activeTurnId ? "running" : "ready");
3703
+ if (!prepare) {
3704
+ this.settingsOnlySessions.delete(record.localSessionId);
3705
+ this.persistRuntimeSession(record, session, session.activeTurnId ? "running" : "ready");
3706
+ }
3669
3707
  const saved = this.store.getSession(record.localSessionId) ?? record;
3670
3708
  return {
3671
3709
  record: { ...saved, contextKey: record.contextKey },
3672
3710
  runtime,
3673
3711
  session,
3674
3712
  };
3675
- })();
3713
+ });
3676
3714
  this.sessionLoads.set(record.localSessionId, loading);
3677
3715
  try {
3678
3716
  return await loading;
3679
3717
  }
3680
3718
  finally {
3681
- this.sessionLoads.delete(record.localSessionId);
3719
+ if (this.sessionLoads.get(record.localSessionId) === loading)
3720
+ this.sessionLoads.delete(record.localSessionId);
3682
3721
  }
3683
3722
  }
3684
3723
  persistRuntimeSession(record, session, status) {
@@ -3716,6 +3755,15 @@ export class ProxySessionController {
3716
3755
  this.store.saveTurnParent(event.turnId, event.sessionId, this.completedParentTurnId(source));
3717
3756
  }
3718
3757
  }
3758
+ if (event.type === "turn_completed" && event.approvedPlan) {
3759
+ if (this.planHandoffs.get(event.sessionId) === event.turnId)
3760
+ return;
3761
+ const attempt = this.store.findIncompleteTurnAttemptByTurnId(event.turnId);
3762
+ if (attempt) {
3763
+ await this.continueApprovedPlan(event, attempt);
3764
+ return;
3765
+ }
3766
+ }
3719
3767
  const failedAttempt = event.type === "turn_failed"
3720
3768
  ? this.store.findIncompleteTurnAttemptByTurnId(event.turnId)
3721
3769
  : undefined;
@@ -3759,7 +3807,7 @@ export class ProxySessionController {
3759
3807
  ...event,
3760
3808
  turnCount: event.turnCount ?? this.store.countTaskTurns(event.sessionId),
3761
3809
  }
3762
- : event;
3810
+ : event.type === "turn_completed" && event.approvedPlan ? { ...event, approvedPlan: false } : event;
3763
3811
  const presentationEvent = event.type === "turn_failed"
3764
3812
  && isRetryableLlmTurnFailure(event.message)
3765
3813
  && (failedAttempt?.retryCount ?? 0) >= MAX_LLM_TURN_RETRIES
@@ -3794,12 +3842,87 @@ export class ProxySessionController {
3794
3842
  : undefined;
3795
3843
  if (latest?.lastTurnId === event.turnId && !activeTurnId) {
3796
3844
  this.store.updateSession(event.sessionId, { status: event.type === "turn_failed" ? "failed" : "ready" });
3845
+ await this.applyPendingProviderWhenIdle(event.sessionId);
3797
3846
  await this.scheduleNextQueuedPrompt(event.sessionId);
3798
3847
  }
3799
3848
  }
3800
3849
  if (presentationError)
3801
3850
  throw presentationError;
3802
3851
  }
3852
+ async continueApprovedPlan(event, attempt) {
3853
+ const { sessionId, turnId } = event;
3854
+ const source = this.store.getSession(sessionId);
3855
+ if (!source || source.status === "closed" || source.lastTurnId !== turnId)
3856
+ return;
3857
+ this.planHandoffs.set(sessionId, turnId);
3858
+ this.store.markTurnAttemptTerminal(turnId, "completed");
3859
+ this.store.updateRuntimeSession(sessionId, { lastTurnId: turnId, lastTurnStatus: "completed" });
3860
+ let continued = false;
3861
+ let failed = false;
3862
+ try {
3863
+ try {
3864
+ await this.outbound.onEvent(event);
3865
+ }
3866
+ catch (error) {
3867
+ this.logger.warn({ error, sessionId, turnId }, "Failed to finalize the approved plan card.");
3868
+ }
3869
+ await this.settingsOperations.run(sessionId, async () => {
3870
+ const current = this.store.getSessionForContext(sessionId, attempt.contextKey);
3871
+ if (this.planHandoffs.get(sessionId) !== turnId || this.sessionResets.has(sessionId)
3872
+ || !current || current.status === "closed" || current.remoteSessionId !== source.remoteSessionId)
3873
+ return;
3874
+ const loaded = await this.loadSession(current);
3875
+ await this.assertSessionTurnOwnership(current, loaded.runtime);
3876
+ if (this.planHandoffs.get(sessionId) !== turnId || this.sessionResets.has(sessionId))
3877
+ return;
3878
+ // Another client may already have started the implementation.
3879
+ let nextTurnId = loaded.session.activeTurnId;
3880
+ if (!nextTurnId) {
3881
+ if (current.lastTurnId !== turnId)
3882
+ return;
3883
+ await this.applyPendingProviderSettings(loaded);
3884
+ if (this.store.getPendingProviderSettings(sessionId))
3885
+ throw new Error("Provider 切换尚未完成。");
3886
+ if (this.planHandoffs.get(sessionId) !== turnId || this.sessionResets.has(sessionId))
3887
+ return;
3888
+ 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 });
3889
+ }
3890
+ continued = true;
3891
+ this.store.rebindPendingTurnMessages(sessionId, turnId, nextTurnId);
3892
+ const latest = this.store.getSession(sessionId);
3893
+ if (latest?.lastTurnId === nextTurnId && (latest.lastTurnStatus === "completed"
3894
+ || latest.lastTurnStatus === "failed" || latest.lastTurnStatus === "cancelled")) {
3895
+ await this.finalizeTurnMessageReactions(nextTurnId, latest.lastTurnStatus);
3896
+ }
3897
+ this.store.audit(attempt.contextKey, "approved_plan_continued", { sessionId, turnId, nextTurnId });
3898
+ });
3899
+ }
3900
+ catch (error) {
3901
+ failed = true;
3902
+ this.logger.error({ error, sessionId, turnId }, "Failed to continue an approved plan.");
3903
+ await this.outbound.sendText(attempt.contextKey, "方案已确认,但后续执行启动失败:" + runtimeErrorMessage(error) + "。请发送消息继续。");
3904
+ }
3905
+ finally {
3906
+ if (this.planHandoffs.get(sessionId) === turnId)
3907
+ this.planHandoffs.delete(sessionId);
3908
+ if (!continued) {
3909
+ try {
3910
+ await this.outbound.onEvent(failed
3911
+ ? { type: "turn_failed", sessionId, turnId, message: "方案已确认,但后续执行未能启动。" }
3912
+ : { type: "turn_cancelled", sessionId, turnId });
3913
+ }
3914
+ catch (error) {
3915
+ this.logger.warn({ error, sessionId, turnId }, "Failed to finish the plan handoff card.");
3916
+ }
3917
+ await this.finalizeTurnMessageReactions(turnId, failed ? "failed" : "cancelled");
3918
+ }
3919
+ const latest = this.store.getSession(sessionId);
3920
+ if (latest?.lastTurnId === turnId && latest.status !== "closed") {
3921
+ this.store.updateSession(sessionId, { status: failed ? "failed" : "ready" });
3922
+ }
3923
+ await this.scheduleNextQueuedPrompt(sessionId);
3924
+ }
3925
+ }
3803
3926
  async retryFailedLlmTurn(event, attempt) {
3804
3927
  const retryNumber = attempt.retryCount;
3805
3928
  const session = this.store.getSession(event.sessionId);
@@ -3833,7 +3956,7 @@ export class ProxySessionController {
3833
3956
  : undefined;
3834
3957
  try {
3835
3958
  const current = this.store.getSession(session.localSessionId) ?? session;
3836
- const loaded = await this.loadSession(current);
3959
+ const loaded = await this.loadSession(current, Boolean(this.store.getPendingProviderSettings(current.localSessionId)));
3837
3960
  const retryTurnId = await this.startTurn(loaded, llmRetryRuntimePrompt(attempt.promptText, retryNumber), replyTarget, attempt.localImagePaths, {
3838
3961
  attemptId: attempt.attemptId,
3839
3962
  messageId: attempt.messageId,
@@ -3870,6 +3993,8 @@ export class ProxySessionController {
3870
3993
  });
3871
3994
  }
3872
3995
  scheduleQueuedPromptRetry(sessionId) {
3996
+ if (this.store.getPendingProviderSettings(sessionId)?.error)
3997
+ return;
3873
3998
  if (this.queuedPromptRetryTimers.has(sessionId))
3874
3999
  return;
3875
4000
  if (this.store.countQueuedPrompts(sessionId) === 0)
@@ -3885,6 +4010,11 @@ export class ProxySessionController {
3885
4010
  this.queuedPromptRetryTimers.set(sessionId, timer);
3886
4011
  }
3887
4012
  async startNextQueuedPromptIfIdle(sessionId) {
4013
+ return this.settingsOperations.run(sessionId, () => this.startNextQueuedPromptIfIdleNow(sessionId));
4014
+ }
4015
+ async startNextQueuedPromptIfIdleNow(sessionId) {
4016
+ if (this.planHandoffs.has(sessionId) || this.sessionResets.has(sessionId))
4017
+ return;
3888
4018
  if (this.store.countQueuedPrompts(sessionId) === 0)
3889
4019
  return;
3890
4020
  const baseRecord = this.store.getSession(sessionId);
@@ -3892,20 +4022,23 @@ export class ProxySessionController {
3892
4022
  return;
3893
4023
  let prompt;
3894
4024
  try {
3895
- let loaded = await this.loadSession(baseRecord);
4025
+ let loaded = await this.loadSession(baseRecord, Boolean(this.store.getPendingProviderSettings(sessionId)));
3896
4026
  const remoteActivity = await this.assertSessionTurnOwnership(loaded.record, loaded.runtime);
3897
4027
  if (needsFullSessionSynchronization(loaded.record, loaded.session, remoteActivity)) {
3898
4028
  loaded = { ...loaded, session: await loaded.runtime.synchronizeSession(sessionId) };
3899
4029
  }
3900
4030
  if (loaded.session.activeTurnId)
3901
4031
  return;
4032
+ await this.applyPendingProviderSettings(loaded);
4033
+ if (this.store.getPendingProviderSettings(sessionId))
4034
+ return;
3902
4035
  prompt = this.store.takeNextQueuedPrompt(sessionId);
3903
4036
  if (!prompt)
3904
4037
  return;
3905
4038
  const record = this.store.getSessionForContext(sessionId, prompt.contextKey) ?? baseRecord;
3906
4039
  loaded = { ...loaded, record };
3907
4040
  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 });
4041
+ 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
4042
  if (prompt.messageId)
3910
4043
  await this.bindMessageReactionToTurn(prompt.messageId, sessionId, turnId);
3911
4044
  }
@@ -3964,6 +4097,7 @@ export class ProxySessionController {
3964
4097
  });
3965
4098
  const pending = {
3966
4099
  contextKey: message.contextKey,
4100
+ userId: message.userId,
3967
4101
  attachmentPromise,
3968
4102
  resolveAttachment,
3969
4103
  };
@@ -3971,7 +4105,7 @@ export class ProxySessionController {
3971
4105
  return pending;
3972
4106
  }
3973
4107
  reserveForwardAttachment(message) {
3974
- if (!message.parentMessageId)
4108
+ if (!message.parentMessageId || isCommandMessage(message))
3975
4109
  return undefined;
3976
4110
  const candidates = [
3977
4111
  { sourceKind: "merged_forward", registry: this.pendingMergedForwards },
@@ -3979,7 +4113,8 @@ export class ProxySessionController {
3979
4113
  ];
3980
4114
  for (const candidate of candidates) {
3981
4115
  const pending = candidate.registry.get(message.parentMessageId);
3982
- if (!pending || pending.contextKey !== message.contextKey || pending.attachmentMessageId)
4116
+ if (!pending || pending.contextKey !== message.contextKey
4117
+ || pending.userId !== message.userId || pending.attachmentMessageId)
3983
4118
  continue;
3984
4119
  pending.attachmentMessageId = message.messageId;
3985
4120
  return {
@@ -4004,17 +4139,20 @@ export class ProxySessionController {
4004
4139
  return true;
4005
4140
  }
4006
4141
  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
- });
4142
+ const timer = setTimeout(() => {
4143
+ // Arrival reserves the pair before acknowledgement. A slow Reaction must not split it.
4144
+ if (pending.attachmentMessageId)
4145
+ return;
4146
+ if (registry.get(messageId) === pending)
4147
+ registry.delete(messageId);
4148
+ pending.resolveAttachment(undefined);
4149
+ }, FORWARD_ATTACHMENT_WINDOW_MS);
4150
+ timer.unref?.();
4012
4151
  try {
4013
- return await Promise.race([pending.attachmentPromise, timeout]);
4152
+ return await pending.attachmentPromise;
4014
4153
  }
4015
4154
  finally {
4016
- if (timer)
4017
- clearTimeout(timer);
4155
+ clearTimeout(timer);
4018
4156
  if (registry.get(messageId) === pending) {
4019
4157
  registry.delete(messageId);
4020
4158
  }
@@ -4075,6 +4213,7 @@ export class ProxySessionController {
4075
4213
  await this.cancelSession(record);
4076
4214
  }
4077
4215
  async cancelSession(record) {
4216
+ this.planHandoffs.delete(record.localSessionId);
4078
4217
  const runtime = this.runtimes.forAgent(record.agentName);
4079
4218
  if (runtime.kind === "codex" && record.remoteSessionId && runtime.interruptRemoteTurn) {
4080
4219
  if (runtime.getGoal && runtime.setGoal) {
@@ -4151,6 +4290,7 @@ export class ProxySessionController {
4151
4290
  });
4152
4291
  }
4153
4292
  async runSessionReset(sessionId, operation) {
4293
+ this.planHandoffs.delete(sessionId);
4154
4294
  const previous = this.sessionResets.get(sessionId) ?? Promise.resolve();
4155
4295
  const reset = previous.catch(() => undefined).then(operation);
4156
4296
  this.sessionResets.set(sessionId, reset);
@@ -4295,6 +4435,14 @@ export class ProxySessionController {
4295
4435
  await loaded.runtime.respondToApproval(sessionId, requestId, decision);
4296
4436
  }
4297
4437
  async openExecutionSettings(contextKey, activeTab, options = {}) {
4438
+ const record = options.sessionId
4439
+ ? this.requireSession(contextKey, options.sessionId)
4440
+ : this.currentSession(contextKey);
4441
+ if (!record)
4442
+ return this.openExecutionSettingsNow(contextKey, activeTab, options);
4443
+ return this.settingsOperations.run(record.localSessionId, () => this.openExecutionSettingsNow(contextKey, activeTab, { ...options, sessionId: record.localSessionId }));
4444
+ }
4445
+ async openExecutionSettingsNow(contextKey, activeTab, options = {}) {
4298
4446
  const context = this.store.getOrCreateUserContext(contextKey, this.config.defaults.agent);
4299
4447
  const record = options.sessionId
4300
4448
  ? this.requireSession(contextKey, options.sessionId)
@@ -4313,22 +4461,31 @@ export class ProxySessionController {
4313
4461
  reasoningOptions: [],
4314
4462
  };
4315
4463
  if (record) {
4316
- const loaded = await this.loadSession(record);
4464
+ const loaded = await this.loadSession(record, true);
4465
+ const pending = this.store.getPendingProviderSettings(record.localSessionId);
4466
+ const selected = pending?.settings ?? loaded.session;
4467
+ if (pending && options.updateMessageId) {
4468
+ this.store.savePendingProviderSettings({
4469
+ ...pending, contextKey, messageId: options.updateMessageId, replyMessageId: options.updateMessageId, activeTab,
4470
+ });
4471
+ }
4317
4472
  const providerSupported = loaded.runtime.kind === "codex" && Boolean(loaded.runtime.listModelProviders);
4318
4473
  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)
4474
+ const currentProvider = selected.modelProvider ?? providers.find((provider) => provider.isDefault)?.id;
4475
+ const { models, warning } = await this.modelOptions(loaded.runtime, currentProvider, selected.model);
4476
+ const currentModel = models.find((model) => model.id === selected.model)
4322
4477
  ?? models.find((model) => model.isDefault)
4323
4478
  ?? models[0];
4324
- const currentEffort = currentModel?.supportedReasoningEfforts.some((option) => option.value === loaded.session.reasoningEffort)
4325
- ? loaded.session.reasoningEffort
4326
- : currentModel?.defaultReasoningEffort ?? loaded.session.reasoningEffort;
4479
+ const currentEffort = currentModel?.supportedReasoningEfforts.some((option) => option.value === selected.reasoningEffort)
4480
+ ? selected.reasoningEffort
4481
+ : currentModel?.defaultReasoningEffort ?? selected.reasoningEffort;
4327
4482
  runtimeSettings = {
4483
+ pendingNotice: pending ? this.pendingProviderNotice(pending) : undefined,
4484
+ pendingFailed: Boolean(pending?.error),
4328
4485
  currentProvider,
4329
- currentModel: currentModel?.id ?? loaded.session.model,
4486
+ currentModel: pending?.settings.model ?? currentModel?.id ?? selected.model,
4330
4487
  currentEffort,
4331
- currentPermissionMode: loaded.session.permissionMode,
4488
+ currentPermissionMode: selected.permissionMode,
4332
4489
  providers,
4333
4490
  providerSupported,
4334
4491
  models,
@@ -4344,7 +4501,10 @@ export class ProxySessionController {
4344
4501
  agents,
4345
4502
  runtimeSettingsAvailable: Boolean(record),
4346
4503
  ...runtimeSettings,
4504
+ modelPage: options.expectedProvider !== undefined && options.expectedProvider !== (runtimeSettings.currentProvider ?? "")
4505
+ ? undefined : options.modelPage,
4347
4506
  notice: options.notice,
4507
+ warning: [runtimeSettings.warning, options.warning].filter(Boolean).join("\n") || undefined,
4348
4508
  });
4349
4509
  if (options.updateMessageId) {
4350
4510
  await this.outbound.updateInteractiveCard(contextKey, options.updateMessageId, card);
@@ -4362,35 +4522,40 @@ export class ProxySessionController {
4362
4522
  await this.openExecutionSettings(contextKey, "agent");
4363
4523
  }
4364
4524
  async selectProvider(contextKey, modelProvider, options = {}) {
4525
+ const record = options.sessionId ? this.requireSession(contextKey, options.sessionId) : this.requireCurrentSession(contextKey);
4526
+ return this.settingsOperations.run(record.localSessionId, () => this.selectProviderNow(contextKey, modelProvider, { ...options, sessionId: record.localSessionId }));
4527
+ }
4528
+ async selectProviderNow(contextKey, modelProvider, options = {}) {
4365
4529
  const record = options.sessionId
4366
4530
  ? this.requireSession(contextKey, options.sessionId)
4367
4531
  : this.requireCurrentSession(contextKey);
4368
- await this.outbound.sendText(contextKey, `正在切换到 Provider ${modelProvider},请稍后。`);
4369
- const loaded = await this.loadSession(record);
4532
+ const loaded = await this.loadSession(record, true);
4533
+ const selected = this.store.getPendingProviderSettings(record.localSessionId)?.settings ?? loaded.session;
4370
4534
  await this.assertModelProvider(loaded, modelProvider);
4371
- const models = await loaded.runtime.listModels(modelProvider, loaded.session.model);
4372
- const model = selectProviderModel(models, loaded.session.model);
4535
+ const models = await loaded.runtime.listModels(modelProvider, selected.model);
4536
+ const model = selectProviderModel(models, selected.model);
4373
4537
  if (!model)
4374
4538
  throw new Error("当前运行时没有可用于 Provider 切换的模型。");
4375
- const effort = selectProviderReasoningEffort(model, loaded.session.reasoningEffort);
4376
- await this.applyProviderSettings(contextKey, {
4539
+ const effort = selectProviderReasoningEffort(model, selected.reasoningEffort);
4540
+ await this.applyProviderSettingsNow(contextKey, {
4377
4541
  provider: modelProvider,
4378
4542
  model: model.id,
4379
4543
  effort,
4380
- mode: loaded.session.permissionMode,
4381
- }, options, false);
4544
+ mode: selected.permissionMode,
4545
+ }, options);
4382
4546
  }
4383
4547
  async openProviderSelector(contextKey, options = {}) {
4384
4548
  await this.openExecutionSettings(contextKey, "provider", options);
4385
4549
  }
4386
- async openProviderModelSelector(contextKey, modelProvider, permissionMode, sessionId, updateMessageId) {
4387
- const loaded = await this.loadSession(this.requireSession(contextKey, sessionId));
4550
+ async openProviderModelSelector(contextKey, modelProvider, permissionMode, sessionId, updateMessageId, modelPage) {
4551
+ const loaded = await this.loadSession(this.requireSession(contextKey, sessionId), true);
4388
4552
  await this.assertModelProvider(loaded, modelProvider);
4389
4553
  const { models, warning } = await this.modelOptions(loaded.runtime, modelProvider, loaded.session.model);
4390
4554
  const card = this.cardRenderer.renderModelSelector({
4391
4555
  sessionId,
4392
4556
  contextKey,
4393
4557
  currentModel: selectProviderModel(models, loaded.session.model)?.id,
4558
+ modelPage,
4394
4559
  reasoningEffort: loaded.session.reasoningEffort,
4395
4560
  models,
4396
4561
  warning,
@@ -4401,7 +4566,7 @@ export class ProxySessionController {
4401
4566
  await this.outbound.updateInteractiveCard(contextKey, updateMessageId, card);
4402
4567
  }
4403
4568
  async openProviderReasoningSelector(contextKey, modelProvider, model, permissionMode, sessionId, updateMessageId) {
4404
- const loaded = await this.loadSession(this.requireSession(contextKey, sessionId));
4569
+ const loaded = await this.loadSession(this.requireSession(contextKey, sessionId), true);
4405
4570
  await this.assertModelProvider(loaded, modelProvider);
4406
4571
  const { models, warning } = await this.modelOptions(loaded.runtime, modelProvider, model);
4407
4572
  const selected = models.find((candidate) => candidate.id === model);
@@ -4427,7 +4592,7 @@ export class ProxySessionController {
4427
4592
  await this.outbound.updateInteractiveCard(contextKey, updateMessageId, card);
4428
4593
  }
4429
4594
  async openProviderPermissionSelector(contextKey, modelProvider, model, effort, permissionMode, sessionId, updateMessageId) {
4430
- const loaded = await this.loadSession(this.requireSession(contextKey, sessionId));
4595
+ const loaded = await this.loadSession(this.requireSession(contextKey, sessionId), true);
4431
4596
  await this.assertProviderModelSettings(loaded, modelProvider, model, effort);
4432
4597
  await this.outbound.updateInteractiveCard(contextKey, updateMessageId, this.cardRenderer.renderPermissionSelector({
4433
4598
  sessionId,
@@ -4439,20 +4604,47 @@ export class ProxySessionController {
4439
4604
  }));
4440
4605
  }
4441
4606
  async applyProviderSettings(contextKey, settings, options = {}, announceStart = true) {
4607
+ const record = options.sessionId ? this.requireSession(contextKey, options.sessionId) : this.requireCurrentSession(contextKey);
4608
+ return this.settingsOperations.run(record.localSessionId, () => this.applyProviderSettingsNow(contextKey, settings, { ...options, sessionId: record.localSessionId }, announceStart));
4609
+ }
4610
+ async applyProviderSettingsNow(contextKey, settings, options = {}, announceStart = true) {
4442
4611
  const record = options.sessionId
4443
4612
  ? this.requireSession(contextKey, options.sessionId)
4444
4613
  : this.requireCurrentSession(contextKey);
4445
- if (announceStart) {
4614
+ const loaded = await this.loadSession(record, true);
4615
+ const warning = await this.assertProviderModelSettings(loaded, settings.provider, settings.model, settings.effort);
4616
+ const next = {
4617
+ modelProvider: settings.provider, model: settings.model,
4618
+ reasoningEffort: settings.effort, permissionMode: settings.mode,
4619
+ };
4620
+ await this.reconcilePreparedSettingsSession(loaded);
4621
+ if (loaded.session.activeTurnId || this.store.getPendingProviderSettings(record.localSessionId)) {
4622
+ await this.savePendingProviderSelection(loaded, next, contextKey, options, "provider");
4623
+ return;
4624
+ }
4625
+ if (announceStart)
4446
4626
  await this.outbound.sendText(contextKey, `正在切换到 Provider ${settings.provider},请稍后。`);
4627
+ const previous = { ...loaded.session };
4628
+ let session;
4629
+ try {
4630
+ session = await this.changeProviderSettings(loaded, next);
4631
+ }
4632
+ catch (error) {
4633
+ if (!this.providerSettingsRestored(loaded, previous, error)) {
4634
+ this.store.savePendingProviderSettings({
4635
+ localSessionId: record.localSessionId, contextKey, settings: next, activeTab: "provider",
4636
+ messageId: options.updateMessageId,
4637
+ replyMessageId: options.updateMessageId ?? this.controlSessionReplyTarget(record)?.messageId,
4638
+ error: runtimeErrorMessage(error),
4639
+ });
4640
+ if (options.updateMessageId) {
4641
+ await this.openExecutionSettingsNow(contextKey, "provider", options).catch((presentationError) => {
4642
+ this.logger.warn({ error: presentationError, sessionId: record.localSessionId }, "Failed to refresh rejected Provider selection.");
4643
+ });
4644
+ }
4645
+ }
4646
+ throw error;
4447
4647
  }
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
4648
  const notice = [
4457
4649
  `Provider 已切换为 ${cardCode(session.modelProvider ?? settings.provider)}`,
4458
4650
  `模型 ${cardCode(session.model ?? settings.model)}`,
@@ -4461,7 +4653,7 @@ export class ProxySessionController {
4461
4653
  `并已保存为 ${cardCode(loaded.record.agentName)} Agent 的默认设置,从下一次请求生效。`,
4462
4654
  ].join(",");
4463
4655
  if (options.updateMessageId) {
4464
- await this.openProviderSelector(contextKey, {
4656
+ await this.openExecutionSettingsNow(contextKey, "provider", {
4465
4657
  sessionId: loaded.record.localSessionId,
4466
4658
  updateMessageId: options.updateMessageId,
4467
4659
  notice,
@@ -4472,16 +4664,23 @@ export class ProxySessionController {
4472
4664
  }
4473
4665
  }
4474
4666
  async changeProviderSettings(loaded, settings) {
4667
+ return this.sessionHydrationOperations.run(loaded.record.localSessionId, () => this.changeProviderSettingsNow(loaded, settings));
4668
+ }
4669
+ async changeProviderSettingsNow(loaded, settings) {
4475
4670
  if (!loaded.runtime.setExecutionSettings)
4476
4671
  throw new Error("当前运行时不支持 Provider 设置。");
4477
- const persist = async (candidate) => {
4672
+ loaded.session = loaded.runtime.getSession(loaded.record.localSessionId) ?? loaded.session;
4673
+ const remoteSessionId = loaded.session.remoteSessionId;
4674
+ const persist = (candidate) => this.agentDefaultsOperations.run(loaded.record.agentName, async () => {
4675
+ if (candidate.remoteSessionId !== remoteSessionId) {
4676
+ throw new Error("设置切换返回了不同的任务 ID,未保存设置;不允许替换任务或更改历史。");
4677
+ }
4478
4678
  assertProviderSettingsApplied(candidate, settings);
4479
4679
  const agent = this.ensureAgent(loaded.record.agentName);
4480
4680
  const previousDefaults = agent.defaults;
4481
- await this.persistAgentExecutionDefaults(loaded.record.localSessionId, candidate);
4681
+ await this.persistAgentExecutionDefaultsNow(loaded.record.localSessionId, candidate);
4482
4682
  try {
4483
4683
  this.store.updateRuntimeSession(loaded.record.localSessionId, {
4484
- remoteSessionId: candidate.remoteSessionId,
4485
4684
  modelProvider: candidate.modelProvider,
4486
4685
  model: candidate.model,
4487
4686
  reasoningEffort: candidate.reasoningEffort,
@@ -4493,11 +4692,147 @@ export class ProxySessionController {
4493
4692
  agent.defaults = previousDefaults;
4494
4693
  throw error;
4495
4694
  }
4496
- };
4695
+ });
4497
4696
  const session = await loaded.runtime.setExecutionSettings(loaded.record.localSessionId, settings, persist);
4697
+ this.settingsOnlySessions.delete(loaded.record.localSessionId);
4498
4698
  this.outbound.updateSessionModel(loaded.record.localSessionId, session.model, session.modelProvider);
4499
4699
  return session;
4500
4700
  }
4701
+ async reconcilePreparedSettingsSession(loaded) {
4702
+ if (!this.settingsOnlySessions.has(loaded.record.localSessionId) || !loaded.session.activeTurnId)
4703
+ return;
4704
+ const activity = await this.assertSessionTurnOwnership(loaded.record, loaded.runtime);
4705
+ if (activity && !activity.active) {
4706
+ loaded.session = await loaded.runtime.synchronizeSession(loaded.record.localSessionId);
4707
+ }
4708
+ }
4709
+ providerSettingsRestored(loaded, previous, error) {
4710
+ const restored = loaded.runtime.getSession(loaded.record.localSessionId);
4711
+ const saved = this.store.getSession(loaded.record.localSessionId);
4712
+ const keys = ["remoteSessionId", "modelProvider", "model", "reasoningEffort", "permissionMode"];
4713
+ return error instanceof ProviderSwitchError && error.previousSettingsRestored
4714
+ && Boolean(restored && saved && keys.every((key) => restored[key] === previous[key] && saved[key] === previous[key]));
4715
+ }
4716
+ pendingProviderNotice(pending) {
4717
+ const target = `Provider ${cardCode(pending.settings.modelProvider)},模型 ${cardCode(pending.settings.model)}`;
4718
+ return pending.error
4719
+ ? `切换失败:${pending.error}。目标为 ${target};后续轮次已暂停,请重新选择 Provider 或模型重试。`
4720
+ : `待生效:当前轮次不变,结束后切换到 ${target}。可继续选择模型,以最后选择为准。`;
4721
+ }
4722
+ async savePendingProviderSelection(loaded, settings, contextKey, options, activeTab) {
4723
+ const previous = this.store.getPendingProviderSettings(loaded.record.localSessionId);
4724
+ const pending = {
4725
+ localSessionId: loaded.record.localSessionId, contextKey, settings, activeTab,
4726
+ messageId: options.updateMessageId ?? previous?.messageId,
4727
+ replyMessageId: options.updateMessageId ?? previous?.replyMessageId
4728
+ ?? this.controlSessionReplyTarget(loaded.record)?.messageId,
4729
+ };
4730
+ this.store.savePendingProviderSettings(pending);
4731
+ await this.reconcilePreparedSettingsSession(loaded);
4732
+ if (!loaded.session.activeTurnId) {
4733
+ await this.applyPendingProviderSettings(loaded);
4734
+ void this.scheduleNextQueuedPrompt(loaded.record.localSessionId).catch((error) => {
4735
+ this.logger.warn({ error }, "Failed to resume queue after Provider selection.");
4736
+ });
4737
+ return;
4738
+ }
4739
+ if (options.updateMessageId) {
4740
+ await this.openExecutionSettingsNow(contextKey, activeTab, options);
4741
+ }
4742
+ else {
4743
+ await this.outbound.sendText(contextKey, this.pendingProviderNotice(pending).replaceAll("`", ""));
4744
+ }
4745
+ }
4746
+ // Caller holds settingsOperations; only a verified rollback can unblock the next turn.
4747
+ async applyPendingProviderSettings(loaded) {
4748
+ const pending = this.store.getPendingProviderSettings(loaded.record.localSessionId);
4749
+ if (!pending)
4750
+ return;
4751
+ await this.reconcilePreparedSettingsSession(loaded);
4752
+ if (loaded.runtime.getSession(loaded.record.localSessionId)?.activeTurnId)
4753
+ return;
4754
+ if (pending.error)
4755
+ throw new Error(this.pendingProviderNotice(pending));
4756
+ const previous = { ...(loaded.runtime.getSession(loaded.record.localSessionId) ?? loaded.session) };
4757
+ let notice;
4758
+ let failure;
4759
+ let rolledBack = false;
4760
+ try {
4761
+ loaded.session = await this.changeProviderSettings(loaded, pending.settings);
4762
+ this.store.deletePendingProviderSettings(pending.localSessionId);
4763
+ notice = `Provider 已切换为 ${cardCode(pending.settings.modelProvider)},模型 ${cardCode(pending.settings.model)},从下一轮生效。`;
4764
+ }
4765
+ catch (error) {
4766
+ const restored = loaded.runtime.getSession(pending.localSessionId);
4767
+ rolledBack = this.providerSettingsRestored(loaded, previous, error);
4768
+ if (rolledBack) {
4769
+ loaded.session = restored;
4770
+ this.store.deletePendingProviderSettings(pending.localSessionId);
4771
+ this.outbound.updateSessionModel(pending.localSessionId, previous.model, previous.modelProvider);
4772
+ notice = `切换到 Provider ${cardCode(pending.settings.modelProvider)}、模型 ${cardCode(pending.settings.model)} 失败:${runtimeErrorMessage(error)} 已回退到 Provider ${cardCode(previous.modelProvider ?? "Agent 默认")}、模型 ${cardCode(previous.model ?? "默认")},后续轮次继续使用原设置。`;
4773
+ }
4774
+ else {
4775
+ failure = error;
4776
+ const failed = { ...pending, error: runtimeErrorMessage(error) };
4777
+ this.store.savePendingProviderSettings(failed);
4778
+ notice = this.pendingProviderNotice(failed);
4779
+ }
4780
+ }
4781
+ try {
4782
+ await this.outbound.withReplyTarget(pending.contextKey, pending.replyMessageId
4783
+ ? { messageId: pending.replyMessageId, replyInThread: true } : undefined, async () => {
4784
+ if (pending.messageId) {
4785
+ try {
4786
+ await this.openExecutionSettingsNow(pending.contextKey, pending.activeTab ?? "provider", {
4787
+ sessionId: pending.localSessionId, updateMessageId: pending.messageId,
4788
+ notice: failure || rolledBack ? undefined : notice,
4789
+ warning: rolledBack ? notice.replaceAll("`", "") : undefined,
4790
+ });
4791
+ return;
4792
+ }
4793
+ catch (error) {
4794
+ this.logger.warn({ error, sessionId: pending.localSessionId }, "Failed to refresh Provider settings card.");
4795
+ }
4796
+ }
4797
+ await this.outbound.sendText(pending.contextKey, notice.replaceAll("`", ""));
4798
+ });
4799
+ }
4800
+ catch (error) {
4801
+ this.logger.warn({ error, sessionId: pending.localSessionId }, "Failed to notify Provider settings result.");
4802
+ }
4803
+ if (failure)
4804
+ throw failure;
4805
+ }
4806
+ async applyPendingProviderWhenIdle(sessionId) {
4807
+ await this.settingsOperations.run(sessionId, async () => {
4808
+ if (this.recoveryClosed)
4809
+ return;
4810
+ const pending = this.store.getPendingProviderSettings(sessionId);
4811
+ if (!pending || pending.error)
4812
+ return;
4813
+ const record = this.store.getSession(sessionId);
4814
+ if (!record || record.status === "closed") {
4815
+ this.store.deletePendingProviderSettings(sessionId);
4816
+ return;
4817
+ }
4818
+ try {
4819
+ const loaded = await this.loadSession(record, true);
4820
+ await this.applyPendingProviderSettings(loaded);
4821
+ }
4822
+ catch (error) {
4823
+ this.logger.warn({ error, sessionId }, "Deferred Provider switch could not be applied.");
4824
+ if (!this.store.getPendingProviderSettings(sessionId)?.error) {
4825
+ const failed = { ...pending, error: runtimeErrorMessage(error) };
4826
+ this.store.savePendingProviderSettings(failed);
4827
+ await this.outbound.withReplyTarget(pending.contextKey, pending.replyMessageId
4828
+ ? { messageId: pending.replyMessageId, replyInThread: true } : undefined, () => this.outbound.sendText(pending.contextKey, this.pendingProviderNotice(failed).replaceAll("`", "")))
4829
+ .catch((notificationError) => {
4830
+ this.logger.warn({ error: notificationError, sessionId }, "Failed to notify Provider recovery failure.");
4831
+ });
4832
+ }
4833
+ }
4834
+ });
4835
+ }
4501
4836
  async assertProviderModelSettings(loaded, modelProvider, model, effort) {
4502
4837
  await this.assertModelProvider(loaded, modelProvider);
4503
4838
  const { models, warning } = await this.modelOptions(loaded.runtime, modelProvider, model);
@@ -4534,36 +4869,51 @@ export class ProxySessionController {
4534
4869
  return providers;
4535
4870
  }
4536
4871
  async model(contextKey, model, options = {}) {
4872
+ const record = options.sessionId ? this.requireSession(contextKey, options.sessionId) : this.requireCurrentSession(contextKey);
4873
+ return this.settingsOperations.run(record.localSessionId, () => this.modelNow(contextKey, model, { ...options, sessionId: record.localSessionId }));
4874
+ }
4875
+ async modelNow(contextKey, model, options = {}) {
4537
4876
  if (!model) {
4538
- await this.openExecutionSettings(contextKey, "model", options);
4877
+ await this.openExecutionSettingsNow(contextKey, "model", options);
4539
4878
  return;
4540
4879
  }
4541
4880
  const record = options.sessionId
4542
4881
  ? this.requireSession(contextKey, options.sessionId)
4543
4882
  : this.requireCurrentSession(contextKey);
4544
- const loaded = await this.loadSession(record);
4545
- const models = await loaded.runtime.listModels(loaded.session.modelProvider, loaded.session.model);
4883
+ const loaded = await this.loadSession(record, true);
4884
+ const pending = this.store.getPendingProviderSettings(record.localSessionId);
4885
+ const selectedSettings = pending?.settings ?? loaded.session;
4886
+ if (options.expectedProvider && options.expectedProvider !== selectedSettings.modelProvider) {
4887
+ throw new Error("Provider 已发生变化,请重新打开运行设置后选择。");
4888
+ }
4889
+ const models = await loaded.runtime.listModels(selectedSettings.modelProvider, selectedSettings.model);
4546
4890
  const selected = models.find((item) => item.id === model);
4547
4891
  if (!selected)
4548
4892
  throw new Error(`未知模型:${model}`);
4549
- const currentEffort = loaded.session.reasoningEffort;
4893
+ const currentEffort = selectedSettings.reasoningEffort;
4550
4894
  const compatible = currentEffort
4551
4895
  ? selected.supportedReasoningEfforts.length === 0
4552
4896
  || selected.supportedReasoningEfforts.some((option) => option.value === currentEffort)
4553
4897
  : false;
4554
4898
  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);
4899
+ if (pending) {
4900
+ await this.savePendingProviderSelection(loaded, { ...pending.settings, model, reasoningEffort: selectProviderReasoningEffort(selected, nextEffort) }, contextKey, options, "model");
4901
+ return;
4558
4902
  }
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);
4903
+ await this.sessionHydrationOperations.run(loaded.record.localSessionId, async () => {
4904
+ await loaded.runtime.setModel(loaded.record.localSessionId, model);
4905
+ if (nextEffort && nextEffort !== currentEffort) {
4906
+ await loaded.runtime.setReasoningEffort(loaded.record.localSessionId, nextEffort);
4907
+ }
4908
+ this.store.updateRuntimeSession(loaded.record.localSessionId, { model, reasoningEffort: nextEffort });
4909
+ this.outbound.updateSessionModel(loaded.record.localSessionId, model, loaded.session.modelProvider);
4910
+ await this.persistAgentExecutionDefaults(loaded.record.localSessionId);
4911
+ });
4562
4912
  const effortMessage = nextEffort && nextEffort !== currentEffort
4563
4913
  ? `,思考强度已自动调整为 ${nextEffort}`
4564
4914
  : "";
4565
4915
  if (options.updateMessageId) {
4566
- await this.openExecutionSettings(contextKey, "model", {
4916
+ await this.openExecutionSettingsNow(contextKey, "model", {
4567
4917
  sessionId: loaded.record.localSessionId,
4568
4918
  updateMessageId: options.updateMessageId,
4569
4919
  notice: `模型已切换为 ${cardCode(model)}${effortMessage},并已保存为 ${cardCode(loaded.record.agentName)} Agent 的默认设置,从下一次请求生效。`,
@@ -4703,6 +5053,7 @@ export class ProxySessionController {
4703
5053
  const created = await this.shellCommandJobs.createJob({ contextKey, sourceMessageId, command, cwd });
4704
5054
  let cardMessageId;
4705
5055
  const initialState = {
5056
+ terminalUrl: this.lifecycle?.terminalUrl?.(created.id),
4706
5057
  jobId: created.id,
4707
5058
  contextKey,
4708
5059
  command,
@@ -4725,7 +5076,7 @@ export class ProxySessionController {
4725
5076
  if (cardMessageId) {
4726
5077
  try {
4727
5078
  const failed = await this.shellCommandJobs.readJob(created.id);
4728
- await this.outbound.updateInteractiveCard(contextKey, cardMessageId, this.cardRenderer.renderShellCommandCard(shellCommandJobCardView(failed)));
5079
+ await this.outbound.updateInteractiveCard(contextKey, cardMessageId, this.cardRenderer.renderShellCommandCard({ ...shellCommandJobCardView(failed), terminalUrl: this.lifecycle?.terminalUrl?.(failed.id) }));
4729
5080
  await this.shellCommandJobs.markPresented(created.id);
4730
5081
  }
4731
5082
  catch (cardError) {
@@ -4776,7 +5127,7 @@ export class ProxySessionController {
4776
5127
  if (!job.cardMessageId || this.shellCommandJobMonitors.has(job.id))
4777
5128
  return;
4778
5129
  const scheduler = new CardUpdateScheduler({
4779
- render: (state) => this.cardRenderer.renderShellCommandCard(state),
5130
+ render: (state) => this.cardRenderer.renderShellCommandCard({ ...state, terminalUrl: this.lifecycle?.terminalUrl?.(job.id) }),
4780
5131
  write: (card) => this.outbound.updateInteractiveCard(job.contextKey, job.cardMessageId, card),
4781
5132
  onError: (error) => this.logger.warn({ error, jobId: job.id, contextKey: job.contextKey, messageId: job.cardMessageId }, "Failed to update a background shell command card."),
4782
5133
  });
@@ -4925,16 +5276,25 @@ export class ProxySessionController {
4925
5276
  });
4926
5277
  }
4927
5278
  async thinking(contextKey, effort, options = {}) {
5279
+ const record = options.sessionId ? this.requireSession(contextKey, options.sessionId) : this.requireCurrentSession(contextKey);
5280
+ return this.settingsOperations.run(record.localSessionId, () => this.thinkingNow(contextKey, effort, { ...options, sessionId: record.localSessionId }));
5281
+ }
5282
+ async thinkingNow(contextKey, effort, options = {}) {
4928
5283
  if (!effort) {
4929
- await this.openExecutionSettings(contextKey, "thinking", options);
5284
+ await this.openExecutionSettingsNow(contextKey, "thinking", options);
4930
5285
  return;
4931
5286
  }
4932
5287
  const record = options.sessionId
4933
5288
  ? this.requireSession(contextKey, options.sessionId)
4934
5289
  : 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)
5290
+ const loaded = await this.loadSession(record, true);
5291
+ const pending = this.store.getPendingProviderSettings(record.localSessionId);
5292
+ const selectedSettings = pending?.settings ?? loaded.session;
5293
+ if (options.expectedProvider && options.expectedProvider !== selectedSettings.modelProvider) {
5294
+ throw new Error("Provider 已发生变化,请重新打开运行设置后选择。");
5295
+ }
5296
+ const models = await loaded.runtime.listModels(selectedSettings.modelProvider, selectedSettings.model);
5297
+ const currentModel = models.find((item) => item.id === selectedSettings.model)
4938
5298
  ?? models.find((item) => item.isDefault);
4939
5299
  if (!currentModel)
4940
5300
  throw new Error("当前运行时没有可配置思考强度的模型。");
@@ -4946,11 +5306,17 @@ export class ProxySessionController {
4946
5306
  const options = supported.map((option) => option.value).join("、") || "无";
4947
5307
  throw new Error(`不支持的思考强度:${effort}。支持的强度:${options}`);
4948
5308
  }
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);
5309
+ if (pending) {
5310
+ await this.savePendingProviderSelection(loaded, { ...pending.settings, reasoningEffort: effort }, contextKey, options, "thinking");
5311
+ return;
5312
+ }
5313
+ await this.sessionHydrationOperations.run(loaded.record.localSessionId, async () => {
5314
+ await loaded.runtime.setReasoningEffort(loaded.record.localSessionId, effort);
5315
+ this.store.updateRuntimeSession(loaded.record.localSessionId, { reasoningEffort: effort });
5316
+ await this.persistAgentExecutionDefaults(loaded.record.localSessionId);
5317
+ });
4952
5318
  if (options.updateMessageId) {
4953
- await this.openExecutionSettings(contextKey, "thinking", {
5319
+ await this.openExecutionSettingsNow(contextKey, "thinking", {
4954
5320
  sessionId: loaded.record.localSessionId,
4955
5321
  updateMessageId: options.updateMessageId,
4956
5322
  notice: `思考强度已切换为 ${cardCode(effort)},并已保存为 ${cardCode(loaded.record.agentName)} Agent 的默认设置,从下一次请求生效。`,
@@ -4960,20 +5326,32 @@ export class ProxySessionController {
4960
5326
  await this.outbound.sendText(contextKey, `思考强度已切换为 ${effort},并已保存为 ${loaded.record.agentName} Agent 的默认设置,从下一次请求生效。`);
4961
5327
  }
4962
5328
  async permissions(contextKey, mode, options = {}) {
5329
+ const record = options.sessionId ? this.requireSession(contextKey, options.sessionId) : this.requireCurrentSession(contextKey);
5330
+ return this.settingsOperations.run(record.localSessionId, () => this.permissionsNow(contextKey, mode, { ...options, sessionId: record.localSessionId }));
5331
+ }
5332
+ async permissionsNow(contextKey, mode, options = {}) {
4963
5333
  if (!mode) {
4964
- await this.openExecutionSettings(contextKey, "permission", options);
5334
+ await this.openExecutionSettingsNow(contextKey, "permission", options);
4965
5335
  return;
4966
5336
  }
4967
5337
  const record = options.sessionId
4968
5338
  ? this.requireSession(contextKey, options.sessionId)
4969
5339
  : 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);
5340
+ const loaded = await this.loadSession(record, true);
5341
+ const pending = this.store.getPendingProviderSettings(record.localSessionId);
5342
+ if (pending) {
5343
+ await this.savePendingProviderSelection(loaded, { ...pending.settings, permissionMode: mode }, contextKey, options, "permission");
5344
+ return;
5345
+ }
5346
+ await this.sessionHydrationOperations.run(record.localSessionId, async () => {
5347
+ await loaded.runtime.setPermissionMode(record.localSessionId, mode);
5348
+ loaded.session = loaded.runtime.getSession(record.localSessionId) ?? loaded.session;
5349
+ loaded.session.permissionMode = mode;
5350
+ this.store.updateRuntimeSession(record.localSessionId, { permissionMode: mode });
5351
+ await this.persistAgentExecutionDefaults(record.localSessionId);
5352
+ });
4975
5353
  if (options.updateMessageId) {
4976
- await this.openExecutionSettings(contextKey, "permission", {
5354
+ await this.openExecutionSettingsNow(contextKey, "permission", {
4977
5355
  sessionId: loaded.record.localSessionId,
4978
5356
  updateMessageId: options.updateMessageId,
4979
5357
  notice: mode === "auto"
@@ -5014,8 +5392,9 @@ export class ProxySessionController {
5014
5392
  if (existing)
5015
5393
  return existing;
5016
5394
  const snapshot = turnViewSnapshot(this.store.getTurnSnapshot(turnId));
5017
- if ((!snapshot || snapshot.previewJournal) && this.store.previews.has(turnId))
5018
- return this.store.previews.load(turnId);
5395
+ const previewTurnId = snapshot?.previewTurnId ?? turnId;
5396
+ if ((!snapshot || snapshot.previewJournal) && this.store.previews.has(previewTurnId))
5397
+ return this.store.previews.load(previewTurnId);
5019
5398
  if (snapshot && !needsHistoricalDetails(snapshot))
5020
5399
  return snapshot;
5021
5400
  const origin = !snapshot ? this.store.getTurnRuntimeOrigin(turnId) : undefined;
@@ -5048,7 +5427,7 @@ export class ProxySessionController {
5048
5427
  // An Agent Bot turn may have arrived while the read-only request was in flight.
5049
5428
  const latest = turnViewSnapshot(this.store.getTurnSnapshot(origin.turnId));
5050
5429
  if (latest)
5051
- return latest.previewJournal ? this.store.previews.load(origin.turnId) ?? latest : latest;
5430
+ return latest.previewJournal ? this.store.previews.load(latest.previewTurnId ?? origin.turnId) ?? latest : latest;
5052
5431
  const state = hydrateTurnViewState(createTurnViewState(origin.localSessionId, origin.turnId, details.startedAt ?? 0, remote?.title, undefined, remote?.cwd, undefined, this.agentLabel(origin.agentName)), details);
5053
5432
  // Viewing external work must not create a task, a chat binding or a pending final reply.
5054
5433
  this.store.previews.seed(state);
@@ -5951,7 +6330,7 @@ export class ProxySessionController {
5951
6330
  const remoteReference = local
5952
6331
  ? remoteSessionReference(local.agentName, local.remoteSessionId)
5953
6332
  : taskId;
5954
- const { agentName, runtime, remote } = await this.resolveRemoteCodexSession(remoteReference);
6333
+ const { agentName, runtime, remote } = await this.resolveRemoteCodexSession(remoteReference, { view: "metadata" });
5955
6334
  if (isRemoteSessionActive(remote)) {
5956
6335
  throw new Error("任务正在执行,无法归档。请先停止任务或等待本轮完成。");
5957
6336
  }
@@ -5959,7 +6338,11 @@ export class ProxySessionController {
5959
6338
  throw new Error(`Agent ${agentName} 不支持归档任务。`);
5960
6339
  }
5961
6340
  const wasCurrent = Boolean(local && this.store.getUserContext(contextKey)?.currentSessionId === local.localSessionId);
5962
- await runtime.archiveRemoteSession(remote.id);
6341
+ const allowUnmaterialized = Boolean(local && !local.lastTurnId && !local.lastTurnStatus
6342
+ && !this.store.hasTurnAttemptsForSession(local.localSessionId)
6343
+ && !this.store.getForkHistorySource(local.localSessionId)
6344
+ && this.store.listTaskTurnGraphIndex(local.localSessionId).length === 0);
6345
+ await runtime.archiveRemoteSession(remote.id, { allowUnmaterialized });
5963
6346
  const title = local?.title ?? remote.title ?? remote.preview ?? remote.id;
5964
6347
  if (local) {
5965
6348
  this.store.archiveSession(local.localSessionId);
@@ -6097,6 +6480,7 @@ export class ProxySessionController {
6097
6480
  }
6098
6481
  const localActiveTurnId = runtime.getSession(record.localSessionId)?.activeTurnId;
6099
6482
  const runtimeOwnsActiveTurn = Boolean(localActiveTurnId)
6483
+ && (!this.settingsOnlySessions.has(record.localSessionId) || Boolean(activity.activeTurnId))
6100
6484
  && (!activity.activeTurnId || activity.activeTurnId === localActiveTurnId);
6101
6485
  const persistedTurnMatches = record.status === "running"
6102
6486
  && record.lastTurnStatus === "running"
@@ -6443,6 +6827,12 @@ export class ProxySessionController {
6443
6827
  return agent;
6444
6828
  }
6445
6829
  async persistAgentExecutionDefaults(localSessionId, candidate) {
6830
+ const record = this.store.getSession(localSessionId);
6831
+ if (!record)
6832
+ throw new Error(`找不到任务:${localSessionId}`);
6833
+ return this.agentDefaultsOperations.run(record.agentName, () => this.persistAgentExecutionDefaultsNow(localSessionId, candidate));
6834
+ }
6835
+ async persistAgentExecutionDefaultsNow(localSessionId, candidate) {
6446
6836
  const record = this.store.getSession(localSessionId);
6447
6837
  if (!record)
6448
6838
  throw new Error(`找不到任务:${localSessionId}`);
@@ -7367,6 +7757,14 @@ function executionSettingsTabValue(value) {
7367
7757
  return value;
7368
7758
  throw new Error("设置卡片的 tab 无效,请重新发送设置命令。");
7369
7759
  }
7760
+ function modelCardPageValue(value) {
7761
+ if (value === undefined)
7762
+ return undefined;
7763
+ const page = typeof value === "number" || (typeof value === "string" && value.trim()) ? Number(value) : NaN;
7764
+ if (!Number.isSafeInteger(page) || page < 0)
7765
+ throw new Error("模型卡片页码无效,请重新发送 /model。");
7766
+ return page;
7767
+ }
7370
7768
  function resetHistoryPageValue(value) {
7371
7769
  const page = Number(value);
7372
7770
  if (!Number.isSafeInteger(page) || page < 0)
@@ -7743,10 +8141,17 @@ function defaultResourcePromptFromCounts(imageCount, fileCount) {
7743
8141
  return fileCount === 1 ? "请查看这个文件" : "请查看这些文件";
7744
8142
  return imageCount === 1 ? "请查看这张图片" : "请查看这些图片";
7745
8143
  }
7746
- function isStandaloneResourceMessage(message) {
8144
+ function isCommandMessage(message) {
8145
+ const text = normalizeFeishuPostText(message.text).trimStart();
8146
+ return text.startsWith("/") || text.startsWith("!");
8147
+ }
8148
+ function isForwardAttachmentSource(message) {
7747
8149
  return !message.mergedForwardMessageId
7748
- && message.text.trim().length === 0
7749
- && ((message.images?.length ?? 0) > 0 || (message.files?.length ?? 0) > 0);
8150
+ && !message.parentMessageId
8151
+ && !message.contextKey.startsWith("console:")
8152
+ && !isCommandMessage(message)
8153
+ && (message.messageType === "post"
8154
+ || (message.images?.length ?? 0) > 0 || (message.files?.length ?? 0) > 0);
7750
8155
  }
7751
8156
  function deduplicateImageReferences(images) {
7752
8157
  const seen = new Set();
@@ -7813,7 +8218,8 @@ function isRetryableLlmTurnFailure(message) {
7813
8218
  if (!normalized)
7814
8219
  return false;
7815
8220
  const permanentFailure = [
7816
- /auth(?:entication|orization)?\b|unauthorized|forbidden|invalid api key|incorrect api key/u,
8221
+ /\b(?:auth|authenticat\w*|unauthenticat\w*|authoriz\w*|unauthorized|forbidden)\b|invalid api key|incorrect api key/u,
8222
+ /(?:^|\D)(?:401|403)(?:\D|$)/u,
7817
8223
  /insufficient[_ -]?quota|quota (?:exceeded|exhausted)|usage limit|billing|credit balance/u,
7818
8224
  /context (?:length|window)|maximum context|too many tokens|token limit/u,
7819
8225
  /invalid request|bad request|invalid argument|malformed|unsupported|not supported/u,