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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +27 -3
  3. package/README.zh.md +27 -3
  4. package/SECURITY.md +12 -0
  5. package/config.example.yaml +5 -0
  6. package/dist/cli/controlProtocol.js.map +1 -1
  7. package/dist/cli/serverStatus.js +1 -0
  8. package/dist/cli/serverStatus.js.map +1 -1
  9. package/dist/cli.js +6 -1
  10. package/dist/cli.js.map +1 -1
  11. package/dist/codex/CodexRuntime.js +202 -115
  12. package/dist/codex/CodexRuntime.js.map +1 -1
  13. package/dist/codex/ProviderSwitch.js +12 -13
  14. package/dist/codex/ProviderSwitch.js.map +1 -1
  15. package/dist/config/AgentExecutionDefaultsStore.js +4 -0
  16. package/dist/config/AgentExecutionDefaultsStore.js.map +1 -1
  17. package/dist/config/loadConfig.js +4 -0
  18. package/dist/config/loadConfig.js.map +1 -1
  19. package/dist/config/schema.js +3 -0
  20. package/dist/config/schema.js.map +1 -1
  21. package/dist/feishu/CardRenderer.js +66 -8
  22. package/dist/feishu/CardRenderer.js.map +1 -1
  23. package/dist/feishu/FeishuConnector.js +1 -0
  24. package/dist/feishu/FeishuConnector.js.map +1 -1
  25. package/dist/feishu/FeishuTurnPresenter.js +121 -17
  26. package/dist/feishu/FeishuTurnPresenter.js.map +1 -1
  27. package/dist/index.js +43 -6
  28. package/dist/index.js.map +1 -1
  29. package/dist/local-files/LocalFileViewerServer.js +51 -4
  30. package/dist/local-files/LocalFileViewerServer.js.map +1 -1
  31. package/dist/local-files/TurnPreviewPage.js +7 -2
  32. package/dist/local-files/TurnPreviewPage.js.map +1 -1
  33. package/dist/presentation/TurnStateReducer.js +41 -6
  34. package/dist/presentation/TurnStateReducer.js.map +1 -1
  35. package/dist/proxy/ProxySessionController.js +615 -214
  36. package/dist/proxy/ProxySessionController.js.map +1 -1
  37. package/dist/proxy/SessionOperationQueue.js +14 -0
  38. package/dist/proxy/SessionOperationQueue.js.map +1 -0
  39. package/dist/runtime/AcpRuntimeAdapter.js +4 -2
  40. package/dist/runtime/AcpRuntimeAdapter.js.map +1 -1
  41. package/dist/state/StateStore.js +22 -0
  42. package/dist/state/StateStore.js.map +1 -1
  43. package/dist/state/TurnPreviewJournal.js +19 -7
  44. package/dist/state/TurnPreviewJournal.js.map +1 -1
  45. package/dist/state/migrations.js +6 -0
  46. package/dist/state/migrations.js.map +1 -1
  47. package/dist/supervision/SafeRestartNotifier.js +1 -0
  48. package/dist/supervision/SafeRestartNotifier.js.map +1 -1
  49. package/dist/supervision/SafeRestartScheduler.js +19 -6
  50. package/dist/supervision/SafeRestartScheduler.js.map +1 -1
  51. package/dist/terminal/TerminalCards.js +13 -0
  52. package/dist/terminal/TerminalCards.js.map +1 -0
  53. package/dist/terminal/TerminalLinks.js +76 -0
  54. package/dist/terminal/TerminalLinks.js.map +1 -0
  55. package/dist/terminal/TerminalPage.js +212 -0
  56. package/dist/terminal/TerminalPage.js.map +1 -0
  57. package/dist/terminal/TerminalProcess.js +71 -0
  58. package/dist/terminal/TerminalProcess.js.map +1 -0
  59. package/dist/terminal/TerminalServer.js +639 -0
  60. package/dist/terminal/TerminalServer.js.map +1 -0
  61. package/docs/technical-reference.md +48 -0
  62. package/docs/technical-reference.zh.md +48 -0
  63. package/npm-shrinkwrap.json +55 -5
  64. package/package.json +11 -4
  65. package/skills/agent-bot/SKILL.md +9 -2
  66. package/src/cli/controlProtocol.ts +2 -1
  67. package/src/cli/serverStatus.ts +1 -0
  68. package/src/cli.ts +6 -1
  69. package/src/codex/CodexRuntime.ts +200 -123
  70. package/src/codex/ProviderSwitch.ts +11 -12
  71. package/src/config/AgentExecutionDefaultsStore.ts +2 -0
  72. package/src/config/loadConfig.ts +4 -0
  73. package/src/config/schema.ts +5 -1
  74. package/src/feishu/CardRenderer.ts +81 -8
  75. package/src/feishu/FeishuConnector.ts +1 -0
  76. package/src/feishu/FeishuTurnPresenter.ts +108 -16
  77. package/src/feishu/types.ts +1 -0
  78. package/src/index.ts +44 -7
  79. package/src/local-files/LocalFileViewerServer.ts +48 -4
  80. package/src/local-files/TurnPreviewPage.ts +7 -2
  81. package/src/presentation/TurnStateReducer.ts +46 -7
  82. package/src/presentation/turnViewTypes.ts +5 -0
  83. package/src/proxy/ProxySessionController.ts +625 -211
  84. package/src/proxy/SessionOperationQueue.ts +13 -0
  85. package/src/runtime/AcpRuntimeAdapter.ts +5 -3
  86. package/src/runtime/types.ts +8 -3
  87. package/src/state/StateStore.ts +38 -1
  88. package/src/state/TurnPreviewJournal.ts +19 -7
  89. package/src/state/migrations.ts +6 -0
  90. package/src/supervision/SafeRestartNotifier.ts +1 -0
  91. package/src/supervision/SafeRestartScheduler.ts +18 -6
  92. package/src/terminal/TerminalCards.ts +18 -0
  93. package/src/terminal/TerminalLinks.ts +61 -0
  94. package/src/terminal/TerminalPage.ts +213 -0
  95. package/src/terminal/TerminalProcess.ts +71 -0
  96. package/src/terminal/TerminalServer.ts +513 -0
@@ -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,8 @@ export interface ControlTaskReleaseResult {
506
513
  }
507
514
 
