@keyou007/agent-bot 0.1.20-alpha.1 → 0.1.21

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 (92) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +27 -4
  3. package/README.zh.md +23 -4
  4. package/dist/cli/AgentPrerequisites.js +11 -47
  5. package/dist/cli/AgentPrerequisites.js.map +1 -1
  6. package/dist/cli/DefaultAgentSelection.js +1 -1
  7. package/dist/cli/DefaultAgentSelection.js.map +1 -1
  8. package/dist/cli.js +15 -0
  9. package/dist/cli.js.map +1 -1
  10. package/dist/codex/CodexEventMapper.js +21 -1
  11. package/dist/codex/CodexEventMapper.js.map +1 -1
  12. package/dist/codex/CodexLocalActivityDetector.js +32 -10
  13. package/dist/codex/CodexLocalActivityDetector.js.map +1 -1
  14. package/dist/codex/CodexProcessManager.js +56 -7
  15. package/dist/codex/CodexProcessManager.js.map +1 -1
  16. package/dist/codex/CodexRuntime.js +361 -168
  17. package/dist/codex/CodexRuntime.js.map +1 -1
  18. package/dist/codex/CodexVersion.js +18 -0
  19. package/dist/codex/CodexVersion.js.map +1 -0
  20. package/dist/codex/ProviderModels.js +159 -0
  21. package/dist/codex/ProviderModels.js.map +1 -0
  22. package/dist/codex/ProviderSwitch.js +32 -0
  23. package/dist/codex/ProviderSwitch.js.map +1 -0
  24. package/dist/commands/CommandRouter.js +8 -3
  25. package/dist/commands/CommandRouter.js.map +1 -1
  26. package/dist/console/ConsoleTurnPresenter.js +2 -1
  27. package/dist/console/ConsoleTurnPresenter.js.map +1 -1
  28. package/dist/feishu/CardRenderer.js +82 -27
  29. package/dist/feishu/CardRenderer.js.map +1 -1
  30. package/dist/feishu/FeishuConnector.js +17 -5
  31. package/dist/feishu/FeishuConnector.js.map +1 -1
  32. package/dist/feishu/FeishuTurnPresenter.js +34 -8
  33. package/dist/feishu/FeishuTurnPresenter.js.map +1 -1
  34. package/dist/feishu/InboundText.js +11 -0
  35. package/dist/feishu/InboundText.js.map +1 -1
  36. package/dist/feishu/MergedForwardMessage.js +17 -5
  37. package/dist/feishu/MergedForwardMessage.js.map +1 -1
  38. package/dist/index.js +3 -1
  39. package/dist/index.js.map +1 -1
  40. package/dist/local-files/LocalFileViewerServer.js +164 -19
  41. package/dist/local-files/LocalFileViewerServer.js.map +1 -1
  42. package/dist/local-files/MarkdownFileLinks.js +65 -0
  43. package/dist/local-files/MarkdownFileLinks.js.map +1 -0
  44. package/dist/local-files/TurnPreviewPage.js +725 -0
  45. package/dist/local-files/TurnPreviewPage.js.map +1 -0
  46. package/dist/presentation/OutboundRouter.js +6 -1
  47. package/dist/presentation/OutboundRouter.js.map +1 -1
  48. package/dist/presentation/TurnStateReducer.js +142 -3
  49. package/dist/presentation/TurnStateReducer.js.map +1 -1
  50. package/dist/proxy/ProxySessionController.js +573 -325
  51. package/dist/proxy/ProxySessionController.js.map +1 -1
  52. package/dist/state/StateStore.js +65 -65
  53. package/dist/state/StateStore.js.map +1 -1
  54. package/dist/utils/formatStorageSize.js +15 -0
  55. package/dist/utils/formatStorageSize.js.map +1 -0
  56. package/dist/utils/semanticVersion.js +39 -0
  57. package/dist/utils/semanticVersion.js.map +1 -0
  58. package/docs/technical-reference.md +11 -3
  59. package/docs/technical-reference.zh.md +11 -3
  60. package/npm-shrinkwrap.json +2 -2
  61. package/package.json +1 -1
  62. package/skills/agent-bot/SKILL.md +11 -1
  63. package/src/cli/AgentPrerequisites.ts +14 -40
  64. package/src/cli/DefaultAgentSelection.ts +1 -1
  65. package/src/cli.ts +13 -0
  66. package/src/codex/CodexEventMapper.ts +31 -1
  67. package/src/codex/CodexLocalActivityDetector.ts +41 -9
  68. package/src/codex/CodexProcessManager.ts +48 -7
  69. package/src/codex/CodexRuntime.ts +397 -204
  70. package/src/codex/CodexVersion.ts +18 -0
  71. package/src/codex/ProviderModels.ts +185 -0
  72. package/src/codex/ProviderSwitch.ts +36 -0
  73. package/src/commands/CommandRouter.ts +7 -2
  74. package/src/commands/commandTypes.ts +1 -1
  75. package/src/console/ConsoleTurnPresenter.ts +3 -0
  76. package/src/feishu/CardRenderer.ts +84 -43
  77. package/src/feishu/FeishuConnector.ts +15 -5
  78. package/src/feishu/FeishuTurnPresenter.ts +46 -4
  79. package/src/feishu/InboundText.ts +11 -0
  80. package/src/feishu/MergedForwardMessage.ts +15 -5
  81. package/src/index.ts +3 -1
  82. package/src/local-files/LocalFileViewerServer.ts +179 -20
  83. package/src/local-files/MarkdownFileLinks.ts +64 -0
  84. package/src/local-files/TurnPreviewPage.ts +792 -0
  85. package/src/presentation/OutboundRouter.ts +8 -0
  86. package/src/presentation/TurnStateReducer.ts +175 -2
  87. package/src/presentation/turnViewTypes.ts +13 -0
  88. package/src/proxy/ProxySessionController.ts +547 -297
  89. package/src/runtime/types.ts +33 -3
  90. package/src/state/StateStore.ts +86 -90
  91. package/src/utils/formatStorageSize.ts +13 -0
  92. package/src/utils/semanticVersion.ts +28 -0
@@ -4,6 +4,8 @@ import os from "node:os";
4
4
  import path from "node:path";
5
5
  import type { Logger } from "pino";
6
6
  import { createProjectlessWorkspace, detectProjectlessWorkspace } from "../codex/ProjectlessWorkspace.js";
