@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
@@ -5,7 +5,7 @@ import path from "node:path";
5
5
  import type { Logger } from "pino";
6
6
  import { createProjectlessWorkspace, detectProjectlessWorkspace } from "../codex/ProjectlessWorkspace.js";
7
7
  import { CodexVersionError } from "../codex/CodexVersion.js";
8
- import { assertProviderSettingsApplied, isMissingRolloutError } from "../codex/ProviderSwitch.js";
8
+ import { assertProviderSettingsApplied, ProviderSwitchError } from "../codex/ProviderSwitch.js";
9
9
  import {
10
10
  SystemThreadWriterProcessController,
11
11
  type ThreadWriterProcess,
@@ -90,11 +90,13 @@ import {
90
90
  type MessageReactionRecord,
91
91
  type MessageReactionStatus,
92
92
  type QueuedPromptRecord,
93
+ type PendingProviderSettings,
93
94
  type SessionRecord,
94
95
  type TurnAttemptRecord,
95
96
  type TurnAnchorRecord,
96
97
  type TurnRuntimeOriginRecord,
97
98
  } from "../state/StateStore.js";
99
+ import { SessionOperationQueue } from "./SessionOperationQueue.js";
98
100
  import { createId } from "../utils/id.js";
99
101
  import { formatStorageSize } from "../utils/formatStorageSize.js";
100
102
  import { truncateMiddle, truncateText } from "../utils/markdown.js";
@@ -117,10 +119,12 @@ interface StartTurnOptions {
117
119
  messageId?: string;
118
120
  displayPrompt?: string;
119
121
  preserveAttemptOnFailure?: boolean;
122
+ approvedPlanTurnId?: string;
120
123
  }
121
124
 
122
125
  interface PendingForwardAttachment {
123
126
  contextKey: string;
127
+ userId?: string;
124
128
  attachmentMessageId?: string;
125
129
  attachmentPromise: Promise<IncomingMessage | undefined>;
126
130
  resolveAttachment: (message: IncomingMessage | undefined) => void;
@@ -198,7 +202,7 @@ const RECOVERY_HEARTBEAT_INTERVAL_MS = 60 * 1_000;
198
202
  const SHELL_COMMAND_JOB_POLL_INTERVAL_MS = 1_000;
199
203
  const SHELL_COMMAND_JOB_RELAUNCH_DELAY_MS = 5_000;
200
204
  const APP_SERVER_RELEASE_POLL_INTERVAL_MS = 500;
201
- const FORWARD_ATTACHMENT_WINDOW_MS = 800;
205
+ const FORWARD_ATTACHMENT_WINDOW_MS = 1_500;
202
206
  const DEFAULT_MERGED_FORWARD_INSTRUCTION = "请参考以下内容回复用户";
203
207
  const DEFAULT_REFERENCED_MESSAGE_INSTRUCTION = "请参考引用消息回复用户";
204
208
  const TURN_STOP_REQUESTED_MESSAGE = "已发送停止信号,任务会自动停止。";
@@ -406,6 +410,8 @@ interface ResetHistoryCardOptions {
406
410
  }
407
411
 
408
412
  interface ModelCardOptions {
413
+ modelPage?: number;
414
+ expectedProvider?: string;
409
415
  sessionId?: string;
410
416
  updateMessageId?: string;
411
417
  }
@@ -416,6 +422,7 @@ interface ThinkingCardOptions extends ModelCardOptions {
416
422
 
417
423
  interface ExecutionSettingsCardOptions extends ModelCardOptions {
418
424
  notice?: string;
425
+ warning?: string;
419
426
  }
420
427
 
421
428
  type SessionExecutionSettings = AgentExecutionDefaults;
@@ -506,6 +513,9 @@ export interface ControlTaskReleaseResult {
506
513
  }
507
514
 
508
515
  export interface ProxyLifecycle {
516
+ terminalUrl?(jobId: string): string | undefined;
517
+ authorizeTerminal?(action: CardAction): Promise<string>;
518
+ stopSafeRestartTerminal?(action: CardAction): Promise<string>;
509
519
  supervised?: boolean;
510
520
  restart(contextKey: string, force: boolean, replyTarget?: MessageReplyTarget): Promise<void>;
511
521
  cancelSafeRestart?(scheduleId: number): Promise<boolean>;
@@ -539,9 +549,14 @@ export class ProxySessionController {
539
549
  private readonly messageQueues = new Map<string, Promise<void>>();
540
550
  private readonly switchGroupOperations = new Set<string>();
541
551
  private readonly sessionLoads = new Map<string, Promise<LoadedSession>>();
552
+ private readonly settingsOnlySessions = new Set<string>();
542
553
  private readonly queuedPromptStarts = new Map<string, Promise<void>>();
543
554
  private readonly queuedPromptCards = new Map<string, Map<string, string>>();
544
555
  private readonly queuedPromptCardWrites = new Map<string, Promise<void>>();
556
+ private readonly settingsOperations = new SessionOperationQueue();
557
+ // Loads may run inside settingsOperations; never acquire that queue while hydrating.
558
+ private readonly sessionHydrationOperations = new SessionOperationQueue();
559
+ private readonly agentDefaultsOperations = new SessionOperationQueue();
545
560
  private readonly sessionResets = new Map<string, Promise<void>>();
546
561
  private readonly resetHistoryOperations = new Map<string, string>();
547
562
  private readonly remoteTurnHistoryRefreshes = new Map<string, number>();
@@ -551,6 +566,7 @@ export class ProxySessionController {
551
566
  private readonly lastSessionListings = new Map<string, string[]>();
552
567
  private readonly threadInitializations = new Map<string, Promise<void>>();
553
568
  private readonly llmRetryingSessions = new Set<string>();
569
+ private readonly planHandoffs = new Map<string, string>();
554
570
  private readonly retriedFailureTurnIds = new Set<string>();
555
571
  private readonly queuedPromptRetryTimers = new Map<string, NodeJS.Timeout>();
556
572
  private readonly recoveryRetryTimers = new Map<string, NodeJS.Timeout>();
@@ -633,7 +649,7 @@ export class ProxySessionController {
633
649
  const pendingMergedForward = message.mergedForwardMessageId
634
650
  ? this.registerPendingForwardAttachment(message, this.pendingMergedForwards)
635
651
  : undefined;
636
- const pendingResourceForward = !attachmentReservation && isStandaloneResourceMessage(message)
652
+ const pendingResourceForward = !attachmentReservation && isForwardAttachmentSource(message)
637
653
  ? this.registerPendingForwardAttachment(message, this.pendingResourceForwards)
638
654
  : undefined;
639
655
  try {
@@ -683,7 +699,9 @@ export class ProxySessionController {
683
699
  );
684
700
  if (attachment) {
685
701
  coalescedForwardAttachment = true;
686
- const instruction = normalizeFeishuPostText(attachment.text) || defaultResourcePrompt(resourceMessage);
702
+ const sourceText = resourceMessage.text.trim();
703
+ const instruction = normalizeFeishuPostText(attachment.text)
704
+ || (sourceText ? DEFAULT_REFERENCED_MESSAGE_INSTRUCTION : defaultResourcePrompt(resourceMessage));
687
705
  forwardedImages = (resourceMessage.images ?? []).map((image) => ({
688
706
  messageId: resourceMessage.messageId,
689
707
  imageKey: image.imageKey,
@@ -695,7 +713,7 @@ export class ProxySessionController {
695
713
  }));
696
714
  message = {
697
715
  ...attachment,
698
- text: instruction,
716
+ text: sourceText ? referencedMessagePrompt(instruction, sourceText) : instruction,
699
717
  displayText: instruction,
700
718
  };
701
719
  this.relatedReactionMessageIds.set(message.messageId, [resourceMessage.messageId]);
@@ -1015,7 +1033,13 @@ export class ProxySessionController {
1015
1033
  await this.outbound.withReplyTarget(contextKey, replyTarget, async () => {
1016
1034
  try {
1017
1035
  const kind = String(scopedAction.value.action ?? "");
1018
- if (kind === "agentbot_update_cancel") {
1036
+ if (kind === "terminal_authorize") {
1037
+ if (!this.lifecycle?.authorizeTerminal) throw new Error("网页终端未启用。");
1038
+ await this.outbound.sendText(contextKey, await this.lifecycle.authorizeTerminal(scopedAction));
1039
+ } else if (kind === "safe_restart_terminal_stop") {
1040
+ if (!this.lifecycle?.stopSafeRestartTerminal) throw new Error("网页终端未启用。");
1041
+ await this.outbound.sendText(contextKey, await this.lifecycle.stopSafeRestartTerminal(scopedAction));
1042
+ } else if (kind === "agentbot_update_cancel") {
1019
1043
  if (!this.lifecycle?.cancelAutomaticUpdate) throw new Error("自动更新功能未启用。");
1020
1044
  await this.lifecycle.cancelAutomaticUpdate(scopedAction);
1021
1045
  } else if (kind === "agentbot_update_select") {
@@ -1231,10 +1255,14 @@ export class ProxySessionController {
1231
1255
  } else if (kind === "session_status_refresh") {
1232
1256
  await this.refreshStatusCardFromAction(scopedAction);
1233
1257
  } else if (kind === "settings_tab_open") {
1234
- await this.openExecutionSettings(contextKey, executionSettingsTabValue(scopedAction.value.tab), {
1235
- sessionId: String(scopedAction.value.sessionId ?? ""),
1236
- updateMessageId: scopedAction.messageId,
1237
- });
1258
+ const sessionId = String(scopedAction.value.sessionId ?? "");
1259
+ await this.openExecutionSettings(
1260
+ contextKey, executionSettingsTabValue(scopedAction.value.tab), {
1261
+ sessionId, updateMessageId: scopedAction.messageId,
1262
+ modelPage: modelCardPageValue(scopedAction.value.page),
1263
+ expectedProvider: typeof scopedAction.value.provider === "string" ? scopedAction.value.provider : undefined,
1264
+ },
1265
+ );
1238
1266
  } else if (kind === "settings_agent_select") {
1239
1267
  await this.setDefaultAgent(contextKey, String(scopedAction.value.agent ?? ""), {
1240
1268
  sessionId: String(scopedAction.value.sessionId ?? ""),
@@ -1247,11 +1275,13 @@ export class ProxySessionController {
1247
1275
  });
1248
1276
  } else if (kind === "settings_model_select") {
1249
1277
  await this.model(contextKey, String(scopedAction.value.model ?? ""), {
1278
+ expectedProvider: String(scopedAction.value.provider ?? ""),
1250
1279
  sessionId: String(scopedAction.value.sessionId ?? ""),
1251
1280
  updateMessageId: scopedAction.messageId,
1252
1281
  });
1253
1282
  } else if (kind === "settings_thinking_select") {
1254
1283
  await this.thinking(contextKey, String(scopedAction.value.effort ?? ""), {
1284
+ expectedProvider: String(scopedAction.value.provider ?? ""),
1255
1285
  sessionId: String(scopedAction.value.sessionId ?? ""),
1256
1286
  updateMessageId: scopedAction.messageId,
1257
1287
  expectedModel: String(scopedAction.value.model ?? ""),
@@ -1273,6 +1303,7 @@ export class ProxySessionController {
1273
1303
  await this.model(contextKey, undefined, {
1274
1304
  sessionId,
1275
1305
  updateMessageId: scopedAction.messageId,
1306
+ modelPage: modelCardPageValue(scopedAction.value.page),
1276
1307
  });
1277
1308
  } else if (kind === "reasoning_select") {
1278
1309
  const sessionId = String(scopedAction.value.sessionId ?? "");
@@ -1295,6 +1326,7 @@ export class ProxySessionController {
1295
1326
  permissionModeValue(scopedAction.value.permissionMode),
1296
1327
  String(scopedAction.value.sessionId ?? ""),
1297
1328
  requiredCardMessageId(scopedAction.messageId),
1329
+ modelCardPageValue(scopedAction.value.page),
1298
1330
  );
1299
1331
  } else if (kind === "provider_model_select" || kind === "provider_reasoning_open") {
1300
1332
  await this.openProviderReasoningSelector(
@@ -1363,6 +1395,7 @@ export class ProxySessionController {
1363
1395
  this.queuedPromptCards.clear();
1364
1396
  this.queuedPromptCardWrites.clear();
1365
1397
  this.sessionResets.clear();
1398
+ this.planHandoffs.clear();
1366
1399
  this.resetHistoryOperations.clear();
1367
1400
  this.remoteTurnHistoryRefreshes.clear();
1368
1401
  this.forkHistoryCursors.clear();
@@ -1766,79 +1799,44 @@ export class ProxySessionController {
1766
1799
  models: Awaited<ReturnType<AgentRuntime["listModels"]>>;
1767
1800
  reasoningOptions: Awaited<ReturnType<AgentRuntime["listModels"]>>[number]["supportedReasoningEfforts"];
1768
1801
  permissionModes: PermissionMode[];
1802
+ pendingProvider?: PendingProviderSettings;
1769
1803
  }> {
1770
1804
  const record = this.requireControlSession(localSessionId);
1771
1805
  if (setting && !value?.trim()) throw new Error(`task ${setting} requires a value.`);
1772
1806
  const nextValue = value?.trim();
1773
- if (setting === "provider" && nextValue) {
1807
+ if (setting && nextValue) {
1774
1808
  const contextKey = this.controlSessionContextKey(record);
1775
- await this.outbound.withReplyTarget(
1776
- contextKey,
1777
- this.controlSessionReplyTarget(record),
1778
- () => this.outbound.sendText(contextKey, `正在切换到 Provider ${nextValue},请稍后。`),
1779
- );
1780
- }
1781
- const loaded = await this.loadSession(record);
1782
- if (setting === "provider" && nextValue) {
1783
- await this.assertModelProvider(loaded, nextValue);
1784
- const models = await loaded.runtime.listModels(nextValue, loaded.session.model);
1785
- const model = selectProviderModel(models, loaded.session.model);
1786
- if (!model) throw new Error("The current runtime has no model available for a Provider change.");
1787
- const effort = selectProviderReasoningEffort(model, loaded.session.reasoningEffort);
1788
- await this.changeProviderSettings(loaded, {
1789
- modelProvider: nextValue,
1790
- model: model.id,
1791
- reasoningEffort: effort,
1792
- permissionMode: loaded.session.permissionMode,
1809
+ await this.outbound.withReplyTarget(contextKey, this.controlSessionReplyTarget(record), async () => {
1810
+ const options = { sessionId: record.localSessionId };
1811
+ if (setting === "provider") await this.selectProvider(contextKey, nextValue, options);
1812
+ else if (setting === "model") await this.model(contextKey, nextValue, options);
1813
+ else if (setting === "thinking") await this.thinking(contextKey, nextValue, options);
1814
+ else {
1815
+ if (nextValue !== "auto" && nextValue !== "confirm") throw new Error("task permissions accepts auto or confirm.");
1816
+ await this.permissions(contextKey, nextValue, options);
1817
+ }
1793
1818
  });
1794
- } else if (setting === "model" && nextValue) {
1795
- const models = await loaded.runtime.listModels(loaded.session.modelProvider, loaded.session.model);
1796
- const selected = models.find((candidate) => candidate.id === nextValue);
1797
- if (!selected) throw new Error(`Unknown model: ${nextValue}`);
1798
- const currentEffort = loaded.session.reasoningEffort;
1799
- const nextEffort = currentEffort && (selected.supportedReasoningEfforts.length === 0
1800
- || selected.supportedReasoningEfforts.some((candidate) => candidate.value === currentEffort))
1801
- ? currentEffort
1802
- : selected.defaultReasoningEffort;
1803
- await loaded.runtime.setModel(record.localSessionId, nextValue);
1804
- if (nextEffort && nextEffort !== currentEffort) {
1805
- await loaded.runtime.setReasoningEffort(record.localSessionId, nextEffort);
1806
- }
1807
- this.store.updateRuntimeSession(record.localSessionId, { model: nextValue, reasoningEffort: nextEffort });
1808
- this.outbound.updateSessionModel(record.localSessionId, nextValue, loaded.session.modelProvider);
1809
- } else if (setting === "thinking" && nextValue) {
1810
- const models = await loaded.runtime.listModels(loaded.session.modelProvider, loaded.session.model);
1811
- const currentModel = models.find((candidate) => candidate.id === loaded.session.model)
1819
+ }
1820
+ return this.settingsOperations.run(localSessionId, async () => {
1821
+ const loaded = await this.loadSession(this.store.getSession(localSessionId) ?? record, true);
1822
+ const session = this.store.getSession(record.localSessionId) ?? record;
1823
+ const pending = this.store.getPendingProviderSettings(record.localSessionId);
1824
+ const selected = pending?.settings ?? session;
1825
+ const models = await loaded.runtime.listModels(selected.modelProvider, selected.model);
1826
+ const currentModel = models.find((candidate) => candidate.id === selected.model)
1812
1827
  ?? models.find((candidate) => candidate.isDefault);
1813
- if (!currentModel) throw new Error("The current runtime has no model with configurable reasoning effort.");
1814
- if (!currentModel.supportedReasoningEfforts.some((candidate) => candidate.value === nextValue)) {
1815
- const supported = currentModel.supportedReasoningEfforts.map((candidate) => candidate.value).join(", ") || "none";
1816
- throw new Error(`Unsupported reasoning effort: ${nextValue}. Supported values: ${supported}.`);
1817
- }
1818
- await loaded.runtime.setReasoningEffort(record.localSessionId, nextValue);
1819
- this.store.updateRuntimeSession(record.localSessionId, { reasoningEffort: nextValue });
1820
- } else if (setting === "permissions" && nextValue) {
1821
- if (nextValue !== "auto" && nextValue !== "confirm") {
1822
- throw new Error("task permissions accepts auto or confirm.");
1823
- }
1824
- await loaded.runtime.setPermissionMode(record.localSessionId, nextValue);
1825
- this.store.updateRuntimeSession(record.localSessionId, { permissionMode: nextValue });
1826
- }
1827
- const session = this.store.getSession(record.localSessionId) ?? record;
1828
- if (setting && setting !== "provider") await this.persistAgentExecutionDefaults(session.localSessionId);
1829
- const models = await loaded.runtime.listModels(session.modelProvider, session.model);
1830
- const currentModel = models.find((candidate) => candidate.id === session.model)
1831
- ?? models.find((candidate) => candidate.isDefault);
1832
- const providers = loaded.runtime.listModelProviders
1833
- ? await this.modelProviderOptions(loaded)
1834
- : [];
1835
- return {
1836
- session,
1837
- providers,
1838
- models,
1839
- reasoningOptions: currentModel?.supportedReasoningEfforts ?? [],
1840
- permissionModes: ["auto", "confirm"],
1841
- };
1828
+ const providers = loaded.runtime.listModelProviders
1829
+ ? await this.modelProviderOptions(loaded)
1830
+ : [];
1831
+ return {
1832
+ session,
1833
+ pendingProvider: pending,
1834
+ providers,
1835
+ models,
1836
+ reasoningOptions: currentModel?.supportedReasoningEfforts ?? [],
1837
+ permissionModes: ["auto", "confirm"],
1838
+ };
1839
+ });
1842
1840
  }
1843
1841
 
1844
1842
  async controlTaskGoal(
@@ -2309,6 +2307,9 @@ export class ProxySessionController {
2309
2307
  if (!this.recoveryRetryTimers.has(attempt.attemptId)) this.recoveryRetryStartedAt.delete(attempt.attemptId);
2310
2308
  }
2311
2309
  }
2310
+ for (const pending of this.store.listPendingProviderSettings()) {
2311
+ await this.applyPendingProviderWhenIdle(pending.localSessionId);
2312
+ }
2312
2313
  this.restorePersistedQueuedPrompts();
2313
2314
  }
2314
2315
 
@@ -2652,6 +2653,16 @@ export class ProxySessionController {
2652
2653
  });
2653
2654
  const pendingAttempt = this.store.getTurnAttempt(attempt.attemptId);
2654
2655
  if (!pendingAttempt || !isIncompleteTurnAttemptStatus(pendingAttempt.status)) return;
2656
+ const continued = turnViewSnapshot(this.store.getTurnSnapshot(turnId));
2657
+ if (continued?.continuedFromTurnId && this.store.getTurnDelivery(turnId)?.progressMessageId) {
2658
+ this.store.updateTurnAttempt(attempt.attemptId, { pendingTurnId: null, turnId, status: "running" });
2659
+ await this.outbound.onEvent({ type: "turn_started", sessionId: session.localSessionId,
2660
+ turnId, startedAt: continued.startedAt });
2661
+ const synchronized = await loaded.runtime.synchronizeSession(session.localSessionId);
2662
+ this.recoveryRetryStartedAt.delete(attempt.attemptId);
2663
+ if (synchronized.activeTurnId) this.recoveredLiveAttempts.add(attempt.attemptId);
2664
+ return;
2665
+ }
2655
2666
  const oldCardTurnId = attempt.pendingTurnId ?? attempt.turnId;
2656
2667
  if (oldCardTurnId) {
2657
2668
  await this.outbound.interruptTurnForRecovery(
@@ -2717,7 +2728,8 @@ export class ProxySessionController {
2717
2728
  });
2718
2729
  }
2719
2730
  const current = this.store.getSession(session.localSessionId) ?? session;
2720
- const loaded = await this.loadSession({ ...current, contextKey: attempt.contextKey });
2731
+ const loaded = await this.loadSession({ ...current, contextKey: attempt.contextKey },
2732
+ Boolean(this.store.getPendingProviderSettings(current.localSessionId)));
2721
2733
  const recoveryText = recoveryRuntimePrompt(attempt.promptText);
2722
2734
  const turnId = await this.startTurn(
2723
2735
  loaded,
@@ -2784,7 +2796,7 @@ export class ProxySessionController {
2784
2796
  localImagePaths?: string[],
2785
2797
  displayPrompt?: string,
2786
2798
  ): Promise<void> {
2787
- if (this.llmRetryingSessions.has(record.localSessionId)) {
2799
+ if (this.llmRetryingSessions.has(record.localSessionId) || this.planHandoffs.has(record.localSessionId)) {
2788
2800
  const queued = this.persistQueuedPrompt(record.localSessionId, contextKey, text, {
2789
2801
  localImagePaths,
2790
2802
  messageId,
@@ -2820,7 +2832,7 @@ export class ProxySessionController {
2820
2832
  }
2821
2833
  let loaded: LoadedSession;
2822
2834
  try {
2823
- loaded = await this.loadSession(record);
2835
+ loaded = await this.loadSession(record, Boolean(this.store.getPendingProviderSettings(record.localSessionId)));
2824
2836
  } catch (error) {
2825
2837
  if (await this.presentThreadWriterConflict(record, contextKey, error, true)) {
2826
2838
  throw new PresentedRequestError(error);
@@ -3960,6 +3972,25 @@ export class ProxySessionController {
3960
3972
  }
3961
3973
 
3962
3974
  private async startTurn(
3975
+ loaded: LoadedSession, text: string, replyTarget?: MessageReplyTarget,
3976
+ localImagePaths?: string[], options: StartTurnOptions = {},
3977
+ ): Promise<string> {
3978
+ return this.settingsOperations.run(loaded.record.localSessionId, async () => {
3979
+ try {
3980
+ await this.applyPendingProviderSettings(loaded);
3981
+ } catch (error) {
3982
+ await this.outbound.failPendingTurn(loaded.record.localSessionId, runtimeErrorMessage(error));
3983
+ throw error;
3984
+ }
3985
+ if (this.store.getPendingProviderSettings(loaded.record.localSessionId)) {
3986
+ throw new Error("Provider 切换尚未完成,请稍后重试。");
3987
+ }
3988
+ loaded.session = loaded.runtime.getSession(loaded.record.localSessionId) ?? loaded.session;
3989
+ return this.startTurnNow(loaded, text, replyTarget, localImagePaths, options);
3990
+ });
3991
+ }
3992
+
3993
+ private async startTurnNow(
3963
3994
  loaded: LoadedSession,
3964
3995
  text: string,
3965
3996
  replyTarget?: MessageReplyTarget,
@@ -4015,9 +4046,15 @@ export class ProxySessionController {
4015
4046
  this.store.updateTurnAttempt(attemptId, { pendingTurnId: pendingTurnId ?? null });
4016
4047
 
4017
4048
  try {
4049
+ if (options.approvedPlanTurnId && (this.planHandoffs.get(loaded.record.localSessionId) !== options.approvedPlanTurnId
4050
+ || this.sessionResets.has(loaded.record.localSessionId))) {
4051
+ throw new Error("方案执行已取消。");
4052
+ }
4018
4053
  turnId = await loaded.runtime.startTurn(
4019
4054
  loaded.record.localSessionId,
4020
- runtimePrompt(runtimeText, localImagePaths),
4055
+ options.approvedPlanTurnId
4056
+ ? { text: runtimeText, collaborationMode: "default" }
4057
+ : runtimePrompt(runtimeText, localImagePaths),
4021
4058
  );
4022
4059
  break;
4023
4060
  } catch (error) {
@@ -4590,18 +4627,27 @@ export class ProxySessionController {
4590
4627
  return { chatId: group.chatId, contextKey: groupContextKey, name: group.name };
4591
4628
  }
4592
4629
 
4593
- private async loadSession(record: SessionRecord): Promise<LoadedSession> {
4630
+ private async loadSession(record: SessionRecord, forSettings = false): Promise<LoadedSession> {
4594
4631
  const agent = this.ensureAgent(record.agentName);
4595
4632
  const runtime = this.runtimes.forAgent(record.agentName);
4596
4633
  const existing = runtime.getSession(record.localSessionId);
4597
- if (existing) return { record, runtime, session: existing };
4634
+ if (existing && (forSettings || !this.settingsOnlySessions.has(record.localSessionId))) {
4635
+ return { record, runtime, session: existing };
4636
+ }
4598
4637
  const pending = this.sessionLoads.get(record.localSessionId);
4599
4638
  if (pending) {
4600
- const loaded = await pending;
4601
- return {
4602
- ...loaded,
4603
- record: { ...loaded.record, contextKey: record.contextKey },
4604
- };
4639
+ try {
4640
+ const loaded = await pending;
4641
+ if (!forSettings && this.settingsOnlySessions.has(record.localSessionId)) {
4642
+ if (this.sessionLoads.get(record.localSessionId) === pending) this.sessionLoads.delete(record.localSessionId);
4643
+ return this.loadSession(this.store.getSession(record.localSessionId) ?? record);
4644
+ }
4645
+ return { ...loaded, record: { ...loaded.record, contextKey: record.contextKey } };
4646
+ } catch (error) {
4647
+ if (!forSettings || !runtime.prepareSessionForSettings) throw error;
4648
+ if (this.sessionLoads.get(record.localSessionId) === pending) this.sessionLoads.delete(record.localSessionId);
4649
+ return this.loadSession(record, forSettings);
4650
+ }
4605
4651
  }
4606
4652
 
4607
4653
  this.outbound.registerSession(
@@ -4613,7 +4659,16 @@ export class ProxySessionController {
4613
4659
  record.model,
4614
4660
  record.modelProvider,
4615
4661
  );
4616
- const loading = (async (): Promise<LoadedSession> => {
4662
+ const loading = this.sessionHydrationOperations.run(record.localSessionId, async (): Promise<LoadedSession> => {
4663
+ const latest = this.store.getSession(record.localSessionId);
4664
+ if (latest) record = {
4665
+ ...record, modelProvider: latest.modelProvider, model: latest.model,
4666
+ reasoningEffort: latest.reasoningEffort, permissionMode: latest.permissionMode,
4667
+ };
4668
+ const current = runtime.getSession(record.localSessionId);
4669
+ if (current && (forSettings || !this.settingsOnlySessions.has(record.localSessionId))) {
4670
+ return { record, runtime, session: current };
4671
+ }
4617
4672
  if (record.lastTurnId) {
4618
4673
  try {
4619
4674
  await this.outbound.resumeDelivery(record.localSessionId, record.contextKey, record.lastTurnId);
@@ -4625,41 +4680,30 @@ export class ProxySessionController {
4625
4680
  }
4626
4681
  }
4627
4682
  const permissionMode = record.permissionMode ?? "auto";
4628
- if (record.remoteSessionId) await this.assertSessionTurnOwnership(record, runtime);
4683
+ const prepare = forSettings && record.remoteSessionId && runtime.prepareSessionForSettings;
4684
+ if (record.remoteSessionId && !prepare) await this.assertSessionTurnOwnership(record, runtime);
4629
4685
  let session: RuntimeSession;
4630
4686
  if (record.remoteSessionId) {
4631
- try {
4632
- session = await runtime.resumeSession({
4633
- localSessionId: record.localSessionId,
4634
- remoteSessionId: record.remoteSessionId,
4635
- agentName: record.agentName,
4636
- cwd: record.cwd,
4637
- title: record.title,
4638
- modelProvider: record.modelProvider,
4639
- model: record.model,
4640
- reasoningEffort: record.reasoningEffort,
4641
- permissionMode,
4642
- activeTurnId: agent.kind === "app-server" && record.status === "running" && record.lastTurnStatus === "running"
4643
- ? record.lastTurnId
4644
- : undefined,
4645
- lastTurnId: record.lastTurnId,
4646
- lastTurnStatus: record.lastTurnStatus,
4647
- });
4648
- } catch (error) {
4649
- if (activeWriterThreadId(error)) throw error;
4650
- if (!(agent.kind === "app-server" && !record.lastTurnId
4651
- && !this.store.getForkHistorySource(record.localSessionId) && isMissingRolloutError(error))) throw error;
4652
- this.logger.warn({ error, sessionId: record.localSessionId }, "App Server task has no rollout; creating a replacement task.");
4653
- session = await runtime.createSession({
4654
- localSessionId: record.localSessionId,
4655
- agentName: record.agentName,
4656
- cwd: record.cwd,
4657
- title: record.title,
4658
- modelProvider: record.modelProvider,
4659
- model: record.model,
4660
- reasoningEffort: record.reasoningEffort,
4661
- permissionMode,
4662
- });
4687
+ const input = {
4688
+ localSessionId: record.localSessionId,
4689
+ remoteSessionId: record.remoteSessionId,
4690
+ agentName: record.agentName,
4691
+ cwd: record.cwd,
4692
+ title: record.title,
4693
+ modelProvider: record.modelProvider,
4694
+ model: record.model,
4695
+ reasoningEffort: record.reasoningEffort,
4696
+ permissionMode,
4697
+ activeTurnId: agent.kind === "app-server" && record.status === "running" && record.lastTurnStatus === "running"
4698
+ ? record.lastTurnId
4699
+ : undefined,
4700
+ lastTurnId: record.lastTurnId,
4701
+ lastTurnStatus: record.lastTurnStatus,
4702
+ };
4703
+ if (prepare) this.settingsOnlySessions.add(record.localSessionId);
4704
+ session = prepare ? await prepare.call(runtime, input) : await runtime.resumeSession(input);
4705
+ if (session.remoteSessionId !== record.remoteSessionId) {
4706
+ throw new Error("加载设置返回了不同的任务 ID,不允许替换任务或更改历史。");
4663
4707
  }
4664
4708
  } else {
4665
4709
  session = await runtime.createSession({
@@ -4673,19 +4717,22 @@ export class ProxySessionController {
4673
4717
  permissionMode,
4674
4718
  });
4675
4719
  }
4676
- this.persistRuntimeSession(record, session, session.activeTurnId ? "running" : "ready");
4720
+ if (!prepare) {
4721
+ this.settingsOnlySessions.delete(record.localSessionId);
4722
+ this.persistRuntimeSession(record, session, session.activeTurnId ? "running" : "ready");
4723
+ }
4677
4724
  const saved = this.store.getSession(record.localSessionId) ?? record;
4678
4725
  return {
4679
4726
  record: { ...saved, contextKey: record.contextKey },
4680
4727
  runtime,
4681
4728
  session,
4682
4729
  };
4683
- })();
4730
+ });
4684
4731
  this.sessionLoads.set(record.localSessionId, loading);
4685
4732
  try {
4686
4733
  return await loading;
4687
4734
  } finally {
4688
- this.sessionLoads.delete(record.localSessionId);
4735
+ if (this.sessionLoads.get(record.localSessionId) === loading) this.sessionLoads.delete(record.localSessionId);
4689
4736
  }
4690
4737
  }
4691
4738
 
@@ -4734,6 +4781,14 @@ export class ProxySessionController {
4734
4781
  );
4735
4782
  }
4736
4783
  }
4784
+ if (event.type === "turn_completed" && event.approvedPlan) {
4785
+ if (this.planHandoffs.get(event.sessionId) === event.turnId) return;
4786
+ const attempt = this.store.findIncompleteTurnAttemptByTurnId(event.turnId);
4787
+ if (attempt) {
4788
+ await this.continueApprovedPlan(event, attempt);
4789
+ return;
4790
+ }
4791
+ }
4737
4792
  const failedAttempt = event.type === "turn_failed"
4738
4793
  ? this.store.findIncompleteTurnAttemptByTurnId(event.turnId)
4739
4794
  : undefined;
@@ -4779,7 +4834,7 @@ export class ProxySessionController {
4779
4834
  ...event,
4780
4835
  turnCount: event.turnCount ?? this.store.countTaskTurns(event.sessionId),
4781
4836
  }
4782
- : event;
4837
+ : event.type === "turn_completed" && event.approvedPlan ? { ...event, approvedPlan: false } : event;
4783
4838
  const presentationEvent = event.type === "turn_failed"
4784
4839
  && isRetryableLlmTurnFailure(event.message)
4785
4840
  && (failedAttempt?.retryCount ?? 0) >= MAX_LLM_TURN_RETRIES
@@ -4817,6 +4872,7 @@ export class ProxySessionController {
4817
4872
  : undefined;
4818
4873
  if (latest?.lastTurnId === event.turnId && !activeTurnId) {
4819
4874
  this.store.updateSession(event.sessionId, { status: event.type === "turn_failed" ? "failed" : "ready" });
4875
+ await this.applyPendingProviderWhenIdle(event.sessionId);
4820
4876
  await this.scheduleNextQueuedPrompt(event.sessionId);
4821
4877
  }
4822
4878
  }
@@ -4824,6 +4880,80 @@ export class ProxySessionController {
4824
4880
  if (presentationError) throw presentationError;
4825
4881
  }
4826
4882
 
4883
+ private async continueApprovedPlan(
4884
+ event: Extract<RuntimeEvent, { type: "turn_completed" }>,
4885
+ attempt: TurnAttemptRecord,
4886
+ ): Promise<void> {
4887
+ const { sessionId, turnId } = event;
4888
+ const source = this.store.getSession(sessionId);
4889
+ if (!source || source.status === "closed" || source.lastTurnId !== turnId) return;
4890
+ this.planHandoffs.set(sessionId, turnId);
4891
+ this.store.markTurnAttemptTerminal(turnId, "completed");
4892
+ this.store.updateRuntimeSession(sessionId, { lastTurnId: turnId, lastTurnStatus: "completed" });
4893
+ let continued = false;
4894
+ let failed = false;
4895
+ try {
4896
+ try {
4897
+ await this.outbound.onEvent(event);
4898
+ } catch (error) {
4899
+ this.logger.warn({ error, sessionId, turnId }, "Failed to finalize the approved plan card.");
4900
+ }
4901
+ await this.settingsOperations.run(sessionId, async () => {
4902
+ const current = this.store.getSessionForContext(sessionId, attempt.contextKey);
4903
+ if (this.planHandoffs.get(sessionId) !== turnId || this.sessionResets.has(sessionId)
4904
+ || !current || current.status === "closed" || current.remoteSessionId !== source.remoteSessionId) return;
4905
+ const loaded = await this.loadSession(current);
4906
+ await this.assertSessionTurnOwnership(current, loaded.runtime);
4907
+ if (this.planHandoffs.get(sessionId) !== turnId || this.sessionResets.has(sessionId)) return;
4908
+ // Another client may already have started the implementation.
4909
+ let nextTurnId = loaded.session.activeTurnId;
4910
+ if (!nextTurnId) {
4911
+ if (current.lastTurnId !== turnId) return;
4912
+ await this.applyPendingProviderSettings(loaded);
4913
+ if (this.store.getPendingProviderSettings(sessionId)) throw new Error("Provider 切换尚未完成。");
4914
+ if (this.planHandoffs.get(sessionId) !== turnId || this.sessionResets.has(sessionId)) return;
4915
+ nextTurnId = await this.startTurnNow(
4916
+ loaded,
4917
+ "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.",
4918
+ attempt.replyMessageId ? { messageId: attempt.replyMessageId, replyInThread: true } : undefined,
4919
+ undefined,
4920
+ { messageId: attempt.messageId, displayPrompt: "方案已确认,继续执行", approvedPlanTurnId: turnId },
4921
+ );
4922
+ }
4923
+ continued = true;
4924
+ this.store.rebindPendingTurnMessages(sessionId, turnId, nextTurnId);
4925
+ const latest = this.store.getSession(sessionId);
4926
+ if (latest?.lastTurnId === nextTurnId && (latest.lastTurnStatus === "completed"
4927
+ || latest.lastTurnStatus === "failed" || latest.lastTurnStatus === "cancelled")) {
4928
+ await this.finalizeTurnMessageReactions(nextTurnId, latest.lastTurnStatus);
4929
+ }
4930
+ this.store.audit(attempt.contextKey, "approved_plan_continued", { sessionId, turnId, nextTurnId });
4931
+ });
4932
+ } catch (error) {
4933
+ failed = true;
4934
+ this.logger.error({ error, sessionId, turnId }, "Failed to continue an approved plan.");
4935
+ await this.outbound.sendText(attempt.contextKey,
4936
+ "方案已确认,但后续执行启动失败:" + runtimeErrorMessage(error) + "。请发送消息继续。");
4937
+ } finally {
4938
+ if (this.planHandoffs.get(sessionId) === turnId) this.planHandoffs.delete(sessionId);
4939
+ if (!continued) {
4940
+ try {
4941
+ await this.outbound.onEvent(failed
4942
+ ? { type: "turn_failed", sessionId, turnId, message: "方案已确认,但后续执行未能启动。" }
4943
+ : { type: "turn_cancelled", sessionId, turnId });
4944
+ } catch (error) {
4945
+ this.logger.warn({ error, sessionId, turnId }, "Failed to finish the plan handoff card.");
4946
+ }
4947
+ await this.finalizeTurnMessageReactions(turnId, failed ? "failed" : "cancelled");
4948
+ }
4949
+ const latest = this.store.getSession(sessionId);
4950
+ if (latest?.lastTurnId === turnId && latest.status !== "closed") {
4951
+ this.store.updateSession(sessionId, { status: failed ? "failed" : "ready" });
4952
+ }
4953
+ await this.scheduleNextQueuedPrompt(sessionId);
4954
+ }
4955
+ }
4956
+
4827
4957
  private async retryFailedLlmTurn(
4828
4958
  event: Extract<RuntimeEvent, { type: "turn_failed" }>,
4829
4959
  attempt: TurnAttemptRecord,
@@ -4865,7 +4995,7 @@ export class ProxySessionController {
4865
4995
  : undefined;
4866
4996
  try {
4867
4997
  const current = this.store.getSession(session.localSessionId) ?? session;
4868
- const loaded = await this.loadSession(current);
4998
+ const loaded = await this.loadSession(current, Boolean(this.store.getPendingProviderSettings(current.localSessionId)));
4869
4999
  const retryTurnId = await this.startTurn(
4870
5000
  loaded,
4871
5001
  llmRetryRuntimePrompt(attempt.promptText, retryNumber),
@@ -4913,6 +5043,7 @@ export class ProxySessionController {
4913
5043
  }
4914
5044
 
4915
5045
  private scheduleQueuedPromptRetry(sessionId: string): void {
5046
+ if (this.store.getPendingProviderSettings(sessionId)?.error) return;
4916
5047
  if (this.queuedPromptRetryTimers.has(sessionId)) return;
4917
5048
  if (this.store.countQueuedPrompts(sessionId) === 0) return;
4918
5049
  const timer = setTimeout(() => {
@@ -4930,24 +5061,31 @@ export class ProxySessionController {
4930
5061
  }
4931
5062
 
4932
5063
  private async startNextQueuedPromptIfIdle(sessionId: string): Promise<void> {
5064
+ return this.settingsOperations.run(sessionId, () => this.startNextQueuedPromptIfIdleNow(sessionId));
5065
+ }
5066
+
5067
+ private async startNextQueuedPromptIfIdleNow(sessionId: string): Promise<void> {
5068
+ if (this.planHandoffs.has(sessionId) || this.sessionResets.has(sessionId)) return;
4933
5069
  if (this.store.countQueuedPrompts(sessionId) === 0) return;
4934
5070
  const baseRecord = this.store.getSession(sessionId);
4935
5071
  if (!baseRecord || baseRecord.status === "closed") return;
4936
5072
  let prompt: QueuedPromptRecord | undefined;
4937
5073
  try {
4938
- let loaded = await this.loadSession(baseRecord);
5074
+ let loaded = await this.loadSession(baseRecord, Boolean(this.store.getPendingProviderSettings(sessionId)));
4939
5075
  const remoteActivity = await this.assertSessionTurnOwnership(loaded.record, loaded.runtime);
4940
5076
  if (needsFullSessionSynchronization(loaded.record, loaded.session, remoteActivity)) {
4941
5077
  loaded = { ...loaded, session: await loaded.runtime.synchronizeSession(sessionId) };
4942
5078
  }
4943
5079
  if (loaded.session.activeTurnId) return;
4944
5080
 
5081
+ await this.applyPendingProviderSettings(loaded);
5082
+ if (this.store.getPendingProviderSettings(sessionId)) return;
4945
5083
  prompt = this.store.takeNextQueuedPrompt(sessionId);
4946
5084
  if (!prompt) return;
4947
5085
  const record = this.store.getSessionForContext(sessionId, prompt.contextKey) ?? baseRecord;
4948
5086
  loaded = { ...loaded, record };
4949
5087
  await this.refreshPromptQueueCards(sessionId);
4950
- const turnId = await this.startTurn(
5088
+ const turnId = await this.startTurnNow(
4951
5089
  loaded,
4952
5090
  prompt.text,
4953
5091
  prompt.replyMessageId ? { messageId: prompt.replyMessageId, replyInThread: true } : undefined,
@@ -5018,6 +5156,7 @@ export class ProxySessionController {
5018
5156
  });
5019
5157
  const pending = {
5020
5158
  contextKey: message.contextKey,
5159
+ userId: message.userId,
5021
5160
  attachmentPromise,
5022
5161
  resolveAttachment,
5023
5162
  };
@@ -5026,14 +5165,15 @@ export class ProxySessionController {
5026
5165
  }
5027
5166
 
5028
5167
  private reserveForwardAttachment(message: IncomingMessage): ForwardAttachmentReservation | undefined {
5029
- if (!message.parentMessageId) return undefined;
5168
+ if (!message.parentMessageId || isCommandMessage(message)) return undefined;
5030
5169
  const candidates = [
5031
5170
  { sourceKind: "merged_forward" as const, registry: this.pendingMergedForwards },
5032
5171
  { sourceKind: "resource" as const, registry: this.pendingResourceForwards },
5033
5172
  ];
5034
5173
  for (const candidate of candidates) {
5035
5174
  const pending = candidate.registry.get(message.parentMessageId);
5036
- if (!pending || pending.contextKey !== message.contextKey || pending.attachmentMessageId) continue;
5175
+ if (!pending || pending.contextKey !== message.contextKey
5176
+ || pending.userId !== message.userId || pending.attachmentMessageId) continue;
5037
5177
  pending.attachmentMessageId = message.messageId;
5038
5178
  return {
5039
5179
  sourceMessageId: message.parentMessageId,
@@ -5062,15 +5202,17 @@ export class ProxySessionController {
5062
5202
  pending: PendingForwardAttachment,
5063
5203
  registry: Map<string, PendingForwardAttachment>,
5064
5204
  ): Promise<IncomingMessage | undefined> {
5065
- let timer: NodeJS.Timeout | undefined;
5066
- const timeout = new Promise<undefined>((resolve) => {
5067
- timer = setTimeout(() => resolve(undefined), FORWARD_ATTACHMENT_WINDOW_MS);
5068
- timer.unref?.();
5069
- });
5205
+ const timer = setTimeout(() => {
5206
+ // Arrival reserves the pair before acknowledgement. A slow Reaction must not split it.
5207
+ if (pending.attachmentMessageId) return;
5208
+ if (registry.get(messageId) === pending) registry.delete(messageId);
5209
+ pending.resolveAttachment(undefined);
5210
+ }, FORWARD_ATTACHMENT_WINDOW_MS);
5211
+ timer.unref?.();
5070
5212
  try {
5071
- return await Promise.race([pending.attachmentPromise, timeout]);
5213
+ return await pending.attachmentPromise;
5072
5214
  } finally {
5073
- if (timer) clearTimeout(timer);
5215
+ clearTimeout(timer);
5074
5216
  if (registry.get(messageId) === pending) {
5075
5217
  registry.delete(messageId);
5076
5218
  }
@@ -5149,6 +5291,7 @@ export class ProxySessionController {
5149
5291
  }
5150
5292
 
5151
5293
  private async cancelSession(record: SessionRecord): Promise<void> {
5294
+ this.planHandoffs.delete(record.localSessionId);
5152
5295
  const runtime = this.runtimes.forAgent(record.agentName);
5153
5296
  if (runtime.kind === "codex" && record.remoteSessionId && runtime.interruptRemoteTurn) {
5154
5297
  if (runtime.getGoal && runtime.setGoal) {
@@ -5227,6 +5370,7 @@ export class ProxySessionController {
5227
5370
  }
5228
5371
 
5229
5372
  private async runSessionReset(sessionId: string, operation: () => Promise<void>): Promise<void> {
5373
+ this.planHandoffs.delete(sessionId);
5230
5374
  const previous = this.sessionResets.get(sessionId) ?? Promise.resolve();
5231
5375
  const reset = previous.catch(() => undefined).then(operation);
5232
5376
  this.sessionResets.set(sessionId, reset);
@@ -5410,6 +5554,20 @@ export class ProxySessionController {
5410
5554
  contextKey: string,
5411
5555
  activeTab: ExecutionSettingsTab,
5412
5556
  options: ExecutionSettingsCardOptions = {},
5557
+ ): Promise<void> {
5558
+ const record = options.sessionId
5559
+ ? this.requireSession(contextKey, options.sessionId)
5560
+ : this.currentSession(contextKey);
5561
+ if (!record) return this.openExecutionSettingsNow(contextKey, activeTab, options);
5562
+ return this.settingsOperations.run(record.localSessionId, () => this.openExecutionSettingsNow(
5563
+ contextKey, activeTab, { ...options, sessionId: record.localSessionId },
5564
+ ));
5565
+ }
5566
+
5567
+ private async openExecutionSettingsNow(
5568
+ contextKey: string,
5569
+ activeTab: ExecutionSettingsTab,
5570
+ options: ExecutionSettingsCardOptions = {},
5413
5571
  ): Promise<void> {
5414
5572
  const context = this.store.getOrCreateUserContext(contextKey, this.config.defaults.agent!);
5415
5573
  const record = options.sessionId
@@ -5430,24 +5588,33 @@ export class ProxySessionController {
5430
5588
  reasoningOptions: [],
5431
5589
  };
5432
5590
  if (record) {
5433
- const loaded = await this.loadSession(record);
5591
+ const loaded = await this.loadSession(record, true);
5592
+ const pending = this.store.getPendingProviderSettings(record.localSessionId);
5593
+ const selected = pending?.settings ?? loaded.session;
5594
+ if (pending && options.updateMessageId) {
5595
+ this.store.savePendingProviderSettings({
5596
+ ...pending, contextKey, messageId: options.updateMessageId, replyMessageId: options.updateMessageId, activeTab,
5597
+ });
5598
+ }
5434
5599
  const providerSupported = loaded.runtime.kind === "codex" && Boolean(loaded.runtime.listModelProviders);
5435
5600
  const providers = providerSupported ? await this.modelProviderOptions(loaded) : [];
5436
- const currentProvider = loaded.session.modelProvider ?? providers.find((provider) => provider.isDefault)?.id;
5437
- const { models, warning } = await this.modelOptions(loaded.runtime, currentProvider, loaded.session.model);
5438
- const currentModel = models.find((model) => model.id === loaded.session.model)
5601
+ const currentProvider = selected.modelProvider ?? providers.find((provider) => provider.isDefault)?.id;
5602
+ const { models, warning } = await this.modelOptions(loaded.runtime, currentProvider, selected.model);
5603
+ const currentModel = models.find((model) => model.id === selected.model)
5439
5604
  ?? models.find((model) => model.isDefault)
5440
5605
  ?? models[0];
5441
5606
  const currentEffort = currentModel?.supportedReasoningEfforts.some(
5442
- (option) => option.value === loaded.session.reasoningEffort,
5607
+ (option) => option.value === selected.reasoningEffort,
5443
5608
  )
5444
- ? loaded.session.reasoningEffort
5445
- : currentModel?.defaultReasoningEffort ?? loaded.session.reasoningEffort;
5609
+ ? selected.reasoningEffort
5610
+ : currentModel?.defaultReasoningEffort ?? selected.reasoningEffort;
5446
5611
  runtimeSettings = {
5612
+ pendingNotice: pending ? this.pendingProviderNotice(pending) : undefined,
5613
+ pendingFailed: Boolean(pending?.error),
5447
5614
  currentProvider,
5448
- currentModel: currentModel?.id ?? loaded.session.model,
5615
+ currentModel: pending?.settings.model ?? currentModel?.id ?? selected.model,
5449
5616
  currentEffort,
5450
- currentPermissionMode: loaded.session.permissionMode,
5617
+ currentPermissionMode: selected.permissionMode,
5451
5618
  providers,
5452
5619
  providerSupported,
5453
5620
  models,
@@ -5463,7 +5630,10 @@ export class ProxySessionController {
5463
5630
  agents,
5464
5631
  runtimeSettingsAvailable: Boolean(record),
5465
5632
  ...runtimeSettings,
5633
+ modelPage: options.expectedProvider !== undefined && options.expectedProvider !== (runtimeSettings.currentProvider ?? "")
5634
+ ? undefined : options.modelPage,
5466
5635
  notice: options.notice,
5636
+ warning: [runtimeSettings.warning, options.warning].filter(Boolean).join("\n") || undefined,
5467
5637
  });
5468
5638
  if (options.updateMessageId) {
5469
5639
  await this.outbound.updateInteractiveCard(contextKey, options.updateMessageId, card);
@@ -5481,7 +5651,12 @@ export class ProxySessionController {
5481
5651
  await this.openExecutionSettings(contextKey, "agent");
5482
5652
  }
5483
5653
 
5484
- private async selectProvider(
5654
+ private async selectProvider(contextKey: string, modelProvider: string, options: ModelCardOptions = {}): Promise<void> {
5655
+ const record = options.sessionId ? this.requireSession(contextKey, options.sessionId) : this.requireCurrentSession(contextKey);
5656
+ return this.settingsOperations.run(record.localSessionId, () => this.selectProviderNow(contextKey, modelProvider, { ...options, sessionId: record.localSessionId }));
5657
+ }
5658
+
5659
+ private async selectProviderNow(
5485
5660
  contextKey: string,
5486
5661
  modelProvider: string,
5487
5662
  options: ModelCardOptions = {},
@@ -5489,19 +5664,19 @@ export class ProxySessionController {
5489
5664
  const record = options.sessionId
5490
5665
  ? this.requireSession(contextKey, options.sessionId)
5491
5666
  : this.requireCurrentSession(contextKey);
5492
- await this.outbound.sendText(contextKey, `正在切换到 Provider ${modelProvider},请稍后。`);
5493
- const loaded = await this.loadSession(record);
5667
+ const loaded = await this.loadSession(record, true);
5668
+ const selected = this.store.getPendingProviderSettings(record.localSessionId)?.settings ?? loaded.session;
5494
5669
  await this.assertModelProvider(loaded, modelProvider);
5495
- const models = await loaded.runtime.listModels(modelProvider, loaded.session.model);
5496
- const model = selectProviderModel(models, loaded.session.model);
5670
+ const models = await loaded.runtime.listModels(modelProvider, selected.model);
5671
+ const model = selectProviderModel(models, selected.model);
5497
5672
  if (!model) throw new Error("当前运行时没有可用于 Provider 切换的模型。");
5498
- const effort = selectProviderReasoningEffort(model, loaded.session.reasoningEffort);
5499
- await this.applyProviderSettings(contextKey, {
5673
+ const effort = selectProviderReasoningEffort(model, selected.reasoningEffort);
5674
+ await this.applyProviderSettingsNow(contextKey, {
5500
5675
  provider: modelProvider,
5501
5676
  model: model.id,
5502
5677
  effort,
5503
- mode: loaded.session.permissionMode,
5504
- }, options, false);
5678
+ mode: selected.permissionMode,
5679
+ }, options);
5505
5680
  }
5506
5681
 
5507
5682
  private async openProviderSelector(
@@ -5517,14 +5692,16 @@ export class ProxySessionController {
5517
5692
  permissionMode: PermissionMode,
5518
5693
  sessionId: string,
5519
5694
  updateMessageId: string,
5695
+ modelPage?: number,
5520
5696
  ): Promise<void> {
5521
- const loaded = await this.loadSession(this.requireSession(contextKey, sessionId));
5697
+ const loaded = await this.loadSession(this.requireSession(contextKey, sessionId), true);
5522
5698
  await this.assertModelProvider(loaded, modelProvider);
5523
5699
  const { models, warning } = await this.modelOptions(loaded.runtime, modelProvider, loaded.session.model);
5524
5700
  const card = this.cardRenderer.renderModelSelector({
5525
5701
  sessionId,
5526
5702
  contextKey,
5527
5703
  currentModel: selectProviderModel(models, loaded.session.model)?.id,
5704
+ modelPage,
5528
5705
  reasoningEffort: loaded.session.reasoningEffort,
5529
5706
  models,
5530
5707
  warning,
@@ -5543,7 +5720,7 @@ export class ProxySessionController {
5543
5720
  sessionId: string,
5544
5721
  updateMessageId: string,
5545
5722
  ): Promise<void> {
5546
- const loaded = await this.loadSession(this.requireSession(contextKey, sessionId));
5723
+ const loaded = await this.loadSession(this.requireSession(contextKey, sessionId), true);
5547
5724
  await this.assertModelProvider(loaded, modelProvider);
5548
5725
  const { models, warning } = await this.modelOptions(loaded.runtime, modelProvider, model);
5549
5726
  const selected = models.find((candidate) => candidate.id === model);
@@ -5579,7 +5756,7 @@ export class ProxySessionController {
5579
5756
  sessionId: string,
5580
5757
  updateMessageId: string,
5581
5758
  ): Promise<void> {
5582
- const loaded = await this.loadSession(this.requireSession(contextKey, sessionId));
5759
+ const loaded = await this.loadSession(this.requireSession(contextKey, sessionId), true);
5583
5760
  await this.assertProviderModelSettings(loaded, modelProvider, model, effort);
5584
5761
  await this.outbound.updateInteractiveCard(
5585
5762
  contextKey,
@@ -5595,7 +5772,12 @@ export class ProxySessionController {
5595
5772
  );
5596
5773
  }
5597
5774
 
5598
- private async applyProviderSettings(
5775
+ private async applyProviderSettings(contextKey: string, settings: { provider: string; model: string; effort: string; mode: PermissionMode }, options: ModelCardOptions = {}, announceStart = true): Promise<void> {
5776
+ const record = options.sessionId ? this.requireSession(contextKey, options.sessionId) : this.requireCurrentSession(contextKey);
5777
+ return this.settingsOperations.run(record.localSessionId, () => this.applyProviderSettingsNow(contextKey, settings, { ...options, sessionId: record.localSessionId }, announceStart));
5778
+ }
5779
+
5780
+ private async applyProviderSettingsNow(
5599
5781
  contextKey: string,
5600
5782
  settings: { provider: string; model: string; effort: string; mode: PermissionMode },
5601
5783
  options: ModelCardOptions = {},
@@ -5604,17 +5786,38 @@ export class ProxySessionController {
5604
5786
  const record = options.sessionId
5605
5787
  ? this.requireSession(contextKey, options.sessionId)
5606
5788
  : this.requireCurrentSession(contextKey);
5607
- if (announceStart) {
5608
- await this.outbound.sendText(contextKey, `正在切换到 Provider ${settings.provider},请稍后。`);
5609
- }
5610
- const loaded = await this.loadSession(record);
5789
+ const loaded = await this.loadSession(record, true);
5611
5790
  const warning = await this.assertProviderModelSettings(loaded, settings.provider, settings.model, settings.effort);
5612
- const session = await this.changeProviderSettings(loaded, {
5613
- modelProvider: settings.provider,
5614
- model: settings.model,
5615
- reasoningEffort: settings.effort,
5616
- permissionMode: settings.mode,
5617
- });
5791
+ const next: RuntimeExecutionSettings = {
5792
+ modelProvider: settings.provider, model: settings.model,
5793
+ reasoningEffort: settings.effort, permissionMode: settings.mode,
5794
+ };
5795
+ await this.reconcilePreparedSettingsSession(loaded);
5796
+ if (loaded.session.activeTurnId || this.store.getPendingProviderSettings(record.localSessionId)) {
5797
+ await this.savePendingProviderSelection(loaded, next, contextKey, options, "provider");
5798
+ return;
5799
+ }
5800
+ if (announceStart) await this.outbound.sendText(contextKey, `正在切换到 Provider ${settings.provider},请稍后。`);
5801
+ const previous = { ...loaded.session };
5802
+ let session: RuntimeSession;
5803
+ try {
5804
+ session = await this.changeProviderSettings(loaded, next);
5805
+ } catch (error) {
5806
+ if (!this.providerSettingsRestored(loaded, previous, error)) {
5807
+ this.store.savePendingProviderSettings({
5808
+ localSessionId: record.localSessionId, contextKey, settings: next, activeTab: "provider",
5809
+ messageId: options.updateMessageId,
5810
+ replyMessageId: options.updateMessageId ?? this.controlSessionReplyTarget(record)?.messageId,
5811
+ error: runtimeErrorMessage(error),
5812
+ });
5813
+ if (options.updateMessageId) {
5814
+ await this.openExecutionSettingsNow(contextKey, "provider", options).catch((presentationError: unknown) => {
5815
+ this.logger.warn({ error: presentationError, sessionId: record.localSessionId }, "Failed to refresh rejected Provider selection.");
5816
+ });
5817
+ }
5818
+ }
5819
+ throw error;
5820
+ }
5618
5821
  const notice = [
5619
5822
  `Provider 已切换为 ${cardCode(session.modelProvider ?? settings.provider)}`,
5620
5823
  `模型 ${cardCode(session.model ?? settings.model)}`,
@@ -5623,7 +5826,7 @@ export class ProxySessionController {
5623
5826
  `并已保存为 ${cardCode(loaded.record.agentName)} Agent 的默认设置,从下一次请求生效。`,
5624
5827
  ].join(",");
5625
5828
  if (options.updateMessageId) {
5626
- await this.openProviderSelector(contextKey, {
5829
+ await this.openExecutionSettingsNow(contextKey, "provider", {
5627
5830
  sessionId: loaded.record.localSessionId,
5628
5831
  updateMessageId: options.updateMessageId,
5629
5832
  notice,
@@ -5634,15 +5837,23 @@ export class ProxySessionController {
5634
5837
  }
5635
5838
 
5636
5839
  private async changeProviderSettings(loaded: LoadedSession, settings: RuntimeExecutionSettings): Promise<RuntimeSession> {
5840
+ return this.sessionHydrationOperations.run(loaded.record.localSessionId, () => this.changeProviderSettingsNow(loaded, settings));
5841
+ }
5842
+
5843
+ private async changeProviderSettingsNow(loaded: LoadedSession, settings: RuntimeExecutionSettings): Promise<RuntimeSession> {
5637
5844
  if (!loaded.runtime.setExecutionSettings) throw new Error("当前运行时不支持 Provider 设置。");
5638
- const persist = async (candidate: RuntimeSession): Promise<void> => {
5845
+ loaded.session = loaded.runtime.getSession(loaded.record.localSessionId) ?? loaded.session;
5846
+ const remoteSessionId = loaded.session.remoteSessionId;
5847
+ const persist = (candidate: RuntimeSession): Promise<void> => this.agentDefaultsOperations.run(loaded.record.agentName, async () => {
5848
+ if (candidate.remoteSessionId !== remoteSessionId) {
5849
+ throw new Error("设置切换返回了不同的任务 ID,未保存设置;不允许替换任务或更改历史。");
5850
+ }
5639
5851
  assertProviderSettingsApplied(candidate, settings);
5640
5852
  const agent = this.ensureAgent(loaded.record.agentName);
5641
5853
  const previousDefaults = agent.defaults;
5642
- await this.persistAgentExecutionDefaults(loaded.record.localSessionId, candidate);
5854
+ await this.persistAgentExecutionDefaultsNow(loaded.record.localSessionId, candidate);
5643
5855
  try {
5644
5856
  this.store.updateRuntimeSession(loaded.record.localSessionId, {
5645
- remoteSessionId: candidate.remoteSessionId,
5646
5857
  modelProvider: candidate.modelProvider,
5647
5858
  model: candidate.model,
5648
5859
  reasoningEffort: candidate.reasoningEffort,
@@ -5653,12 +5864,145 @@ export class ProxySessionController {
5653
5864
  agent.defaults = previousDefaults;
5654
5865
  throw error;
5655
5866
  }
5656
- };
5867
+ });
5657
5868
  const session = await loaded.runtime.setExecutionSettings(loaded.record.localSessionId, settings, persist);
5869
+ this.settingsOnlySessions.delete(loaded.record.localSessionId);
5658
5870
  this.outbound.updateSessionModel(loaded.record.localSessionId, session.model, session.modelProvider);
5659
5871
  return session;
5660
5872
  }
5661
5873
 
5874
+ private async reconcilePreparedSettingsSession(loaded: LoadedSession): Promise<void> {
5875
+ if (!this.settingsOnlySessions.has(loaded.record.localSessionId) || !loaded.session.activeTurnId) return;
5876
+ const activity = await this.assertSessionTurnOwnership(loaded.record, loaded.runtime);
5877
+ if (activity && !activity.active) {
5878
+ loaded.session = await loaded.runtime.synchronizeSession(loaded.record.localSessionId);
5879
+ }
5880
+ }
5881
+
5882
+ private providerSettingsRestored(loaded: LoadedSession, previous: RuntimeSession, error: unknown): boolean {
5883
+ const restored = loaded.runtime.getSession(loaded.record.localSessionId);
5884
+ const saved = this.store.getSession(loaded.record.localSessionId);
5885
+ const keys = ["remoteSessionId", "modelProvider", "model", "reasoningEffort", "permissionMode"] as const;
5886
+ return error instanceof ProviderSwitchError && error.previousSettingsRestored
5887
+ && Boolean(restored && saved && keys.every((key) => restored[key] === previous[key] && saved[key] === previous[key]));
5888
+ }
5889
+
5890
+ private pendingProviderNotice(pending: PendingProviderSettings): string {
5891
+ const target = `Provider ${cardCode(pending.settings.modelProvider)},模型 ${cardCode(pending.settings.model)}`;
5892
+ return pending.error
5893
+ ? `切换失败:${pending.error}。目标为 ${target};后续轮次已暂停,请重新选择 Provider 或模型重试。`
5894
+ : `待生效:当前轮次不变,结束后切换到 ${target}。可继续选择模型,以最后选择为准。`;
5895
+ }
5896
+
5897
+ private async savePendingProviderSelection(
5898
+ loaded: LoadedSession, settings: RuntimeExecutionSettings, contextKey: string,
5899
+ options: ModelCardOptions, activeTab: ExecutionSettingsTab,
5900
+ ): Promise<void> {
5901
+ const previous = this.store.getPendingProviderSettings(loaded.record.localSessionId);
5902
+ const pending: PendingProviderSettings = {
5903
+ localSessionId: loaded.record.localSessionId, contextKey, settings, activeTab,
5904
+ messageId: options.updateMessageId ?? previous?.messageId,
5905
+ replyMessageId: options.updateMessageId ?? previous?.replyMessageId
5906
+ ?? this.controlSessionReplyTarget(loaded.record)?.messageId,
5907
+ };
5908
+ this.store.savePendingProviderSettings(pending);
5909
+ await this.reconcilePreparedSettingsSession(loaded);
5910
+ if (!loaded.session.activeTurnId) {
5911
+ await this.applyPendingProviderSettings(loaded);
5912
+ void this.scheduleNextQueuedPrompt(loaded.record.localSessionId).catch((error: unknown) => {
5913
+ this.logger.warn({ error }, "Failed to resume queue after Provider selection.");
5914
+ });
5915
+ return;
5916
+ }
5917
+ if (options.updateMessageId) {
5918
+ await this.openExecutionSettingsNow(contextKey, activeTab, options);
5919
+ } else {
5920
+ await this.outbound.sendText(contextKey, this.pendingProviderNotice(pending).replaceAll("`", ""));
5921
+ }
5922
+ }
5923
+
5924
+ // Caller holds settingsOperations; only a verified rollback can unblock the next turn.
5925
+ private async applyPendingProviderSettings(loaded: LoadedSession): Promise<void> {
5926
+ const pending = this.store.getPendingProviderSettings(loaded.record.localSessionId);
5927
+ if (!pending) return;
5928
+ await this.reconcilePreparedSettingsSession(loaded);
5929
+ if (loaded.runtime.getSession(loaded.record.localSessionId)?.activeTurnId) return;
5930
+ if (pending.error) throw new Error(this.pendingProviderNotice(pending));
5931
+ const previous = { ...(loaded.runtime.getSession(loaded.record.localSessionId) ?? loaded.session) };
5932
+ let notice: string;
5933
+ let failure: unknown;
5934
+ let rolledBack = false;
5935
+ try {
5936
+ loaded.session = await this.changeProviderSettings(loaded, pending.settings);
5937
+ this.store.deletePendingProviderSettings(pending.localSessionId);
5938
+ notice = `Provider 已切换为 ${cardCode(pending.settings.modelProvider)},模型 ${cardCode(pending.settings.model)},从下一轮生效。`;
5939
+ } catch (error) {
5940
+ const restored = loaded.runtime.getSession(pending.localSessionId);
5941
+ rolledBack = this.providerSettingsRestored(loaded, previous, error);
5942
+ if (rolledBack) {
5943
+ loaded.session = restored!;
5944
+ this.store.deletePendingProviderSettings(pending.localSessionId);
5945
+ this.outbound.updateSessionModel(pending.localSessionId, previous.model, previous.modelProvider);
5946
+ notice = `切换到 Provider ${cardCode(pending.settings.modelProvider)}、模型 ${cardCode(pending.settings.model)} 失败:${runtimeErrorMessage(error)} 已回退到 Provider ${cardCode(previous.modelProvider ?? "Agent 默认")}、模型 ${cardCode(previous.model ?? "默认")},后续轮次继续使用原设置。`;
5947
+ } else {
5948
+ failure = error;
5949
+ const failed = { ...pending, error: runtimeErrorMessage(error) };
5950
+ this.store.savePendingProviderSettings(failed);
5951
+ notice = this.pendingProviderNotice(failed);
5952
+ }
5953
+ }
5954
+ try {
5955
+ await this.outbound.withReplyTarget(pending.contextKey, pending.replyMessageId
5956
+ ? { messageId: pending.replyMessageId, replyInThread: true } : undefined, async () => {
5957
+ if (pending.messageId) {
5958
+ try {
5959
+ await this.openExecutionSettingsNow(pending.contextKey, pending.activeTab ?? "provider", {
5960
+ sessionId: pending.localSessionId, updateMessageId: pending.messageId,
5961
+ notice: failure || rolledBack ? undefined : notice,
5962
+ warning: rolledBack ? notice.replaceAll("`", "") : undefined,
5963
+ });
5964
+ return;
5965
+ } catch (error) {
5966
+ this.logger.warn({ error, sessionId: pending.localSessionId }, "Failed to refresh Provider settings card.");
5967
+ }
5968
+ }
5969
+ await this.outbound.sendText(pending.contextKey, notice.replaceAll("`", ""));
5970
+ });
5971
+ } catch (error) {
5972
+ this.logger.warn({ error, sessionId: pending.localSessionId }, "Failed to notify Provider settings result.");
5973
+ }
5974
+ if (failure) throw failure;
5975
+ }
5976
+
5977
+ private async applyPendingProviderWhenIdle(sessionId: string): Promise<void> {
5978
+ await this.settingsOperations.run(sessionId, async () => {
5979
+ if (this.recoveryClosed) return;
5980
+ const pending = this.store.getPendingProviderSettings(sessionId);
5981
+ if (!pending || pending.error) return;
5982
+ const record = this.store.getSession(sessionId);
5983
+ if (!record || record.status === "closed") {
5984
+ this.store.deletePendingProviderSettings(sessionId);
5985
+ return;
5986
+ }
5987
+ try {
5988
+ const loaded = await this.loadSession(record, true);
5989
+ await this.applyPendingProviderSettings(loaded);
5990
+ } catch (error) {
5991
+ this.logger.warn({ error, sessionId }, "Deferred Provider switch could not be applied.");
5992
+ if (!this.store.getPendingProviderSettings(sessionId)?.error) {
5993
+ const failed = { ...pending, error: runtimeErrorMessage(error) };
5994
+ this.store.savePendingProviderSettings(failed);
5995
+ await this.outbound.withReplyTarget(pending.contextKey, pending.replyMessageId
5996
+ ? { messageId: pending.replyMessageId, replyInThread: true } : undefined,
5997
+ () => this.outbound.sendText(pending.contextKey, this.pendingProviderNotice(failed).replaceAll("`", "")))
5998
+ .catch((notificationError: unknown) => {
5999
+ this.logger.warn({ error: notificationError, sessionId }, "Failed to notify Provider recovery failure.");
6000
+ });
6001
+ }
6002
+ }
6003
+ });
6004
+ }
6005
+
5662
6006
  private async assertProviderModelSettings(
5663
6007
  loaded: LoadedSession,
5664
6008
  modelProvider: string,
@@ -5703,36 +6047,52 @@ export class ProxySessionController {
5703
6047
  }
5704
6048
 
5705
6049
  private async model(contextKey: string, model?: string, options: ModelCardOptions = {}): Promise<void> {
6050
+ const record = options.sessionId ? this.requireSession(contextKey, options.sessionId) : this.requireCurrentSession(contextKey);
6051
+ return this.settingsOperations.run(record.localSessionId, () => this.modelNow(contextKey, model, { ...options, sessionId: record.localSessionId }));
6052
+ }
6053
+
6054
+ private async modelNow(contextKey: string, model?: string, options: ModelCardOptions = {}): Promise<void> {
5706
6055
  if (!model) {
5707
- await this.openExecutionSettings(contextKey, "model", options);
6056
+ await this.openExecutionSettingsNow(contextKey, "model", options);
5708
6057
  return;
5709
6058
  }
5710
6059
  const record = options.sessionId
5711
6060
  ? this.requireSession(contextKey, options.sessionId)
5712
6061
  : this.requireCurrentSession(contextKey);
5713
- const loaded = await this.loadSession(record);
5714
- const models = await loaded.runtime.listModels(loaded.session.modelProvider, loaded.session.model);
6062
+ const loaded = await this.loadSession(record, true);
6063
+ const pending = this.store.getPendingProviderSettings(record.localSessionId);
6064
+ const selectedSettings = pending?.settings ?? loaded.session;
6065
+ if (options.expectedProvider && options.expectedProvider !== selectedSettings.modelProvider) {
6066
+ throw new Error("Provider 已发生变化,请重新打开运行设置后选择。");
6067
+ }
6068
+ const models = await loaded.runtime.listModels(selectedSettings.modelProvider, selectedSettings.model);
5715
6069
  const selected = models.find((item) => item.id === model);
5716
6070
  if (!selected) throw new Error(`未知模型:${model}`);
5717
- const currentEffort = loaded.session.reasoningEffort;
6071
+ const currentEffort = selectedSettings.reasoningEffort;
5718
6072
  const compatible = currentEffort
5719
6073
  ? selected.supportedReasoningEfforts.length === 0
5720
6074
  || selected.supportedReasoningEfforts.some((option) => option.value === currentEffort)
5721
6075
  : false;
5722
6076
  const nextEffort = compatible ? currentEffort : selected.defaultReasoningEffort;
5723
6077
 
5724
- await loaded.runtime.setModel(loaded.record.localSessionId, model);
5725
- if (nextEffort && nextEffort !== currentEffort) {
5726
- await loaded.runtime.setReasoningEffort(loaded.record.localSessionId, nextEffort);
6078
+ if (pending) {
6079
+ await this.savePendingProviderSelection(loaded, { ...pending.settings, model, reasoningEffort: selectProviderReasoningEffort(selected, nextEffort) }, contextKey, options, "model");
6080
+ return;
5727
6081
  }
5728
- this.store.updateRuntimeSession(loaded.record.localSessionId, { model, reasoningEffort: nextEffort });
5729
- this.outbound.updateSessionModel(loaded.record.localSessionId, model, loaded.session.modelProvider);
5730
- await this.persistAgentExecutionDefaults(loaded.record.localSessionId);
6082
+ await this.sessionHydrationOperations.run(loaded.record.localSessionId, async () => {
6083
+ await loaded.runtime.setModel(loaded.record.localSessionId, model);
6084
+ if (nextEffort && nextEffort !== currentEffort) {
6085
+ await loaded.runtime.setReasoningEffort(loaded.record.localSessionId, nextEffort);
6086
+ }
6087
+ this.store.updateRuntimeSession(loaded.record.localSessionId, { model, reasoningEffort: nextEffort });
6088
+ this.outbound.updateSessionModel(loaded.record.localSessionId, model, loaded.session.modelProvider);
6089
+ await this.persistAgentExecutionDefaults(loaded.record.localSessionId);
6090
+ });
5731
6091
  const effortMessage = nextEffort && nextEffort !== currentEffort
5732
6092
  ? `,思考强度已自动调整为 ${nextEffort}`
5733
6093
  : "";
5734
6094
  if (options.updateMessageId) {
5735
- await this.openExecutionSettings(contextKey, "model", {
6095
+ await this.openExecutionSettingsNow(contextKey, "model", {
5736
6096
  sessionId: loaded.record.localSessionId,
5737
6097
  updateMessageId: options.updateMessageId,
5738
6098
  notice: `模型已切换为 ${cardCode(model)}${effortMessage},并已保存为 ${cardCode(loaded.record.agentName)} Agent 的默认设置,从下一次请求生效。`,
@@ -5908,6 +6268,7 @@ export class ProxySessionController {
5908
6268
  const created = await this.shellCommandJobs.createJob({ contextKey, sourceMessageId, command, cwd });
5909
6269
  let cardMessageId: string | undefined;
5910
6270
  const initialState: ShellCommandCardView = {
6271
+ terminalUrl: this.lifecycle?.terminalUrl?.(created.id),
5911
6272
  jobId: created.id,
5912
6273
  contextKey,
5913
6274
  command,
@@ -5934,7 +6295,7 @@ export class ProxySessionController {
5934
6295
  await this.outbound.updateInteractiveCard(
5935
6296
  contextKey,
5936
6297
  cardMessageId,
5937
- this.cardRenderer.renderShellCommandCard(shellCommandJobCardView(failed)),
6298
+ this.cardRenderer.renderShellCommandCard({ ...shellCommandJobCardView(failed), terminalUrl: this.lifecycle?.terminalUrl?.(failed.id) }),
5938
6299
  );
5939
6300
  await this.shellCommandJobs.markPresented(created.id);
5940
6301
  } catch (cardError) {
@@ -5992,7 +6353,7 @@ export class ProxySessionController {
5992
6353
  ): void {
5993
6354
  if (!job.cardMessageId || this.shellCommandJobMonitors.has(job.id)) return;
5994
6355
  const scheduler = new CardUpdateScheduler<ShellCommandCardView>({
5995
- render: (state) => this.cardRenderer.renderShellCommandCard(state),
6356
+ render: (state) => this.cardRenderer.renderShellCommandCard({ ...state, terminalUrl: this.lifecycle?.terminalUrl?.(job.id) }),
5996
6357
  write: (card) => this.outbound.updateInteractiveCard(job.contextKey, job.cardMessageId!, card),
5997
6358
  onError: (error) => this.logger.warn(
5998
6359
  { error, jobId: job.id, contextKey: job.contextKey, messageId: job.cardMessageId },
@@ -6143,16 +6504,26 @@ export class ProxySessionController {
6143
6504
  }
6144
6505
 
6145
6506
  private async thinking(contextKey: string, effort?: string, options: ThinkingCardOptions = {}): Promise<void> {
6507
+ const record = options.sessionId ? this.requireSession(contextKey, options.sessionId) : this.requireCurrentSession(contextKey);
6508
+ return this.settingsOperations.run(record.localSessionId, () => this.thinkingNow(contextKey, effort, { ...options, sessionId: record.localSessionId }));
6509
+ }
6510
+
6511
+ private async thinkingNow(contextKey: string, effort?: string, options: ThinkingCardOptions = {}): Promise<void> {
6146
6512
  if (!effort) {
6147
- await this.openExecutionSettings(contextKey, "thinking", options);
6513
+ await this.openExecutionSettingsNow(contextKey, "thinking", options);
6148
6514
  return;
6149
6515
  }
6150
6516
  const record = options.sessionId
6151
6517
  ? this.requireSession(contextKey, options.sessionId)
6152
6518
  : this.requireCurrentSession(contextKey);
6153
- const loaded = await this.loadSession(record);
6154
- const models = await loaded.runtime.listModels(loaded.session.modelProvider, loaded.session.model);
6155
- const currentModel = models.find((item) => item.id === loaded.session.model)
6519
+ const loaded = await this.loadSession(record, true);
6520
+ const pending = this.store.getPendingProviderSettings(record.localSessionId);
6521
+ const selectedSettings = pending?.settings ?? loaded.session;
6522
+ if (options.expectedProvider && options.expectedProvider !== selectedSettings.modelProvider) {
6523
+ throw new Error("Provider 已发生变化,请重新打开运行设置后选择。");
6524
+ }
6525
+ const models = await loaded.runtime.listModels(selectedSettings.modelProvider, selectedSettings.model);
6526
+ const currentModel = models.find((item) => item.id === selectedSettings.model)
6156
6527
  ?? models.find((item) => item.isDefault);
6157
6528
  if (!currentModel) throw new Error("当前运行时没有可配置思考强度的模型。");
6158
6529
  if (options.expectedModel && currentModel.id !== options.expectedModel) {
@@ -6164,11 +6535,17 @@ export class ProxySessionController {
6164
6535
  const options = supported.map((option) => option.value).join("、") || "无";
6165
6536
  throw new Error(`不支持的思考强度:${effort}。支持的强度:${options}`);
6166
6537
  }
6167
- await loaded.runtime.setReasoningEffort(loaded.record.localSessionId, effort);
6168
- this.store.updateRuntimeSession(loaded.record.localSessionId, { reasoningEffort: effort });
6169
- await this.persistAgentExecutionDefaults(loaded.record.localSessionId);
6538
+ if (pending) {
6539
+ await this.savePendingProviderSelection(loaded, { ...pending.settings, reasoningEffort: effort }, contextKey, options, "thinking");
6540
+ return;
6541
+ }
6542
+ await this.sessionHydrationOperations.run(loaded.record.localSessionId, async () => {
6543
+ await loaded.runtime.setReasoningEffort(loaded.record.localSessionId, effort);
6544
+ this.store.updateRuntimeSession(loaded.record.localSessionId, { reasoningEffort: effort });
6545
+ await this.persistAgentExecutionDefaults(loaded.record.localSessionId);
6546
+ });
6170
6547
  if (options.updateMessageId) {
6171
- await this.openExecutionSettings(contextKey, "thinking", {
6548
+ await this.openExecutionSettingsNow(contextKey, "thinking", {
6172
6549
  sessionId: loaded.record.localSessionId,
6173
6550
  updateMessageId: options.updateMessageId,
6174
6551
  notice: `思考强度已切换为 ${cardCode(effort)},并已保存为 ${cardCode(loaded.record.agentName)} Agent 的默认设置,从下一次请求生效。`,
@@ -6181,25 +6558,38 @@ export class ProxySessionController {
6181
6558
  );
6182
6559
  }
6183
6560
 
6184
- private async permissions(
6561
+ private async permissions(contextKey: string, mode?: PermissionMode, options: ModelCardOptions = {}): Promise<void> {
6562
+ const record = options.sessionId ? this.requireSession(contextKey, options.sessionId) : this.requireCurrentSession(contextKey);
6563
+ return this.settingsOperations.run(record.localSessionId, () => this.permissionsNow(contextKey, mode, { ...options, sessionId: record.localSessionId }));
6564
+ }
6565
+
6566
+ private async permissionsNow(
6185
6567
  contextKey: string,
6186
6568
  mode?: PermissionMode,
6187
6569
  options: ModelCardOptions = {},
6188
6570
  ): Promise<void> {
6189
6571
  if (!mode) {
6190
- await this.openExecutionSettings(contextKey, "permission", options);
6572
+ await this.openExecutionSettingsNow(contextKey, "permission", options);
6191
6573
  return;
6192
6574
  }
6193
6575
  const record = options.sessionId
6194
6576
  ? this.requireSession(contextKey, options.sessionId)
6195
6577
  : this.requireCurrentSession(contextKey);
6196
- const loaded = await this.loadSession(record);
6197
- await loaded.runtime.setPermissionMode(record.localSessionId, mode);
6198
- loaded.session.permissionMode = mode;
6199
- this.store.updateRuntimeSession(record.localSessionId, { permissionMode: mode });
6200
- await this.persistAgentExecutionDefaults(record.localSessionId);
6578
+ const loaded = await this.loadSession(record, true);
6579
+ const pending = this.store.getPendingProviderSettings(record.localSessionId);
6580
+ if (pending) {
6581
+ await this.savePendingProviderSelection(loaded, { ...pending.settings, permissionMode: mode }, contextKey, options, "permission");
6582
+ return;
6583
+ }
6584
+ await this.sessionHydrationOperations.run(record.localSessionId, async () => {
6585
+ await loaded.runtime.setPermissionMode(record.localSessionId, mode);
6586
+ loaded.session = loaded.runtime.getSession(record.localSessionId) ?? loaded.session;
6587
+ loaded.session.permissionMode = mode;
6588
+ this.store.updateRuntimeSession(record.localSessionId, { permissionMode: mode });
6589
+ await this.persistAgentExecutionDefaults(record.localSessionId);
6590
+ });
6201
6591
  if (options.updateMessageId) {
6202
- await this.openExecutionSettings(contextKey, "permission", {
6592
+ await this.openExecutionSettingsNow(contextKey, "permission", {
6203
6593
  sessionId: loaded.record.localSessionId,
6204
6594
  updateMessageId: options.updateMessageId,
6205
6595
  notice: mode === "auto"
@@ -6244,7 +6634,8 @@ export class ProxySessionController {
6244
6634
  const existing = this.turnDetailHydrations.get(turnId);
6245
6635
  if (existing) return existing;
6246
6636
  const snapshot = turnViewSnapshot(this.store.getTurnSnapshot(turnId));
6247
- if ((!snapshot || snapshot.previewJournal) && this.store.previews.has(turnId)) return this.store.previews.load(turnId);
6637
+ const previewTurnId = snapshot?.previewTurnId ?? turnId;
6638
+ if ((!snapshot || snapshot.previewJournal) && this.store.previews.has(previewTurnId)) return this.store.previews.load(previewTurnId);
6248
6639
  if (snapshot && !needsHistoricalDetails(snapshot)) return snapshot;
6249
6640
  const origin = !snapshot ? this.store.getTurnRuntimeOrigin(turnId) : undefined;
6250
6641
  if (!snapshot && (!origin || !this.runtimes.forAgent(origin.agentName).readRemoteTurn)) return undefined;
@@ -6270,7 +6661,7 @@ export class ProxySessionController {
6270
6661
  }
6271
6662
  // An Agent Bot turn may have arrived while the read-only request was in flight.
6272
6663
  const latest = turnViewSnapshot(this.store.getTurnSnapshot(origin.turnId));
6273
- if (latest) return latest.previewJournal ? this.store.previews.load(origin.turnId) ?? latest : latest;
6664
+ if (latest) return latest.previewJournal ? this.store.previews.load(latest.previewTurnId ?? origin.turnId) ?? latest : latest;
6274
6665
  const state = hydrateTurnViewState(createTurnViewState(
6275
6666
  origin.localSessionId, origin.turnId, details.startedAt ?? 0,
6276
6667
  remote?.title, undefined, remote?.cwd, undefined, this.agentLabel(origin.agentName),
@@ -7245,7 +7636,7 @@ export class ProxySessionController {
7245
7636
  const remoteReference = local
7246
7637
  ? remoteSessionReference(local.agentName, local.remoteSessionId!)
7247
7638
  : taskId;
7248
- const { agentName, runtime, remote } = await this.resolveRemoteCodexSession(remoteReference);
7639
+ const { agentName, runtime, remote } = await this.resolveRemoteCodexSession(remoteReference, { view: "metadata" });
7249
7640
  if (isRemoteSessionActive(remote)) {
7250
7641
  throw new Error("任务正在执行,无法归档。请先停止任务或等待本轮完成。");
7251
7642
  }
@@ -7254,7 +7645,11 @@ export class ProxySessionController {
7254
7645
  }
7255
7646
 
7256
7647
  const wasCurrent = Boolean(local && this.store.getUserContext(contextKey)?.currentSessionId === local.localSessionId);
7257
- await runtime.archiveRemoteSession(remote.id);
7648
+ const allowUnmaterialized = Boolean(local && !local.lastTurnId && !local.lastTurnStatus
7649
+ && !this.store.hasTurnAttemptsForSession(local.localSessionId)
7650
+ && !this.store.getForkHistorySource(local.localSessionId)
7651
+ && this.store.listTaskTurnGraphIndex(local.localSessionId).length === 0);
7652
+ await runtime.archiveRemoteSession(remote.id, { allowUnmaterialized });
7258
7653
  const title = local?.title ?? remote.title ?? remote.preview ?? remote.id;
7259
7654
  if (local) {
7260
7655
  this.store.archiveSession(local.localSessionId);
@@ -7407,6 +7802,7 @@ export class ProxySessionController {
7407
7802
  }
7408
7803
  const localActiveTurnId = runtime.getSession(record.localSessionId)?.activeTurnId;
7409
7804
  const runtimeOwnsActiveTurn = Boolean(localActiveTurnId)
7805
+ && (!this.settingsOnlySessions.has(record.localSessionId) || Boolean(activity.activeTurnId))
7410
7806
  && (!activity.activeTurnId || activity.activeTurnId === localActiveTurnId);
7411
7807
  const persistedTurnMatches = record.status === "running"
7412
7808
  && record.lastTurnStatus === "running"
@@ -7808,6 +8204,12 @@ export class ProxySessionController {
7808
8204
  }
7809
8205
 
7810
8206
  private async persistAgentExecutionDefaults(localSessionId: string, candidate?: RuntimeSession): Promise<void> {
8207
+ const record = this.store.getSession(localSessionId);
8208
+ if (!record) throw new Error(`找不到任务:${localSessionId}`);
8209
+ return this.agentDefaultsOperations.run(record.agentName, () => this.persistAgentExecutionDefaultsNow(localSessionId, candidate));
8210
+ }
8211
+
8212
+ private async persistAgentExecutionDefaultsNow(localSessionId: string, candidate?: RuntimeSession): Promise<void> {
7811
8213
  const record = this.store.getSession(localSessionId);
7812
8214
  if (!record) throw new Error(`找不到任务:${localSessionId}`);
7813
8215
  const session = candidate ?? record;
@@ -8937,6 +9339,13 @@ function executionSettingsTabValue(value: unknown): ExecutionSettingsTab {
8937
9339
  throw new Error("设置卡片的 tab 无效,请重新发送设置命令。");
8938
9340
  }
8939
9341
 
9342
+ function modelCardPageValue(value: unknown): number | undefined {
9343
+ if (value === undefined) return undefined;
9344
+ const page = typeof value === "number" || (typeof value === "string" && value.trim()) ? Number(value) : NaN;
9345
+ if (!Number.isSafeInteger(page) || page < 0) throw new Error("模型卡片页码无效,请重新发送 /model。");
9346
+ return page;
9347
+ }
9348
+
8940
9349
  function resetHistoryPageValue(value: unknown): number {
8941
9350
  const page = Number(value);
8942
9351
  if (!Number.isSafeInteger(page) || page < 0) throw new Error("历史轮次卡片页码无效,请重新发送 /turns。");
@@ -9344,10 +9753,18 @@ function defaultResourcePromptFromCounts(imageCount: number, fileCount: number):
9344
9753
  return imageCount === 1 ? "请查看这张图片" : "请查看这些图片";
9345
9754
  }
9346
9755
 
9347
- function isStandaloneResourceMessage(message: IncomingMessage): boolean {
9756
+ function isCommandMessage(message: IncomingMessage): boolean {
9757
+ const text = normalizeFeishuPostText(message.text).trimStart();
9758
+ return text.startsWith("/") || text.startsWith("!");
9759
+ }
9760
+
9761
+ function isForwardAttachmentSource(message: IncomingMessage): boolean {
9348
9762
  return !message.mergedForwardMessageId
9349
- && message.text.trim().length === 0
9350
- && ((message.images?.length ?? 0) > 0 || (message.files?.length ?? 0) > 0);
9763
+ && !message.parentMessageId
9764
+ && !message.contextKey.startsWith("console:")
9765
+ && !isCommandMessage(message)
9766
+ && (message.messageType === "post"
9767
+ || (message.images?.length ?? 0) > 0 || (message.files?.length ?? 0) > 0);
9351
9768
  }
9352
9769
 
9353
9770
  function deduplicateImageReferences(
@@ -9424,7 +9841,8 @@ function isRetryableLlmTurnFailure(message: string): boolean {
9424
9841
  const normalized = message.trim().toLowerCase();
9425
9842
  if (!normalized) return false;
9426
9843
  const permanentFailure = [
9427
- /auth(?:entication|orization)?\b|unauthorized|forbidden|invalid api key|incorrect api key/u,
9844
+ /\b(?:auth|authenticat\w*|unauthenticat\w*|authoriz\w*|unauthorized|forbidden)\b|invalid api key|incorrect api key/u,
9845
+ /(?:^|\D)(?:401|403)(?:\D|$)/u,
9428
9846
  /insufficient[_ -]?quota|quota (?:exceeded|exhausted)|usage limit|billing|credit balance/u,
9429
9847
  /context (?:length|window)|maximum context|too many tokens|token limit/u,
9430
9848
  /invalid request|bad request|invalid argument|malformed|unsupported|not supported/u,