508
515
  export interface ProxyLifecycle {
516
+ terminalUrl?(jobId: string): string | undefined;
517
+ authorizeTerminal?(action: CardAction): Promise<string>;
509
518
  supervised?: boolean;
510
519
  restart(contextKey: string, force: boolean, replyTarget?: MessageReplyTarget): Promise<void>;
511
520
  cancelSafeRestart?(scheduleId: number): Promise<boolean>;
@@ -539,9 +548,14 @@ export class ProxySessionController {
539
548
  private readonly messageQueues = new Map<string, Promise<void>>();
540
549
  private readonly switchGroupOperations = new Set<string>();
541
550
  private readonly sessionLoads = new Map<string, Promise<LoadedSession>>();
551
+ private readonly settingsOnlySessions = new Set<string>();
542
552
  private readonly queuedPromptStarts = new Map<string, Promise<void>>();
543
553
  private readonly queuedPromptCards = new Map<string, Map<string, string>>();
544
554
  private readonly queuedPromptCardWrites = new Map<string, Promise<void>>();
555
+ private readonly settingsOperations = new SessionOperationQueue();
556
+ // Loads may run inside settingsOperations; never acquire that queue while hydrating.
557
+ private readonly sessionHydrationOperations = new SessionOperationQueue();
558
+ private readonly agentDefaultsOperations = new SessionOperationQueue();
545
559
  private readonly sessionResets = new Map<string, Promise<void>>();
546
560
  private readonly resetHistoryOperations = new Map<string, string>();
547
561
  private readonly remoteTurnHistoryRefreshes = new Map<string, number>();
@@ -551,6 +565,7 @@ export class ProxySessionController {
551
565
  private readonly lastSessionListings = new Map<string, string[]>();
552
566
  private readonly threadInitializations = new Map<string, Promise<void>>();
553
567
  private readonly llmRetryingSessions = new Set<string>();
568
+ private readonly planHandoffs = new Map<string, string>();
554
569
  private readonly retriedFailureTurnIds = new Set<string>();
555
570
  private readonly queuedPromptRetryTimers = new Map<string, NodeJS.Timeout>();
556
571
  private readonly recoveryRetryTimers = new Map<string, NodeJS.Timeout>();
@@ -633,7 +648,7 @@ export class ProxySessionController {
633
648
  const pendingMergedForward = message.mergedForwardMessageId
634
649
  ? this.registerPendingForwardAttachment(message, this.pendingMergedForwards)
635
650
  : undefined;
636
- const pendingResourceForward = !attachmentReservation && isStandaloneResourceMessage(message)
651
+ const pendingResourceForward = !attachmentReservation && isForwardAttachmentSource(message)
637
652
  ? this.registerPendingForwardAttachment(message, this.pendingResourceForwards)
638
653
  : undefined;
639
654
  try {
@@ -683,7 +698,9 @@ export class ProxySessionController {
683
698
  );
684
699
  if (attachment) {
685
700
  coalescedForwardAttachment = true;
686
- const instruction = normalizeFeishuPostText(attachment.text) || defaultResourcePrompt(resourceMessage);
701
+ const sourceText = resourceMessage.text.trim();
702
+ const instruction = normalizeFeishuPostText(attachment.text)
703
+ || (sourceText ? DEFAULT_REFERENCED_MESSAGE_INSTRUCTION : defaultResourcePrompt(resourceMessage));
687
704
  forwardedImages = (resourceMessage.images ?? []).map((image) => ({
688
705
  messageId: resourceMessage.messageId,
689
706
  imageKey: image.imageKey,
@@ -695,7 +712,7 @@ export class ProxySessionController {
695
712
  }));
696
713
  message = {
697
714
  ...attachment,
698
- text: instruction,
715
+ text: sourceText ? referencedMessagePrompt(instruction, sourceText) : instruction,
699
716
  displayText: instruction,
700
717
  };
701
718
  this.relatedReactionMessageIds.set(message.messageId, [resourceMessage.messageId]);
@@ -1015,7 +1032,10 @@ export class ProxySessionController {
1015
1032
  await this.outbound.withReplyTarget(contextKey, replyTarget, async () => {
1016
1033
  try {
1017
1034
  const kind = String(scopedAction.value.action ?? "");
1018
- if (kind === "agentbot_update_cancel") {
1035
+ if (kind === "terminal_authorize") {
1036
+ if (!this.lifecycle?.authorizeTerminal) throw new Error("网页终端未启用。");
1037
+ await this.outbound.sendText(contextKey, await this.lifecycle.authorizeTerminal(scopedAction));
1038
+ } else if (kind === "agentbot_update_cancel") {
1019
1039
  if (!this.lifecycle?.cancelAutomaticUpdate) throw new Error("自动更新功能未启用。");
1020
1040
  await this.lifecycle.cancelAutomaticUpdate(scopedAction);
1021
1041
  } else if (kind === "agentbot_update_select") {
@@ -1231,10 +1251,14 @@ export class ProxySessionController {
1231
1251
  } else if (kind === "session_status_refresh") {
1232
1252
  await this.refreshStatusCardFromAction(scopedAction);
1233
1253
  } 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
- });
1254
+ const sessionId = String(scopedAction.value.sessionId ?? "");
1255
+ await this.openExecutionSettings(
1256
+ contextKey, executionSettingsTabValue(scopedAction.value.tab), {
1257
+ sessionId, updateMessageId: scopedAction.messageId,
1258
+ modelPage: modelCardPageValue(scopedAction.value.page),
1259
+ expectedProvider: typeof scopedAction.value.provider === "string" ? scopedAction.value.provider : undefined,
1260
+ },
1261
+ );
1238
1262
  } else if (kind === "settings_agent_select") {
1239
1263
  await this.setDefaultAgent(contextKey, String(scopedAction.value.agent ?? ""), {
1240
1264
  sessionId: String(scopedAction.value.sessionId ?? ""),
@@ -1247,11 +1271,13 @@ export class ProxySessionController {
1247
1271
  });
1248
1272
  } else if (kind === "settings_model_select") {
1249
1273
  await this.model(contextKey, String(scopedAction.value.model ?? ""), {
1274
+ expectedProvider: String(scopedAction.value.provider ?? ""),
1250
1275
  sessionId: String(scopedAction.value.sessionId ?? ""),
1251
1276
  updateMessageId: scopedAction.messageId,
1252
1277
  });
1253
1278
  } else if (kind === "settings_thinking_select") {
1254
1279
  await this.thinking(contextKey, String(scopedAction.value.effort ?? ""), {
1280
+ expectedProvider: String(scopedAction.value.provider ?? ""),
1255
1281
  sessionId: String(scopedAction.value.sessionId ?? ""),
1256
1282
  updateMessageId: scopedAction.messageId,
1257
1283
  expectedModel: String(scopedAction.value.model ?? ""),
@@ -1273,6 +1299,7 @@ export class ProxySessionController {
1273
1299
  await this.model(contextKey, undefined, {
1274
1300
  sessionId,
1275
1301
  updateMessageId: scopedAction.messageId,
1302
+ modelPage: modelCardPageValue(scopedAction.value.page),
1276
1303
  });
1277
1304
  } else if (kind === "reasoning_select") {
1278
1305
  const sessionId = String(scopedAction.value.sessionId ?? "");
@@ -1295,6 +1322,7 @@ export class ProxySessionController {
1295
1322
  permissionModeValue(scopedAction.value.permissionMode),
1296
1323
  String(scopedAction.value.sessionId ?? ""),
1297
1324
  requiredCardMessageId(scopedAction.messageId),
1325
+ modelCardPageValue(scopedAction.value.page),
1298
1326
  );
1299
1327
  } else if (kind === "provider_model_select" || kind === "provider_reasoning_open") {
1300
1328
  await this.openProviderReasoningSelector(
@@ -1363,6 +1391,7 @@ export class ProxySessionController {
1363
1391
  this.queuedPromptCards.clear();
1364
1392
  this.queuedPromptCardWrites.clear();
1365
1393
  this.sessionResets.clear();
1394
+ this.planHandoffs.clear();
1366
1395
  this.resetHistoryOperations.clear();
1367
1396
  this.remoteTurnHistoryRefreshes.clear();
1368
1397
  this.forkHistoryCursors.clear();
@@ -1766,79 +1795,44 @@ export class ProxySessionController {
1766
1795
  models: Awaited<ReturnType<AgentRuntime["listModels"]>>;
1767
1796
  reasoningOptions: Awaited<ReturnType<AgentRuntime["listModels"]>>[number]["supportedReasoningEfforts"];
1768
1797
  permissionModes: PermissionMode[];
1798
+ pendingProvider?: PendingProviderSettings;
1769
1799
  }> {
1770
1800
  const record = this.requireControlSession(localSessionId);
1771
1801
  if (setting && !value?.trim()) throw new Error(`task ${setting} requires a value.`);
1772
1802
  const nextValue = value?.trim();
1773
- if (setting === "provider" && nextValue) {
1803
+ if (setting && nextValue) {
1774
1804
  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,
1805
+ await this.outbound.withReplyTarget(contextKey, this.controlSessionReplyTarget(record), async () => {
1806
+ const options = { sessionId: record.localSessionId };
1807
+ if (setting === "provider") await this.selectProvider(contextKey, nextValue, options);
1808
+ else if (setting === "model") await this.model(contextKey, nextValue, options);
1809
+ else if (setting === "thinking") await this.thinking(contextKey, nextValue, options);
1810
+ else {
1811
+ if (nextValue !== "auto" && nextValue !== "confirm") throw new Error("task permissions accepts auto or confirm.");
1812
+ await this.permissions(contextKey, nextValue, options);
1813
+ }
1793
1814
  });
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)
1815
+ }
1816
+ return this.settingsOperations.run(localSessionId, async () => {
1817
+ const loaded = await this.loadSession(this.store.getSession(localSessionId) ?? record, true);
1818
+ const session = this.store.getSession(record.localSessionId) ?? record;
1819
+ const pending = this.store.getPendingProviderSettings(record.localSessionId);
1820
+ const selected = pending?.settings ?? session;
1821
+ const models = await loaded.runtime.listModels(selected.modelProvider, selected.model);
1822
+ const currentModel = models.find((candidate) => candidate.id === selected.model)
1812
1823
  ?? 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
- };
1824
+ const providers = loaded.runtime.listModelProviders
1825
+ ? await this.modelProviderOptions(loaded)
1826
+ : [];
1827
+ return {
1828
+ session,
1829
+ pendingProvider: pending,
1830
+ providers,
1831
+ models,
1832
+ reasoningOptions: currentModel?.supportedReasoningEfforts ?? [],
1833
+ permissionModes: ["auto", "confirm"],
1834
+ };
1835
+ });
1842
1836
  }
1843
1837
 
1844
1838
  async controlTaskGoal(
@@ -2309,6 +2303,9 @@ export class ProxySessionController {
2309
2303
  if (!this.recoveryRetryTimers.has(attempt.attemptId)) this.recoveryRetryStartedAt.delete(attempt.attemptId);
2310
2304
  }
2311
2305
  }
2306
+ for (const pending of this.store.listPendingProviderSettings()) {
2307
+ await this.applyPendingProviderWhenIdle(pending.localSessionId);
2308
+ }
2312
2309
  this.restorePersistedQueuedPrompts();
2313
2310
  }
2314
2311
 
@@ -2652,6 +2649,16 @@ export class ProxySessionController {
2652
2649
  });
2653
2650
  const pendingAttempt = this.store.getTurnAttempt(attempt.attemptId);
2654
2651
  if (!pendingAttempt || !isIncompleteTurnAttemptStatus(pendingAttempt.status)) return;
2652
+ const continued = turnViewSnapshot(this.store.getTurnSnapshot(turnId));
2653
+ if (continued?.continuedFromTurnId && this.store.getTurnDelivery(turnId)?.progressMessageId) {
2654
+ this.store.updateTurnAttempt(attempt.attemptId, { pendingTurnId: null, turnId, status: "running" });
2655
+ await this.outbound.onEvent({ type: "turn_started", sessionId: session.localSessionId,
2656
+ turnId, startedAt: continued.startedAt });
2657
+ const synchronized = await loaded.runtime.synchronizeSession(session.localSessionId);
2658
+ this.recoveryRetryStartedAt.delete(attempt.attemptId);
2659
+ if (synchronized.activeTurnId) this.recoveredLiveAttempts.add(attempt.attemptId);
2660
+ return;
2661
+ }
2655
2662
  const oldCardTurnId = attempt.pendingTurnId ?? attempt.turnId;
2656
2663
  if (oldCardTurnId) {
2657
2664
  await this.outbound.interruptTurnForRecovery(
@@ -2717,7 +2724,8 @@ export class ProxySessionController {
2717
2724
  });
2718
2725
  }
2719
2726
  const current = this.store.getSession(session.localSessionId) ?? session;
2720
- const loaded = await this.loadSession({ ...current, contextKey: attempt.contextKey });
2727
+ const loaded = await this.loadSession({ ...current, contextKey: attempt.contextKey },
2728
+ Boolean(this.store.getPendingProviderSettings(current.localSessionId)));
2721
2729
  const recoveryText = recoveryRuntimePrompt(attempt.promptText);
2722
2730
  const turnId = await this.startTurn(
2723
2731
  loaded,
@@ -2784,7 +2792,7 @@ export class ProxySessionController {
2784
2792
  localImagePaths?: string[],
2785
2793
  displayPrompt?: string,
2786
2794
  ): Promise<void> {
2787
- if (this.llmRetryingSessions.has(record.localSessionId)) {
2795
+ if (this.llmRetryingSessions.has(record.localSessionId) || this.planHandoffs.has(record.localSessionId)) {
2788
2796
  const queued = this.persistQueuedPrompt(record.localSessionId, contextKey, text, {
2789
2797
  localImagePaths,
2790
2798
  messageId,
@@ -2820,7 +2828,7 @@ export class ProxySessionController {
2820
2828
  }
2821
2829
  let loaded: LoadedSession;
2822
2830
  try {
2823
- loaded = await this.loadSession(record);
2831
+ loaded = await this.loadSession(record, Boolean(this.store.getPendingProviderSettings(record.localSessionId)));
2824
2832
  } catch (error) {
2825
2833
  if (await this.presentThreadWriterConflict(record, contextKey, error, true)) {
2826
2834
  throw new PresentedRequestError(error);
@@ -3960,6 +3968,25 @@ export class ProxySessionController {
3960
3968
  }
3961
3969
 
3962
3970
  private async startTurn(
3971
+ loaded: LoadedSession, text: string, replyTarget?: MessageReplyTarget,
3972
+ localImagePaths?: string[], options: StartTurnOptions = {},
3973
+ ): Promise<string> {
3974
+ return this.settingsOperations.run(loaded.record.localSessionId, async () => {
3975
+ try {
3976
+ await this.applyPendingProviderSettings(loaded);
3977
+ } catch (error) {
3978
+ await this.outbound.failPendingTurn(loaded.record.localSessionId, runtimeErrorMessage(error));
3979
+ throw error;
3980
+ }
3981
+ if (this.store.getPendingProviderSettings(loaded.record.localSessionId)) {
3982
+ throw new Error("Provider 切换尚未完成,请稍后重试。");
3983
+ }
3984
+ loaded.session = loaded.runtime.getSession(loaded.record.localSessionId) ?? loaded.session;
3985
+ return this.startTurnNow(loaded, text, replyTarget, localImagePaths, options);
3986
+ });
3987
+ }
3988
+
3989
+ private async startTurnNow(
3963
3990
  loaded: LoadedSession,
3964
3991
  text: string,
3965
3992
  replyTarget?: MessageReplyTarget,
@@ -4015,9 +4042,15 @@ export class ProxySessionController {
4015
4042
  this.store.updateTurnAttempt(attemptId, { pendingTurnId: pendingTurnId ?? null });
4016
4043
 
4017
4044
  try {
4045
+ if (options.approvedPlanTurnId && (this.planHandoffs.get(loaded.record.localSessionId) !== options.approvedPlanTurnId
4046
+ || this.sessionResets.has(loaded.record.localSessionId))) {
4047
+ throw new Error("方案执行已取消。");
4048
+ }
4018
4049
  turnId = await loaded.runtime.startTurn(
4019
4050
  loaded.record.localSessionId,
4020
- runtimePrompt(runtimeText, localImagePaths),
4051
+ options.approvedPlanTurnId
4052
+ ? { text: runtimeText, collaborationMode: "default" }
4053
+ : runtimePrompt(runtimeText, localImagePaths),
4021
4054
  );
4022
4055
  break;
4023
4056
  } catch (error) {
@@ -4590,18 +4623,27 @@ export class ProxySessionController {
4590
4623
  return { chatId: group.chatId, contextKey: groupContextKey, name: group.name };
4591
4624
  }
4592
4625
 
4593
- private async loadSession(record: SessionRecord): Promise<LoadedSession> {
4626
+ private async loadSession(record: SessionRecord, forSettings = false): Promise<LoadedSession> {
4594
4627
  const agent = this.ensureAgent(record.agentName);
4595
4628
  const runtime = this.runtimes.forAgent(record.agentName);
4596
4629
  const existing = runtime.getSession(record.localSessionId);
4597
- if (existing) return { record, runtime, session: existing };
4630
+ if (existing && (forSettings || !this.settingsOnlySessions.has(record.localSessionId))) {
4631
+ return { record, runtime, session: existing };
4632
+ }
4598
4633
  const pending = this.sessionLoads.get(record.localSessionId);
4599
4634
  if (pending) {
4600
- const loaded = await pending;
4601
- return {
4602
- ...loaded,
4603
- record: { ...loaded.record, contextKey: record.contextKey },
4604
- };
4635
+ try {
4636
+ const loaded = await pending;
4637
+ if (!forSettings && this.settingsOnlySessions.has(record.localSessionId)) {
4638
+ if (this.sessionLoads.get(record.localSessionId) === pending) this.sessionLoads.delete(record.localSessionId);
4639
+ return this.loadSession(this.store.getSession(record.localSessionId) ?? record);
4640
+ }
4641
+ return { ...loaded, record: { ...loaded.record, contextKey: record.contextKey } };
4642
+ } catch (error) {
4643
+ if (!forSettings || !runtime.prepareSessionForSettings) throw error;
4644
+ if (this.sessionLoads.get(record.localSessionId) === pending) this.sessionLoads.delete(record.localSessionId);
4645
+ return this.loadSession(record, forSettings);
4646
+ }
4605
4647
  }
4606
4648
 
4607
4649
  this.outbound.registerSession(
@@ -4613,7 +4655,16 @@ export class ProxySessionController {
4613
4655
  record.model,
4614
4656
  record.modelProvider,
4615
4657
  );
4616
- const loading = (async (): Promise<LoadedSession> => {
4658
+ const loading = this.sessionHydrationOperations.run(record.localSessionId, async (): Promise<LoadedSession> => {
4659
+ const latest = this.store.getSession(record.localSessionId);
4660
+ if (latest) record = {
4661
+ ...record, modelProvider: latest.modelProvider, model: latest.model,
4662
+ reasoningEffort: latest.reasoningEffort, permissionMode: latest.permissionMode,
4663
+ };
4664
+ const current = runtime.getSession(record.localSessionId);
4665
+ if (current && (forSettings || !this.settingsOnlySessions.has(record.localSessionId))) {
4666
+ return { record, runtime, session: current };
4667
+ }
4617
4668
  if (record.lastTurnId) {
4618
4669
  try {
4619
4670
  await this.outbound.resumeDelivery(record.localSessionId, record.contextKey, record.lastTurnId);
@@ -4625,41 +4676,30 @@ export class ProxySessionController {
4625
4676
  }
4626
4677
  }
4627
4678
  const permissionMode = record.permissionMode ?? "auto";
4628
- if (record.remoteSessionId) await this.assertSessionTurnOwnership(record, runtime);
4679
+ const prepare = forSettings && record.remoteSessionId && runtime.prepareSessionForSettings;
4680
+ if (record.remoteSessionId && !prepare) await this.assertSessionTurnOwnership(record, runtime);
4629
4681
  let session: RuntimeSession;
4630
4682
  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
- });
4683
+ const input = {
4684
+ localSessionId: record.localSessionId,
4685
+ remoteSessionId: record.remoteSessionId,
4686
+ agentName: record.agentName,
4687
+ cwd: record.cwd,
4688
+ title: record.title,
4689
+ modelProvider: record.modelProvider,
4690
+ model: record.model,
4691
+ reasoningEffort: record.reasoningEffort,
4692
+ permissionMode,
4693
+ activeTurnId: agent.kind === "app-server" && record.status === "running" && record.lastTurnStatus === "running"
4694
+ ? record.lastTurnId
4695
+ : undefined,
4696
+ lastTurnId: record.lastTurnId,
4697
+ lastTurnStatus: record.lastTurnStatus,
4698
+ };
4699
+ if (prepare) this.settingsOnlySessions.add(record.localSessionId);
4700
+ session = prepare ? await prepare.call(runtime, input) : await runtime.resumeSession(input);
4701
+ if (session.remoteSessionId !== record.remoteSessionId) {
4702
+ throw new Error("加载设置返回了不同的任务 ID,不允许替换任务或更改历史。");
4663
4703
  }
4664
4704
  } else {
4665
4705
  session = await runtime.createSession({
@@ -4673,19 +4713,22 @@ export class ProxySessionController {
4673
4713
  permissionMode,
4674
4714
  });
4675
4715
  }
4676
- this.persistRuntimeSession(record, session, session.activeTurnId ? "running" : "ready");
4716
+ if (!prepare) {
4717
+ this.settingsOnlySessions.delete(record.localSessionId);
4718
+ this.persistRuntimeSession(record, session, session.activeTurnId ? "running" : "ready");
4719
+ }
4677
4720
  const saved = this.store.getSession(record.localSessionId) ?? record;
4678
4721
  return {
4679
4722
  record: { ...saved, contextKey: record.contextKey },
4680
4723
  runtime,
4681
4724
  session,
4682
4725
  };
4683
- })();
4726
+ });
4684
4727
  this.sessionLoads.set(record.localSessionId, loading);
4685
4728
  try {
4686
4729
  return await loading;
4687
4730
  } finally {
4688
- this.sessionLoads.delete(record.localSessionId);
4731
+ if (this.sessionLoads.get(record.localSessionId) === loading) this.sessionLoads.delete(record.localSessionId);
4689
4732
  }
4690
4733
  }
4691
4734
 
@@ -4734,6 +4777,14 @@ export class ProxySessionController {
4734
4777
  );
4735
4778
  }
4736
4779
  }
4780
+ if (event.type === "turn_completed" && event.approvedPlan) {
4781
+ if (this.planHandoffs.get(event.sessionId) === event.turnId) return;
4782
+ const attempt = this.store.findIncompleteTurnAttemptByTurnId(event.turnId);
4783
+ if (attempt) {
4784
+ await this.continueApprovedPlan(event, attempt);
4785
+ return;
4786
+ }
4787
+ }
4737
4788
  const failedAttempt = event.type === "turn_failed"
4738
4789
  ? this.store.findIncompleteTurnAttemptByTurnId(event.turnId)
4739
4790
  : undefined;
@@ -4779,7 +4830,7 @@ export class ProxySessionController {
4779
4830
  ...event,
4780
4831
  turnCount: event.turnCount ?? this.store.countTaskTurns(event.sessionId),
4781
4832
  }
4782
- : event;
4833
+ : event.type === "turn_completed" && event.approvedPlan ? { ...event, approvedPlan: false } : event;
4783
4834
  const presentationEvent = event.type === "turn_failed"
4784
4835
  && isRetryableLlmTurnFailure(event.message)
4785
4836
  && (failedAttempt?.retryCount ?? 0) >= MAX_LLM_TURN_RETRIES
@@ -4817,6 +4868,7 @@ export class ProxySessionController {
4817
4868
  : undefined;
4818
4869
  if (latest?.lastTurnId === event.turnId && !activeTurnId) {
4819
4870
  this.store.updateSession(event.sessionId, { status: event.type === "turn_failed" ? "failed" : "ready" });
4871
+ await this.applyPendingProviderWhenIdle(event.sessionId);
4820
4872
  await this.scheduleNextQueuedPrompt(event.sessionId);
4821
4873
  }
4822
4874
  }
@@ -4824,6 +4876,80 @@ export class ProxySessionController {
4824
4876
  if (presentationError) throw presentationError;
4825
4877
  }
4826
4878
 
4879
+ private async continueApprovedPlan(
4880
+ event: Extract<RuntimeEvent, { type: "turn_completed" }>,
4881
+ attempt: TurnAttemptRecord,
4882
+ ): Promise<void> {
4883
+ const { sessionId, turnId } = event;
4884
+ const source = this.store.getSession(sessionId);
4885
+ if (!source || source.status === "closed" || source.lastTurnId !== turnId) return;
4886
+ this.planHandoffs.set(sessionId, turnId);
4887
+ this.store.markTurnAttemptTerminal(turnId, "completed");
4888
+ this.store.updateRuntimeSession(sessionId, { lastTurnId: turnId, lastTurnStatus: "completed" });
4889
+ let continued = false;
4890
+ let failed = false;
4891
+ try {
4892
+ try {
4893
+ await this.outbound.onEvent(event);
4894
+ } catch (error) {
4895
+ this.logger.warn({ error, sessionId, turnId }, "Failed to finalize the approved plan card.");
4896
+ }
4897
+ await this.settingsOperations.run(sessionId, async () => {
4898
+ const current = this.store.getSessionForContext(sessionId, attempt.contextKey);
4899
+ if (this.planHandoffs.get(sessionId) !== turnId || this.sessionResets.has(sessionId)
4900
+ || !current || current.status === "closed" || current.remoteSessionId !== source.remoteSessionId) return;
4901
+ const loaded = await this.loadSession(current);
4902
+ await this.assertSessionTurnOwnership(current, loaded.runtime);
4903
+ if (this.planHandoffs.get(sessionId) !== turnId || this.sessionResets.has(sessionId)) return;
4904
+ // Another client may already have started the implementation.
4905
+ let nextTurnId = loaded.session.activeTurnId;
4906
+ if (!nextTurnId) {
4907
+ if (current.lastTurnId !== turnId) return;
4908
+ await this.applyPendingProviderSettings(loaded);
4909
+ if (this.store.getPendingProviderSettings(sessionId)) throw new Error("Provider 切换尚未完成。");
4910
+ if (this.planHandoffs.get(sessionId) !== turnId || this.sessionResets.has(sessionId)) return;
4911
+ nextTurnId = await this.startTurnNow(
4912
+ loaded,
4913
+ "The proposed plan has been approved. Continue in the current task and workspace without asking for plan approval again; preserve the existing conversation and completed work. Implement the plan unless the user explicitly requested discussion or planning only, or prohibited implementation; those constraints still apply.",
4914
+ attempt.replyMessageId ? { messageId: attempt.replyMessageId, replyInThread: true } : undefined,
4915
+ undefined,
4916
+ { messageId: attempt.messageId, displayPrompt: "方案已确认,继续执行", approvedPlanTurnId: turnId },
4917
+ );
4918
+ }
4919
+ continued = true;
4920
+ this.store.rebindPendingTurnMessages(sessionId, turnId, nextTurnId);
4921
+ const latest = this.store.getSession(sessionId);
4922
+ if (latest?.lastTurnId === nextTurnId && (latest.lastTurnStatus === "completed"
4923
+ || latest.lastTurnStatus === "failed" || latest.lastTurnStatus === "cancelled")) {
4924
+ await this.finalizeTurnMessageReactions(nextTurnId, latest.lastTurnStatus);
4925
+ }
4926
+ this.store.audit(attempt.contextKey, "approved_plan_continued", { sessionId, turnId, nextTurnId });
4927
+ });
4928
+ } catch (error) {
4929
+ failed = true;
4930
+ this.logger.error({ error, sessionId, turnId }, "Failed to continue an approved plan.");
4931
+ await this.outbound.sendText(attempt.contextKey,
4932
+ "方案已确认,但后续执行启动失败:" + runtimeErrorMessage(error) + "。请发送消息继续。");
4933
+ } finally {
4934
+ if (this.planHandoffs.get(sessionId) === turnId) this.planHandoffs.delete(sessionId);
4935
+ if (!continued) {
4936
+ try {
4937
+ await this.outbound.onEvent(failed
4938
+ ? { type: "turn_failed", sessionId, turnId, message: "方案已确认,但后续执行未能启动。" }
4939
+ : { type: "turn_cancelled", sessionId, turnId });
4940
+ } catch (error) {
4941
+ this.logger.warn({ error, sessionId, turnId }, "Failed to finish the plan handoff card.");
4942
+ }
4943
+ await this.finalizeTurnMessageReactions(turnId, failed ? "failed" : "cancelled");
4944
+ }
4945
+ const latest = this.store.getSession(sessionId);
4946
+ if (latest?.lastTurnId === turnId && latest.status !== "closed") {
4947
+ this.store.updateSession(sessionId, { status: failed ? "failed" : "ready" });
4948
+ }
4949
+ await this.scheduleNextQueuedPrompt(sessionId);
4950
+ }
4951
+ }
4952
+
4827
4953
  private async retryFailedLlmTurn(
4828
4954
  event: Extract<RuntimeEvent, { type: "turn_failed" }>,
4829
4955
  attempt: TurnAttemptRecord,
@@ -4865,7 +4991,7 @@ export class ProxySessionController {
4865
4991
  : undefined;
4866
4992
  try {
4867
4993
  const current = this.store.getSession(session.localSessionId) ?? session;
4868
- const loaded = await this.loadSession(current);
4994
+ const loaded = await this.loadSession(current, Boolean(this.store.getPendingProviderSettings(current.localSessionId)));
4869
4995
  const retryTurnId = await this.startTurn(
4870
4996
  loaded,
4871
4997
  llmRetryRuntimePrompt(attempt.promptText, retryNumber),
@@ -4913,6 +5039,7 @@ export class ProxySessionController {
4913
5039
  }
4914
5040
 
4915
5041
  private scheduleQueuedPromptRetry(sessionId: string): void {
5042
+ if (this.store.getPendingProviderSettings(sessionId)?.error) return;
4916
5043
  if (this.queuedPromptRetryTimers.has(sessionId)) return;
4917
5044
  if (this.store.countQueuedPrompts(sessionId) === 0) return;
4918
5045
  const timer = setTimeout(() => {
@@ -4930,24 +5057,31 @@ export class ProxySessionController {
4930
5057
  }
4931
5058
 
4932
5059
  private async startNextQueuedPromptIfIdle(sessionId: string): Promise<void> {
5060
+ return this.settingsOperations.run(sessionId, () => this.startNextQueuedPromptIfIdleNow(sessionId));
5061
+ }
5062
+
5063
+ private async startNextQueuedPromptIfIdleNow(sessionId: string): Promise<void> {
5064
+ if (this.planHandoffs.has(sessionId) || this.sessionResets.has(sessionId)) return;
4933
5065
  if (this.store.countQueuedPrompts(sessionId) === 0) return;
4934
5066
  const baseRecord = this.store.getSession(sessionId);
4935
5067
  if (!baseRecord || baseRecord.status === "closed") return;
4936
5068
  let prompt: QueuedPromptRecord | undefined;
4937
5069
  try {
4938
- let loaded = await this.loadSession(baseRecord);
5070
+ let loaded = await this.loadSession(baseRecord, Boolean(this.store.getPendingProviderSettings(sessionId)));
4939
5071
  const remoteActivity = await this.assertSessionTurnOwnership(loaded.record, loaded.runtime);
4940
5072
  if (needsFullSessionSynchronization(loaded.record, loaded.session, remoteActivity)) {
4941
5073
  loaded = { ...loaded, session: await loaded.runtime.synchronizeSession(sessionId) };
4942
5074
  }
4943
5075
  if (loaded.session.activeTurnId) return;
4944
5076
 
5077
+ await this.applyPendingProviderSettings(loaded);
5078
+ if (this.store.getPendingProviderSettings(sessionId)) return;
4945
5079
  prompt = this.store.takeNextQueuedPrompt(sessionId);
4946
5080
  if (!prompt) return;
4947
5081
  const record = this.store.getSessionForContext(sessionId, prompt.contextKey) ?? baseRecord;
4948
5082
  loaded = { ...loaded, record };
4949
5083
  await this.refreshPromptQueueCards(sessionId);
4950
- const turnId = await this.startTurn(
5084
+ const turnId = await this.startTurnNow(
4951
5085
  loaded,
4952
5086
  prompt.text,
4953
5087
  prompt.replyMessageId ? { messageId: prompt.replyMessageId, replyInThread: true } : undefined,
@@ -5018,6 +5152,7 @@ export class ProxySessionController {
5018
5152
  });
5019
5153
  const pending = {
5020
5154
  contextKey: message.contextKey,
5155
+ userId: message.userId,
5021
5156
  attachmentPromise,
5022
5157
  resolveAttachment,
5023
5158
  };
@@ -5026,14 +5161,15 @@ export class ProxySessionController {
5026
5161
  }
5027
5162
 
5028
5163
  private reserveForwardAttachment(message: IncomingMessage): ForwardAttachmentReservation | undefined {
5029
- if (!message.parentMessageId) return undefined;
5164
+ if (!message.parentMessageId || isCommandMessage(message)) return undefined;
5030
5165
  const candidates = [
5031
5166
  { sourceKind: "merged_forward" as const, registry: this.pendingMergedForwards },
5032
5167
  { sourceKind: "resource" as const, registry: this.pendingResourceForwards },
5033
5168
  ];
5034
5169
  for (const candidate of candidates) {
5035
5170
  const pending = candidate.registry.get(message.parentMessageId);
5036
- if (!pending || pending.contextKey !== message.contextKey || pending.attachmentMessageId) continue;
5171
+ if (!pending || pending.contextKey !== message.contextKey
5172
+ || pending.userId !== message.userId || pending.attachmentMessageId) continue;
5037
5173
  pending.attachmentMessageId = message.messageId;
5038
5174
  return {
5039
5175
  sourceMessageId: message.parentMessageId,
@@ -5062,15 +5198,17 @@ export class ProxySessionController {
5062
5198
  pending: PendingForwardAttachment,
5063
5199
  registry: Map<string, PendingForwardAttachment>,
5064
5200
  ): 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
- });
5201
+ const timer = setTimeout(() => {
5202
+ // Arrival reserves the pair before acknowledgement. A slow Reaction must not split it.
5203
+ if (pending.attachmentMessageId) return;
5204
+ if (registry.get(messageId) === pending) registry.delete(messageId);
5205
+ pending.resolveAttachment(undefined);
5206
+ }, FORWARD_ATTACHMENT_WINDOW_MS);
5207
+ timer.unref?.();
5070
5208
  try {
5071
- return await Promise.race([pending.attachmentPromise, timeout]);
5209
+ return await pending.attachmentPromise;
5072
5210
  } finally {
5073
- if (timer) clearTimeout(timer);
5211
+ clearTimeout(timer);
5074
5212
  if (registry.get(messageId) === pending) {
5075
5213
  registry.delete(messageId);
5076
5214
  }
@@ -5149,6 +5287,7 @@ export class ProxySessionController {
5149
5287
  }
5150
5288
 
5151
5289
  private async cancelSession(record: SessionRecord): Promise<void> {
5290
+ this.planHandoffs.delete(record.localSessionId);
5152
5291
  const runtime = this.runtimes.forAgent(record.agentName);
5153
5292
  if (runtime.kind === "codex" && record.remoteSessionId && runtime.interruptRemoteTurn) {
5154
5293
  if (runtime.getGoal && runtime.setGoal) {
@@ -5227,6 +5366,7 @@ export class ProxySessionController {
5227
5366
  }
5228
5367
 
5229
5368
  private async runSessionReset(sessionId: string, operation: () => Promise<void>): Promise<void> {
5369
+ this.planHandoffs.delete(sessionId);
5230
5370
  const previous = this.sessionResets.get(sessionId) ?? Promise.resolve();
5231
5371
  const reset = previous.catch(() => undefined).then(operation);
5232
5372
  this.sessionResets.set(sessionId, reset);
@@ -5410,6 +5550,20 @@ export class ProxySessionController {
5410
5550
  contextKey: string,
5411
5551
  activeTab: ExecutionSettingsTab,
5412
5552
  options: ExecutionSettingsCardOptions = {},
5553
+ ): Promise<void> {
5554
+ const record = options.sessionId
5555
+ ? this.requireSession(contextKey, options.sessionId)
5556
+ : this.currentSession(contextKey);
5557
+ if (!record) return this.openExecutionSettingsNow(contextKey, activeTab, options);
5558
+ return this.settingsOperations.run(record.localSessionId, () => this.openExecutionSettingsNow(
5559
+ contextKey, activeTab, { ...options, sessionId: record.localSessionId },
5560
+ ));
5561
+ }
5562
+
5563
+ private async openExecutionSettingsNow(
5564
+ contextKey: string,
5565
+ activeTab: ExecutionSettingsTab,
5566
+ options: ExecutionSettingsCardOptions = {},
5413
5567
  ): Promise<void> {
5414
5568
  const context = this.store.getOrCreateUserContext(contextKey, this.config.defaults.agent!);
5415
5569
  const record = options.sessionId
@@ -5430,24 +5584,33 @@ export class ProxySessionController {
5430
5584
  reasoningOptions: [],
5431
5585
  };
5432
5586
  if (record) {
5433
- const loaded = await this.loadSession(record);
5587
+ const loaded = await this.loadSession(record, true);
5588
+ const pending = this.store.getPendingProviderSettings(record.localSessionId);
5589
+ const selected = pending?.settings ?? loaded.session;
5590
+ if (pending && options.updateMessageId) {
5591
+ this.store.savePendingProviderSettings({
5592
+ ...pending, contextKey, messageId: options.updateMessageId, replyMessageId: options.updateMessageId, activeTab,
5593
+ });
5594
+ }
5434
5595
  const providerSupported = loaded.runtime.kind === "codex" && Boolean(loaded.runtime.listModelProviders);
5435
5596
  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)
5597
+ const currentProvider = selected.modelProvider ?? providers.find((provider) => provider.isDefault)?.id;
5598
+ const { models, warning } = await this.modelOptions(loaded.runtime, currentProvider, selected.model);
5599
+ const currentModel = models.find((model) => model.id === selected.model)
5439
5600
  ?? models.find((model) => model.isDefault)
5440
5601
  ?? models[0];
5441
5602
  const currentEffort = currentModel?.supportedReasoningEfforts.some(
5442
- (option) => option.value === loaded.session.reasoningEffort,
5603
+ (option) => option.value === selected.reasoningEffort,
5443
5604
  )
5444
- ? loaded.session.reasoningEffort
5445
- : currentModel?.defaultReasoningEffort ?? loaded.session.reasoningEffort;
5605
+ ? selected.reasoningEffort
5606
+ : currentModel?.defaultReasoningEffort ?? selected.reasoningEffort;
5446
5607
  runtimeSettings = {
5608
+ pendingNotice: pending ? this.pendingProviderNotice(pending) : undefined,
5609
+ pendingFailed: Boolean(pending?.error),
5447
5610
  currentProvider,
5448
- currentModel: currentModel?.id ?? loaded.session.model,
5611
+ currentModel: pending?.settings.model ?? currentModel?.id ?? selected.model,
5449
5612
  currentEffort,
5450
- currentPermissionMode: loaded.session.permissionMode,
5613
+ currentPermissionMode: selected.permissionMode,
5451
5614
  providers,
5452
5615
  providerSupported,
5453
5616
  models,
@@ -5463,7 +5626,10 @@ export class ProxySessionController {
5463
5626
  agents,
5464
5627
  runtimeSettingsAvailable: Boolean(record),
5465
5628
  ...runtimeSettings,
5629
+ modelPage: options.expectedProvider !== undefined && options.expectedProvider !== (runtimeSettings.currentProvider ?? "")
5630
+ ? undefined : options.modelPage,
5466
5631
  notice: options.notice,
5632
+ warning: [runtimeSettings.warning, options.warning].filter(Boolean).join("\n") || undefined,
5467
5633
  });
5468
5634
  if (options.updateMessageId) {
5469
5635
  await this.outbound.updateInteractiveCard(contextKey, options.updateMessageId, card);
@@ -5481,7 +5647,12 @@ export class ProxySessionController {
5481
5647
  await this.openExecutionSettings(contextKey, "agent");
5482
5648
  }
5483
5649
 
5484
- private async selectProvider(
5650
+ private async selectProvider(contextKey: string, modelProvider: string, options: ModelCardOptions = {}): Promise<void> {
5651
+ const record = options.sessionId ? this.requireSession(contextKey, options.sessionId) : this.requireCurrentSession(contextKey);
5652
+ return this.settingsOperations.run(record.localSessionId, () => this.selectProviderNow(contextKey, modelProvider, { ...options, sessionId: record.localSessionId }));
5653
+ }
5654
+
5655
+ private async selectProviderNow(
5485
5656
  contextKey: string,
5486
5657
  modelProvider: string,
5487
5658
  options: ModelCardOptions = {},
@@ -5489,19 +5660,19 @@ export class ProxySessionController {
5489
5660
  const record = options.sessionId
5490
5661
  ? this.requireSession(contextKey, options.sessionId)
5491
5662
  : this.requireCurrentSession(contextKey);
5492
- await this.outbound.sendText(contextKey, `正在切换到 Provider ${modelProvider},请稍后。`);
5493
- const loaded = await this.loadSession(record);
5663
+ const loaded = await this.loadSession(record, true);
5664
+ const selected = this.store.getPendingProviderSettings(record.localSessionId)?.settings ?? loaded.session;
5494
5665
  await this.assertModelProvider(loaded, modelProvider);
5495
- const models = await loaded.runtime.listModels(modelProvider, loaded.session.model);
5496
- const model = selectProviderModel(models, loaded.session.model);
5666
+ const models = await loaded.runtime.listModels(modelProvider, selected.model);
5667
+ const model = selectProviderModel(models, selected.model);
5497
5668
  if (!model) throw new Error("当前运行时没有可用于 Provider 切换的模型。");
5498
- const effort = selectProviderReasoningEffort(model, loaded.session.reasoningEffort);
5499
- await this.applyProviderSettings(contextKey, {
5669
+ const effort = selectProviderReasoningEffort(model, selected.reasoningEffort);
5670
+ await this.applyProviderSettingsNow(contextKey, {
5500
5671
  provider: modelProvider,
5501
5672
  model: model.id,
5502
5673
  effort,
5503
- mode: loaded.session.permissionMode,
5504
- }, options, false);
5674
+ mode: selected.permissionMode,
5675
+ }, options);
5505
5676
  }
5506
5677
 
5507
5678
  private async openProviderSelector(
@@ -5517,14 +5688,16 @@ export class ProxySessionController {
5517
5688
  permissionMode: PermissionMode,
5518
5689
  sessionId: string,
5519
5690
  updateMessageId: string,
5691
+ modelPage?: number,
5520
5692
  ): Promise<void> {
5521
- const loaded = await this.loadSession(this.requireSession(contextKey, sessionId));
5693
+ const loaded = await this.loadSession(this.requireSession(contextKey, sessionId), true);
5522
5694
  await this.assertModelProvider(loaded, modelProvider);
5523
5695
  const { models, warning } = await this.modelOptions(loaded.runtime, modelProvider, loaded.session.model);
5524
5696
  const card = this.cardRenderer.renderModelSelector({
5525
5697
  sessionId,
5526
5698
  contextKey,
5527
5699
  currentModel: selectProviderModel(models, loaded.session.model)?.id,
5700
+ modelPage,
5528
5701
  reasoningEffort: loaded.session.reasoningEffort,
5529
5702
  models,
5530
5703
  warning,
@@ -5543,7 +5716,7 @@ export class ProxySessionController {
5543
5716
  sessionId: string,
5544
5717
  updateMessageId: string,
5545
5718
  ): Promise<void> {
5546
- const loaded = await this.loadSession(this.requireSession(contextKey, sessionId));
5719
+ const loaded = await this.loadSession(this.requireSession(contextKey, sessionId), true);
5547
5720
  await this.assertModelProvider(loaded, modelProvider);
5548
5721
  const { models, warning } = await this.modelOptions(loaded.runtime, modelProvider, model);
5549
5722
  const selected = models.find((candidate) => candidate.id === model);
@@ -5579,7 +5752,7 @@ export class ProxySessionController {
5579
5752
  sessionId: string,
5580
5753
  updateMessageId: string,
5581
5754
  ): Promise<void> {
5582
- const loaded = await this.loadSession(this.requireSession(contextKey, sessionId));
5755
+ const loaded = await this.loadSession(this.requireSession(contextKey, sessionId), true);
5583
5756
  await this.assertProviderModelSettings(loaded, modelProvider, model, effort);
5584
5757
  await this.outbound.updateInteractiveCard(
5585
5758
  contextKey,
@@ -5595,7 +5768,12 @@ export class ProxySessionController {
5595
5768
  );
5596
5769
  }
5597
5770
 
5598
- private async applyProviderSettings(
5771
+ private async applyProviderSettings(contextKey: string, settings: { provider: string; model: string; effort: string; mode: PermissionMode }, options: ModelCardOptions = {}, announceStart = true): Promise<void> {
5772
+ const record = options.sessionId ? this.requireSession(contextKey, options.sessionId) : this.requireCurrentSession(contextKey);
5773
+ return this.settingsOperations.run(record.localSessionId, () => this.applyProviderSettingsNow(contextKey, settings, { ...options, sessionId: record.localSessionId }, announceStart));
5774
+ }
5775
+
5776
+ private async applyProviderSettingsNow(
5599
5777
  contextKey: string,
5600
5778
  settings: { provider: string; model: string; effort: string; mode: PermissionMode },
5601
5779
  options: ModelCardOptions = {},
@@ -5604,17 +5782,38 @@ export class ProxySessionController {
5604
5782
  const record = options.sessionId
5605
5783
  ? this.requireSession(contextKey, options.sessionId)
5606
5784
  : this.requireCurrentSession(contextKey);
5607
- if (announceStart) {
5608
- await this.outbound.sendText(contextKey, `正在切换到 Provider ${settings.provider},请稍后。`);
5609
- }
5610
- const loaded = await this.loadSession(record);
5785
+ const loaded = await this.loadSession(record, true);
5611
5786
  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
- });
5787
+ const next: RuntimeExecutionSettings = {
5788
+ modelProvider: settings.provider, model: settings.model,
5789
+ reasoningEffort: settings.effort, permissionMode: settings.mode,
5790
+ };
5791
+ await this.reconcilePreparedSettingsSession(loaded);
5792
+ if (loaded.session.activeTurnId || this.store.getPendingProviderSettings(record.localSessionId)) {
5793
+ await this.savePendingProviderSelection(loaded, next, contextKey, options, "provider");
5794
+ return;
5795
+ }
5796
+ if (announceStart) await this.outbound.sendText(contextKey, `正在切换到 Provider ${settings.provider},请稍后。`);
5797
+ const previous = { ...loaded.session };
5798
+ let session: RuntimeSession;
5799
+ try {
5800
+ session = await this.changeProviderSettings(loaded, next);
5801
+ } catch (error) {
5802
+ if (!this.providerSettingsRestored(loaded, previous, error)) {
5803
+ this.store.savePendingProviderSettings({
5804
+ localSessionId: record.localSessionId, contextKey, settings: next, activeTab: "provider",
5805
+ messageId: options.updateMessageId,
5806
+ replyMessageId: options.updateMessageId ?? this.controlSessionReplyTarget(record)?.messageId,
5807
+ error: runtimeErrorMessage(error),
5808
+ });
5809
+ if (options.updateMessageId) {
5810
+ await this.openExecutionSettingsNow(contextKey, "provider", options).catch((presentationError: unknown) => {
5811
+ this.logger.warn({ error: presentationError, sessionId: record.localSessionId }, "Failed to refresh rejected Provider selection.");
5812
+ });
5813
+ }
5814
+ }
5815
+ throw error;
5816
+ }
5618
5817
  const notice = [
5619
5818
  `Provider 已切换为 ${cardCode(session.modelProvider ?? settings.provider)}`,
5620
5819
  `模型 ${cardCode(session.model ?? settings.model)}`,
@@ -5623,7 +5822,7 @@ export class ProxySessionController {
5623
5822
  `并已保存为 ${cardCode(loaded.record.agentName)} Agent 的默认设置,从下一次请求生效。`,
5624
5823
  ].join(",");
5625
5824
  if (options.updateMessageId) {
5626
- await this.openProviderSelector(contextKey, {
5825
+ await this.openExecutionSettingsNow(contextKey, "provider", {
5627
5826
  sessionId: loaded.record.localSessionId,
5628
5827
  updateMessageId: options.updateMessageId,
5629
5828
  notice,
@@ -5634,15 +5833,23 @@ export class ProxySessionController {
5634
5833
  }
5635
5834
 
5636
5835
  private async changeProviderSettings(loaded: LoadedSession, settings: RuntimeExecutionSettings): Promise<RuntimeSession> {
5836
+ return this.sessionHydrationOperations.run(loaded.record.localSessionId, () => this.changeProviderSettingsNow(loaded, settings));
5837
+ }
5838
+
5839
+ private async changeProviderSettingsNow(loaded: LoadedSession, settings: RuntimeExecutionSettings): Promise<RuntimeSession> {
5637
5840
  if (!loaded.runtime.setExecutionSettings) throw new Error("当前运行时不支持 Provider 设置。");
5638
- const persist = async (candidate: RuntimeSession): Promise<void> => {
5841
+ loaded.session = loaded.runtime.getSession(loaded.record.localSessionId) ?? loaded.session;
5842
+ const remoteSessionId = loaded.session.remoteSessionId;
5843
+ const persist = (candidate: RuntimeSession): Promise<void> => this.agentDefaultsOperations.run(loaded.record.agentName, async () => {
5844
+ if (candidate.remoteSessionId !== remoteSessionId) {
5845
+ throw new Error("设置切换返回了不同的任务 ID,未保存设置;不允许替换任务或更改历史。");
5846
+ }
5639
5847
  assertProviderSettingsApplied(candidate, settings);
5640
5848
  const agent = this.ensureAgent(loaded.record.agentName);
5641
5849
  const previousDefaults = agent.defaults;
5642
- await this.persistAgentExecutionDefaults(loaded.record.localSessionId, candidate);
5850
+ await this.persistAgentExecutionDefaultsNow(loaded.record.localSessionId, candidate);
5643
5851
  try {
5644
5852
  this.store.updateRuntimeSession(loaded.record.localSessionId, {
5645
- remoteSessionId: candidate.remoteSessionId,
5646
5853
  modelProvider: candidate.modelProvider,
5647
5854
  model: candidate.model,
5648
5855
  reasoningEffort: candidate.reasoningEffort,
@@ -5653,12 +5860,145 @@ export class ProxySessionController {
5653
5860
  agent.defaults = previousDefaults;
5654
5861
  throw error;
5655
5862
  }
5656
- };
5863
+ });
5657
5864
  const session = await loaded.runtime.setExecutionSettings(loaded.record.localSessionId, settings, persist);
5865
+ this.settingsOnlySessions.delete(loaded.record.localSessionId);
5658
5866
  this.outbound.updateSessionModel(loaded.record.localSessionId, session.model, session.modelProvider);
5659
5867
  return session;
5660
5868
  }
5661
5869
 
5870
+ private async reconcilePreparedSettingsSession(loaded: LoadedSession): Promise<void> {
5871
+ if (!this.settingsOnlySessions.has(loaded.record.localSessionId) || !loaded.session.activeTurnId) return;
5872
+ const activity = await this.assertSessionTurnOwnership(loaded.record, loaded.runtime);
5873
+ if (activity && !activity.active) {
5874
+ loaded.session = await loaded.runtime.synchronizeSession(loaded.record.localSessionId);
5875
+ }
5876
+ }
5877
+
5878
+ private providerSettingsRestored(loaded: LoadedSession, previous: RuntimeSession, error: unknown): boolean {
5879
+ const restored = loaded.runtime.getSession(loaded.record.localSessionId);
5880
+ const saved = this.store.getSession(loaded.record.localSessionId);
5881
+ const keys = ["remoteSessionId", "modelProvider", "model", "reasoningEffort", "permissionMode"] as const;
5882
+ return error instanceof ProviderSwitchError && error.previousSettingsRestored
5883
+ && Boolean(restored && saved && keys.every((key) => restored[key] === previous[key] && saved[key] === previous[key]));
5884
+ }
5885
+
5886
+ private pendingProviderNotice(pending: PendingProviderSettings): string {
5887
+ const target = `Provider ${cardCode(pending.settings.modelProvider)},模型 ${cardCode(pending.settings.model)}`;
5888
+ return pending.error
5889
+ ? `切换失败:${pending.error}。目标为 ${target};后续轮次已暂停,请重新选择 Provider 或模型重试。`
5890
+ : `待生效:当前轮次不变,结束后切换到 ${target}。可继续选择模型,以最后选择为准。`;
5891
+ }
5892
+
5893
+ private async savePendingProviderSelection(
5894
+ loaded: LoadedSession, settings: RuntimeExecutionSettings, contextKey: string,
5895
+ options: ModelCardOptions, activeTab: ExecutionSettingsTab,
5896
+ ): Promise<void> {
5897
+ const previous = this.store.getPendingProviderSettings(loaded.record.localSessionId);
5898
+ const pending: PendingProviderSettings = {
5899
+ localSessionId: loaded.record.localSessionId, contextKey, settings, activeTab,
5900
+ messageId: options.updateMessageId ?? previous?.messageId,
5901
+ replyMessageId: options.updateMessageId ?? previous?.replyMessageId
5902
+ ?? this.controlSessionReplyTarget(loaded.record)?.messageId,
5903
+ };
5904
+ this.store.savePendingProviderSettings(pending);
5905
+ await this.reconcilePreparedSettingsSession(loaded);
5906
+ if (!loaded.session.activeTurnId) {
5907
+ await this.applyPendingProviderSettings(loaded);
5908
+ void this.scheduleNextQueuedPrompt(loaded.record.localSessionId).catch((error: unknown) => {
5909
+ this.logger.warn({ error }, "Failed to resume queue after Provider selection.");
5910
+ });
5911
+ return;
5912
+ }
5913
+ if (options.updateMessageId) {
5914
+ await this.openExecutionSettingsNow(contextKey, activeTab, options);
5915
+ } else {
5916
+ await this.outbound.sendText(contextKey, this.pendingProviderNotice(pending).replaceAll("`", ""));
5917
+ }
5918
+ }
5919
+
5920
+ // Caller holds settingsOperations; only a verified rollback can unblock the next turn.
5921
+ private async applyPendingProviderSettings(loaded: LoadedSession): Promise<void> {
5922
+ const pending = this.store.getPendingProviderSettings(loaded.record.localSessionId);
5923
+ if (!pending) return;
5924
+ await this.reconcilePreparedSettingsSession(loaded);
5925
+ if (loaded.runtime.getSession(loaded.record.localSessionId)?.activeTurnId) return;
5926
+ if (pending.error) throw new Error(this.pendingProviderNotice(pending));
5927
+ const previous = { ...(loaded.runtime.getSession(loaded.record.localSessionId) ?? loaded.session) };
5928
+ let notice: string;
5929
+ let failure: unknown;
5930
+ let rolledBack = false;
5931
+ try {
5932
+ loaded.session = await this.changeProviderSettings(loaded, pending.settings);
5933
+ this.store.deletePendingProviderSettings(pending.localSessionId);
5934
+ notice = `Provider 已切换为 ${cardCode(pending.settings.modelProvider)},模型 ${cardCode(pending.settings.model)},从下一轮生效。`;
5935
+ } catch (error) {
5936
+ const restored = loaded.runtime.getSession(pending.localSessionId);
5937
+ rolledBack = this.providerSettingsRestored(loaded, previous, error);
5938
+ if (rolledBack) {
5939
+ loaded.session = restored!;
5940
+ this.store.deletePendingProviderSettings(pending.localSessionId);
5941
+ this.outbound.updateSessionModel(pending.localSessionId, previous.model, previous.modelProvider);
5942
+ notice = `切换到 Provider ${cardCode(pending.settings.modelProvider)}、模型 ${cardCode(pending.settings.model)} 失败:${runtimeErrorMessage(error)} 已回退到 Provider ${cardCode(previous.modelProvider ?? "Agent 默认")}、模型 ${cardCode(previous.model ?? "默认")},后续轮次继续使用原设置。`;
5943
+ } else {
5944
+ failure = error;
5945
+ const failed = { ...pending, error: runtimeErrorMessage(error) };
5946
+ this.store.savePendingProviderSettings(failed);
5947
+ notice = this.pendingProviderNotice(failed);
5948
+ }
5949
+ }
5950
+ try {
5951
+ await this.outbound.withReplyTarget(pending.contextKey, pending.replyMessageId
5952
+ ? { messageId: pending.replyMessageId, replyInThread: true } : undefined, async () => {
5953
+ if (pending.messageId) {
5954
+ try {
5955
+ await this.openExecutionSettingsNow(pending.contextKey, pending.activeTab ?? "provider", {
5956
+ sessionId: pending.localSessionId, updateMessageId: pending.messageId,
5957
+ notice: failure || rolledBack ? undefined : notice,
5958
+ warning: rolledBack ? notice.replaceAll("`", "") : undefined,
5959
+ });
5960
+ return;
5961
+ } catch (error) {
5962
+ this.logger.warn({ error, sessionId: pending.localSessionId }, "Failed to refresh Provider settings card.");
5963
+ }
5964
+ }
5965
+ await this.outbound.sendText(pending.contextKey, notice.replaceAll("`", ""));
5966
+ });
5967
+ } catch (error) {
5968
+ this.logger.warn({ error, sessionId: pending.localSessionId }, "Failed to notify Provider settings result.");
5969
+ }
5970
+ if (failure) throw failure;
5971
+ }
5972
+
5973
+ private async applyPendingProviderWhenIdle(sessionId: string): Promise<void> {
5974
+ await this.settingsOperations.run(sessionId, async () => {
5975
+ if (this.recoveryClosed) return;
5976
+ const pending = this.store.getPendingProviderSettings(sessionId);
5977
+ if (!pending || pending.error) return;
5978
+ const record = this.store.getSession(sessionId);
5979
+ if (!record || record.status === "closed") {
5980
+ this.store.deletePendingProviderSettings(sessionId);
5981
+ return;
5982
+ }
5983
+ try {
5984
+ const loaded = await this.loadSession(record, true);
5985
+ await this.applyPendingProviderSettings(loaded);
5986
+ } catch (error) {
5987
+ this.logger.warn({ error, sessionId }, "Deferred Provider switch could not be applied.");
5988
+ if (!this.store.getPendingProviderSettings(sessionId)?.error) {
5989
+ const failed = { ...pending, error: runtimeErrorMessage(error) };
5990
+ this.store.savePendingProviderSettings(failed);
5991
+ await this.outbound.withReplyTarget(pending.contextKey, pending.replyMessageId
5992
+ ? { messageId: pending.replyMessageId, replyInThread: true } : undefined,
5993
+ () => this.outbound.sendText(pending.contextKey, this.pendingProviderNotice(failed).replaceAll("`", "")))
5994
+ .catch((notificationError: unknown) => {
5995
+ this.logger.warn({ error: notificationError, sessionId }, "Failed to notify Provider recovery failure.");
5996
+ });
5997
+ }
5998
+ }
5999
+ });
6000
+ }
6001
+
5662
6002
  private async assertProviderModelSettings(
5663
6003
  loaded: LoadedSession,
5664
6004
  modelProvider: string,
@@ -5703,36 +6043,52 @@ export class ProxySessionController {
5703
6043
  }
5704
6044
 
5705
6045
  private async model(contextKey: string, model?: string, options: ModelCardOptions = {}): Promise<void> {
6046
+ const record = options.sessionId ? this.requireSession(contextKey, options.sessionId) : this.requireCurrentSession(contextKey);
6047
+ return this.settingsOperations.run(record.localSessionId, () => this.modelNow(contextKey, model, { ...options, sessionId: record.localSessionId }));
6048
+ }
6049
+
6050
+ private async modelNow(contextKey: string, model?: string, options: ModelCardOptions = {}): Promise<void> {
5706
6051
  if (!model) {
5707
- await this.openExecutionSettings(contextKey, "model", options);
6052
+ await this.openExecutionSettingsNow(contextKey, "model", options);
5708
6053
  return;
5709
6054
  }
5710
6055
  const record = options.sessionId
5711
6056
  ? this.requireSession(contextKey, options.sessionId)
5712
6057
  : this.requireCurrentSession(contextKey);
5713
- const loaded = await this.loadSession(record);
5714
- const models = await loaded.runtime.listModels(loaded.session.modelProvider, loaded.session.model);
6058
+ const loaded = await this.loadSession(record, true);
6059
+ const pending = this.store.getPendingProviderSettings(record.localSessionId);
6060
+ const selectedSettings = pending?.settings ?? loaded.session;
6061
+ if (options.expectedProvider && options.expectedProvider !== selectedSettings.modelProvider) {
6062
+ throw new Error("Provider 已发生变化,请重新打开运行设置后选择。");
6063
+ }
6064
+ const models = await loaded.runtime.listModels(selectedSettings.modelProvider, selectedSettings.model);
5715
6065
  const selected = models.find((item) => item.id === model);
5716
6066
  if (!selected) throw new Error(`未知模型:${model}`);
5717
- const currentEffort = loaded.session.reasoningEffort;
6067
+ const currentEffort = selectedSettings.reasoningEffort;
5718
6068
  const compatible = currentEffort
5719
6069
  ? selected.supportedReasoningEfforts.length === 0
5720
6070
  || selected.supportedReasoningEfforts.some((option) => option.value === currentEffort)
5721
6071
  : false;
5722
6072
  const nextEffort = compatible ? currentEffort : selected.defaultReasoningEffort;
5723
6073
 
5724
- await loaded.runtime.setModel(loaded.record.localSessionId, model);
5725
- if (nextEffort && nextEffort !== currentEffort) {
5726
- await loaded.runtime.setReasoningEffort(loaded.record.localSessionId, nextEffort);
6074
+ if (pending) {
6075
+ await this.savePendingProviderSelection(loaded, { ...pending.settings, model, reasoningEffort: selectProviderReasoningEffort(selected, nextEffort) }, contextKey, options, "model");
6076
+ return;
5727
6077
  }
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);
6078
+ await this.sessionHydrationOperations.run(loaded.record.localSessionId, async () => {
6079
+ await loaded.runtime.setModel(loaded.record.localSessionId, model);
6080
+ if (nextEffort && nextEffort !== currentEffort) {
6081
+ await loaded.runtime.setReasoningEffort(loaded.record.localSessionId, nextEffort);
6082
+ }
6083
+ this.store.updateRuntimeSession(loaded.record.localSessionId, { model, reasoningEffort: nextEffort });
6084
+ this.outbound.updateSessionModel(loaded.record.localSessionId, model, loaded.session.modelProvider);
6085
+ await this.persistAgentExecutionDefaults(loaded.record.localSessionId);
6086
+ });
5731
6087
  const effortMessage = nextEffort && nextEffort !== currentEffort
5732
6088
  ? `,思考强度已自动调整为 ${nextEffort}`
5733
6089
  : "";
5734
6090
  if (options.updateMessageId) {
5735
- await this.openExecutionSettings(contextKey, "model", {
6091
+ await this.openExecutionSettingsNow(contextKey, "model", {
5736
6092
  sessionId: loaded.record.localSessionId,
5737
6093
  updateMessageId: options.updateMessageId,
5738
6094
  notice: `模型已切换为 ${cardCode(model)}${effortMessage},并已保存为 ${cardCode(loaded.record.agentName)} Agent 的默认设置,从下一次请求生效。`,
@@ -5908,6 +6264,7 @@ export class ProxySessionController {
5908
6264
  const created = await this.shellCommandJobs.createJob({ contextKey, sourceMessageId, command, cwd });
5909
6265
  let cardMessageId: string | undefined;
5910
6266
  const initialState: ShellCommandCardView = {
6267
+ terminalUrl: this.lifecycle?.terminalUrl?.(created.id),
5911
6268
  jobId: created.id,
5912
6269
  contextKey,
5913
6270
  command,
@@ -5934,7 +6291,7 @@ export class ProxySessionController {
5934
6291
  await this.outbound.updateInteractiveCard(
5935
6292
  contextKey,
5936
6293
  cardMessageId,
5937
- this.cardRenderer.renderShellCommandCard(shellCommandJobCardView(failed)),
6294
+ this.cardRenderer.renderShellCommandCard({ ...shellCommandJobCardView(failed), terminalUrl: this.lifecycle?.terminalUrl?.(failed.id) }),
5938
6295
  );
5939
6296
  await this.shellCommandJobs.markPresented(created.id);
5940
6297
  } catch (cardError) {
@@ -5992,7 +6349,7 @@ export class ProxySessionController {
5992
6349
  ): void {
5993
6350
  if (!job.cardMessageId || this.shellCommandJobMonitors.has(job.id)) return;
5994
6351
  const scheduler = new CardUpdateScheduler<ShellCommandCardView>({
5995
- render: (state) => this.cardRenderer.renderShellCommandCard(state),
6352
+ render: (state) => this.cardRenderer.renderShellCommandCard({ ...state, terminalUrl: this.lifecycle?.terminalUrl?.(job.id) }),
5996
6353
  write: (card) => this.outbound.updateInteractiveCard(job.contextKey, job.cardMessageId!, card),
5997
6354
  onError: (error) => this.logger.warn(
5998
6355
  { error, jobId: job.id, contextKey: job.contextKey, messageId: job.cardMessageId },
@@ -6143,16 +6500,26 @@ export class ProxySessionController {
6143
6500
  }
6144
6501
 
6145
6502
  private async thinking(contextKey: string, effort?: string, options: ThinkingCardOptions = {}): Promise<void> {
6503
+ const record = options.sessionId ? this.requireSession(contextKey, options.sessionId) : this.requireCurrentSession(contextKey);
6504
+ return this.settingsOperations.run(record.localSessionId, () => this.thinkingNow(contextKey, effort, { ...options, sessionId: record.localSessionId }));
6505
+ }
6506
+
6507
+ private async thinkingNow(contextKey: string, effort?: string, options: ThinkingCardOptions = {}): Promise<void> {
6146
6508
  if (!effort) {
6147
- await this.openExecutionSettings(contextKey, "thinking", options);
6509
+ await this.openExecutionSettingsNow(contextKey, "thinking", options);
6148
6510
  return;
6149
6511
  }
6150
6512
  const record = options.sessionId
6151
6513
  ? this.requireSession(contextKey, options.sessionId)
6152
6514
  : 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)
6515
+ const loaded = await this.loadSession(record, true);
6516
+ const pending = this.store.getPendingProviderSettings(record.localSessionId);
6517
+ const selectedSettings = pending?.settings ?? loaded.session;
6518
+ if (options.expectedProvider && options.expectedProvider !== selectedSettings.modelProvider) {
6519
+ throw new Error("Provider 已发生变化,请重新打开运行设置后选择。");
6520
+ }
6521
+ const models = await loaded.runtime.listModels(selectedSettings.modelProvider, selectedSettings.model);
6522
+ const currentModel = models.find((item) => item.id === selectedSettings.model)
6156
6523
  ?? models.find((item) => item.isDefault);
6157
6524
  if (!currentModel) throw new Error("当前运行时没有可配置思考强度的模型。");
6158
6525
  if (options.expectedModel && currentModel.id !== options.expectedModel) {
@@ -6164,11 +6531,17 @@ export class ProxySessionController {
6164
6531
  const options = supported.map((option) => option.value).join("、") || "无";
6165
6532
  throw new Error(`不支持的思考强度:${effort}。支持的强度:${options}`);
6166
6533
  }
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);
6534
+ if (pending) {
6535
+ await this.savePendingProviderSelection(loaded, { ...pending.settings, reasoningEffort: effort }, contextKey, options, "thinking");
6536
+ return;
6537
+ }
6538
+ await this.sessionHydrationOperations.run(loaded.record.localSessionId, async () => {
6539
+ await loaded.runtime.setReasoningEffort(loaded.record.localSessionId, effort);
6540
+ this.store.updateRuntimeSession(loaded.record.localSessionId, { reasoningEffort: effort });
6541
+ await this.persistAgentExecutionDefaults(loaded.record.localSessionId);
6542
+ });
6170
6543
  if (options.updateMessageId) {
6171
- await this.openExecutionSettings(contextKey, "thinking", {
6544
+ await this.openExecutionSettingsNow(contextKey, "thinking", {
6172
6545
  sessionId: loaded.record.localSessionId,
6173
6546
  updateMessageId: options.updateMessageId,
6174
6547
  notice: `思考强度已切换为 ${cardCode(effort)},并已保存为 ${cardCode(loaded.record.agentName)} Agent 的默认设置,从下一次请求生效。`,
@@ -6181,25 +6554,38 @@ export class ProxySessionController {
6181
6554
  );
6182
6555
  }
6183
6556
 
6184
- private async permissions(
6557
+ private async permissions(contextKey: string, mode?: PermissionMode, options: ModelCardOptions = {}): Promise<void> {
6558
+ const record = options.sessionId ? this.requireSession(contextKey, options.sessionId) : this.requireCurrentSession(contextKey);
6559
+ return this.settingsOperations.run(record.localSessionId, () => this.permissionsNow(contextKey, mode, { ...options, sessionId: record.localSessionId }));
6560
+ }
6561
+
6562
+ private async permissionsNow(
6185
6563
  contextKey: string,
6186
6564
  mode?: PermissionMode,
6187
6565
  options: ModelCardOptions = {},
6188
6566
  ): Promise<void> {
6189
6567
  if (!mode) {
6190
- await this.openExecutionSettings(contextKey, "permission", options);
6568
+ await this.openExecutionSettingsNow(contextKey, "permission", options);
6191
6569
  return;
6192
6570
  }
6193
6571
  const record = options.sessionId
6194
6572
  ? this.requireSession(contextKey, options.sessionId)
6195
6573
  : 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);
6574
+ const loaded = await this.loadSession(record, true);
6575
+ const pending = this.store.getPendingProviderSettings(record.localSessionId);
6576
+ if (pending) {
6577
+ await this.savePendingProviderSelection(loaded, { ...pending.settings, permissionMode: mode }, contextKey, options, "permission");
6578
+ return;
6579
+ }
6580
+ await this.sessionHydrationOperations.run(record.localSessionId, async () => {
6581
+ await loaded.runtime.setPermissionMode(record.localSessionId, mode);
6582
+ loaded.session = loaded.runtime.getSession(record.localSessionId) ?? loaded.session;
6583
+ loaded.session.permissionMode = mode;
6584
+ this.store.updateRuntimeSession(record.localSessionId, { permissionMode: mode });
6585
+ await this.persistAgentExecutionDefaults(record.localSessionId);
6586
+ });
6201
6587
  if (options.updateMessageId) {
6202
- await this.openExecutionSettings(contextKey, "permission", {
6588
+ await this.openExecutionSettingsNow(contextKey, "permission", {
6203
6589
  sessionId: loaded.record.localSessionId,
6204
6590
  updateMessageId: options.updateMessageId,
6205
6591
  notice: mode === "auto"
@@ -6244,7 +6630,8 @@ export class ProxySessionController {
6244
6630
  const existing = this.turnDetailHydrations.get(turnId);
6245
6631
  if (existing) return existing;
6246
6632
  const snapshot = turnViewSnapshot(this.store.getTurnSnapshot(turnId));
6247
- if ((!snapshot || snapshot.previewJournal) && this.store.previews.has(turnId)) return this.store.previews.load(turnId);
6633
+ const previewTurnId = snapshot?.previewTurnId ?? turnId;
6634
+ if ((!snapshot || snapshot.previewJournal) && this.store.previews.has(previewTurnId)) return this.store.previews.load(previewTurnId);
6248
6635
  if (snapshot && !needsHistoricalDetails(snapshot)) return snapshot;
6249
6636
  const origin = !snapshot ? this.store.getTurnRuntimeOrigin(turnId) : undefined;
6250
6637
  if (!snapshot && (!origin || !this.runtimes.forAgent(origin.agentName).readRemoteTurn)) return undefined;
@@ -6270,7 +6657,7 @@ export class ProxySessionController {
6270
6657
  }
6271
6658
  // An Agent Bot turn may have arrived while the read-only request was in flight.
6272
6659
  const latest = turnViewSnapshot(this.store.getTurnSnapshot(origin.turnId));
6273
- if (latest) return latest.previewJournal ? this.store.previews.load(origin.turnId) ?? latest : latest;
6660
+ if (latest) return latest.previewJournal ? this.store.previews.load(latest.previewTurnId ?? origin.turnId) ?? latest : latest;
6274
6661
  const state = hydrateTurnViewState(createTurnViewState(
6275
6662
  origin.localSessionId, origin.turnId, details.startedAt ?? 0,
6276
6663
  remote?.title, undefined, remote?.cwd, undefined, this.agentLabel(origin.agentName),
@@ -7245,7 +7632,7 @@ export class ProxySessionController {
7245
7632
  const remoteReference = local
7246
7633
  ? remoteSessionReference(local.agentName, local.remoteSessionId!)
7247
7634
  : taskId;
7248
- const { agentName, runtime, remote } = await this.resolveRemoteCodexSession(remoteReference);
7635
+ const { agentName, runtime, remote } = await this.resolveRemoteCodexSession(remoteReference, { view: "metadata" });
7249
7636
  if (isRemoteSessionActive(remote)) {
7250
7637
  throw new Error("任务正在执行,无法归档。请先停止任务或等待本轮完成。");
7251
7638
  }
@@ -7254,7 +7641,11 @@ export class ProxySessionController {
7254
7641
  }
7255
7642
 
7256
7643
  const wasCurrent = Boolean(local && this.store.getUserContext(contextKey)?.currentSessionId === local.localSessionId);
7257
- await runtime.archiveRemoteSession(remote.id);
7644
+ const allowUnmaterialized = Boolean(local && !local.lastTurnId && !local.lastTurnStatus
7645
+ && !this.store.hasTurnAttemptsForSession(local.localSessionId)
7646
+ && !this.store.getForkHistorySource(local.localSessionId)
7647
+ && this.store.listTaskTurnGraphIndex(local.localSessionId).length === 0);
7648
+ await runtime.archiveRemoteSession(remote.id, { allowUnmaterialized });
7258
7649
  const title = local?.title ?? remote.title ?? remote.preview ?? remote.id;
7259
7650
  if (local) {
7260
7651
  this.store.archiveSession(local.localSessionId);
@@ -7407,6 +7798,7 @@ export class ProxySessionController {
7407
7798
  }
7408
7799
  const localActiveTurnId = runtime.getSession(record.localSessionId)?.activeTurnId;
7409
7800
  const runtimeOwnsActiveTurn = Boolean(localActiveTurnId)
7801
+ && (!this.settingsOnlySessions.has(record.localSessionId) || Boolean(activity.activeTurnId))
7410
7802
  && (!activity.activeTurnId || activity.activeTurnId === localActiveTurnId);
7411
7803
  const persistedTurnMatches = record.status === "running"
7412
7804
  && record.lastTurnStatus === "running"
@@ -7808,6 +8200,12 @@ export class ProxySessionController {
7808
8200
  }
7809
8201
 
7810
8202
  private async persistAgentExecutionDefaults(localSessionId: string, candidate?: RuntimeSession): Promise<void> {
8203
+ const record = this.store.getSession(localSessionId);
8204
+ if (!record) throw new Error(`找不到任务:${localSessionId}`);
8205
+ return this.agentDefaultsOperations.run(record.agentName, () => this.persistAgentExecutionDefaultsNow(localSessionId, candidate));
8206
+ }
8207
+
8208
+ private async persistAgentExecutionDefaultsNow(localSessionId: string, candidate?: RuntimeSession): Promise<void> {
7811
8209
  const record = this.store.getSession(localSessionId);
7812
8210
  if (!record) throw new Error(`找不到任务:${localSessionId}`);
7813
8211
  const session = candidate ?? record;
@@ -8937,6 +9335,13 @@ function executionSettingsTabValue(value: unknown): ExecutionSettingsTab {
8937
9335
  throw new Error("设置卡片的 tab 无效,请重新发送设置命令。");
8938
9336
  }
8939
9337
 
9338
+ function modelCardPageValue(value: unknown): number | undefined {
9339
+ if (value === undefined) return undefined;
9340
+ const page = typeof value === "number" || (typeof value === "string" && value.trim()) ? Number(value) : NaN;
9341
+ if (!Number.isSafeInteger(page) || page < 0) throw new Error("模型卡片页码无效,请重新发送 /model。");
9342
+ return page;
9343
+ }
9344
+
8940
9345
  function resetHistoryPageValue(value: unknown): number {
8941
9346
  const page = Number(value);
8942
9347
  if (!Number.isSafeInteger(page) || page < 0) throw new Error("历史轮次卡片页码无效,请重新发送 /turns。");
@@ -9344,10 +9749,18 @@ function defaultResourcePromptFromCounts(imageCount: number, fileCount: number):
9344
9749
  return imageCount === 1 ? "请查看这张图片" : "请查看这些图片";
9345
9750
  }
9346
9751
 
9347
- function isStandaloneResourceMessage(message: IncomingMessage): boolean {
9752
+ function isCommandMessage(message: IncomingMessage): boolean {
9753
+ const text = normalizeFeishuPostText(message.text).trimStart();
9754
+ return text.startsWith("/") || text.startsWith("!");
9755
+ }
9756
+
9757
+ function isForwardAttachmentSource(message: IncomingMessage): boolean {
9348
9758
  return !message.mergedForwardMessageId
9349
- && message.text.trim().length === 0
9350
- && ((message.images?.length ?? 0) > 0 || (message.files?.length ?? 0) > 0);
9759
+ && !message.parentMessageId
9760
+ && !message.contextKey.startsWith("console:")
9761
+ && !isCommandMessage(message)
9762
+ && (message.messageType === "post"
9763
+ || (message.images?.length ?? 0) > 0 || (message.files?.length ?? 0) > 0);
9351
9764
  }
9352
9765
 
9353
9766
  function deduplicateImageReferences(
@@ -9424,7 +9837,8 @@ function isRetryableLlmTurnFailure(message: string): boolean {
9424
9837
  const normalized = message.trim().toLowerCase();
9425
9838
  if (!normalized) return false;
9426
9839
  const permanentFailure = [
9427
- /auth(?:entication|orization)?\b|unauthorized|forbidden|invalid api key|incorrect api key/u,
9840
+ /\b(?:auth|authenticat\w*|unauthenticat\w*|authoriz\w*|unauthorized|forbidden)\b|invalid api key|incorrect api key/u,
9841
+ /(?:^|\D)(?:401|403)(?:\D|$)/u,
9428
9842
  /insufficient[_ -]?quota|quota (?:exceeded|exhausted)|usage limit|billing|credit balance/u,
9429
9843
  /context (?:length|window)|maximum context|too many tokens|token limit/u,
9430
9844
  /invalid request|bad request|invalid argument|malformed|unsupported|not supported/u,