7
+ import { CodexVersionError } from "../codex/CodexVersion.js";
8
+ import { assertProviderSettingsApplied, isMissingRolloutError } from "../codex/ProviderSwitch.js";
7
9
  import {
8
10
  SystemThreadWriterProcessController,
9
11
  type ThreadWriterProcess,
@@ -57,13 +59,16 @@ import type { AgentRuntimeRegistry } from "../runtime/AgentRuntimeRegistry.js";
57
59
  import type {
58
60
  AgentRuntime,
59
61
  ApprovalDecision,
62
+ ModelOption,
60
63
  PermissionMode,
61
64
  RemoteCompletedTurnSummary,
62
65
  RemoteSessionActivity,
66
+ RemoteSessionMetrics,
63
67
  RemoteSessionPage,
64
68
  RemoteSessionSummary,
65
69
  RuntimeGoal,
66
70
  RuntimeEvent,
71
+ RuntimeExecutionSettings,
67
72
  RuntimePrompt,
68
73
  RuntimeSession,
69
74
  } from "../runtime/types.js";
@@ -84,6 +89,7 @@ import {
84
89
  type TurnAnchorRecord,
85
90
  } from "../state/StateStore.js";
86
91
  import { createId } from "../utils/id.js";
92
+ import { formatStorageSize } from "../utils/formatStorageSize.js";
87
93
  import { truncateMiddle, truncateText } from "../utils/markdown.js";
88
94
  import { normalizeTaskTitle } from "../utils/taskTitle.js";
89
95
  import {
@@ -187,6 +193,22 @@ const FORWARD_ATTACHMENT_WINDOW_MS = 800;
187
193
  const DEFAULT_MERGED_FORWARD_INSTRUCTION = "请参考以下内容回复用户";
188
194
  const DEFAULT_REFERENCED_MESSAGE_INSTRUCTION = "请参考引用消息回复用户";
189
195
  const TURN_STOP_REQUESTED_MESSAGE = "已发送停止信号,任务会自动停止。";
196
+ const TURN_RESET_STARTED_MESSAGE = "Reset 正在进行,请稍后。期间发送的消息会自动排队。";
197
+ const CREATE_TASK_STARTED_MESSAGE = "正在创建任务,请稍后。";
198
+ const CREATE_GROUP_STARTED_MESSAGE = "正在创建群聊和任务,请稍后。";
199
+ const FORK_TASK_STARTED_MESSAGE = "正在创建分支任务,请稍后。";
200
+ const FORK_GROUP_STARTED_MESSAGE = "正在创建分支群聊,请稍后。";
201
+ const LIST_SESSIONS_STARTED_MESSAGE = "正在读取任务列表,请稍后。";
202
+ const LIST_TURNS_STARTED_MESSAGE = "正在读取历史轮次,请稍后。";
203
+ const SWITCH_TASK_STARTED_MESSAGE = "正在切换任务,请稍后。";
204
+ const STATUS_STARTED_MESSAGE = "正在读取任务状态,请稍后。";
205
+ const GOAL_STARTED_MESSAGE = "正在处理 Goal,请稍后。";
206
+ const ARCHIVE_TASK_STARTED_MESSAGE = "正在归档任务,请稍后。";
207
+ const DISMISS_GROUP_STARTED_MESSAGE = "正在归档任务并解散群聊,请稍后。";
208
+ const SEND_FILE_STARTED_MESSAGE = "正在发送文件,请稍后。";
209
+ const CHANGE_TITLE_STARTED_MESSAGE = "正在修改任务标题,请稍后。";
210
+ const STOP_TASK_STARTED_MESSAGE = "正在停止任务,请稍后。";
211
+ const CLOSE_WRITER_STARTED_MESSAGE = "正在检查并关闭占用进程,请稍后。";
190
212
  const REMOTE_SESSION_REFERENCE_PREFIX = "agent-runtime:";
191
213
  const WINDOWS_DRIVES_DIRECTORY = "agentbot://windows-drives";
192
214
  const IMAGE_FILE_EXTENSIONS = new Set([
@@ -238,7 +260,7 @@ const HELP_COMMAND_SECTIONS: Array<{
238
260
  usage: "[序号或任务 ID]",
239
261
  description: "从当前或指定任务最近完成轮次创建分支",
240
262
  },
241
- { command: "/turns", description: "浏览历史轮次,并可 Reset 对话上下文" },
263
+ { command: "/turns", usage: "[Turn ID 或序号]", description: "浏览历史轮次,并可 Reset 对话上下文;带参数查看运行详情" },
242
264
  {
243
265
  command: "/title",
244
266
  usage: "&#60;新标题&#62;",
@@ -409,7 +431,6 @@ interface ForkSessionPlan {
409
431
  sourceTurnPrompt?: string;
410
432
  sourceTurnStartedAt?: number;
411
433
  sourceTurnCompletedAt?: number;
412
- sourceCompletedTurns?: RemoteCompletedTurnSummary[];
413
434
  sourceWasRunning: boolean;
414
435
  forkedFromHistoricalTurn: boolean;
415
436
  }
@@ -419,6 +440,14 @@ interface ForkSessionResult {
419
440
  session: RuntimeSession;
420
441
  }
421
442
 
443
+ interface ForkHistoryCursor {
444
+ cursor?: string;
445
+ anchorFound: boolean;
446
+ tailTurnId?: string;
447
+ loadedCount?: number;
448
+ complete: boolean;
449
+ }
450
+
422
451
  interface ForkGroupSessionPlan {
423
452
  plan: ForkSessionPlan;
424
453
  sourceDescription: string;
@@ -489,6 +518,8 @@ export class ProxySessionController {
489
518
  private readonly sessionResets = new Map<string, Promise<void>>();
490
519
  private readonly resetHistoryOperations = new Map<string, string>();
491
520
  private readonly remoteTurnHistoryRefreshes = new Map<string, number>();
521
+ private readonly turnHistoryHydrations = new Map<string, Promise<boolean>>();
522
+ private readonly forkHistoryCursors = new Map<string, ForkHistoryCursor>();
492
523
  private readonly lastSessionListings = new Map<string, string[]>();
493
524
  private readonly threadInitializations = new Map<string, Promise<void>>();
494
525
  private readonly llmRetryingSessions = new Set<string>();
@@ -589,6 +620,7 @@ export class ProxySessionController {
589
620
  "Failed to acknowledge the incoming Feishu message with a reaction.",
590
621
  );
591
622
  }
623
+ await this.waitForCurrentSessionReset(message.contextKey);
592
624
  if (attachmentReservation && this.completeForwardAttachment(message, attachmentReservation)) {
593
625
  this.store.audit(message.contextKey, `${attachmentReservation.sourceKind}_attachment_coalesced`, {
594
626
  messageId: message.messageId,
@@ -826,10 +858,11 @@ export class ProxySessionController {
826
858
  "Processing Agent Bot command.",
827
859
  );
828
860
  }
829
- // Operational and read-only commands must remain available even if a prompt operation is slow.
861
+ // Operational and read-only commands bypass the prompt queue, but still wait for a Reset binding change.
830
862
  if (isQueueIndependentCommand(command)) {
831
863
  await this.outbound.withReplyTarget(message.contextKey, replyTarget, async () => {
832
864
  try {
865
+ await this.waitForCurrentSessionReset(message.contextKey);
833
866
  if (command.type === "prompt") await this.ensureThreadFork(message);
834
867
  await this.execute(
835
868
  message.contextKey,
@@ -857,8 +890,9 @@ export class ProxySessionController {
857
890
  }
858
891
 
859
892
  const previous = this.messageQueues.get(message.contextKey) ?? Promise.resolve();
860
- const next = previous.catch(() => undefined).then(() =>
861
- this.outbound.withReplyTarget(message.contextKey, replyTarget, async () => {
893
+ const next = previous.catch(() => undefined).then(async () => {
894
+ await this.waitForCurrentSessionReset(message.contextKey);
895
+ return this.outbound.withReplyTarget(message.contextKey, replyTarget, async () => {
862
896
  try {
863
897
  let executableCommand = command;
864
898
  let executableImagePaths = localImagePaths;
@@ -903,8 +937,8 @@ export class ProxySessionController {
903
937
  phase: "command_execute",
904
938
  });
905
939
  }
906
- }),
907
- );
940
+ });
941
+ });
908
942
  this.messageQueues.set(message.contextKey, next);
909
943
  await next;
910
944
  if (this.messageQueues.get(message.contextKey) === next) this.messageQueues.delete(message.contextKey);
@@ -1022,6 +1056,7 @@ export class ProxySessionController {
1022
1056
  } else if (kind === "directory_send_file") {
1023
1057
  const filePath = directoryFileActionPath(scopedAction.value.filePath);
1024
1058
  await this.assertSendableFile(filePath);
1059
+ await this.outbound.sendText(contextKey, SEND_FILE_STARTED_MESSAGE);
1025
1060
  await this.outbound.sendFile(contextKey, filePath);
1026
1061
  } else if (kind === "directory_new_folder_prompt") {
1027
1062
  const directory = directoryActionPath(scopedAction.value.directory);
@@ -1077,10 +1112,12 @@ export class ProxySessionController {
1077
1112
  await this.refreshSessionsCardFromAction(scopedAction);
1078
1113
  } else if (kind === "session_new") {
1079
1114
  const sessionId = String(scopedAction.value.sessionId ?? "");
1115
+ await this.outbound.sendText(contextKey, CREATE_TASK_STARTED_MESSAGE);
1080
1116
  await this.createProjectSessionFromReference(contextKey, sessionId);
1081
1117
  await this.refreshSessionsCardFromAction(scopedAction, { page: 0 });
1082
1118
  } else if (kind === "session_new_group") {
1083
1119
  const sessionId = String(scopedAction.value.sessionId ?? "");
1120
+ await this.outbound.sendText(contextKey, CREATE_GROUP_STARTED_MESSAGE);
1084
1121
  await this.createFeishuGroupFromReference(contextKey, sessionId, scopedAction.userId);
1085
1122
  await this.refreshSessionsCardFromAction(scopedAction);
1086
1123
  } else if (kind === "session_archive") {
@@ -1099,6 +1136,7 @@ export class ProxySessionController {
1099
1136
  );
1100
1137
  } else if (kind === "group_dismiss_confirm") {
1101
1138
  this.assertDismissGroupRequester(scopedAction);
1139
+ await this.outbound.sendText(contextKey, DISMISS_GROUP_STARTED_MESSAGE);
1102
1140
  await this.dismissGroupAndArchiveTask(
1103
1141
  contextKey,
1104
1142
  String(scopedAction.value.sessionId ?? ""),
@@ -1106,10 +1144,12 @@ export class ProxySessionController {
1106
1144
  );
1107
1145
  } else if (kind === "session_stop") {
1108
1146
  const sessionId = String(scopedAction.value.sessionId ?? "");
1147
+ await this.outbound.sendText(contextKey, STOP_TASK_STARTED_MESSAGE);
1109
1148
  await this.stopSessionReference(contextKey, sessionId);
1110
1149
  if (scopedAction.value.cardView === "status") await this.refreshStatusCardFromAction(scopedAction, sessionId);
1111
1150
  else await this.refreshSessionsCardFromAction(scopedAction, { forceSwitchTaskId: sessionId });
1112
1151
  } else if (kind === "session_status") {
1152
+ await this.outbound.sendText(contextKey, STATUS_STARTED_MESSAGE);
1113
1153
  await this.status(contextKey, String(scopedAction.value.sessionId ?? ""));
1114
1154
  } else if (kind === "session_status_refresh") {
1115
1155
  await this.refreshStatusCardFromAction(scopedAction);
@@ -1247,6 +1287,8 @@ export class ProxySessionController {
1247
1287
  this.sessionResets.clear();
1248
1288
  this.resetHistoryOperations.clear();
1249
1289
  this.remoteTurnHistoryRefreshes.clear();
1290
+ this.forkHistoryCursors.clear();
1291
+ this.turnHistoryHydrations.clear();
1250
1292
  for (const pending of this.pendingMergedForwards.values()) pending.resolveAttachment(undefined);
1251
1293
  this.pendingMergedForwards.clear();
1252
1294
  for (const pending of this.pendingResourceForwards.values()) pending.resolveAttachment(undefined);
@@ -1326,8 +1368,9 @@ export class ProxySessionController {
1326
1368
  const runtime = this.runtimes.forAgent(record.agentName);
1327
1369
  if (runtime.readRemoteSession) {
1328
1370
  try {
1329
- remote = await runtime.readRemoteSession(record.remoteSessionId);
1371
+ remote = await runtime.readRemoteSession(record.remoteSessionId, "latest-full");
1330
1372
  } catch (error) {
1373
+ if (error instanceof CodexVersionError) throw error;
1331
1374
  this.logger.warn({ error, sessionId: record.localSessionId }, "Failed to inspect App Server task status for CLI.");
1332
1375
  }
1333
1376
  }
@@ -1393,6 +1436,7 @@ export class ProxySessionController {
1393
1436
  scopedRecord.title,
1394
1437
  scopedRecord.cwd,
1395
1438
  this.agentLabel(scopedRecord.agentName),
1439
+ scopedRecord.model,
1396
1440
  );
1397
1441
  const promptMessageId = await this.outbound.withReplyTarget(
1398
1442
  responseContextKey,
@@ -1627,42 +1671,36 @@ export class ProxySessionController {
1627
1671
  permissionModes: PermissionMode[];
1628
1672
  }> {
1629
1673
  const record = this.requireControlSession(localSessionId);
1630
- const loaded = await this.loadSession(record);
1631
1674
  if (setting && !value?.trim()) throw new Error(`task ${setting} requires a value.`);
1632
1675
  const nextValue = value?.trim();
1676
+ if (setting === "provider" && nextValue) {
1677
+ const contextKey = this.controlSessionContextKey(record);
1678
+ await this.outbound.withReplyTarget(
1679
+ contextKey,
1680
+ this.controlSessionReplyTarget(record),
1681
+ () => this.outbound.sendText(contextKey, `正在切换到 Provider ${nextValue},请稍后。`),
1682
+ );
1683
+ }
1684
+ const loaded = await this.loadSession(record);
1633
1685
  if (setting === "provider" && nextValue) {
1634
1686
  await this.assertModelProvider(loaded, nextValue);
1635
- const models = await loaded.runtime.listModels();
1636
- const model = models.find((candidate) => candidate.id === loaded.session.model)
1637
- ?? models.find((candidate) => candidate.isDefault);
1687
+ const models = await loaded.runtime.listModels(nextValue, loaded.session.model);
1688
+ const model = selectProviderModel(models, loaded.session.model);
1638
1689
  if (!model) throw new Error("The current runtime has no model available for a Provider change.");
1639
- const effort = model.supportedReasoningEfforts.some(
1640
- (candidate) => candidate.value === loaded.session.reasoningEffort,
1641
- )
1642
- ? loaded.session.reasoningEffort
1643
- : model.defaultReasoningEffort ?? model.supportedReasoningEfforts[0]?.value;
1644
- if (!effort) throw new Error(`Model ${model.id} has no reasoning effort available for a Provider change.`);
1645
- if (!loaded.runtime.setExecutionSettings) throw new Error("The current runtime does not support Provider settings.");
1646
- const updated = await loaded.runtime.setExecutionSettings(record.localSessionId, {
1690
+ const effort = selectProviderReasoningEffort(model, loaded.session.reasoningEffort);
1691
+ await this.changeProviderSettings(loaded, {
1647
1692
  modelProvider: nextValue,
1648
1693
  model: model.id,
1649
1694
  reasoningEffort: effort,
1650
1695
  permissionMode: loaded.session.permissionMode,
1651
1696
  });
1652
- this.store.updateRuntimeSession(record.localSessionId, {
1653
- modelProvider: updated.modelProvider ?? nextValue,
1654
- model: updated.model ?? model.id,
1655
- reasoningEffort: updated.reasoningEffort ?? effort,
1656
- permissionMode: updated.permissionMode,
1657
- });
1658
1697
  } else if (setting === "model" && nextValue) {
1659
- const models = await loaded.runtime.listModels();
1698
+ const models = await loaded.runtime.listModels(loaded.session.modelProvider, loaded.session.model);
1660
1699
  const selected = models.find((candidate) => candidate.id === nextValue);
1661
1700
  if (!selected) throw new Error(`Unknown model: ${nextValue}`);
1662
1701
  const currentEffort = loaded.session.reasoningEffort;
1663
- const nextEffort = currentEffort && selected.supportedReasoningEfforts.some(
1664
- (candidate) => candidate.value === currentEffort,
1665
- )
1702
+ const nextEffort = currentEffort && (selected.supportedReasoningEfforts.length === 0
1703
+ || selected.supportedReasoningEfforts.some((candidate) => candidate.value === currentEffort))
1666
1704
  ? currentEffort
1667
1705
  : selected.defaultReasoningEffort;
1668
1706
  await loaded.runtime.setModel(record.localSessionId, nextValue);
@@ -1671,7 +1709,7 @@ export class ProxySessionController {
1671
1709
  }
1672
1710
  this.store.updateRuntimeSession(record.localSessionId, { model: nextValue, reasoningEffort: nextEffort });
1673
1711
  } else if (setting === "thinking" && nextValue) {
1674
- const models = await loaded.runtime.listModels();
1712
+ const models = await loaded.runtime.listModels(loaded.session.modelProvider, loaded.session.model);
1675
1713
  const currentModel = models.find((candidate) => candidate.id === loaded.session.model)
1676
1714
  ?? models.find((candidate) => candidate.isDefault);
1677
1715
  if (!currentModel) throw new Error("The current runtime has no model with configurable reasoning effort.");
@@ -1689,12 +1727,12 @@ export class ProxySessionController {
1689
1727
  this.store.updateRuntimeSession(record.localSessionId, { permissionMode: nextValue });
1690
1728
  }
1691
1729
  const session = this.store.getSession(record.localSessionId) ?? record;
1692
- if (setting) await this.persistAgentExecutionDefaults(session.localSessionId);
1693
- const models = await loaded.runtime.listModels();
1730
+ if (setting && setting !== "provider") await this.persistAgentExecutionDefaults(session.localSessionId);
1731
+ const models = await loaded.runtime.listModels(session.modelProvider, session.model);
1694
1732
  const currentModel = models.find((candidate) => candidate.id === session.model)
1695
1733
  ?? models.find((candidate) => candidate.isDefault);
1696
1734
  const providers = loaded.runtime.listModelProviders
1697
- ? await loaded.runtime.listModelProviders()
1735
+ ? await this.modelProviderOptions(loaded)
1698
1736
  : [];
1699
1737
  return {
1700
1738
  session,
@@ -1738,7 +1776,7 @@ export class ProxySessionController {
1738
1776
  };
1739
1777
  }
1740
1778
 
1741
- controlListTaskTurns(localSessionId: string): {
1779
+ async controlListTaskTurns(localSessionId: string): Promise<{
1742
1780
  session: SessionRecord;
1743
1781
  turns: Array<{
1744
1782
  sequence: number;
@@ -1749,17 +1787,18 @@ export class ProxySessionController {
1749
1787
  completedAt?: number;
1750
1788
  current: boolean;
1751
1789
  }>;
1752
- } {
1790
+ }> {
1753
1791
  const session = this.requireControlSession(localSessionId);
1754
1792
  if (!session.remoteSessionId || !this.isCodexSession(session)) {
1755
1793
  throw new Error("The task is not an App Server task that can be reset.");
1756
1794
  }
1757
- const rows = this.store.listTaskTurnGraph(session.localSessionId);
1795
+ await this.hydrateTaskTurnHistory(session);
1796
+ const rows = this.store.listTaskTurnGraphIndex(session.localSessionId);
1758
1797
  const graph = buildTurnGraphRows(rows.map((turn) => ({ turnId: turn.turnId, parentTurnId: turn.parentTurnId })));
1759
1798
  return {
1760
1799
  session,
1761
1800
  turns: rows.flatMap((turn, index) => {
1762
- const snapshot = turnViewSnapshot(turn.snapshot);
1801
+ const snapshot = this.store.getTurnPromptSummary(turn.turnId);
1763
1802
  if (!snapshot) return [];
1764
1803
  return [{
1765
1804
  sequence: graph[index]!.sequence,
@@ -1777,15 +1816,10 @@ export class ProxySessionController {
1777
1816
  async controlResetTaskToTurn(localSessionId: string, turnId: string): Promise<SessionRecord> {
1778
1817
  const record = this.requireControlSession(localSessionId);
1779
1818
  const contextKey = this.controlSessionContextKey(record);
1780
- const previous = this.sessionResets.get(record.localSessionId) ?? Promise.resolve();
1781
- const reset = previous.catch(() => undefined).then(() =>
1782
- this.performSessionReset(contextKey, record, turnId, false));
1783
- this.sessionResets.set(record.localSessionId, reset);
1784
- try {
1785
- await reset;
1786
- } finally {
1787
- if (this.sessionResets.get(record.localSessionId) === reset) this.sessionResets.delete(record.localSessionId);
1788
- }
1819
+ await this.runSessionReset(record.localSessionId, async () => {
1820
+ await this.outbound.sendText(contextKey, TURN_RESET_STARTED_MESSAGE);
1821
+ await this.performSessionReset(contextKey, record, turnId, false);
1822
+ });
1789
1823
  return this.store.getSession(record.localSessionId) ?? record;
1790
1824
  }
1791
1825
 
@@ -1942,6 +1976,7 @@ export class ProxySessionController {
1942
1976
  if (command.projectless && this.ensureAgent(context.defaultAgent).kind !== "app-server") {
1943
1977
  throw new Error("/new --nodir 仅支持 App Server Agent。");
1944
1978
  }
1979
+ await this.outbound.sendText(contextKey, CREATE_TASK_STARTED_MESSAGE);
1945
1980
  await this.createSession(
1946
1981
  contextKey,
1947
1982
  context.defaultAgent,
@@ -1962,6 +1997,10 @@ export class ProxySessionController {
1962
1997
  if (command.projectless && this.ensureAgent(context.defaultAgent).kind !== "app-server") {
1963
1998
  throw new Error("/newgroup --nodir 仅支持 App Server Agent。");
1964
1999
  }
2000
+ if (!userId?.startsWith("ou_")) {
2001
+ throw new Error("/newgroup 只能由具有 open_id 的飞书用户消息触发。");
2002
+ }
2003
+ await this.outbound.sendText(contextKey, CREATE_GROUP_STARTED_MESSAGE);
1965
2004
  await this.createFeishuGroup(
1966
2005
  contextKey,
1967
2006
  context.defaultAgent,
@@ -1994,6 +2033,7 @@ export class ProxySessionController {
1994
2033
  await this.enqueueNoSteerPrompt(contextKey, command.text, messageId, replyTarget);
1995
2034
  return;
1996
2035
  case "sessions":
2036
+ await this.outbound.sendText(contextKey, LIST_SESSIONS_STARTED_MESSAGE);
1997
2037
  await this.listSessions(contextKey, command.searchTerm);
1998
2038
  return;
1999
2039
  case "switch":
@@ -2016,9 +2056,11 @@ export class ProxySessionController {
2016
2056
  await this.openDismissGroupCard(contextKey, userId);
2017
2057
  return;
2018
2058
  case "status":
2059
+ await this.outbound.sendText(contextKey, STATUS_STARTED_MESSAGE);
2019
2060
  await this.status(contextKey, command.sessionId);
2020
2061
  return;
2021
2062
  case "goal":
2063
+ await this.outbound.sendText(contextKey, GOAL_STARTED_MESSAGE);
2022
2064
  await this.goal(contextKey, command);
2023
2065
  return;
2024
2066
  case "restart":
@@ -2032,7 +2074,13 @@ export class ProxySessionController {
2032
2074
  await this.setGroupMute(contextKey, command.enabled);
2033
2075
  return;
2034
2076
  case "turns":
2035
- await this.openResetHistory(contextKey);
2077
+ if (command.turnReference) {
2078
+ await this.outbound.sendText(contextKey, "正在读取轮次运行信息,请稍后。");
2079
+ await this.openTurnDetails(contextKey, command.turnReference);
2080
+ } else {
2081
+ await this.outbound.sendText(contextKey, LIST_TURNS_STARTED_MESSAGE);
2082
+ await this.openResetHistory(contextKey);
2083
+ }
2036
2084
  return;
2037
2085
  case "model":
2038
2086
  await this.openExecutionSettings(contextKey, "model");
@@ -2070,6 +2118,7 @@ export class ProxySessionController {
2070
2118
  session.title,
2071
2119
  session.cwd,
2072
2120
  this.agentLabel(session.agentName),
2121
+ session.model,
2073
2122
  );
2074
2123
  if (!session.lastTurnId) continue;
2075
2124
  void this.outbound.resumeDelivery(session.localSessionId, turnContextKey, session.lastTurnId).catch((error: unknown) => {
@@ -2156,6 +2205,7 @@ export class ProxySessionController {
2156
2205
  session.title,
2157
2206
  session.cwd,
2158
2207
  this.agentLabel(session.agentName),
2208
+ session.model,
2159
2209
  );
2160
2210
  }
2161
2211
  try {
@@ -2284,6 +2334,7 @@ export class ProxySessionController {
2284
2334
  session.title,
2285
2335
  session.cwd,
2286
2336
  this.agentLabel(session.agentName),
2337
+ session.model,
2287
2338
  );
2288
2339
  if (announce) {
2289
2340
  await this.outbound.withReplyTarget(contextKey, replyTarget, () =>
@@ -2309,6 +2360,7 @@ export class ProxySessionController {
2309
2360
  try {
2310
2361
  remote = await runtime.readRemoteSession(session.remoteSessionId);
2311
2362
  } catch (error) {
2363
+ if (error instanceof CodexVersionError) throw error;
2312
2364
  this.logger.warn(
2313
2365
  { error, attemptId: attempt.attemptId, remoteSessionId: session.remoteSessionId },
2314
2366
  "Failed to read the remote task during startup recovery; treating the old local execution as interrupted.",
@@ -2564,6 +2616,7 @@ export class ProxySessionController {
2564
2616
  record.title,
2565
2617
  record.cwd,
2566
2618
  this.agentLabel(record.agentName),
2619
+ record.model,
2567
2620
  );
2568
2621
  await this.outbound.startPendingTurn(
2569
2622
  record.localSessionId,
@@ -2922,6 +2975,7 @@ export class ProxySessionController {
2922
2975
  forkTitle,
2923
2976
  source.cwd,
2924
2977
  this.agentLabel(source.agentName),
2978
+ source.model,
2925
2979
  );
2926
2980
 
2927
2981
  try {
@@ -2990,6 +3044,7 @@ export class ProxySessionController {
2990
3044
  }
2991
3045
 
2992
3046
  private async forkSessionReference(contextKey: string, reference?: string): Promise<void> {
3047
+ await this.outbound.sendText(contextKey, FORK_TASK_STARTED_MESSAGE);
2993
3048
  const plan = await this.prepareForkSession(contextKey, reference);
2994
3049
  const forked = await this.forkSessionIntoContext(contextKey, plan);
2995
3050
  const forkSourceLabel = plan.forkedFromHistoricalTurn
@@ -3050,35 +3105,24 @@ export class ProxySessionController {
3050
3105
  if (!runtime.readRemoteSession && !source) {
3051
3106
  throw new Error("当前 App Server Agent 不支持读取指定任务。");
3052
3107
  }
3053
- const persistedCompletedTurns = source ? this.localForkCompletedTurns(source) : [];
3108
+ const persistedCompletedTurnId = source
3109
+ ? this.store.findLatestCompletedTurnId(source.localSessionId)
3110
+ : undefined;
3054
3111
  const sourceLastSnapshot = turnViewSnapshot(
3055
3112
  source?.lastTurnId ? this.store.getTurnSnapshot(source.lastTurnId) : undefined,
3056
3113
  );
3057
- const localCompletedTurns = persistedCompletedTurns.length > 0
3058
- ? persistedCompletedTurns
3059
- : source?.lastTurnId
3114
+ const localCompletedTurnId = persistedCompletedTurnId
3115
+ ?? (source?.lastTurnId
3060
3116
  && (source.lastTurnStatus === "completed" || sourceLastSnapshot?.status === "completed")
3061
- ? [{
3062
- id: source.lastTurnId,
3063
- prompt: latestUserPromptFromTurnView(sourceLastSnapshot) ?? source.title,
3064
- startedAt: sourceLastSnapshot?.startedAt ?? parseIsoTimestamp(source.updatedAt),
3065
- completedAt: sourceLastSnapshot?.completedAt
3066
- ?? sourceLastSnapshot?.startedAt
3067
- ?? parseIsoTimestamp(source.updatedAt),
3068
- }]
3069
- : [];
3070
- if (!remote && runtime.readRemoteSession && (!source || persistedCompletedTurns.length === 0)) {
3071
- try {
3072
- remote = await runtime.readRemoteSession(remoteSessionId);
3073
- } catch (error) {
3074
- if (localCompletedTurns.length === 0) throw error;
3075
- this.logger.warn(
3076
- { error, contextKey, remoteSessionId, sourceTurnId: localCompletedTurns.at(-1)?.id },
3077
- "Failed to read the complete source task before Fork; using the locally persisted completed Turn.",
3078
- );
3079
- }
3117
+ ? source.lastTurnId
3118
+ : undefined)
3119
+ ?? (source ? this.store.getForkHistorySource(source.localSessionId)?.sourceTurnId : undefined);
3120
+ if (!remote && !localCompletedTurnId) {
3121
+ remote = runtime.readRemoteForkSource
3122
+ ? await runtime.readRemoteForkSource(remoteSessionId)
3123
+ : await runtime.readRemoteSession?.(remoteSessionId);
3080
3124
  }
3081
- const latestTurnId = remote?.lastTurnId ?? source?.lastTurnId ?? localCompletedTurns.at(-1)?.id;
3125
+ const latestTurnId = remote?.lastTurnId ?? source?.lastTurnId ?? localCompletedTurnId;
3082
3126
  const latestSnapshot = turnViewSnapshot(latestTurnId ? this.store.getTurnSnapshot(latestTurnId) : undefined);
3083
3127
  const isRunning = remote
3084
3128
  ? isRemoteSessionActive(remote)
@@ -3089,7 +3133,7 @@ export class ProxySessionController {
3089
3133
  );
3090
3134
  const lastTurnId = remote?.lastCompletedTurnId
3091
3135
  ?? (remote?.lastTurnStatus === "completed" ? remote.lastTurnId : undefined)
3092
- ?? localCompletedTurns.at(-1)?.id;
3136
+ ?? localCompletedTurnId;
3093
3137
  if (!lastTurnId) {
3094
3138
  if (isRunning) {
3095
3139
  throw new Error(`${sourceLabel}正在执行,且还没有已完成轮次可供 fork。请等待当前轮次完成后重试。`);
@@ -3098,8 +3142,7 @@ export class ProxySessionController {
3098
3142
  }
3099
3143
  const snapshot = turnViewSnapshot(this.store.getTurnSnapshot(lastTurnId));
3100
3144
  const forkedFromHistoricalTurn = lastTurnId !== latestTurnId;
3101
- const completedTurn = (remote?.completedTurns ?? localCompletedTurns)
3102
- .find((turn) => turn.id === lastTurnId);
3145
+ const completedTurn = remote?.completedTurns?.find((turn) => turn.id === lastTurnId);
3103
3146
 
3104
3147
  const cwd = remote?.cwd || source?.cwd;
3105
3148
  if (!cwd) throw new Error("指定的 App Server 任务没有可用的工作目录,暂时不能 fork。");
@@ -3145,29 +3188,11 @@ export class ProxySessionController {
3145
3188
  sourceTurnPrompt,
3146
3189
  sourceTurnStartedAt: snapshot?.startedAt ?? sourceTurnCompletedAt,
3147
3190
  sourceTurnCompletedAt,
3148
- sourceCompletedTurns: remote?.completedTurns
3149
- ?? (persistedCompletedTurns.length > 0 ? [] : localCompletedTurns),
3150
3191
  sourceWasRunning: isRunning,
3151
3192
  forkedFromHistoricalTurn,
3152
3193
  };
3153
3194
  }
3154
3195
 
3155
- private localForkCompletedTurns(source: SessionRecord): RemoteCompletedTurnSummary[] {
3156
- return this.store.listTaskTurnGraph(source.localSessionId)
3157
- .slice()
3158
- .reverse()
3159
- .map((record) => {
3160
- const snapshot = turnViewSnapshot(record.snapshot);
3161
- const startedAt = snapshot?.startedAt ?? parseIsoTimestamp(record.updatedAt);
3162
- return {
3163
- id: record.turnId,
3164
- prompt: latestUserPromptFromTurnView(snapshot),
3165
- startedAt,
3166
- completedAt: snapshot?.completedAt ?? startedAt,
3167
- };
3168
- });
3169
- }
3170
-
3171
3196
  private async prepareForkGroupSession(
3172
3197
  contextKey: string,
3173
3198
  requestedTitle: string | undefined,
@@ -3270,7 +3295,6 @@ export class ProxySessionController {
3270
3295
  contextKey: string,
3271
3296
  plan: ForkSessionPlan,
3272
3297
  ): Promise<ForkSessionResult> {
3273
- const sourceCompletedTurns = await this.resolveForkSourceCompletedTurns(plan);
3274
3298
  const localSessionId = createId("sess");
3275
3299
  const record = this.store.createSession({
3276
3300
  localSessionId,
@@ -3312,12 +3336,7 @@ export class ProxySessionController {
3312
3336
  forked.title ?? plan.forkTitle,
3313
3337
  plan.cwd,
3314
3338
  this.agentLabel(plan.agentName),
3315
- );
3316
- const sourceTurnHistoryCount = this.persistForkSourceHistory(
3317
- contextKey,
3318
- localSessionId,
3319
- plan,
3320
- sourceCompletedTurns,
3339
+ plan.model,
3321
3340
  );
3322
3341
  this.store.audit(contextKey, "session_forked", {
3323
3342
  sourceLocalSessionId: plan.source?.localSessionId,
@@ -3326,7 +3345,6 @@ export class ProxySessionController {
3326
3345
  sourceTurnPrompt: plan.sourceTurnPrompt,
3327
3346
  sourceTurnStartedAt: plan.sourceTurnStartedAt,
3328
3347
  sourceTurnCompletedAt: plan.sourceTurnCompletedAt,
3329
- sourceTurnHistoryCount,
3330
3348
  sourceWasRunning: plan.sourceWasRunning,
3331
3349
  forkedLocalSessionId: localSessionId,
3332
3350
  forkedRemoteSessionId: forked.remoteSessionId,
@@ -3342,54 +3360,6 @@ export class ProxySessionController {
3342
3360
  }
3343
3361
  }
3344
3362
 
3345
- private async resolveForkSourceCompletedTurns(
3346
- plan: ForkSessionPlan,
3347
- ): Promise<RemoteCompletedTurnSummary[]> {
3348
- let completedTurns = plan.sourceCompletedTurns;
3349
- if (completedTurns === undefined && plan.runtime.readRemoteSession) {
3350
- try {
3351
- completedTurns = (await plan.runtime.readRemoteSession(plan.remoteSessionId)).completedTurns ?? [];
3352
- } catch (error) {
3353
- this.logger.warn(
3354
- { error, remoteSessionId: plan.remoteSessionId, sourceTurnId: plan.lastTurnId },
3355
- "Failed to read the source task Turn history before Fork; keeping the Fork anchor fallback.",
3356
- );
3357
- }
3358
- }
3359
- if (!completedTurns?.length) return [];
3360
- const anchorIndex = completedTurns.findIndex((turn) => turn.id === plan.lastTurnId);
3361
- if (anchorIndex < 0) {
3362
- this.logger.warn(
3363
- { remoteSessionId: plan.remoteSessionId, sourceTurnId: plan.lastTurnId },
3364
- "The source task Turn history did not include the Fork anchor; keeping the Fork anchor fallback.",
3365
- );
3366
- return [];
3367
- }
3368
- return completedTurns.slice(0, anchorIndex + 1);
3369
- }
3370
-
3371
- private persistForkSourceHistory(
3372
- contextKey: string,
3373
- forkedLocalSessionId: string,
3374
- plan: ForkSessionPlan,
3375
- completedTurns: RemoteCompletedTurnSummary[],
3376
- ): number {
3377
- if (completedTurns.length === 0) return 0;
3378
- const localSessionId = plan.source?.localSessionId ?? forkedLocalSessionId;
3379
- const historyContextKey = plan.source?.contextKey ?? contextKey;
3380
- const fallbackStart = plan.sourceTurnStartedAt
3381
- ?? plan.sourceTurnCompletedAt
3382
- ?? Date.now() - completedTurns.length;
3383
- return this.persistCompletedTurnHistory({
3384
- localSessionId,
3385
- contextKey: historyContextKey,
3386
- agentName: plan.agentName,
3387
- remoteSessionId: plan.remoteSessionId,
3388
- completedTurns,
3389
- fallbackStart,
3390
- });
3391
- }
3392
-
3393
3363
  private persistCompletedTurnHistory(input: {
3394
3364
  localSessionId: string;
3395
3365
  contextKey: string;
@@ -3397,6 +3367,7 @@ export class ProxySessionController {
3397
3367
  remoteSessionId: string;
3398
3368
  completedTurns: RemoteCompletedTurnSummary[];
3399
3369
  fallbackStart: number;
3370
+ replaceParents?: boolean;
3400
3371
  }): number {
3401
3372
  try {
3402
3373
  this.store.importCompletedTurnHistory({
@@ -3404,6 +3375,7 @@ export class ProxySessionController {
3404
3375
  contextKey: input.contextKey,
3405
3376
  agentName: input.agentName,
3406
3377
  remoteSessionId: input.remoteSessionId,
3378
+ replaceParents: input.replaceParents,
3407
3379
  turns: input.completedTurns.map((turn, index) => {
3408
3380
  const startedAt = turn.startedAt ?? input.fallbackStart + index;
3409
3381
  const completedAt = turn.completedAt ?? startedAt;
@@ -3437,82 +3409,210 @@ export class ProxySessionController {
3437
3409
  }
3438
3410
  }
3439
3411
 
3440
- private async hydrateLegacyForkTurnHistory(
3412
+ private async hydrateTaskTurnHistory(
3413
+ current: SessionRecord,
3414
+ requiredCount = RESET_HISTORY_PAGE_SIZE,
3415
+ targetTurnId?: string,
3416
+ ): Promise<boolean> {
3417
+ const existing = this.turnHistoryHydrations.get(current.localSessionId);
3418
+ if (existing) await existing;
3419
+ const hydration = (async () => {
3420
+ if (this.store.getForkHistorySource(current.localSessionId)) {
3421
+ return this.hydrateForkTurnHistory(current.contextKey, current, requiredCount, new Set(), targetTurnId);
3422
+ }
3423
+ return this.hydrateRemoteTurnHistory(current.contextKey, current, requiredCount, targetTurnId);
3424
+ })();
3425
+ this.turnHistoryHydrations.set(current.localSessionId, hydration);
3426
+ try {
3427
+ return await hydration;
3428
+ } finally {
3429
+ if (this.turnHistoryHydrations.get(current.localSessionId) === hydration) {
3430
+ this.turnHistoryHydrations.delete(current.localSessionId);
3431
+ }
3432
+ }
3433
+ }
3434
+
3435
+ private async hydrateForkTurnHistory(
3441
3436
  contextKey: string,
3442
3437
  current: SessionRecord,
3443
- ): Promise<void> {
3444
- const source = this.store.getForkHistorySource(current.localSessionId);
3445
- if (!source || source.sourceTurnHistoryCount !== undefined
3446
- || this.store.hasImportedForkTurnHistory(current.localSessionId)) return;
3447
- if (!source.sourceRemoteSessionId) return;
3438
+ requiredCount: number,
3439
+ visited = new Set<string>(),
3440
+ targetTurnId?: string,
3441
+ ): Promise<boolean> {
3442
+ if (visited.has(current.localSessionId)) return false;
3443
+ visited.add(current.localSessionId);
3444
+ let source = this.store.getForkHistorySource(current.localSessionId);
3445
+ if (!source) return false;
3446
+ const completedHead = current.lastTurnStatus === "completed"
3447
+ ? current.lastTurnId
3448
+ : this.store.findLatestCompletedTurnId(current.localSessionId);
3449
+ if (completedHead && completedHead !== source.sourceTurnId && current.remoteSessionId) {
3450
+ // Once the branch has its own completed work, its remote history is the
3451
+ // authority for missing local turns, including work continued in Desktop.
3452
+ source = {
3453
+ sourceLocalSessionId: current.localSessionId,
3454
+ sourceRemoteSessionId: current.remoteSessionId,
3455
+ sourceTurnId: completedHead,
3456
+ createdAt: current.createdAt,
3457
+ };
3458
+ }
3459
+ if (this.store.hasImportedForkTurnHistory(current.localSessionId, source.sourceRemoteSessionId, source.sourceTurnId)) return false;
3460
+ const localTurns = this.store.listTaskTurnGraphIndex(current.localSessionId);
3461
+ const availableCount = localTurns.length;
3462
+ const key = JSON.stringify([current.agentName, source.sourceRemoteSessionId, source.sourceTurnId]);
3463
+ let state = this.forkHistoryCursors.get(key) ?? { anchorFound: false, complete: false };
3464
+ const localAnchorIndex = localTurns.findIndex((turn) => turn.turnId === source.sourceTurnId);
3465
+ const ownTurnCount = source.sourceLocalSessionId === current.localSessionId
3466
+ ? Math.max(0, localAnchorIndex)
3467
+ : localAnchorIndex >= 0 ? localAnchorIndex : availableCount;
3468
+ const neededInheritedCount = Math.max(0, requiredCount - ownTurnCount);
3469
+ if (!targetTurnId && localAnchorIndex >= 0 && availableCount >= requiredCount
3470
+ && (!state.anchorFound || (state.loadedCount ?? 0) >= neededInheritedCount)) return !state.complete;
3471
+ const sourceSession = source.sourceLocalSessionId
3472
+ ? this.store.getSession(source.sourceLocalSessionId)
3473
+ : undefined;
3474
+ // A branch with no new turns can reuse its ancestor's history, even before
3475
+ // the Agent materializes that intermediate branch.
3476
+ if (sourceSession && sourceSession.localSessionId !== current.localSessionId
3477
+ && this.store.getForkHistorySource(sourceSession.localSessionId)?.sourceTurnId === source.sourceTurnId) {
3478
+ const hasMore = await this.hydrateForkTurnHistory(
3479
+ sourceSession.contextKey, sourceSession,
3480
+ this.store.listTaskTurnGraphIndex(sourceSession.localSessionId).length + requiredCount - availableCount,
3481
+ visited,
3482
+ targetTurnId,
3483
+ );
3484
+ if (!hasMore && this.store.hasImportedForkTurnHistory(sourceSession.localSessionId)) {
3485
+ this.store.audit(contextKey, "fork_turn_history_imported", {
3486
+ forkedLocalSessionId: current.localSessionId,
3487
+ sourceRemoteSessionId: source.sourceRemoteSessionId,
3488
+ sourceTurnId: source.sourceTurnId,
3489
+ reusedLocalSessionId: sourceSession.localSessionId,
3490
+ });
3491
+ }
3492
+ return hasMore;
3493
+ }
3494
+ if (!source.sourceRemoteSessionId) return false;
3448
3495
  const runtime = this.runtimes.forAgent(current.agentName);
3449
- if (runtime.kind !== "codex" || !runtime.readRemoteSession) return;
3496
+ if (runtime.kind !== "codex" || !runtime.listRemoteTurnSummaries) return false;
3497
+ const seenCursors = new Set<string>();
3450
3498
  try {
3451
- const remote = await runtime.readRemoteSession(source.sourceRemoteSessionId);
3452
- const completedTurns = remote.completedTurns ?? [];
3453
- const anchorIndex = completedTurns.findIndex((turn) => turn.id === source.sourceTurnId);
3454
- if (anchorIndex < 0) return;
3455
- const inheritedTurns = completedTurns.slice(0, anchorIndex + 1);
3456
- const sourceSession = source.sourceLocalSessionId
3457
- ? this.store.getSession(source.sourceLocalSessionId)
3458
- : undefined;
3459
- const imported = this.persistCompletedTurnHistory({
3460
- localSessionId: sourceSession?.localSessionId ?? current.localSessionId,
3461
- contextKey: sourceSession?.contextKey ?? contextKey,
3462
- agentName: current.agentName,
3463
- remoteSessionId: source.sourceRemoteSessionId,
3464
- completedTurns: inheritedTurns,
3465
- fallbackStart: source.sourceTurnStartedAt
3466
- ?? source.sourceTurnCompletedAt
3467
- ?? Date.now() - inheritedTurns.length,
3468
- });
3469
- if (imported > 0) {
3499
+ while (!state.complete && (targetTurnId
3500
+ ? !this.store.hasTaskHistoryTurn(current.localSessionId, targetTurnId)
3501
+ : (state.loadedCount ?? 0) < neededInheritedCount)) {
3502
+ const remaining = targetTurnId ? RESET_HISTORY_PAGE_SIZE : neededInheritedCount - (state.loadedCount ?? 0);
3503
+ const page = await runtime.listRemoteTurnSummaries(source.sourceRemoteSessionId, {
3504
+ cursor: state.cursor, limit: Math.min(RESET_HISTORY_PAGE_SIZE, remaining),
3505
+ });
3506
+ if (page.nextCursor && (page.nextCursor === state.cursor || seenCursors.has(page.nextCursor))) {
3507
+ throw new Error("App Server repeated a Turn history cursor.");
3508
+ }
3509
+ const anchorIndex = state.anchorFound ? 0 : page.turns.findIndex((turn) => turn.id === source.sourceTurnId);
3510
+ const inheritedTurns = anchorIndex >= 0 ? page.turns.slice(anchorIndex) : [];
3511
+ const anchorFound = state.anchorFound || anchorIndex >= 0;
3512
+ if (!anchorFound && !page.nextCursor) {
3513
+ this.forkHistoryCursors.delete(key);
3514
+ throw new Error("The Fork source Turn was not found in the available history.");
3515
+ }
3516
+ if (inheritedTurns.length > 0) {
3517
+ const ownerId = sourceSession?.localSessionId ?? current.localSessionId;
3518
+ const imported = this.persistCompletedTurnHistory({
3519
+ localSessionId: ownerId,
3520
+ contextKey: sourceSession?.contextKey ?? contextKey,
3521
+ agentName: current.agentName,
3522
+ remoteSessionId: source.sourceRemoteSessionId,
3523
+ completedTurns: inheritedTurns.slice().reverse(),
3524
+ // Adjacent remote summaries repair gaps in locally inferred ancestry.
3525
+ replaceParents: true,
3526
+ fallbackStart: (source.sourceTurnStartedAt ?? source.sourceTurnCompletedAt
3527
+ ?? parseIsoTimestamp(source.createdAt) ?? 0) - (state.loadedCount ?? 0) - inheritedTurns.length,
3528
+ });
3529
+ if (imported === 0) throw new Error("Failed to persist the requested Turn history page.");
3530
+ if (state.tailTurnId) {
3531
+ this.store.saveTurnParent(state.tailTurnId, ownerId, inheritedTurns[0]!.id, true);
3532
+ }
3533
+ }
3534
+ state = {
3535
+ cursor: page.nextCursor,
3536
+ anchorFound,
3537
+ tailTurnId: inheritedTurns.at(-1)?.id ?? state.tailTurnId,
3538
+ loadedCount: (state.loadedCount ?? 0) + inheritedTurns.length,
3539
+ complete: !page.nextCursor,
3540
+ };
3541
+ this.forkHistoryCursors.set(key, state);
3542
+ if (page.nextCursor) seenCursors.add(page.nextCursor);
3543
+ }
3544
+ if (state.complete) {
3470
3545
  this.store.audit(contextKey, "fork_turn_history_imported", {
3471
3546
  forkedLocalSessionId: current.localSessionId,
3472
3547
  sourceRemoteSessionId: source.sourceRemoteSessionId,
3473
3548
  sourceTurnId: source.sourceTurnId,
3474
- importedTurnCount: imported,
3475
3549
  });
3476
3550
  }
3551
+ return !state.complete;
3477
3552
  } catch (error) {
3553
+ if (error instanceof CodexVersionError) throw error;
3478
3554
  this.logger.warn(
3479
3555
  { error, localSessionId: current.localSessionId, sourceRemoteSessionId: source.sourceRemoteSessionId },
3480
- "Failed to hydrate legacy Fork Turn history; rendering the locally available history.",
3556
+ "Failed to load the requested Fork Turn history page; rendering the locally available history.",
3481
3557
  );
3482
3558
  }
3559
+ return true;
3483
3560
  }
3484
3561
 
3485
3562
  private async hydrateRemoteTurnHistory(
3486
3563
  contextKey: string,
3487
3564
  current: SessionRecord,
3488
- ): Promise<void> {
3489
- if (!current.remoteSessionId) return;
3565
+ requiredCount: number,
3566
+ targetTurnId?: string,
3567
+ ): Promise<boolean> {
3568
+ if (!current.remoteSessionId) return false;
3569
+ const key = JSON.stringify([current.agentName, current.remoteSessionId, "turn-card"]);
3490
3570
  const refreshedAt = this.remoteTurnHistoryRefreshes.get(current.localSessionId);
3491
- if (refreshedAt !== undefined && Date.now() - refreshedAt < REMOTE_TURN_HISTORY_REFRESH_INTERVAL_MS) return;
3492
- const forkSource = this.store.getForkHistorySource(current.localSessionId);
3493
- if (forkSource?.sourceTurnId === current.lastTurnId
3494
- && this.store.hasImportedForkTurnHistory(current.localSessionId)) return;
3571
+ let state = this.forkHistoryCursors.get(key);
3572
+ if (refreshedAt === undefined || Date.now() - refreshedAt >= REMOTE_TURN_HISTORY_REFRESH_INTERVAL_MS) state = undefined;
3573
+ state ??= { anchorFound: true, complete: false };
3495
3574
  const runtime = this.runtimes.forAgent(current.agentName);
3496
- if (runtime.kind !== "codex" || !runtime.readRemoteSession) return;
3575
+ if (runtime.kind !== "codex" || !runtime.listRemoteTurnSummaries) return false;
3576
+ const seen = new Set<string>();
3497
3577
  try {
3498
- const remote = await runtime.readRemoteSession(current.remoteSessionId);
3499
- this.remoteTurnHistoryRefreshes.set(current.localSessionId, Date.now());
3500
- const completedTurns = remote.completedTurns ?? [];
3501
- if (completedTurns.length === 0) return;
3502
- this.persistCompletedTurnHistory({
3503
- localSessionId: current.localSessionId,
3504
- contextKey,
3505
- agentName: current.agentName,
3506
- remoteSessionId: current.remoteSessionId,
3507
- completedTurns,
3508
- fallbackStart: parseIsoTimestamp(current.createdAt) ?? Date.now() - completedTurns.length,
3509
- });
3578
+ while (!state.complete && (targetTurnId
3579
+ ? !this.store.hasTaskHistoryTurn(current.localSessionId, targetTurnId)
3580
+ : (state.loadedCount ?? 0) < requiredCount)) {
3581
+ const page = await runtime.listRemoteTurnSummaries(current.remoteSessionId, {
3582
+ cursor: state.cursor,
3583
+ limit: targetTurnId ? RESET_HISTORY_PAGE_SIZE : Math.min(RESET_HISTORY_PAGE_SIZE, requiredCount - (state.loadedCount ?? 0)),
3584
+ });
3585
+ if (page.nextCursor && (page.nextCursor === state.cursor || seen.has(page.nextCursor))) {
3586
+ throw new Error("App Server repeated a Turn history cursor.");
3587
+ }
3588
+ if (page.turns.length) {
3589
+ const imported = this.persistCompletedTurnHistory({
3590
+ localSessionId: current.localSessionId, contextKey, agentName: current.agentName,
3591
+ remoteSessionId: current.remoteSessionId,
3592
+ completedTurns: page.turns.slice().reverse(), replaceParents: true,
3593
+ fallbackStart: (parseIsoTimestamp(current.createdAt) ?? 0) - (state.loadedCount ?? 0) - page.turns.length,
3594
+ });
3595
+ if (!imported) throw new Error("Failed to persist the requested Turn history page.");
3596
+ if (state.tailTurnId) this.store.saveTurnParent(state.tailTurnId, current.localSessionId, page.turns[0]!.id, true);
3597
+ }
3598
+ state = {
3599
+ anchorFound: true, cursor: page.nextCursor, complete: !page.nextCursor,
3600
+ loadedCount: (state.loadedCount ?? 0) + page.turns.length,
3601
+ tailTurnId: page.turns.at(-1)?.id ?? state.tailTurnId,
3602
+ };
3603
+ this.forkHistoryCursors.set(key, state);
3604
+ this.remoteTurnHistoryRefreshes.set(current.localSessionId, Date.now());
3605
+ if (page.nextCursor) seen.add(page.nextCursor);
3606
+ }
3607
+ return !state.complete;
3510
3608
  } catch (error) {
3609
+ if (error instanceof CodexVersionError) throw error;
3511
3610
  this.logger.warn(
3512
3611
  { error, localSessionId: current.localSessionId, remoteSessionId: current.remoteSessionId },
3513
3612
  "Failed to hydrate App Server Turn history; rendering the locally available history.",
3514
3613
  );
3515
3614
  }
3615
+ return true;
3516
3616
  }
3517
3617
 
3518
3618
  private async createProjectSessionFromReference(contextKey: string, reference: string): Promise<void> {
@@ -3559,7 +3659,7 @@ export class ProxySessionController {
3559
3659
  agentName = source.agentName;
3560
3660
  runtime = this.runtimes.forAgent(agentName);
3561
3661
  } else {
3562
- const resolved = await this.resolveRemoteCodexSession(taskId);
3662
+ const resolved = await this.resolveRemoteCodexSession(taskId, { view: "metadata" });
3563
3663
  agentName = resolved.agentName;
3564
3664
  runtime = resolved.runtime;
3565
3665
  remote = resolved.remote;
@@ -3568,9 +3668,9 @@ export class ProxySessionController {
3568
3668
  const remoteSessionId = source?.remoteSessionId ?? remote?.id ?? taskId;
3569
3669
  if (!remote && runtime.readRemoteSession && remoteSessionId) {
3570
3670
  try {
3571
- remote = await runtime.readRemoteSession(remoteSessionId);
3671
+ remote = await runtime.readRemoteSession(remoteSessionId, "metadata");
3572
3672
  } catch (error) {
3573
- if (!source) throw error;
3673
+ if (!source || error instanceof CodexVersionError) throw error;
3574
3674
  this.logger.warn(
3575
3675
  { error, contextKey, taskId: remoteSessionId },
3576
3676
  "Failed to refresh the source task before creating a project task; using the local project path.",
@@ -3754,6 +3854,7 @@ export class ProxySessionController {
3754
3854
  initialTitle,
3755
3855
  sessionCwd,
3756
3856
  this.agentLabel(agentName),
3857
+ resolvedExecutionSettings.model,
3757
3858
  );
3758
3859
  const runtime = this.runtimes.forAgent(agentName);
3759
3860
  try {
@@ -3771,6 +3872,7 @@ export class ProxySessionController {
3771
3872
  permissionMode: resolvedExecutionSettings.permissionMode ?? "auto",
3772
3873
  });
3773
3874
  this.persistRuntimeSession(record, session, session.activeTurnId ? "running" : "ready");
3875
+ this.outbound.updateSessionModel(localSessionId, session.model);
3774
3876
  const saved = this.store.getSession(localSessionId) ?? record;
3775
3877
  if (announce) {
3776
3878
  const task = initialTitle ? `${initialTitle}(${session.remoteSessionId})` : session.remoteSessionId;
@@ -3907,6 +4009,7 @@ export class ProxySessionController {
3907
4009
  if (!userId?.startsWith("ou_")) {
3908
4010
  throw new Error("/forkgroup 只能由具有 open_id 的飞书用户消息触发。");
3909
4011
  }
4012
+ await this.outbound.sendText(sourceContextKey, FORK_GROUP_STARTED_MESSAGE);
3910
4013
  const prepared = await this.prepareForkGroupSession(sourceContextKey, requestedTitle, incomingMessage);
3911
4014
  await this.forkPreparedSessionToFeishuGroup(
3912
4015
  sourceContextKey,
@@ -3924,6 +4027,7 @@ export class ProxySessionController {
3924
4027
  if (!userId?.startsWith("ou_")) {
3925
4028
  throw new Error("ForkGroup 只能由具有 open_id 的飞书用户触发。");
3926
4029
  }
4030
+ await this.outbound.sendText(sourceContextKey, FORK_GROUP_STARTED_MESSAGE);
3927
4031
  const plan = await this.prepareForkSession(sourceContextKey, reference);
3928
4032
  await this.forkPreparedSessionToFeishuGroup(
3929
4033
  sourceContextKey,
@@ -4041,6 +4145,7 @@ export class ProxySessionController {
4041
4145
  record.title,
4042
4146
  record.cwd,
4043
4147
  this.agentLabel(record.agentName),
4148
+ record.model,
4044
4149
  );
4045
4150
  const loading = (async (): Promise<LoadedSession> => {
4046
4151
  if (record.lastTurnId) {
@@ -4076,7 +4181,8 @@ export class ProxySessionController {
4076
4181
  });
4077
4182
  } catch (error) {
4078
4183
  if (activeWriterThreadId(error)) throw error;
4079
- if (!(agent.kind === "app-server" && !record.lastTurnId && isMissingRolloutError(error))) throw error;
4184
+ if (!(agent.kind === "app-server" && !record.lastTurnId
4185
+ && !this.store.getForkHistorySource(record.localSessionId) && isMissingRolloutError(error))) throw error;
4080
4186
  this.logger.warn({ error, sessionId: record.localSessionId }, "App Server task has no rollout; creating a replacement task.");
4081
4187
  session = await runtime.createSession({
4082
4188
  localSessionId: record.localSessionId,
@@ -4190,14 +4296,20 @@ export class ProxySessionController {
4190
4296
  });
4191
4297
  }
4192
4298
 
4299
+ const enrichedEvent: RuntimeEvent = event.type === "context_compaction"
4300
+ ? {
4301
+ ...event,
4302
+ turnCount: event.turnCount ?? this.store.countTaskTurns(event.sessionId),
4303
+ }
4304
+ : event;
4193
4305
  const presentationEvent = event.type === "turn_failed"
4194
4306
  && isRetryableLlmTurnFailure(event.message)
4195
4307
  && (failedAttempt?.retryCount ?? 0) >= MAX_LLM_TURN_RETRIES
4196
4308
  ? {
4197
- ...event,
4309
+ ...enrichedEvent,
4198
4310
  message: `${event.message}\n\n已自动重试 ${MAX_LLM_TURN_RETRIES} 次,仍未成功。`,
4199
4311
  }
4200
- : event;
4312
+ : enrichedEvent;
4201
4313
  let presentationError: unknown;
4202
4314
  try {
4203
4315
  await this.outbound.onEvent(presentationEvent);
@@ -4554,6 +4666,7 @@ export class ProxySessionController {
4554
4666
 
4555
4667
  private async cancel(contextKey: string): Promise<void> {
4556
4668
  const record = this.requireCurrentSession(contextKey);
4669
+ await this.outbound.sendText(contextKey, STOP_TASK_STARTED_MESSAGE);
4557
4670
  await this.cancelSession(record);
4558
4671
  }
4559
4672
 
@@ -4566,6 +4679,7 @@ export class ProxySessionController {
4566
4679
  const goal = await runtime.getGoal(record.localSessionId);
4567
4680
  if (goal?.status === "active") await runtime.setGoal(record.localSessionId, { status: "paused" });
4568
4681
  } catch (error) {
4682
+ if (error instanceof CodexVersionError) throw error;
4569
4683
  this.logger.warn(
4570
4684
  { error, sessionId: record.localSessionId },
4571
4685
  "Failed to pause the active Agent goal before interrupting its turn.",
@@ -4580,6 +4694,7 @@ export class ProxySessionController {
4580
4694
  ? remote.lastTurnId
4581
4695
  : undefined;
4582
4696
  } catch (error) {
4697
+ if (error instanceof CodexVersionError) throw error;
4583
4698
  this.logger.warn(
4584
4699
  { error, sessionId: record.localSessionId, remoteSessionId: record.remoteSessionId },
4585
4700
  "Failed to inspect the current App Server turn before interrupting; using the locally tracked turn.",
@@ -4627,9 +4742,15 @@ export class ProxySessionController {
4627
4742
  turnId: string,
4628
4743
  ): Promise<void> {
4629
4744
  if (!sessionId || !turnId) throw new Error("无效的 Reset 请求。请使用最新的思考卡片重试。");
4745
+ await this.runSessionReset(sessionId, async () => {
4746
+ await this.outbound.sendText(contextKey, TURN_RESET_STARTED_MESSAGE);
4747
+ await this.performCurrentSessionReset(contextKey, sessionId, turnId);
4748
+ });
4749
+ }
4750
+
4751
+ private async runSessionReset(sessionId: string, operation: () => Promise<void>): Promise<void> {
4630
4752
  const previous = this.sessionResets.get(sessionId) ?? Promise.resolve();
4631
- const reset = previous.catch(() => undefined).then(() =>
4632
- this.performCurrentSessionReset(contextKey, sessionId, turnId));
4753
+ const reset = previous.catch(() => undefined).then(operation);
4633
4754
  this.sessionResets.set(sessionId, reset);
4634
4755
  try {
4635
4756
  await reset;
@@ -4638,6 +4759,19 @@ export class ProxySessionController {
4638
4759
  }
4639
4760
  }
4640
4761
 
4762
+ private async waitForCurrentSessionReset(contextKey: string): Promise<void> {
4763
+ const sessionId = this.currentSession(contextKey)?.localSessionId;
4764
+ if (!sessionId) return;
4765
+
4766
+ let reset = this.sessionResets.get(sessionId);
4767
+ while (reset) {
4768
+ await reset.catch(() => undefined);
4769
+ const next = this.sessionResets.get(sessionId);
4770
+ if (!next || next === reset) return;
4771
+ reset = next;
4772
+ }
4773
+ }
4774
+
4641
4775
  private async resetFromHistoryCard(
4642
4776
  contextKey: string,
4643
4777
  sessionId: string,
@@ -4654,16 +4788,11 @@ export class ProxySessionController {
4654
4788
 
4655
4789
  this.resetHistoryOperations.set(sessionId, turnId);
4656
4790
  try {
4657
- await this.openResetHistory(contextKey, { expectedSessionId: sessionId, updateMessageId, page });
4658
- } catch (error) {
4659
- if (this.resetHistoryOperations.get(sessionId) === turnId) {
4660
- this.resetHistoryOperations.delete(sessionId);
4661
- }
4662
- throw error;
4663
- }
4664
-
4665
- try {
4666
- await this.resetCurrentSessionToTurn(contextKey, sessionId, turnId);
4791
+ await this.runSessionReset(sessionId, async () => {
4792
+ await this.outbound.sendText(contextKey, TURN_RESET_STARTED_MESSAGE);
4793
+ await this.openResetHistory(contextKey, { expectedSessionId: sessionId, updateMessageId, page });
4794
+ await this.performCurrentSessionReset(contextKey, sessionId, turnId);
4795
+ });
4667
4796
  } catch (error) {
4668
4797
  if (this.resetHistoryOperations.get(sessionId) === turnId) {
4669
4798
  this.resetHistoryOperations.delete(sessionId);
@@ -4707,9 +4836,9 @@ export class ProxySessionController {
4707
4836
  throw new Error("当前任务不是可 Reset 的 App Server 任务。");
4708
4837
  }
4709
4838
 
4710
- const historyTurn = this.store.listTaskTurnGraph(current.localSessionId)
4839
+ const historyTurn = this.store.listTaskTurnGraphIndex(current.localSessionId)
4711
4840
  .find((turn) => turn.turnId === turnId);
4712
- const snapshot = turnViewSnapshot(historyTurn?.snapshot);
4841
+ const snapshot = historyTurn ? this.store.getTurnPromptSummary(turnId) : undefined;
4713
4842
  if (!snapshot || snapshot.status !== "completed") {
4714
4843
  throw new Error("只能将当前任务 Reset 到已成功完成的轮次。");
4715
4844
  }
@@ -4759,6 +4888,7 @@ export class ProxySessionController {
4759
4888
  forked.title ?? current.title,
4760
4889
  current.cwd,
4761
4890
  this.agentLabel(current.agentName),
4891
+ current.model,
4762
4892
  );
4763
4893
  this.store.audit(contextKey, "session_reset_to_turn", {
4764
4894
  localSessionId: current.localSessionId,
@@ -4822,12 +4952,13 @@ export class ProxySessionController {
4822
4952
  };
4823
4953
  if (record) {
4824
4954
  const loaded = await this.loadSession(record);
4825
- const models = await loaded.runtime.listModels();
4826
- const currentModel = models.find((model) => model.id === loaded.session.model)
4827
- ?? models.find((model) => model.isDefault);
4828
4955
  const providerSupported = loaded.runtime.kind === "codex" && Boolean(loaded.runtime.listModelProviders);
4829
4956
  const providers = providerSupported ? await this.modelProviderOptions(loaded) : [];
4830
4957
  const currentProvider = loaded.session.modelProvider ?? providers.find((provider) => provider.isDefault)?.id;
4958
+ const models = await loaded.runtime.listModels(currentProvider, loaded.session.model);
4959
+ const currentModel = models.find((model) => model.id === loaded.session.model)
4960
+ ?? models.find((model) => model.isDefault)
4961
+ ?? models[0];
4831
4962
  const currentEffort = currentModel?.supportedReasoningEfforts.some(
4832
4963
  (option) => option.value === loaded.session.reasoningEffort,
4833
4964
  )
@@ -4893,24 +5024,19 @@ export class ProxySessionController {
4893
5024
  const record = options.sessionId
4894
5025
  ? this.requireSession(contextKey, options.sessionId)
4895
5026
  : this.requireCurrentSession(contextKey);
5027
+ await this.outbound.sendText(contextKey, `正在切换到 Provider ${modelProvider},请稍后。`);
4896
5028
  const loaded = await this.loadSession(record);
4897
5029
  await this.assertModelProvider(loaded, modelProvider);
4898
- const models = await loaded.runtime.listModels();
4899
- const model = models.find((candidate) => candidate.id === loaded.session.model)
4900
- ?? models.find((candidate) => candidate.isDefault);
5030
+ const models = await loaded.runtime.listModels(modelProvider, loaded.session.model);
5031
+ const model = selectProviderModel(models, loaded.session.model);
4901
5032
  if (!model) throw new Error("当前运行时没有可用于 Provider 切换的模型。");
4902
- const effort = model.supportedReasoningEfforts.some(
4903
- (candidate) => candidate.value === loaded.session.reasoningEffort,
4904
- )
4905
- ? loaded.session.reasoningEffort
4906
- : model.defaultReasoningEffort ?? model.supportedReasoningEfforts[0]?.value;
4907
- if (!effort) throw new Error(`模型 ${model.id} 没有可用于 Provider 切换的思考强度。`);
5033
+ const effort = selectProviderReasoningEffort(model, loaded.session.reasoningEffort);
4908
5034
  await this.applyProviderSettings(contextKey, {
4909
5035
  provider: modelProvider,
4910
5036
  model: model.id,
4911
5037
  effort,
4912
5038
  mode: loaded.session.permissionMode,
4913
- }, options);
5039
+ }, options, false);
4914
5040
  }
4915
5041
 
4916
5042
  private async openProviderSelector(
@@ -4929,11 +5055,11 @@ export class ProxySessionController {
4929
5055
  ): Promise<void> {
4930
5056
  const loaded = await this.loadSession(this.requireSession(contextKey, sessionId));
4931
5057
  await this.assertModelProvider(loaded, modelProvider);
4932
- const models = await loaded.runtime.listModels();
5058
+ const models = await loaded.runtime.listModels(modelProvider, loaded.session.model);
4933
5059
  const card = this.cardRenderer.renderModelSelector({
4934
5060
  sessionId,
4935
5061
  contextKey,
4936
- currentModel: loaded.session.model ?? models.find((model) => model.isDefault)?.id,
5062
+ currentModel: selectProviderModel(models, loaded.session.model)?.id,
4937
5063
  reasoningEffort: loaded.session.reasoningEffort,
4938
5064
  models,
4939
5065
  modelProvider,
@@ -4953,21 +5079,24 @@ export class ProxySessionController {
4953
5079
  ): Promise<void> {
4954
5080
  const loaded = await this.loadSession(this.requireSession(contextKey, sessionId));
4955
5081
  await this.assertModelProvider(loaded, modelProvider);
4956
- const models = await loaded.runtime.listModels();
5082
+ const models = await loaded.runtime.listModels(modelProvider, model);
4957
5083
  const selected = models.find((candidate) => candidate.id === model);
4958
5084
  if (!selected) throw new Error(`未知模型:${model}`);
4959
- const currentEffort = selected.supportedReasoningEfforts.some(
5085
+ const options = selected.supportedReasoningEfforts.length > 0
5086
+ ? selected.supportedReasoningEfforts
5087
+ : [{ value: selectProviderReasoningEffort(selected, loaded.session.reasoningEffort) }];
5088
+ const currentEffort = options.some(
4960
5089
  (option) => option.value === loaded.session.reasoningEffort,
4961
5090
  )
4962
5091
  ? loaded.session.reasoningEffort
4963
- : selected.defaultReasoningEffort;
5092
+ : selected.defaultReasoningEffort ?? options[0]?.value;
4964
5093
  const card = this.cardRenderer.renderReasoningSelector({
4965
5094
  sessionId,
4966
5095
  contextKey,
4967
5096
  modelProvider,
4968
5097
  model,
4969
5098
  currentEffort,
4970
- options: selected.supportedReasoningEfforts,
5099
+ options,
4971
5100
  permissionMode,
4972
5101
  unifiedSettings: true,
4973
5102
  });
@@ -5003,28 +5132,22 @@ export class ProxySessionController {
5003
5132
  contextKey: string,
5004
5133
  settings: { provider: string; model: string; effort: string; mode: PermissionMode },
5005
5134
  options: ModelCardOptions = {},
5135
+ announceStart = true,
5006
5136
  ): Promise<void> {
5007
5137
  const record = options.sessionId
5008
5138
  ? this.requireSession(contextKey, options.sessionId)
5009
5139
  : this.requireCurrentSession(contextKey);
5140
+ if (announceStart) {
5141
+ await this.outbound.sendText(contextKey, `正在切换到 Provider ${settings.provider},请稍后。`);
5142
+ }
5010
5143
  const loaded = await this.loadSession(record);
5011
5144
  await this.assertProviderModelSettings(loaded, settings.provider, settings.model, settings.effort);
5012
- if (!loaded.runtime.setExecutionSettings) {
5013
- throw new Error("当前运行时不支持 Provider 设置。");
5014
- }
5015
- const session = await loaded.runtime.setExecutionSettings(loaded.record.localSessionId, {
5145
+ const session = await this.changeProviderSettings(loaded, {
5016
5146
  modelProvider: settings.provider,
5017
5147
  model: settings.model,
5018
5148
  reasoningEffort: settings.effort,
5019
5149
  permissionMode: settings.mode,
5020
5150
  });
5021
- this.store.updateRuntimeSession(loaded.record.localSessionId, {
5022
- modelProvider: session.modelProvider ?? settings.provider,
5023
- model: session.model ?? settings.model,
5024
- reasoningEffort: session.reasoningEffort ?? settings.effort,
5025
- permissionMode: session.permissionMode,
5026
- });
5027
- await this.persistAgentExecutionDefaults(loaded.record.localSessionId);
5028
5151
  const notice = [
5029
5152
  `Provider 已切换为 ${cardCode(session.modelProvider ?? settings.provider)}`,
5030
5153
  `模型 ${cardCode(session.model ?? settings.model)}`,
@@ -5043,6 +5166,30 @@ export class ProxySessionController {
5043
5166
  }
5044
5167
  }
5045
5168
 
5169
+ private async changeProviderSettings(loaded: LoadedSession, settings: RuntimeExecutionSettings): Promise<RuntimeSession> {
5170
+ if (!loaded.runtime.setExecutionSettings) throw new Error("当前运行时不支持 Provider 设置。");
5171
+ const persist = async (candidate: RuntimeSession): Promise<void> => {
5172
+ assertProviderSettingsApplied(candidate, settings);
5173
+ const agent = this.ensureAgent(loaded.record.agentName);
5174
+ const previousDefaults = agent.defaults;
5175
+ await this.persistAgentExecutionDefaults(loaded.record.localSessionId, candidate);
5176
+ try {
5177
+ this.store.updateRuntimeSession(loaded.record.localSessionId, {
5178
+ remoteSessionId: candidate.remoteSessionId,
5179
+ modelProvider: candidate.modelProvider,
5180
+ model: candidate.model,
5181
+ reasoningEffort: candidate.reasoningEffort,
5182
+ permissionMode: candidate.permissionMode,
5183
+ });
5184
+ } catch (error) {
5185
+ await this.lifecycle?.persistAgentExecutionDefaults?.(loaded.record.agentName, previousDefaults ?? {});
5186
+ agent.defaults = previousDefaults;
5187
+ throw error;
5188
+ }
5189
+ };
5190
+ return loaded.runtime.setExecutionSettings(loaded.record.localSessionId, settings, persist);
5191
+ }
5192
+
5046
5193
  private async assertProviderModelSettings(
5047
5194
  loaded: LoadedSession,
5048
5195
  modelProvider: string,
@@ -5050,9 +5197,10 @@ export class ProxySessionController {
5050
5197
  effort: string,
5051
5198
  ): Promise<void> {
5052
5199
  await this.assertModelProvider(loaded, modelProvider);
5053
- const selected = (await loaded.runtime.listModels()).find((candidate) => candidate.id === model);
5200
+ const selected = (await loaded.runtime.listModels(modelProvider, model)).find((candidate) => candidate.id === model);
5054
5201
  if (!selected) throw new Error(`未知模型:${model}`);
5055
- if (!selected.supportedReasoningEfforts.some((option) => option.value === effort)) {
5202
+ if (selected.supportedReasoningEfforts.length > 0
5203
+ && !selected.supportedReasoningEfforts.some((option) => option.value === effort)) {
5056
5204
  const supported = selected.supportedReasoningEfforts.map((option) => option.value).join("、") || "无";
5057
5205
  throw new Error(`模型 ${model} 不支持思考强度 ${effort}。支持的强度:${supported}`);
5058
5206
  }
@@ -5086,12 +5234,13 @@ export class ProxySessionController {
5086
5234
  ? this.requireSession(contextKey, options.sessionId)
5087
5235
  : this.requireCurrentSession(contextKey);
5088
5236
  const loaded = await this.loadSession(record);
5089
- const models = await loaded.runtime.listModels();
5237
+ const models = await loaded.runtime.listModels(loaded.session.modelProvider, loaded.session.model);
5090
5238
  const selected = models.find((item) => item.id === model);
5091
5239
  if (!selected) throw new Error(`未知模型:${model}`);
5092
5240
  const currentEffort = loaded.session.reasoningEffort;
5093
5241
  const compatible = currentEffort
5094
- ? selected.supportedReasoningEfforts.some((option) => option.value === currentEffort)
5242
+ ? selected.supportedReasoningEfforts.length === 0
5243
+ || selected.supportedReasoningEfforts.some((option) => option.value === currentEffort)
5095
5244
  : false;
5096
5245
  const nextEffort = compatible ? currentEffort : selected.defaultReasoningEffort;
5097
5246
 
@@ -5463,7 +5612,9 @@ export class ProxySessionController {
5463
5612
  private async setTitle(contextKey: string, title: string): Promise<void> {
5464
5613
  const normalizedTitle = normalizeTaskTitle(title);
5465
5614
  if (!normalizedTitle) throw new Error("任务标题不能为空。");
5466
- const loaded = await this.loadSession(this.requireCurrentSession(contextKey));
5615
+ const current = this.requireCurrentSession(contextKey);
5616
+ await this.outbound.sendText(contextKey, CHANGE_TITLE_STARTED_MESSAGE);
5617
+ const loaded = await this.loadSession(current);
5467
5618
  if (loaded.runtime.setTitle) await loaded.runtime.setTitle(loaded.record.localSessionId, normalizedTitle);
5468
5619
  else loaded.session.title = normalizedTitle;
5469
5620
  this.store.updateRuntimeSession(loaded.record.localSessionId, { title: normalizedTitle });
@@ -5520,7 +5671,7 @@ export class ProxySessionController {
5520
5671
  ? this.requireSession(contextKey, options.sessionId)
5521
5672
  : this.requireCurrentSession(contextKey);
5522
5673
  const loaded = await this.loadSession(record);
5523
- const models = await loaded.runtime.listModels();
5674
+ const models = await loaded.runtime.listModels(loaded.session.modelProvider, loaded.session.model);
5524
5675
  const currentModel = models.find((item) => item.id === loaded.session.model)
5525
5676
  ?? models.find((item) => item.isDefault);
5526
5677
  if (!currentModel) throw new Error("当前运行时没有可配置思考强度的模型。");
@@ -5585,6 +5736,71 @@ export class ProxySessionController {
5585
5736
  );
5586
5737
  }
5587
5738
 
5739
+ private taskTurnIds(current: SessionRecord): string[] {
5740
+ const ids = this.store.listTaskTurnGraphIndex(current.localSessionId).map((turn) => turn.turnId);
5741
+ if (current.lastTurnStatus === "running" && current.lastTurnId && !ids.includes(current.lastTurnId)) {
5742
+ ids.unshift(current.lastTurnId);
5743
+ }
5744
+ return ids;
5745
+ }
5746
+
5747
+ private runningTurnSnapshot(current: SessionRecord, turnId: string): TurnViewState {
5748
+ const snapshot = turnViewSnapshot(this.store.getTurnSnapshot(turnId));
5749
+ if (snapshot) return snapshot;
5750
+ const attempt = this.store.findIncompleteTurnAttemptByTurnId(turnId);
5751
+ const startedAt = Date.parse(attempt?.createdAt ?? "");
5752
+ return {
5753
+ sessionId: current.localSessionId,
5754
+ turnId,
5755
+ taskTitle: current.title,
5756
+ prompt: attempt?.promptText ?? current.title,
5757
+ status: "running",
5758
+ startedAt: Number.isFinite(startedAt) ? startedAt : Date.now(),
5759
+ assistantText: "", plan: [], activities: [], completedTools: [], failedTools: [], fileSummary: [],
5760
+ };
5761
+ }
5762
+
5763
+ private async openTurnDetails(contextKey: string, reference: string): Promise<void> {
5764
+ const current = this.requireCurrentSession(contextKey);
5765
+ let turnId: string | undefined;
5766
+ if (/^\d+$/.test(reference)) {
5767
+ const index = Number(reference);
5768
+ const runningCount = current.lastTurnStatus === "running" && current.lastTurnId ? 1 : 0;
5769
+ const hasMore = index > runningCount && this.isCodexSession(current)
5770
+ ? await this.hydrateTaskTurnHistory(current, index - runningCount)
5771
+ : false;
5772
+ turnId = this.taskTurnIds(current)[index - 1];
5773
+ if (!turnId) {
5774
+ throw new Error(hasMore
5775
+ ? "轮次历史暂未加载完成,请稍后重试。"
5776
+ : `未找到序号为 ${reference} 的轮次。请发送 /turn 查看当前轮次序号。`);
5777
+ }
5778
+ } else {
5779
+ const snapshot = turnViewSnapshot(this.store.getTurnSnapshot(reference));
5780
+ if (snapshot && (snapshot.sessionId === current.localSessionId
5781
+ || this.store.hasTaskHistoryTurn(current.localSessionId, reference))) {
5782
+ turnId = reference;
5783
+ } else if (current.lastTurnStatus === "running" && current.lastTurnId === reference) {
5784
+ turnId = reference;
5785
+ } else if (this.isCodexSession(current) && (!snapshot || this.store.getForkHistorySource(current.localSessionId))) {
5786
+ const hasMore = await this.hydrateTaskTurnHistory(current, 0, reference);
5787
+ if (this.store.hasTaskHistoryTurn(current.localSessionId, reference)) turnId = reference;
5788
+ else if (hasMore) throw new Error("轮次历史暂未加载完成,请稍后重试。");
5789
+ }
5790
+ if (!turnId && snapshot) throw new Error("这个 Turn 不属于当前任务的历史。请先切换到对应任务。");
5791
+ if (!turnId) throw new Error(`未找到当前任务的 Turn:${reference}`);
5792
+ }
5793
+
5794
+ if (current.lastTurnStatus === "running" && current.lastTurnId === turnId
5795
+ && !this.store.getTurnPromptSummary(turnId)) {
5796
+ await this.outbound.sendInteractiveCard(contextKey, this.cardRenderer.renderTurnDetails(
5797
+ this.runningTurnSnapshot(current, turnId),
5798
+ ));
5799
+ return;
5800
+ }
5801
+ await this.outbound.showDetails(contextKey, turnId);
5802
+ }
5803
+
5588
5804
  private async openResetHistory(
5589
5805
  contextKey: string,
5590
5806
  options: ResetHistoryCardOptions = {},
@@ -5596,35 +5812,19 @@ export class ProxySessionController {
5596
5812
  if (!current.remoteSessionId || !this.isCodexSession(current)) {
5597
5813
  throw new Error("当前任务不是可 Reset 的 App Server 任务。");
5598
5814
  }
5599
- await this.hydrateLegacyForkTurnHistory(contextKey, current);
5600
- await this.hydrateRemoteTurnHistory(contextKey, current);
5601
- const completedTurns = this.store.listTaskTurnGraph(current.localSessionId);
5815
+ const requestedPage = Math.max(0, Math.trunc(options.page ?? 0));
5816
+ const hasMoreHistory = await this.hydrateTaskTurnHistory(current, (requestedPage + 1) * RESET_HISTORY_PAGE_SIZE);
5817
+ const completedTurns = this.store.listTaskTurnGraphIndex(current.localSessionId);
5602
5818
  const completedTurnIds = new Set(completedTurns.map((turn) => turn.turnId));
5603
5819
  const runningTurnId = current.lastTurnStatus === "running" && current.lastTurnId
5604
5820
  && !completedTurnIds.has(current.lastTurnId)
5605
5821
  ? current.lastTurnId
5606
5822
  : undefined;
5607
- const persistedRunningSnapshot = runningTurnId
5608
- ? turnViewSnapshot(this.store.getTurnSnapshot(runningTurnId))
5609
- : undefined;
5610
5823
  const runningAttempt = runningTurnId
5611
5824
  ? this.store.findIncompleteTurnAttemptByTurnId(runningTurnId)
5612
5825
  : undefined;
5613
- const attemptStartedAt = Date.parse(runningAttempt?.createdAt ?? "");
5614
5826
  const runningSnapshot: TurnViewState | undefined = runningTurnId
5615
- ? persistedRunningSnapshot ?? {
5616
- sessionId: current.localSessionId,
5617
- turnId: runningTurnId,
5618
- prompt: runningAttempt?.promptText ?? current.title,
5619
- status: "running",
5620
- startedAt: Number.isFinite(attemptStartedAt) ? attemptStartedAt : Date.now(),
5621
- assistantText: "",
5622
- plan: [],
5623
- activities: [],
5624
- completedTools: [],
5625
- failedTools: [],
5626
- fileSummary: [],
5627
- }
5827
+ ? this.runningTurnSnapshot(current, runningTurnId)
5628
5828
  : undefined;
5629
5829
  const runningTurn = runningTurnId && runningSnapshot
5630
5830
  ? {
@@ -5640,13 +5840,13 @@ export class ProxySessionController {
5640
5840
  parentTurnId: turn.parentTurnId,
5641
5841
  })));
5642
5842
  const total = allTurns.length;
5643
- const totalPages = Math.max(1, Math.ceil(total / RESET_HISTORY_PAGE_SIZE));
5843
+ const totalPages = Math.max(1, Math.ceil(total / RESET_HISTORY_PAGE_SIZE)) + (hasMoreHistory ? 1 : 0);
5644
5844
  const page = Math.max(0, Math.min(Math.trunc(options.page ?? 0), totalPages - 1));
5645
5845
  const offset = page * RESET_HISTORY_PAGE_SIZE;
5646
5846
  const turns = allTurns.slice(offset, offset + RESET_HISTORY_PAGE_SIZE);
5647
5847
  const resettingTurnId = this.resetHistoryOperations.get(current.localSessionId);
5648
5848
  const entries: ResetHistoryCardEntry[] = turns.flatMap((turn, index) => {
5649
- const snapshot = turnViewSnapshot(turn.snapshot);
5849
+ const snapshot = turn.turnId === runningTurnId ? runningSnapshot : this.store.getTurnPromptSummary(turn.turnId);
5650
5850
  if (!snapshot) return [];
5651
5851
  const graph = graphRows[offset + index]!;
5652
5852
  const summary = truncateText(
@@ -5701,7 +5901,9 @@ export class ProxySessionController {
5701
5901
  const card = this.cardRenderer.renderResetHistoryCard({
5702
5902
  entries,
5703
5903
  footerLines: [
5704
- runningTurn
5904
+ hasMoreHistory
5905
+ ? `第 ${page + 1} 页 · 已加载 ${total} 个 turn`
5906
+ : runningTurn
5705
5907
  ? `第 ${page + 1}/${totalPages} 页 · 共 ${total} 个 turn(${completedTurns.length} 个已完成,1 个运行中)`
5706
5908
  : `第 ${page + 1}/${totalPages} 页 · 共 ${total} 个已完成 turn`,
5707
5909
  ...(resettingTurnId ? ["正在 Reset 到所选轮次,请稍候…"] : []),
@@ -5911,6 +6113,7 @@ export class ProxySessionController {
5911
6113
  const record = this.requireCurrentSession(contextKey);
5912
6114
  const filePath = resolveUserPath(requestedFilePath, record.cwd);
5913
6115
  await this.assertSendableFile(filePath);
6116
+ await this.outbound.sendText(contextKey, SEND_FILE_STARTED_MESSAGE);
5914
6117
  await this.outbound.sendFile(contextKey, filePath);
5915
6118
  }
5916
6119
 
@@ -6215,7 +6418,7 @@ export class ProxySessionController {
6215
6418
 
6216
6419
  private async resolveRemoteCodexSession(
6217
6420
  reference: string,
6218
- options: { forFork?: boolean } = {},
6421
+ options: { forFork?: boolean; view?: "metadata" | "latest" | "latest-full" } = {},
6219
6422
  ): Promise<AgentRemoteSession> {
6220
6423
  const scoped = parseRemoteSessionReference(reference);
6221
6424
  const candidates = scoped
@@ -6233,7 +6436,7 @@ export class ProxySessionController {
6233
6436
  runtime,
6234
6437
  remote: options.forFork && runtime.readRemoteForkSource
6235
6438
  ? await runtime.readRemoteForkSource(remoteSessionId)
6236
- : await runtime.readRemoteSession(remoteSessionId),
6439
+ : await runtime.readRemoteSession(remoteSessionId, options.view ?? "latest"),
6237
6440
  } satisfies AgentRemoteSession;
6238
6441
  }));
6239
6442
  const matches = reads
@@ -6259,6 +6462,7 @@ export class ProxySessionController {
6259
6462
  if (direct?.status === "closed") {
6260
6463
  throw new Error(`找不到任务:${taskId}`);
6261
6464
  }
6465
+ await this.outbound.sendText(contextKey, SWITCH_TASK_STARTED_MESSAGE);
6262
6466
  const existing = direct ?? this.findStoredSessionByReference(taskId);
6263
6467
  if (existing) {
6264
6468
  const runtime = this.runtimes.forAgent(existing.agentName);
@@ -6271,6 +6475,7 @@ export class ProxySessionController {
6271
6475
  existing.title,
6272
6476
  existing.cwd,
6273
6477
  this.agentLabel(existing.agentName),
6478
+ existing.model,
6274
6479
  );
6275
6480
  await this.outbound.sendText(contextKey, `已切换到任务:${existing.title ?? existing.remoteSessionId ?? taskId}`);
6276
6481
  return;
@@ -6304,6 +6509,7 @@ export class ProxySessionController {
6304
6509
  remote.title ?? remote.preview,
6305
6510
  remote.cwd,
6306
6511
  this.agentLabel(agentName),
6512
+ remote.model,
6307
6513
  );
6308
6514
  await this.outbound.sendText(
6309
6515
  contextKey,
@@ -6367,6 +6573,10 @@ export class ProxySessionController {
6367
6573
  throw new Error("任务仍有排队消息,无法归档。请先处理或取消排队消息。");
6368
6574
  }
6369
6575
 
6576
+ if (options.source !== "dismiss") {
6577
+ await this.outbound.sendText(contextKey, ARCHIVE_TASK_STARTED_MESSAGE);
6578
+ }
6579
+
6370
6580
  const remoteReference = local
6371
6581
  ? remoteSessionReference(local.agentName, local.remoteSessionId!)
6372
6582
  : taskId;
@@ -6598,27 +6808,29 @@ export class ProxySessionController {
6598
6808
  current = context.currentSessionId ? this.store.getSession(context.currentSessionId) : undefined;
6599
6809
  }
6600
6810
 
6601
- if (current && current.status === "running") {
6602
- try {
6603
- const runtime = this.runtimes.forAgent(current.agentName);
6604
- if (runtime.getSession(current.localSessionId)) {
6605
- await runtime.synchronizeSession(current.localSessionId);
6811
+ let remote: RemoteSessionSummary | undefined;
6812
+ let metrics: RemoteSessionMetrics | undefined;
6813
+ if (current?.status === "running") {
6814
+ const runtime = this.runtimes.forAgent(current.agentName);
6815
+ if (runtime.getSession(current.localSessionId)) {
6816
+ try {
6817
+ remote = (await runtime.synchronizeSession(current.localSessionId)).remoteSummary;
6606
6818
  current = this.store.getSession(current.localSessionId) ?? current;
6819
+ } catch (error) {
6820
+ if (error instanceof CodexVersionError) throw error;
6821
+ this.logger.warn({ error, sessionId: current.localSessionId }, "Failed to synchronize task status.");
6607
6822
  }
6608
- } catch (error) {
6609
- this.logger.warn({ error, sessionId: current.localSessionId }, "Failed to synchronize task status.");
6610
6823
  }
6611
6824
  }
6612
-
6613
6825
  const localCurrent = current;
6614
- let remote: RemoteSessionSummary | undefined;
6615
6826
  let goal: RuntimeGoal | undefined;
6616
6827
  if (current?.remoteSessionId) {
6617
6828
  const runtime = this.runtimes.forAgent(current.agentName);
6618
- if (runtime.readRemoteSession) {
6829
+ if (!remote && runtime.readRemoteSession) {
6619
6830
  try {
6620
- remote = await runtime.readRemoteSession(current.remoteSessionId);
6831
+ remote = await runtime.readRemoteSession(current.remoteSessionId, "latest-full");
6621
6832
  } catch (error) {
6833
+ if (error instanceof CodexVersionError) throw error;
6622
6834
  this.logger.warn({ error, sessionId: current.localSessionId }, "Failed to inspect App Server task status.");
6623
6835
  }
6624
6836
  }
@@ -6630,6 +6842,13 @@ export class ProxySessionController {
6630
6842
  this.logger.warn({ error, sessionId: current.localSessionId }, "Failed to inspect Agent goal status.");
6631
6843
  }
6632
6844
  }
6845
+ if (runtime.readRemoteSessionMetrics) {
6846
+ try {
6847
+ metrics = await runtime.readRemoteSessionMetrics(current.remoteSessionId);
6848
+ } catch (error) {
6849
+ this.logger.warn({ error, sessionId: current.localSessionId }, "Failed to inspect App Server task metrics.");
6850
+ }
6851
+ }
6633
6852
  }
6634
6853
 
6635
6854
  if (current) current = mergeRemoteTaskStatus(current, remote);
@@ -6664,6 +6883,7 @@ export class ProxySessionController {
6664
6883
  `**Agent**:${cardCode(agent.title)}`,
6665
6884
  `**权限 / 任务范围**:${current.permissionMode === "confirm" ? "执行前确认" : "自动执行"} / ${detectProjectlessWorkspace(current.cwd) ? "未指定项目" : "指定项目"}`,
6666
6885
  `**App Server 任务 ID**:${cardCode(current.remoteSessionId ?? "尚未创建")}`,
6886
+ statusTaskMetricsLine(metrics?.turnCount ?? this.store.countTaskTurns(current.localSessionId), metrics?.storageBytes),
6667
6887
  `**当前执行 / 排队消息**:${activeTurnId ? cardCode(activeTurnId) : "无"} / ${queued} 条`,
6668
6888
  `**创建时间 / 最近活动**:${formatStatusTime(current.createdAt)} / ${formatStatusTime(current.updatedAt)}`,
6669
6889
  );
@@ -6729,7 +6949,16 @@ export class ProxySessionController {
6729
6949
  reference: string,
6730
6950
  options: StatusCardOptions = {},
6731
6951
  ): Promise<void> {
6732
- const { agentName, remote } = await this.resolveRemoteCodexSession(reference);
6952
+ const { agentName, remote } = await this.resolveRemoteCodexSession(reference, { view: "latest-full" });
6953
+ const runtime = this.runtimes.forAgent(agentName);
6954
+ let metrics: RemoteSessionMetrics | undefined;
6955
+ if (runtime.readRemoteSessionMetrics) {
6956
+ try {
6957
+ metrics = await runtime.readRemoteSessionMetrics(remote.id);
6958
+ } catch (error) {
6959
+ this.logger.warn({ error, remoteSessionId: remote.id }, "Failed to inspect App Server task metrics.");
6960
+ }
6961
+ }
6733
6962
  const actionReference = remoteSessionReference(agentName, remote.id);
6734
6963
  const sections: CardSection[] = [
6735
6964
  {
@@ -6740,6 +6969,7 @@ export class ProxySessionController {
6740
6969
  `**状态 / 当前任务**:${remoteSessionDetailStatus(remote)} / 未切换`,
6741
6970
  `**Agent**:${cardCode(agentName)}`,
6742
6971
  `**App Server 任务 ID**:${cardCode(remote.id)}`,
6972
+ statusTaskMetricsLine(metrics?.turnCount, metrics?.storageBytes),
6743
6973
  `**最近回合**:${cardCode(remote.lastTurnId ?? "无")} ${remoteTurnStatusLabel(remote.lastTurnStatus)}`,
6744
6974
  `**创建时间 / 最近活动**:${formatRemoteTime(remote.createdAt)} / ${formatRemoteTime(latestRemoteTimestamp(remote.recencyAt, remote.updatedAt))}`,
6745
6975
  ],
@@ -6891,9 +7121,10 @@ export class ProxySessionController {
6891
7121
  return agent;
6892
7122
  }
6893
7123
 
6894
- private async persistAgentExecutionDefaults(localSessionId: string): Promise<void> {
6895
- const session = this.store.getSession(localSessionId);
6896
- if (!session) throw new Error(`找不到任务:${localSessionId}`);
7124
+ private async persistAgentExecutionDefaults(localSessionId: string, candidate?: RuntimeSession): Promise<void> {
7125
+ const record = this.store.getSession(localSessionId);
7126
+ if (!record) throw new Error(`找不到任务:${localSessionId}`);
7127
+ const session = candidate ?? record;
6897
7128
  const agent = this.ensureAgent(session.agentName);
6898
7129
  const observed: AgentExecutionDefaults = {
6899
7130
  ...(session.modelProvider?.trim() ? { modelProvider: session.modelProvider } : {}),
@@ -7328,6 +7559,7 @@ export class ProxySessionController {
7328
7559
  const lockPath = runtime.getThreadWriterLockPath?.(threadId);
7329
7560
  if (!lockPath) throw new Error("当前 Agent 不支持自动关闭占用程序。");
7330
7561
 
7562
+ await this.outbound.sendText(contextKey, CLOSE_WRITER_STARTED_MESSAGE);
7331
7563
  let owners = await this.threadWriterProcesses.inspect(lockPath);
7332
7564
  const expectedOwner = owners.length === 1 && threadWriterMatches(
7333
7565
  owners[0],
@@ -7787,11 +8019,6 @@ function runtimeErrorMessage(error: unknown): string {
7787
8019
  return error instanceof Error ? error.message : String(error);
7788
8020
  }
7789
8021
 
7790
- function isMissingRolloutError(error: unknown): boolean {
7791
- const message = error instanceof Error ? error.message : String(error);
7792
- return /no rollout found|rollout[^\n]*(?:not found|missing)|thread\/resume failed/i.test(message);
7793
- }
7794
-
7795
8022
  function isUnmaterializedCodexThreadError(error: unknown): boolean {
7796
8023
  const message = error instanceof Error ? error.message : String(error);
7797
8024
  return /thread\/read failed:[^\n]*(?:not materialized|not loaded|includeTurns is unavailable before first user message)/i.test(message);
@@ -7970,6 +8197,12 @@ function formatDuration(milliseconds: number): string {
7970
8197
  return milliseconds < 1_000 ? `${milliseconds}ms` : `${(milliseconds / 1_000).toFixed(1)}s`;
7971
8198
  }
7972
8199
 
8200
+ function statusTaskMetricsLine(turnCount: number | undefined, storageBytes: number | undefined): string {
8201
+ const turns = turnCount === undefined ? "未知" : `${Math.max(0, Math.round(turnCount)).toLocaleString("en-US")} 轮`;
8202
+ const storage = storageBytes === undefined ? "未知" : formatStorageSize(storageBytes);
8203
+ return `**已执行轮次 / 磁盘占用**:${turns} / ${storage}`;
8204
+ }
8205
+
7973
8206
  function formatStatusTime(value: string): string {
7974
8207
  const date = new Date(value);
7975
8208
  if (Number.isNaN(date.getTime())) return value;
@@ -8443,6 +8676,23 @@ function deduplicateFileReferences(
8443
8676
  });
8444
8677
  }
8445
8678
 
8679
+ function selectProviderModel(models: ModelOption[], previousModel?: string): ModelOption | undefined {
8680
+ return models.find((model) => model.id === previousModel)
8681
+ ?? models.find((model) => model.isDefault)
8682
+ ?? models[0];
8683
+ }
8684
+
8685
+ function selectProviderReasoningEffort(model: ModelOption, previousEffort?: string): string {
8686
+ if (previousEffort && (model.supportedReasoningEfforts.length === 0
8687
+ || model.supportedReasoningEfforts.some((option) => option.value === previousEffort))) {
8688
+ return previousEffort;
8689
+ }
8690
+ return model.defaultReasoningEffort
8691
+ ?? model.supportedReasoningEfforts[0]?.value
8692
+ ?? previousEffort
8693
+ ?? "medium";
8694
+ }
8695
+
8446
8696
  function recoveryCardPrompt(prompt: string): string {
8447
8697
  return `恢复重启前的任务:${prompt}`;
8448
8698
  }