@keyou007/agent-bot 0.1.20-alpha.0 → 0.1.20

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 (66) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +18 -1
  3. package/README.zh.md +9 -0
  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/CodexLocalActivityDetector.js +32 -10
  11. package/dist/codex/CodexLocalActivityDetector.js.map +1 -1
  12. package/dist/codex/CodexProcessManager.js +53 -7
  13. package/dist/codex/CodexProcessManager.js.map +1 -1
  14. package/dist/codex/CodexRuntime.js +225 -164
  15. package/dist/codex/CodexRuntime.js.map +1 -1
  16. package/dist/codex/CodexVersion.js +18 -0
  17. package/dist/codex/CodexVersion.js.map +1 -0
  18. package/dist/codex/ProviderSwitch.js +32 -0
  19. package/dist/codex/ProviderSwitch.js.map +1 -0
  20. package/dist/config/AgentExecutionDefaultsStore.js +1 -1
  21. package/dist/config/AgentExecutionDefaultsStore.js.map +1 -1
  22. package/dist/feishu/CardRenderer.js +36 -11
  23. package/dist/feishu/CardRenderer.js.map +1 -1
  24. package/dist/feishu/FeishuConnector.js +17 -5
  25. package/dist/feishu/FeishuConnector.js.map +1 -1
  26. package/dist/feishu/InboundText.js +11 -0
  27. package/dist/feishu/InboundText.js.map +1 -1
  28. package/dist/feishu/MergedForwardMessage.js +17 -5
  29. package/dist/feishu/MergedForwardMessage.js.map +1 -1
  30. package/dist/index.js +1 -1
  31. package/dist/index.js.map +1 -1
  32. package/dist/local-files/LocalFileViewerServer.js +25 -7
  33. package/dist/local-files/LocalFileViewerServer.js.map +1 -1
  34. package/dist/local-files/MarkdownFileLinks.js +65 -0
  35. package/dist/local-files/MarkdownFileLinks.js.map +1 -0
  36. package/dist/proxy/ProxySessionController.js +400 -259
  37. package/dist/proxy/ProxySessionController.js.map +1 -1
  38. package/dist/state/StateStore.js +99 -65
  39. package/dist/state/StateStore.js.map +1 -1
  40. package/dist/utils/semanticVersion.js +39 -0
  41. package/dist/utils/semanticVersion.js.map +1 -0
  42. package/docs/technical-reference.md +5 -3
  43. package/docs/technical-reference.zh.md +5 -3
  44. package/npm-shrinkwrap.json +2 -2
  45. package/package.json +1 -1
  46. package/skills/agent-bot/SKILL.md +7 -1
  47. package/src/cli/AgentPrerequisites.ts +14 -40
  48. package/src/cli/DefaultAgentSelection.ts +1 -1
  49. package/src/cli.ts +13 -0
  50. package/src/codex/CodexLocalActivityDetector.ts +41 -9
  51. package/src/codex/CodexProcessManager.ts +44 -7
  52. package/src/codex/CodexRuntime.ts +244 -195
  53. package/src/codex/CodexVersion.ts +18 -0
  54. package/src/codex/ProviderSwitch.ts +36 -0
  55. package/src/config/AgentExecutionDefaultsStore.ts +1 -1
  56. package/src/feishu/CardRenderer.ts +37 -27
  57. package/src/feishu/FeishuConnector.ts +15 -5
  58. package/src/feishu/InboundText.ts +11 -0
  59. package/src/feishu/MergedForwardMessage.ts +15 -5
  60. package/src/index.ts +1 -1
  61. package/src/local-files/LocalFileViewerServer.ts +26 -7
  62. package/src/local-files/MarkdownFileLinks.ts +64 -0
  63. package/src/proxy/ProxySessionController.ts +370 -240
  64. package/src/runtime/types.ts +15 -2
  65. package/src/state/StateStore.ts +117 -90
  66. 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,
@@ -64,6 +66,7 @@ import type {
64
66
  RemoteSessionSummary,
65
67
  RuntimeGoal,
66
68
  RuntimeEvent,
69
+ RuntimeExecutionSettings,
67
70
  RuntimePrompt,
68
71
  RuntimeSession,
69
72
  } from "../runtime/types.js";
@@ -187,6 +190,22 @@ const FORWARD_ATTACHMENT_WINDOW_MS = 800;
187
190
  const DEFAULT_MERGED_FORWARD_INSTRUCTION = "请参考以下内容回复用户";
188
191
  const DEFAULT_REFERENCED_MESSAGE_INSTRUCTION = "请参考引用消息回复用户";
189
192
  const TURN_STOP_REQUESTED_MESSAGE = "已发送停止信号,任务会自动停止。";
193
+ const TURN_RESET_STARTED_MESSAGE = "Reset 正在进行,请稍后。期间发送的消息会自动排队。";
194
+ const CREATE_TASK_STARTED_MESSAGE = "正在创建任务,请稍后。";
195
+ const CREATE_GROUP_STARTED_MESSAGE = "正在创建群聊和任务,请稍后。";
196
+ const FORK_TASK_STARTED_MESSAGE = "正在创建分支任务,请稍后。";
197
+ const FORK_GROUP_STARTED_MESSAGE = "正在创建分支群聊,请稍后。";
198
+ const LIST_SESSIONS_STARTED_MESSAGE = "正在读取任务列表,请稍后。";
199
+ const LIST_TURNS_STARTED_MESSAGE = "正在读取历史轮次,请稍后。";
200
+ const SWITCH_TASK_STARTED_MESSAGE = "正在切换任务,请稍后。";
201
+ const STATUS_STARTED_MESSAGE = "正在读取任务状态,请稍后。";
202
+ const GOAL_STARTED_MESSAGE = "正在处理 Goal,请稍后。";
203
+ const ARCHIVE_TASK_STARTED_MESSAGE = "正在归档任务,请稍后。";
204
+ const DISMISS_GROUP_STARTED_MESSAGE = "正在归档任务并解散群聊,请稍后。";
205
+ const SEND_FILE_STARTED_MESSAGE = "正在发送文件,请稍后。";
206
+ const CHANGE_TITLE_STARTED_MESSAGE = "正在修改任务标题,请稍后。";
207
+ const STOP_TASK_STARTED_MESSAGE = "正在停止任务,请稍后。";
208
+ const CLOSE_WRITER_STARTED_MESSAGE = "正在检查并关闭占用进程,请稍后。";
190
209
  const REMOTE_SESSION_REFERENCE_PREFIX = "agent-runtime:";
191
210
  const WINDOWS_DRIVES_DIRECTORY = "agentbot://windows-drives";
192
211
  const IMAGE_FILE_EXTENSIONS = new Set([
@@ -409,7 +428,6 @@ interface ForkSessionPlan {
409
428
  sourceTurnPrompt?: string;
410
429
  sourceTurnStartedAt?: number;
411
430
  sourceTurnCompletedAt?: number;
412
- sourceCompletedTurns?: RemoteCompletedTurnSummary[];
413
431
  sourceWasRunning: boolean;
414
432
  forkedFromHistoricalTurn: boolean;
415
433
  }
@@ -419,6 +437,14 @@ interface ForkSessionResult {
419
437
  session: RuntimeSession;
420
438
  }
421
439
 
440
+ interface ForkHistoryCursor {
441
+ cursor?: string;
442
+ anchorFound: boolean;
443
+ tailTurnId?: string;
444
+ loadedCount?: number;
445
+ complete: boolean;
446
+ }
447
+
422
448
  interface ForkGroupSessionPlan {
423
449
  plan: ForkSessionPlan;
424
450
  sourceDescription: string;
@@ -489,6 +515,8 @@ export class ProxySessionController {
489
515
  private readonly sessionResets = new Map<string, Promise<void>>();
490
516
  private readonly resetHistoryOperations = new Map<string, string>();
491
517
  private readonly remoteTurnHistoryRefreshes = new Map<string, number>();
518
+ private readonly turnHistoryHydrations = new Map<string, Promise<boolean>>();
519
+ private readonly forkHistoryCursors = new Map<string, ForkHistoryCursor>();
492
520
  private readonly lastSessionListings = new Map<string, string[]>();
493
521
  private readonly threadInitializations = new Map<string, Promise<void>>();
494
522
  private readonly llmRetryingSessions = new Set<string>();
@@ -589,6 +617,7 @@ export class ProxySessionController {
589
617
  "Failed to acknowledge the incoming Feishu message with a reaction.",
590
618
  );
591
619
  }
620
+ await this.waitForCurrentSessionReset(message.contextKey);
592
621
  if (attachmentReservation && this.completeForwardAttachment(message, attachmentReservation)) {
593
622
  this.store.audit(message.contextKey, `${attachmentReservation.sourceKind}_attachment_coalesced`, {
594
623
  messageId: message.messageId,
@@ -826,10 +855,11 @@ export class ProxySessionController {
826
855
  "Processing Agent Bot command.",
827
856
  );
828
857
  }
829
- // Operational and read-only commands must remain available even if a prompt operation is slow.
858
+ // Operational and read-only commands bypass the prompt queue, but still wait for a Reset binding change.
830
859
  if (isQueueIndependentCommand(command)) {
831
860
  await this.outbound.withReplyTarget(message.contextKey, replyTarget, async () => {
832
861
  try {
862
+ await this.waitForCurrentSessionReset(message.contextKey);
833
863
  if (command.type === "prompt") await this.ensureThreadFork(message);
834
864
  await this.execute(
835
865
  message.contextKey,
@@ -857,8 +887,9 @@ export class ProxySessionController {
857
887
  }
858
888
 
859
889
  const previous = this.messageQueues.get(message.contextKey) ?? Promise.resolve();
860
- const next = previous.catch(() => undefined).then(() =>
861
- this.outbound.withReplyTarget(message.contextKey, replyTarget, async () => {
890
+ const next = previous.catch(() => undefined).then(async () => {
891
+ await this.waitForCurrentSessionReset(message.contextKey);
892
+ return this.outbound.withReplyTarget(message.contextKey, replyTarget, async () => {
862
893
  try {
863
894
  let executableCommand = command;
864
895
  let executableImagePaths = localImagePaths;
@@ -903,8 +934,8 @@ export class ProxySessionController {
903
934
  phase: "command_execute",
904
935
  });
905
936
  }
906
- }),
907
- );
937
+ });
938
+ });
908
939
  this.messageQueues.set(message.contextKey, next);
909
940
  await next;
910
941
  if (this.messageQueues.get(message.contextKey) === next) this.messageQueues.delete(message.contextKey);
@@ -1022,6 +1053,7 @@ export class ProxySessionController {
1022
1053
  } else if (kind === "directory_send_file") {
1023
1054
  const filePath = directoryFileActionPath(scopedAction.value.filePath);
1024
1055
  await this.assertSendableFile(filePath);
1056
+ await this.outbound.sendText(contextKey, SEND_FILE_STARTED_MESSAGE);
1025
1057
  await this.outbound.sendFile(contextKey, filePath);
1026
1058
  } else if (kind === "directory_new_folder_prompt") {
1027
1059
  const directory = directoryActionPath(scopedAction.value.directory);
@@ -1077,10 +1109,12 @@ export class ProxySessionController {
1077
1109
  await this.refreshSessionsCardFromAction(scopedAction);
1078
1110
  } else if (kind === "session_new") {
1079
1111
  const sessionId = String(scopedAction.value.sessionId ?? "");
1112
+ await this.outbound.sendText(contextKey, CREATE_TASK_STARTED_MESSAGE);
1080
1113
  await this.createProjectSessionFromReference(contextKey, sessionId);
1081
1114
  await this.refreshSessionsCardFromAction(scopedAction, { page: 0 });
1082
1115
  } else if (kind === "session_new_group") {
1083
1116
  const sessionId = String(scopedAction.value.sessionId ?? "");
1117
+ await this.outbound.sendText(contextKey, CREATE_GROUP_STARTED_MESSAGE);
1084
1118
  await this.createFeishuGroupFromReference(contextKey, sessionId, scopedAction.userId);
1085
1119
  await this.refreshSessionsCardFromAction(scopedAction);
1086
1120
  } else if (kind === "session_archive") {
@@ -1099,6 +1133,7 @@ export class ProxySessionController {
1099
1133
  );
1100
1134
  } else if (kind === "group_dismiss_confirm") {
1101
1135
  this.assertDismissGroupRequester(scopedAction);
1136
+ await this.outbound.sendText(contextKey, DISMISS_GROUP_STARTED_MESSAGE);
1102
1137
  await this.dismissGroupAndArchiveTask(
1103
1138
  contextKey,
1104
1139
  String(scopedAction.value.sessionId ?? ""),
@@ -1106,10 +1141,12 @@ export class ProxySessionController {
1106
1141
  );
1107
1142
  } else if (kind === "session_stop") {
1108
1143
  const sessionId = String(scopedAction.value.sessionId ?? "");
1144
+ await this.outbound.sendText(contextKey, STOP_TASK_STARTED_MESSAGE);
1109
1145
  await this.stopSessionReference(contextKey, sessionId);
1110
1146
  if (scopedAction.value.cardView === "status") await this.refreshStatusCardFromAction(scopedAction, sessionId);
1111
1147
  else await this.refreshSessionsCardFromAction(scopedAction, { forceSwitchTaskId: sessionId });
1112
1148
  } else if (kind === "session_status") {
1149
+ await this.outbound.sendText(contextKey, STATUS_STARTED_MESSAGE);
1113
1150
  await this.status(contextKey, String(scopedAction.value.sessionId ?? ""));
1114
1151
  } else if (kind === "session_status_refresh") {
1115
1152
  await this.refreshStatusCardFromAction(scopedAction);
@@ -1247,6 +1284,8 @@ export class ProxySessionController {
1247
1284
  this.sessionResets.clear();
1248
1285
  this.resetHistoryOperations.clear();
1249
1286
  this.remoteTurnHistoryRefreshes.clear();
1287
+ this.forkHistoryCursors.clear();
1288
+ this.turnHistoryHydrations.clear();
1250
1289
  for (const pending of this.pendingMergedForwards.values()) pending.resolveAttachment(undefined);
1251
1290
  this.pendingMergedForwards.clear();
1252
1291
  for (const pending of this.pendingResourceForwards.values()) pending.resolveAttachment(undefined);
@@ -1326,8 +1365,9 @@ export class ProxySessionController {
1326
1365
  const runtime = this.runtimes.forAgent(record.agentName);
1327
1366
  if (runtime.readRemoteSession) {
1328
1367
  try {
1329
- remote = await runtime.readRemoteSession(record.remoteSessionId);
1368
+ remote = await runtime.readRemoteSession(record.remoteSessionId, "latest-full");
1330
1369
  } catch (error) {
1370
+ if (error instanceof CodexVersionError) throw error;
1331
1371
  this.logger.warn({ error, sessionId: record.localSessionId }, "Failed to inspect App Server task status for CLI.");
1332
1372
  }
1333
1373
  }
@@ -1627,9 +1667,17 @@ export class ProxySessionController {
1627
1667
  permissionModes: PermissionMode[];
1628
1668
  }> {
1629
1669
  const record = this.requireControlSession(localSessionId);
1630
- const loaded = await this.loadSession(record);
1631
1670
  if (setting && !value?.trim()) throw new Error(`task ${setting} requires a value.`);
1632
1671
  const nextValue = value?.trim();
1672
+ if (setting === "provider" && nextValue) {
1673
+ const contextKey = this.controlSessionContextKey(record);
1674
+ await this.outbound.withReplyTarget(
1675
+ contextKey,
1676
+ this.controlSessionReplyTarget(record),
1677
+ () => this.outbound.sendText(contextKey, `正在切换到 Provider ${nextValue},请稍后。`),
1678
+ );
1679
+ }
1680
+ const loaded = await this.loadSession(record);
1633
1681
  if (setting === "provider" && nextValue) {
1634
1682
  await this.assertModelProvider(loaded, nextValue);
1635
1683
  const models = await loaded.runtime.listModels();
@@ -1642,19 +1690,12 @@ export class ProxySessionController {
1642
1690
  ? loaded.session.reasoningEffort
1643
1691
  : model.defaultReasoningEffort ?? model.supportedReasoningEfforts[0]?.value;
1644
1692
  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, {
1693
+ await this.changeProviderSettings(loaded, {
1647
1694
  modelProvider: nextValue,
1648
1695
  model: model.id,
1649
1696
  reasoningEffort: effort,
1650
1697
  permissionMode: loaded.session.permissionMode,
1651
1698
  });
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
1699
  } else if (setting === "model" && nextValue) {
1659
1700
  const models = await loaded.runtime.listModels();
1660
1701
  const selected = models.find((candidate) => candidate.id === nextValue);
@@ -1689,12 +1730,12 @@ export class ProxySessionController {
1689
1730
  this.store.updateRuntimeSession(record.localSessionId, { permissionMode: nextValue });
1690
1731
  }
1691
1732
  const session = this.store.getSession(record.localSessionId) ?? record;
1692
- if (setting) await this.persistAgentExecutionDefaults(session.localSessionId);
1733
+ if (setting && setting !== "provider") await this.persistAgentExecutionDefaults(session.localSessionId);
1693
1734
  const models = await loaded.runtime.listModels();
1694
1735
  const currentModel = models.find((candidate) => candidate.id === session.model)
1695
1736
  ?? models.find((candidate) => candidate.isDefault);
1696
1737
  const providers = loaded.runtime.listModelProviders
1697
- ? await loaded.runtime.listModelProviders()
1738
+ ? await this.modelProviderOptions(loaded)
1698
1739
  : [];
1699
1740
  return {
1700
1741
  session,
@@ -1738,7 +1779,7 @@ export class ProxySessionController {
1738
1779
  };
1739
1780
  }
1740
1781
 
1741
- controlListTaskTurns(localSessionId: string): {
1782
+ async controlListTaskTurns(localSessionId: string): Promise<{
1742
1783
  session: SessionRecord;
1743
1784
  turns: Array<{
1744
1785
  sequence: number;
@@ -1749,17 +1790,18 @@ export class ProxySessionController {
1749
1790
  completedAt?: number;
1750
1791
  current: boolean;
1751
1792
  }>;
1752
- } {
1793
+ }> {
1753
1794
  const session = this.requireControlSession(localSessionId);
1754
1795
  if (!session.remoteSessionId || !this.isCodexSession(session)) {
1755
1796
  throw new Error("The task is not an App Server task that can be reset.");
1756
1797
  }
1757
- const rows = this.store.listTaskTurnGraph(session.localSessionId);
1798
+ await this.hydrateTaskTurnHistory(session);
1799
+ const rows = this.store.listTaskTurnGraphIndex(session.localSessionId);
1758
1800
  const graph = buildTurnGraphRows(rows.map((turn) => ({ turnId: turn.turnId, parentTurnId: turn.parentTurnId })));
1759
1801
  return {
1760
1802
  session,
1761
1803
  turns: rows.flatMap((turn, index) => {
1762
- const snapshot = turnViewSnapshot(turn.snapshot);
1804
+ const snapshot = this.store.getTurnPromptSummary(turn.turnId);
1763
1805
  if (!snapshot) return [];
1764
1806
  return [{
1765
1807
  sequence: graph[index]!.sequence,
@@ -1777,15 +1819,10 @@ export class ProxySessionController {
1777
1819
  async controlResetTaskToTurn(localSessionId: string, turnId: string): Promise<SessionRecord> {
1778
1820
  const record = this.requireControlSession(localSessionId);
1779
1821
  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
- }
1822
+ await this.runSessionReset(record.localSessionId, async () => {
1823
+ await this.outbound.sendText(contextKey, TURN_RESET_STARTED_MESSAGE);
1824
+ await this.performSessionReset(contextKey, record, turnId, false);
1825
+ });
1789
1826
  return this.store.getSession(record.localSessionId) ?? record;
1790
1827
  }
1791
1828
 
@@ -1942,6 +1979,7 @@ export class ProxySessionController {
1942
1979
  if (command.projectless && this.ensureAgent(context.defaultAgent).kind !== "app-server") {
1943
1980
  throw new Error("/new --nodir 仅支持 App Server Agent。");
1944
1981
  }
1982
+ await this.outbound.sendText(contextKey, CREATE_TASK_STARTED_MESSAGE);
1945
1983
  await this.createSession(
1946
1984
  contextKey,
1947
1985
  context.defaultAgent,
@@ -1962,6 +2000,10 @@ export class ProxySessionController {
1962
2000
  if (command.projectless && this.ensureAgent(context.defaultAgent).kind !== "app-server") {
1963
2001
  throw new Error("/newgroup --nodir 仅支持 App Server Agent。");
1964
2002
  }
2003
+ if (!userId?.startsWith("ou_")) {
2004
+ throw new Error("/newgroup 只能由具有 open_id 的飞书用户消息触发。");
2005
+ }
2006
+ await this.outbound.sendText(contextKey, CREATE_GROUP_STARTED_MESSAGE);
1965
2007
  await this.createFeishuGroup(
1966
2008
  contextKey,
1967
2009
  context.defaultAgent,
@@ -1994,6 +2036,7 @@ export class ProxySessionController {
1994
2036
  await this.enqueueNoSteerPrompt(contextKey, command.text, messageId, replyTarget);
1995
2037
  return;
1996
2038
  case "sessions":
2039
+ await this.outbound.sendText(contextKey, LIST_SESSIONS_STARTED_MESSAGE);
1997
2040
  await this.listSessions(contextKey, command.searchTerm);
1998
2041
  return;
1999
2042
  case "switch":
@@ -2016,9 +2059,11 @@ export class ProxySessionController {
2016
2059
  await this.openDismissGroupCard(contextKey, userId);
2017
2060
  return;
2018
2061
  case "status":
2062
+ await this.outbound.sendText(contextKey, STATUS_STARTED_MESSAGE);
2019
2063
  await this.status(contextKey, command.sessionId);
2020
2064
  return;
2021
2065
  case "goal":
2066
+ await this.outbound.sendText(contextKey, GOAL_STARTED_MESSAGE);
2022
2067
  await this.goal(contextKey, command);
2023
2068
  return;
2024
2069
  case "restart":
@@ -2032,6 +2077,7 @@ export class ProxySessionController {
2032
2077
  await this.setGroupMute(contextKey, command.enabled);
2033
2078
  return;
2034
2079
  case "turns":
2080
+ await this.outbound.sendText(contextKey, LIST_TURNS_STARTED_MESSAGE);
2035
2081
  await this.openResetHistory(contextKey);
2036
2082
  return;
2037
2083
  case "model":
@@ -2309,6 +2355,7 @@ export class ProxySessionController {
2309
2355
  try {
2310
2356
  remote = await runtime.readRemoteSession(session.remoteSessionId);
2311
2357
  } catch (error) {
2358
+ if (error instanceof CodexVersionError) throw error;
2312
2359
  this.logger.warn(
2313
2360
  { error, attemptId: attempt.attemptId, remoteSessionId: session.remoteSessionId },
2314
2361
  "Failed to read the remote task during startup recovery; treating the old local execution as interrupted.",
@@ -2884,8 +2931,7 @@ export class ProxySessionController {
2884
2931
  const anchor = this.store.findTurnAnchorByMessageId(messageId);
2885
2932
  if (!anchor) return false;
2886
2933
  if (record.lastTurnId === anchor.turnId) return true;
2887
- return this.store.listTaskTurnGraph(record.localSessionId)
2888
- .some((turn) => turn.turnId === anchor.turnId);
2934
+ return this.store.hasTaskHistoryTurn(record.localSessionId, anchor.turnId);
2889
2935
  }
2890
2936
 
2891
2937
  private async forkThreadSession(message: IncomingMessage): Promise<void> {
@@ -2991,6 +3037,7 @@ export class ProxySessionController {
2991
3037
  }
2992
3038
 
2993
3039
  private async forkSessionReference(contextKey: string, reference?: string): Promise<void> {
3040
+ await this.outbound.sendText(contextKey, FORK_TASK_STARTED_MESSAGE);
2994
3041
  const plan = await this.prepareForkSession(contextKey, reference);
2995
3042
  const forked = await this.forkSessionIntoContext(contextKey, plan);
2996
3043
  const forkSourceLabel = plan.forkedFromHistoricalTurn
@@ -3051,35 +3098,24 @@ export class ProxySessionController {
3051
3098
  if (!runtime.readRemoteSession && !source) {
3052
3099
  throw new Error("当前 App Server Agent 不支持读取指定任务。");
3053
3100
  }
3054
- const persistedCompletedTurns = source ? this.localForkCompletedTurns(source) : [];
3101
+ const persistedCompletedTurnId = source
3102
+ ? this.store.findLatestCompletedTurnId(source.localSessionId)
3103
+ : undefined;
3055
3104
  const sourceLastSnapshot = turnViewSnapshot(
3056
3105
  source?.lastTurnId ? this.store.getTurnSnapshot(source.lastTurnId) : undefined,
3057
3106
  );
3058
- const localCompletedTurns = persistedCompletedTurns.length > 0
3059
- ? persistedCompletedTurns
3060
- : source?.lastTurnId
3107
+ const localCompletedTurnId = persistedCompletedTurnId
3108
+ ?? (source?.lastTurnId
3061
3109
  && (source.lastTurnStatus === "completed" || sourceLastSnapshot?.status === "completed")
3062
- ? [{
3063
- id: source.lastTurnId,
3064
- prompt: latestUserPromptFromTurnView(sourceLastSnapshot) ?? source.title,
3065
- startedAt: sourceLastSnapshot?.startedAt ?? parseIsoTimestamp(source.updatedAt),
3066
- completedAt: sourceLastSnapshot?.completedAt
3067
- ?? sourceLastSnapshot?.startedAt
3068
- ?? parseIsoTimestamp(source.updatedAt),
3069
- }]
3070
- : [];
3071
- if (!remote && runtime.readRemoteSession && (!source || persistedCompletedTurns.length === 0)) {
3072
- try {
3073
- remote = await runtime.readRemoteSession(remoteSessionId);
3074
- } catch (error) {
3075
- if (localCompletedTurns.length === 0) throw error;
3076
- this.logger.warn(
3077
- { error, contextKey, remoteSessionId, sourceTurnId: localCompletedTurns.at(-1)?.id },
3078
- "Failed to read the complete source task before Fork; using the locally persisted completed Turn.",
3079
- );
3080
- }
3110
+ ? source.lastTurnId
3111
+ : undefined)
3112
+ ?? (source ? this.store.getForkHistorySource(source.localSessionId)?.sourceTurnId : undefined);
3113
+ if (!remote && !localCompletedTurnId) {
3114
+ remote = runtime.readRemoteForkSource
3115
+ ? await runtime.readRemoteForkSource(remoteSessionId)
3116
+ : await runtime.readRemoteSession?.(remoteSessionId);
3081
3117
  }
3082
- const latestTurnId = remote?.lastTurnId ?? source?.lastTurnId ?? localCompletedTurns.at(-1)?.id;
3118
+ const latestTurnId = remote?.lastTurnId ?? source?.lastTurnId ?? localCompletedTurnId;
3083
3119
  const latestSnapshot = turnViewSnapshot(latestTurnId ? this.store.getTurnSnapshot(latestTurnId) : undefined);
3084
3120
  const isRunning = remote
3085
3121
  ? isRemoteSessionActive(remote)
@@ -3090,7 +3126,7 @@ export class ProxySessionController {
3090
3126
  );
3091
3127
  const lastTurnId = remote?.lastCompletedTurnId
3092
3128
  ?? (remote?.lastTurnStatus === "completed" ? remote.lastTurnId : undefined)
3093
- ?? localCompletedTurns.at(-1)?.id;
3129
+ ?? localCompletedTurnId;
3094
3130
  if (!lastTurnId) {
3095
3131
  if (isRunning) {
3096
3132
  throw new Error(`${sourceLabel}正在执行,且还没有已完成轮次可供 fork。请等待当前轮次完成后重试。`);
@@ -3099,8 +3135,7 @@ export class ProxySessionController {
3099
3135
  }
3100
3136
  const snapshot = turnViewSnapshot(this.store.getTurnSnapshot(lastTurnId));
3101
3137
  const forkedFromHistoricalTurn = lastTurnId !== latestTurnId;
3102
- const completedTurn = (remote?.completedTurns ?? localCompletedTurns)
3103
- .find((turn) => turn.id === lastTurnId);
3138
+ const completedTurn = remote?.completedTurns?.find((turn) => turn.id === lastTurnId);
3104
3139
 
3105
3140
  const cwd = remote?.cwd || source?.cwd;
3106
3141
  if (!cwd) throw new Error("指定的 App Server 任务没有可用的工作目录,暂时不能 fork。");
@@ -3146,29 +3181,11 @@ export class ProxySessionController {
3146
3181
  sourceTurnPrompt,
3147
3182
  sourceTurnStartedAt: snapshot?.startedAt ?? sourceTurnCompletedAt,
3148
3183
  sourceTurnCompletedAt,
3149
- sourceCompletedTurns: remote?.completedTurns
3150
- ?? (persistedCompletedTurns.length > 0 ? [] : localCompletedTurns),
3151
3184
  sourceWasRunning: isRunning,
3152
3185
  forkedFromHistoricalTurn,
3153
3186
  };
3154
3187
  }
3155
3188
 
3156
- private localForkCompletedTurns(source: SessionRecord): RemoteCompletedTurnSummary[] {
3157
- return this.store.listTaskTurnGraph(source.localSessionId)
3158
- .slice()
3159
- .reverse()
3160
- .map((record) => {
3161
- const snapshot = turnViewSnapshot(record.snapshot);
3162
- const startedAt = snapshot?.startedAt ?? parseIsoTimestamp(record.updatedAt);
3163
- return {
3164
- id: record.turnId,
3165
- prompt: latestUserPromptFromTurnView(snapshot),
3166
- startedAt,
3167
- completedAt: snapshot?.completedAt ?? startedAt,
3168
- };
3169
- });
3170
- }
3171
-
3172
3189
  private async prepareForkGroupSession(
3173
3190
  contextKey: string,
3174
3191
  requestedTitle: string | undefined,
@@ -3271,7 +3288,6 @@ export class ProxySessionController {
3271
3288
  contextKey: string,
3272
3289
  plan: ForkSessionPlan,
3273
3290
  ): Promise<ForkSessionResult> {
3274
- const sourceCompletedTurns = await this.resolveForkSourceCompletedTurns(plan);
3275
3291
  const localSessionId = createId("sess");
3276
3292
  const record = this.store.createSession({
3277
3293
  localSessionId,
@@ -3314,12 +3330,6 @@ export class ProxySessionController {
3314
3330
  plan.cwd,
3315
3331
  this.agentLabel(plan.agentName),
3316
3332
  );
3317
- const sourceTurnHistoryCount = this.persistForkSourceHistory(
3318
- contextKey,
3319
- localSessionId,
3320
- plan,
3321
- sourceCompletedTurns,
3322
- );
3323
3333
  this.store.audit(contextKey, "session_forked", {
3324
3334
  sourceLocalSessionId: plan.source?.localSessionId,
3325
3335
  sourceRemoteSessionId: plan.remoteSessionId,
@@ -3327,7 +3337,6 @@ export class ProxySessionController {
3327
3337
  sourceTurnPrompt: plan.sourceTurnPrompt,
3328
3338
  sourceTurnStartedAt: plan.sourceTurnStartedAt,
3329
3339
  sourceTurnCompletedAt: plan.sourceTurnCompletedAt,
3330
- sourceTurnHistoryCount,
3331
3340
  sourceWasRunning: plan.sourceWasRunning,
3332
3341
  forkedLocalSessionId: localSessionId,
3333
3342
  forkedRemoteSessionId: forked.remoteSessionId,
@@ -3343,54 +3352,6 @@ export class ProxySessionController {
3343
3352
  }
3344
3353
  }
3345
3354
 
3346
- private async resolveForkSourceCompletedTurns(
3347
- plan: ForkSessionPlan,
3348
- ): Promise<RemoteCompletedTurnSummary[]> {
3349
- let completedTurns = plan.sourceCompletedTurns;
3350
- if (completedTurns === undefined && plan.runtime.readRemoteSession) {
3351
- try {
3352
- completedTurns = (await plan.runtime.readRemoteSession(plan.remoteSessionId)).completedTurns ?? [];
3353
- } catch (error) {
3354
- this.logger.warn(
3355
- { error, remoteSessionId: plan.remoteSessionId, sourceTurnId: plan.lastTurnId },
3356
- "Failed to read the source task Turn history before Fork; keeping the Fork anchor fallback.",
3357
- );
3358
- }
3359
- }
3360
- if (!completedTurns?.length) return [];
3361
- const anchorIndex = completedTurns.findIndex((turn) => turn.id === plan.lastTurnId);
3362
- if (anchorIndex < 0) {
3363
- this.logger.warn(
3364
- { remoteSessionId: plan.remoteSessionId, sourceTurnId: plan.lastTurnId },
3365
- "The source task Turn history did not include the Fork anchor; keeping the Fork anchor fallback.",
3366
- );
3367
- return [];
3368
- }
3369
- return completedTurns.slice(0, anchorIndex + 1);
3370
- }
3371
-
3372
- private persistForkSourceHistory(
3373
- contextKey: string,
3374
- forkedLocalSessionId: string,
3375
- plan: ForkSessionPlan,
3376
- completedTurns: RemoteCompletedTurnSummary[],
3377
- ): number {
3378
- if (completedTurns.length === 0) return 0;
3379
- const localSessionId = plan.source?.localSessionId ?? forkedLocalSessionId;
3380
- const historyContextKey = plan.source?.contextKey ?? contextKey;
3381
- const fallbackStart = plan.sourceTurnStartedAt
3382
- ?? plan.sourceTurnCompletedAt
3383
- ?? Date.now() - completedTurns.length;
3384
- return this.persistCompletedTurnHistory({
3385
- localSessionId,
3386
- contextKey: historyContextKey,
3387
- agentName: plan.agentName,
3388
- remoteSessionId: plan.remoteSessionId,
3389
- completedTurns,
3390
- fallbackStart,
3391
- });
3392
- }
3393
-
3394
3355
  private persistCompletedTurnHistory(input: {
3395
3356
  localSessionId: string;
3396
3357
  contextKey: string;
@@ -3398,6 +3359,7 @@ export class ProxySessionController {
3398
3359
  remoteSessionId: string;
3399
3360
  completedTurns: RemoteCompletedTurnSummary[];
3400
3361
  fallbackStart: number;
3362
+ replaceParents?: boolean;
3401
3363
  }): number {
3402
3364
  try {
3403
3365
  this.store.importCompletedTurnHistory({
@@ -3405,6 +3367,7 @@ export class ProxySessionController {
3405
3367
  contextKey: input.contextKey,
3406
3368
  agentName: input.agentName,
3407
3369
  remoteSessionId: input.remoteSessionId,
3370
+ replaceParents: input.replaceParents,
3408
3371
  turns: input.completedTurns.map((turn, index) => {
3409
3372
  const startedAt = turn.startedAt ?? input.fallbackStart + index;
3410
3373
  const completedAt = turn.completedAt ?? startedAt;
@@ -3438,82 +3401,202 @@ export class ProxySessionController {
3438
3401
  }
3439
3402
  }
3440
3403
 
3441
- private async hydrateLegacyForkTurnHistory(
3404
+ private async hydrateTaskTurnHistory(
3405
+ current: SessionRecord,
3406
+ requiredCount = RESET_HISTORY_PAGE_SIZE,
3407
+ ): Promise<boolean> {
3408
+ const existing = this.turnHistoryHydrations.get(current.localSessionId);
3409
+ if (existing) await existing;
3410
+ const hydration = (async () => {
3411
+ if (this.store.getForkHistorySource(current.localSessionId)) {
3412
+ return this.hydrateForkTurnHistory(current.contextKey, current, requiredCount);
3413
+ }
3414
+ return this.hydrateRemoteTurnHistory(current.contextKey, current, requiredCount);
3415
+ })();
3416
+ this.turnHistoryHydrations.set(current.localSessionId, hydration);
3417
+ try {
3418
+ return await hydration;
3419
+ } finally {
3420
+ if (this.turnHistoryHydrations.get(current.localSessionId) === hydration) {
3421
+ this.turnHistoryHydrations.delete(current.localSessionId);
3422
+ }
3423
+ }
3424
+ }
3425
+
3426
+ private async hydrateForkTurnHistory(
3442
3427
  contextKey: string,
3443
3428
  current: SessionRecord,
3444
- ): Promise<void> {
3445
- const source = this.store.getForkHistorySource(current.localSessionId);
3446
- if (!source || source.sourceTurnHistoryCount !== undefined
3447
- || this.store.hasImportedForkTurnHistory(current.localSessionId)) return;
3448
- if (!source.sourceRemoteSessionId) return;
3429
+ requiredCount: number,
3430
+ visited = new Set<string>(),
3431
+ ): Promise<boolean> {
3432
+ if (visited.has(current.localSessionId)) return false;
3433
+ visited.add(current.localSessionId);
3434
+ let source = this.store.getForkHistorySource(current.localSessionId);
3435
+ if (!source) return false;
3436
+ const completedHead = current.lastTurnStatus === "completed"
3437
+ ? current.lastTurnId
3438
+ : this.store.findLatestCompletedTurnId(current.localSessionId);
3439
+ if (completedHead && completedHead !== source.sourceTurnId && current.remoteSessionId) {
3440
+ // Once the branch has its own completed work, its remote history is the
3441
+ // authority for missing local turns, including work continued in Desktop.
3442
+ source = {
3443
+ sourceLocalSessionId: current.localSessionId,
3444
+ sourceRemoteSessionId: current.remoteSessionId,
3445
+ sourceTurnId: completedHead,
3446
+ createdAt: current.createdAt,
3447
+ };
3448
+ }
3449
+ if (this.store.hasImportedForkTurnHistory(current.localSessionId, source.sourceRemoteSessionId, source.sourceTurnId)) return false;
3450
+ const localTurns = this.store.listTaskTurnGraphIndex(current.localSessionId);
3451
+ const availableCount = localTurns.length;
3452
+ const key = JSON.stringify([current.agentName, source.sourceRemoteSessionId, source.sourceTurnId]);
3453
+ let state = this.forkHistoryCursors.get(key) ?? { anchorFound: false, complete: false };
3454
+ const localAnchorIndex = localTurns.findIndex((turn) => turn.turnId === source.sourceTurnId);
3455
+ const ownTurnCount = source.sourceLocalSessionId === current.localSessionId
3456
+ ? Math.max(0, localAnchorIndex)
3457
+ : localAnchorIndex >= 0 ? localAnchorIndex : availableCount;
3458
+ const neededInheritedCount = Math.max(0, requiredCount - ownTurnCount);
3459
+ if (localAnchorIndex >= 0 && availableCount >= requiredCount
3460
+ && (!state.anchorFound || (state.loadedCount ?? 0) >= neededInheritedCount)) return !state.complete;
3461
+ const sourceSession = source.sourceLocalSessionId
3462
+ ? this.store.getSession(source.sourceLocalSessionId)
3463
+ : undefined;
3464
+ // A branch with no new turns can reuse its ancestor's history, even before
3465
+ // the Agent materializes that intermediate branch.
3466
+ if (sourceSession && sourceSession.localSessionId !== current.localSessionId
3467
+ && this.store.getForkHistorySource(sourceSession.localSessionId)?.sourceTurnId === source.sourceTurnId) {
3468
+ const hasMore = await this.hydrateForkTurnHistory(
3469
+ sourceSession.contextKey, sourceSession,
3470
+ this.store.listTaskTurnGraphIndex(sourceSession.localSessionId).length + requiredCount - availableCount,
3471
+ visited,
3472
+ );
3473
+ if (!hasMore && this.store.hasImportedForkTurnHistory(sourceSession.localSessionId)) {
3474
+ this.store.audit(contextKey, "fork_turn_history_imported", {
3475
+ forkedLocalSessionId: current.localSessionId,
3476
+ sourceRemoteSessionId: source.sourceRemoteSessionId,
3477
+ sourceTurnId: source.sourceTurnId,
3478
+ reusedLocalSessionId: sourceSession.localSessionId,
3479
+ });
3480
+ }
3481
+ return hasMore;
3482
+ }
3483
+ if (!source.sourceRemoteSessionId) return false;
3449
3484
  const runtime = this.runtimes.forAgent(current.agentName);
3450
- if (runtime.kind !== "codex" || !runtime.readRemoteSession) return;
3485
+ if (runtime.kind !== "codex" || !runtime.listRemoteTurnSummaries) return false;
3486
+ const seenCursors = new Set<string>();
3451
3487
  try {
3452
- const remote = await runtime.readRemoteSession(source.sourceRemoteSessionId);
3453
- const completedTurns = remote.completedTurns ?? [];
3454
- const anchorIndex = completedTurns.findIndex((turn) => turn.id === source.sourceTurnId);
3455
- if (anchorIndex < 0) return;
3456
- const inheritedTurns = completedTurns.slice(0, anchorIndex + 1);
3457
- const sourceSession = source.sourceLocalSessionId
3458
- ? this.store.getSession(source.sourceLocalSessionId)
3459
- : undefined;
3460
- const imported = this.persistCompletedTurnHistory({
3461
- localSessionId: sourceSession?.localSessionId ?? current.localSessionId,
3462
- contextKey: sourceSession?.contextKey ?? contextKey,
3463
- agentName: current.agentName,
3464
- remoteSessionId: source.sourceRemoteSessionId,
3465
- completedTurns: inheritedTurns,
3466
- fallbackStart: source.sourceTurnStartedAt
3467
- ?? source.sourceTurnCompletedAt
3468
- ?? Date.now() - inheritedTurns.length,
3469
- });
3470
- if (imported > 0) {
3488
+ while (!state.complete && (state.loadedCount ?? 0) < neededInheritedCount) {
3489
+ const remaining = neededInheritedCount - (state.loadedCount ?? 0);
3490
+ const page = await runtime.listRemoteTurnSummaries(source.sourceRemoteSessionId, {
3491
+ cursor: state.cursor, limit: Math.min(RESET_HISTORY_PAGE_SIZE, remaining),
3492
+ });
3493
+ if (page.nextCursor && (page.nextCursor === state.cursor || seenCursors.has(page.nextCursor))) {
3494
+ throw new Error("App Server repeated a Turn history cursor.");
3495
+ }
3496
+ const anchorIndex = state.anchorFound ? 0 : page.turns.findIndex((turn) => turn.id === source.sourceTurnId);
3497
+ const inheritedTurns = anchorIndex >= 0 ? page.turns.slice(anchorIndex) : [];
3498
+ const anchorFound = state.anchorFound || anchorIndex >= 0;
3499
+ if (!anchorFound && !page.nextCursor) {
3500
+ this.forkHistoryCursors.delete(key);
3501
+ throw new Error("The Fork source Turn was not found in the available history.");
3502
+ }
3503
+ if (inheritedTurns.length > 0) {
3504
+ const ownerId = sourceSession?.localSessionId ?? current.localSessionId;
3505
+ const imported = this.persistCompletedTurnHistory({
3506
+ localSessionId: ownerId,
3507
+ contextKey: sourceSession?.contextKey ?? contextKey,
3508
+ agentName: current.agentName,
3509
+ remoteSessionId: source.sourceRemoteSessionId,
3510
+ completedTurns: inheritedTurns.slice().reverse(),
3511
+ // Adjacent remote summaries repair gaps in locally inferred ancestry.
3512
+ replaceParents: true,
3513
+ fallbackStart: (source.sourceTurnStartedAt ?? source.sourceTurnCompletedAt
3514
+ ?? parseIsoTimestamp(source.createdAt) ?? 0) - (state.loadedCount ?? 0) - inheritedTurns.length,
3515
+ });
3516
+ if (imported === 0) throw new Error("Failed to persist the requested Turn history page.");
3517
+ if (state.tailTurnId) {
3518
+ this.store.saveTurnParent(state.tailTurnId, ownerId, inheritedTurns[0]!.id, true);
3519
+ }
3520
+ }
3521
+ state = {
3522
+ cursor: page.nextCursor,
3523
+ anchorFound,
3524
+ tailTurnId: inheritedTurns.at(-1)?.id ?? state.tailTurnId,
3525
+ loadedCount: (state.loadedCount ?? 0) + inheritedTurns.length,
3526
+ complete: !page.nextCursor,
3527
+ };
3528
+ this.forkHistoryCursors.set(key, state);
3529
+ if (page.nextCursor) seenCursors.add(page.nextCursor);
3530
+ }
3531
+ if (state.complete) {
3471
3532
  this.store.audit(contextKey, "fork_turn_history_imported", {
3472
3533
  forkedLocalSessionId: current.localSessionId,
3473
3534
  sourceRemoteSessionId: source.sourceRemoteSessionId,
3474
3535
  sourceTurnId: source.sourceTurnId,
3475
- importedTurnCount: imported,
3476
3536
  });
3477
3537
  }
3538
+ return !state.complete;
3478
3539
  } catch (error) {
3540
+ if (error instanceof CodexVersionError) throw error;
3479
3541
  this.logger.warn(
3480
3542
  { error, localSessionId: current.localSessionId, sourceRemoteSessionId: source.sourceRemoteSessionId },
3481
- "Failed to hydrate legacy Fork Turn history; rendering the locally available history.",
3543
+ "Failed to load the requested Fork Turn history page; rendering the locally available history.",
3482
3544
  );
3483
3545
  }
3546
+ return true;
3484
3547
  }
3485
3548
 
3486
3549
  private async hydrateRemoteTurnHistory(
3487
3550
  contextKey: string,
3488
3551
  current: SessionRecord,
3489
- ): Promise<void> {
3490
- if (!current.remoteSessionId) return;
3552
+ requiredCount: number,
3553
+ ): Promise<boolean> {
3554
+ if (!current.remoteSessionId) return false;
3555
+ const key = JSON.stringify([current.agentName, current.remoteSessionId, "turn-card"]);
3491
3556
  const refreshedAt = this.remoteTurnHistoryRefreshes.get(current.localSessionId);
3492
- if (refreshedAt !== undefined && Date.now() - refreshedAt < REMOTE_TURN_HISTORY_REFRESH_INTERVAL_MS) return;
3493
- const forkSource = this.store.getForkHistorySource(current.localSessionId);
3494
- if (forkSource?.sourceTurnId === current.lastTurnId
3495
- && this.store.hasImportedForkTurnHistory(current.localSessionId)) return;
3557
+ let state = this.forkHistoryCursors.get(key);
3558
+ if (refreshedAt === undefined || Date.now() - refreshedAt >= REMOTE_TURN_HISTORY_REFRESH_INTERVAL_MS) state = undefined;
3559
+ state ??= { anchorFound: true, complete: false };
3496
3560
  const runtime = this.runtimes.forAgent(current.agentName);
3497
- if (runtime.kind !== "codex" || !runtime.readRemoteSession) return;
3561
+ if (runtime.kind !== "codex" || !runtime.listRemoteTurnSummaries) return false;
3562
+ const seen = new Set<string>();
3498
3563
  try {
3499
- const remote = await runtime.readRemoteSession(current.remoteSessionId);
3500
- this.remoteTurnHistoryRefreshes.set(current.localSessionId, Date.now());
3501
- const completedTurns = remote.completedTurns ?? [];
3502
- if (completedTurns.length === 0) return;
3503
- this.persistCompletedTurnHistory({
3504
- localSessionId: current.localSessionId,
3505
- contextKey,
3506
- agentName: current.agentName,
3507
- remoteSessionId: current.remoteSessionId,
3508
- completedTurns,
3509
- fallbackStart: parseIsoTimestamp(current.createdAt) ?? Date.now() - completedTurns.length,
3510
- });
3564
+ while (!state.complete && (state.loadedCount ?? 0) < requiredCount) {
3565
+ const page = await runtime.listRemoteTurnSummaries(current.remoteSessionId, {
3566
+ cursor: state.cursor,
3567
+ limit: Math.min(RESET_HISTORY_PAGE_SIZE, requiredCount - (state.loadedCount ?? 0)),
3568
+ });
3569
+ if (page.nextCursor && (page.nextCursor === state.cursor || seen.has(page.nextCursor))) {
3570
+ throw new Error("App Server repeated a Turn history cursor.");
3571
+ }
3572
+ if (page.turns.length) {
3573
+ const imported = this.persistCompletedTurnHistory({
3574
+ localSessionId: current.localSessionId, contextKey, agentName: current.agentName,
3575
+ remoteSessionId: current.remoteSessionId,
3576
+ completedTurns: page.turns.slice().reverse(), replaceParents: true,
3577
+ fallbackStart: (parseIsoTimestamp(current.createdAt) ?? 0) - (state.loadedCount ?? 0) - page.turns.length,
3578
+ });
3579
+ if (!imported) throw new Error("Failed to persist the requested Turn history page.");
3580
+ if (state.tailTurnId) this.store.saveTurnParent(state.tailTurnId, current.localSessionId, page.turns[0]!.id, true);
3581
+ }
3582
+ state = {
3583
+ anchorFound: true, cursor: page.nextCursor, complete: !page.nextCursor,
3584
+ loadedCount: (state.loadedCount ?? 0) + page.turns.length,
3585
+ tailTurnId: page.turns.at(-1)?.id ?? state.tailTurnId,
3586
+ };
3587
+ this.forkHistoryCursors.set(key, state);
3588
+ this.remoteTurnHistoryRefreshes.set(current.localSessionId, Date.now());
3589
+ if (page.nextCursor) seen.add(page.nextCursor);
3590
+ }
3591
+ return !state.complete;
3511
3592
  } catch (error) {
3593
+ if (error instanceof CodexVersionError) throw error;
3512
3594
  this.logger.warn(
3513
3595
  { error, localSessionId: current.localSessionId, remoteSessionId: current.remoteSessionId },
3514
3596
  "Failed to hydrate App Server Turn history; rendering the locally available history.",
3515
3597
  );
3516
3598
  }
3599
+ return true;
3517
3600
  }
3518
3601
 
3519
3602
  private async createProjectSessionFromReference(contextKey: string, reference: string): Promise<void> {
@@ -3560,7 +3643,7 @@ export class ProxySessionController {
3560
3643
  agentName = source.agentName;
3561
3644
  runtime = this.runtimes.forAgent(agentName);
3562
3645
  } else {
3563
- const resolved = await this.resolveRemoteCodexSession(taskId);
3646
+ const resolved = await this.resolveRemoteCodexSession(taskId, { view: "metadata" });
3564
3647
  agentName = resolved.agentName;
3565
3648
  runtime = resolved.runtime;
3566
3649
  remote = resolved.remote;
@@ -3569,9 +3652,9 @@ export class ProxySessionController {
3569
3652
  const remoteSessionId = source?.remoteSessionId ?? remote?.id ?? taskId;
3570
3653
  if (!remote && runtime.readRemoteSession && remoteSessionId) {
3571
3654
  try {
3572
- remote = await runtime.readRemoteSession(remoteSessionId);
3655
+ remote = await runtime.readRemoteSession(remoteSessionId, "metadata");
3573
3656
  } catch (error) {
3574
- if (!source) throw error;
3657
+ if (!source || error instanceof CodexVersionError) throw error;
3575
3658
  this.logger.warn(
3576
3659
  { error, contextKey, taskId: remoteSessionId },
3577
3660
  "Failed to refresh the source task before creating a project task; using the local project path.",
@@ -3908,6 +3991,7 @@ export class ProxySessionController {
3908
3991
  if (!userId?.startsWith("ou_")) {
3909
3992
  throw new Error("/forkgroup 只能由具有 open_id 的飞书用户消息触发。");
3910
3993
  }
3994
+ await this.outbound.sendText(sourceContextKey, FORK_GROUP_STARTED_MESSAGE);
3911
3995
  const prepared = await this.prepareForkGroupSession(sourceContextKey, requestedTitle, incomingMessage);
3912
3996
  await this.forkPreparedSessionToFeishuGroup(
3913
3997
  sourceContextKey,
@@ -3925,6 +4009,7 @@ export class ProxySessionController {
3925
4009
  if (!userId?.startsWith("ou_")) {
3926
4010
  throw new Error("ForkGroup 只能由具有 open_id 的飞书用户触发。");
3927
4011
  }
4012
+ await this.outbound.sendText(sourceContextKey, FORK_GROUP_STARTED_MESSAGE);
3928
4013
  const plan = await this.prepareForkSession(sourceContextKey, reference);
3929
4014
  await this.forkPreparedSessionToFeishuGroup(
3930
4015
  sourceContextKey,
@@ -4077,7 +4162,8 @@ export class ProxySessionController {
4077
4162
  });
4078
4163
  } catch (error) {
4079
4164
  if (activeWriterThreadId(error)) throw error;
4080
- if (!(agent.kind === "app-server" && !record.lastTurnId && isMissingRolloutError(error))) throw error;
4165
+ if (!(agent.kind === "app-server" && !record.lastTurnId
4166
+ && !this.store.getForkHistorySource(record.localSessionId) && isMissingRolloutError(error))) throw error;
4081
4167
  this.logger.warn({ error, sessionId: record.localSessionId }, "App Server task has no rollout; creating a replacement task.");
4082
4168
  session = await runtime.createSession({
4083
4169
  localSessionId: record.localSessionId,
@@ -4555,6 +4641,7 @@ export class ProxySessionController {
4555
4641
 
4556
4642
  private async cancel(contextKey: string): Promise<void> {
4557
4643
  const record = this.requireCurrentSession(contextKey);
4644
+ await this.outbound.sendText(contextKey, STOP_TASK_STARTED_MESSAGE);
4558
4645
  await this.cancelSession(record);
4559
4646
  }
4560
4647
 
@@ -4567,6 +4654,7 @@ export class ProxySessionController {
4567
4654
  const goal = await runtime.getGoal(record.localSessionId);
4568
4655
  if (goal?.status === "active") await runtime.setGoal(record.localSessionId, { status: "paused" });
4569
4656
  } catch (error) {
4657
+ if (error instanceof CodexVersionError) throw error;
4570
4658
  this.logger.warn(
4571
4659
  { error, sessionId: record.localSessionId },
4572
4660
  "Failed to pause the active Agent goal before interrupting its turn.",
@@ -4581,6 +4669,7 @@ export class ProxySessionController {
4581
4669
  ? remote.lastTurnId
4582
4670
  : undefined;
4583
4671
  } catch (error) {
4672
+ if (error instanceof CodexVersionError) throw error;
4584
4673
  this.logger.warn(
4585
4674
  { error, sessionId: record.localSessionId, remoteSessionId: record.remoteSessionId },
4586
4675
  "Failed to inspect the current App Server turn before interrupting; using the locally tracked turn.",
@@ -4628,9 +4717,15 @@ export class ProxySessionController {
4628
4717
  turnId: string,
4629
4718
  ): Promise<void> {
4630
4719
  if (!sessionId || !turnId) throw new Error("无效的 Reset 请求。请使用最新的思考卡片重试。");
4720
+ await this.runSessionReset(sessionId, async () => {
4721
+ await this.outbound.sendText(contextKey, TURN_RESET_STARTED_MESSAGE);
4722
+ await this.performCurrentSessionReset(contextKey, sessionId, turnId);
4723
+ });
4724
+ }
4725
+
4726
+ private async runSessionReset(sessionId: string, operation: () => Promise<void>): Promise<void> {
4631
4727
  const previous = this.sessionResets.get(sessionId) ?? Promise.resolve();
4632
- const reset = previous.catch(() => undefined).then(() =>
4633
- this.performCurrentSessionReset(contextKey, sessionId, turnId));
4728
+ const reset = previous.catch(() => undefined).then(operation);
4634
4729
  this.sessionResets.set(sessionId, reset);
4635
4730
  try {
4636
4731
  await reset;
@@ -4639,6 +4734,19 @@ export class ProxySessionController {
4639
4734
  }
4640
4735
  }
4641
4736
 
4737
+ private async waitForCurrentSessionReset(contextKey: string): Promise<void> {
4738
+ const sessionId = this.currentSession(contextKey)?.localSessionId;
4739
+ if (!sessionId) return;
4740
+
4741
+ let reset = this.sessionResets.get(sessionId);
4742
+ while (reset) {
4743
+ await reset.catch(() => undefined);
4744
+ const next = this.sessionResets.get(sessionId);
4745
+ if (!next || next === reset) return;
4746
+ reset = next;
4747
+ }
4748
+ }
4749
+
4642
4750
  private async resetFromHistoryCard(
4643
4751
  contextKey: string,
4644
4752
  sessionId: string,
@@ -4655,16 +4763,11 @@ export class ProxySessionController {
4655
4763
 
4656
4764
  this.resetHistoryOperations.set(sessionId, turnId);
4657
4765
  try {
4658
- await this.openResetHistory(contextKey, { expectedSessionId: sessionId, updateMessageId, page });
4659
- } catch (error) {
4660
- if (this.resetHistoryOperations.get(sessionId) === turnId) {
4661
- this.resetHistoryOperations.delete(sessionId);
4662
- }
4663
- throw error;
4664
- }
4665
-
4666
- try {
4667
- await this.resetCurrentSessionToTurn(contextKey, sessionId, turnId);
4766
+ await this.runSessionReset(sessionId, async () => {
4767
+ await this.outbound.sendText(contextKey, TURN_RESET_STARTED_MESSAGE);
4768
+ await this.openResetHistory(contextKey, { expectedSessionId: sessionId, updateMessageId, page });
4769
+ await this.performCurrentSessionReset(contextKey, sessionId, turnId);
4770
+ });
4668
4771
  } catch (error) {
4669
4772
  if (this.resetHistoryOperations.get(sessionId) === turnId) {
4670
4773
  this.resetHistoryOperations.delete(sessionId);
@@ -4708,9 +4811,9 @@ export class ProxySessionController {
4708
4811
  throw new Error("当前任务不是可 Reset 的 App Server 任务。");
4709
4812
  }
4710
4813
 
4711
- const historyTurn = this.store.listTaskTurnGraph(current.localSessionId)
4814
+ const historyTurn = this.store.listTaskTurnGraphIndex(current.localSessionId)
4712
4815
  .find((turn) => turn.turnId === turnId);
4713
- const snapshot = turnViewSnapshot(historyTurn?.snapshot);
4816
+ const snapshot = historyTurn ? this.store.getTurnPromptSummary(turnId) : undefined;
4714
4817
  if (!snapshot || snapshot.status !== "completed") {
4715
4818
  throw new Error("只能将当前任务 Reset 到已成功完成的轮次。");
4716
4819
  }
@@ -4894,6 +4997,7 @@ export class ProxySessionController {
4894
4997
  const record = options.sessionId
4895
4998
  ? this.requireSession(contextKey, options.sessionId)
4896
4999
  : this.requireCurrentSession(contextKey);
5000
+ await this.outbound.sendText(contextKey, `正在切换到 Provider ${modelProvider},请稍后。`);
4897
5001
  const loaded = await this.loadSession(record);
4898
5002
  await this.assertModelProvider(loaded, modelProvider);
4899
5003
  const models = await loaded.runtime.listModels();
@@ -4911,7 +5015,7 @@ export class ProxySessionController {
4911
5015
  model: model.id,
4912
5016
  effort,
4913
5017
  mode: loaded.session.permissionMode,
4914
- }, options);
5018
+ }, options, false);
4915
5019
  }
4916
5020
 
4917
5021
  private async openProviderSelector(
@@ -5004,28 +5108,22 @@ export class ProxySessionController {
5004
5108
  contextKey: string,
5005
5109
  settings: { provider: string; model: string; effort: string; mode: PermissionMode },
5006
5110
  options: ModelCardOptions = {},
5111
+ announceStart = true,
5007
5112
  ): Promise<void> {
5008
5113
  const record = options.sessionId
5009
5114
  ? this.requireSession(contextKey, options.sessionId)
5010
5115
  : this.requireCurrentSession(contextKey);
5116
+ if (announceStart) {
5117
+ await this.outbound.sendText(contextKey, `正在切换到 Provider ${settings.provider},请稍后。`);
5118
+ }
5011
5119
  const loaded = await this.loadSession(record);
5012
5120
  await this.assertProviderModelSettings(loaded, settings.provider, settings.model, settings.effort);
5013
- if (!loaded.runtime.setExecutionSettings) {
5014
- throw new Error("当前运行时不支持 Provider 设置。");
5015
- }
5016
- const session = await loaded.runtime.setExecutionSettings(loaded.record.localSessionId, {
5121
+ const session = await this.changeProviderSettings(loaded, {
5017
5122
  modelProvider: settings.provider,
5018
5123
  model: settings.model,
5019
5124
  reasoningEffort: settings.effort,
5020
5125
  permissionMode: settings.mode,
5021
5126
  });
5022
- this.store.updateRuntimeSession(loaded.record.localSessionId, {
5023
- modelProvider: session.modelProvider ?? settings.provider,
5024
- model: session.model ?? settings.model,
5025
- reasoningEffort: session.reasoningEffort ?? settings.effort,
5026
- permissionMode: session.permissionMode,
5027
- });
5028
- await this.persistAgentExecutionDefaults(loaded.record.localSessionId);
5029
5127
  const notice = [
5030
5128
  `Provider 已切换为 ${cardCode(session.modelProvider ?? settings.provider)}`,
5031
5129
  `模型 ${cardCode(session.model ?? settings.model)}`,
@@ -5044,6 +5142,30 @@ export class ProxySessionController {
5044
5142
  }
5045
5143
  }
5046
5144
 
5145
+ private async changeProviderSettings(loaded: LoadedSession, settings: RuntimeExecutionSettings): Promise<RuntimeSession> {
5146
+ if (!loaded.runtime.setExecutionSettings) throw new Error("当前运行时不支持 Provider 设置。");
5147
+ const persist = async (candidate: RuntimeSession): Promise<void> => {
5148
+ assertProviderSettingsApplied(candidate, settings);
5149
+ const agent = this.ensureAgent(loaded.record.agentName);
5150
+ const previousDefaults = agent.defaults;
5151
+ await this.persistAgentExecutionDefaults(loaded.record.localSessionId, candidate);
5152
+ try {
5153
+ this.store.updateRuntimeSession(loaded.record.localSessionId, {
5154
+ remoteSessionId: candidate.remoteSessionId,
5155
+ modelProvider: candidate.modelProvider,
5156
+ model: candidate.model,
5157
+ reasoningEffort: candidate.reasoningEffort,
5158
+ permissionMode: candidate.permissionMode,
5159
+ });
5160
+ } catch (error) {
5161
+ await this.lifecycle?.persistAgentExecutionDefaults?.(loaded.record.agentName, previousDefaults ?? {});
5162
+ agent.defaults = previousDefaults;
5163
+ throw error;
5164
+ }
5165
+ };
5166
+ return loaded.runtime.setExecutionSettings(loaded.record.localSessionId, settings, persist);
5167
+ }
5168
+
5047
5169
  private async assertProviderModelSettings(
5048
5170
  loaded: LoadedSession,
5049
5171
  modelProvider: string,
@@ -5464,7 +5586,9 @@ export class ProxySessionController {
5464
5586
  private async setTitle(contextKey: string, title: string): Promise<void> {
5465
5587
  const normalizedTitle = normalizeTaskTitle(title);
5466
5588
  if (!normalizedTitle) throw new Error("任务标题不能为空。");
5467
- const loaded = await this.loadSession(this.requireCurrentSession(contextKey));
5589
+ const current = this.requireCurrentSession(contextKey);
5590
+ await this.outbound.sendText(contextKey, CHANGE_TITLE_STARTED_MESSAGE);
5591
+ const loaded = await this.loadSession(current);
5468
5592
  if (loaded.runtime.setTitle) await loaded.runtime.setTitle(loaded.record.localSessionId, normalizedTitle);
5469
5593
  else loaded.session.title = normalizedTitle;
5470
5594
  this.store.updateRuntimeSession(loaded.record.localSessionId, { title: normalizedTitle });
@@ -5597,9 +5721,9 @@ export class ProxySessionController {
5597
5721
  if (!current.remoteSessionId || !this.isCodexSession(current)) {
5598
5722
  throw new Error("当前任务不是可 Reset 的 App Server 任务。");
5599
5723
  }
5600
- await this.hydrateLegacyForkTurnHistory(contextKey, current);
5601
- await this.hydrateRemoteTurnHistory(contextKey, current);
5602
- const completedTurns = this.store.listTaskTurnGraph(current.localSessionId);
5724
+ const requestedPage = Math.max(0, Math.trunc(options.page ?? 0));
5725
+ const hasMoreHistory = await this.hydrateTaskTurnHistory(current, (requestedPage + 1) * RESET_HISTORY_PAGE_SIZE);
5726
+ const completedTurns = this.store.listTaskTurnGraphIndex(current.localSessionId);
5603
5727
  const completedTurnIds = new Set(completedTurns.map((turn) => turn.turnId));
5604
5728
  const runningTurnId = current.lastTurnStatus === "running" && current.lastTurnId
5605
5729
  && !completedTurnIds.has(current.lastTurnId)
@@ -5641,13 +5765,13 @@ export class ProxySessionController {
5641
5765
  parentTurnId: turn.parentTurnId,
5642
5766
  })));
5643
5767
  const total = allTurns.length;
5644
- const totalPages = Math.max(1, Math.ceil(total / RESET_HISTORY_PAGE_SIZE));
5768
+ const totalPages = Math.max(1, Math.ceil(total / RESET_HISTORY_PAGE_SIZE)) + (hasMoreHistory ? 1 : 0);
5645
5769
  const page = Math.max(0, Math.min(Math.trunc(options.page ?? 0), totalPages - 1));
5646
5770
  const offset = page * RESET_HISTORY_PAGE_SIZE;
5647
5771
  const turns = allTurns.slice(offset, offset + RESET_HISTORY_PAGE_SIZE);
5648
5772
  const resettingTurnId = this.resetHistoryOperations.get(current.localSessionId);
5649
5773
  const entries: ResetHistoryCardEntry[] = turns.flatMap((turn, index) => {
5650
- const snapshot = turnViewSnapshot(turn.snapshot);
5774
+ const snapshot = turn.turnId === runningTurnId ? runningSnapshot : this.store.getTurnPromptSummary(turn.turnId);
5651
5775
  if (!snapshot) return [];
5652
5776
  const graph = graphRows[offset + index]!;
5653
5777
  const summary = truncateText(
@@ -5702,7 +5826,9 @@ export class ProxySessionController {
5702
5826
  const card = this.cardRenderer.renderResetHistoryCard({
5703
5827
  entries,
5704
5828
  footerLines: [
5705
- runningTurn
5829
+ hasMoreHistory
5830
+ ? `第 ${page + 1} 页 · 已加载 ${total} 个 turn`
5831
+ : runningTurn
5706
5832
  ? `第 ${page + 1}/${totalPages} 页 · 共 ${total} 个 turn(${completedTurns.length} 个已完成,1 个运行中)`
5707
5833
  : `第 ${page + 1}/${totalPages} 页 · 共 ${total} 个已完成 turn`,
5708
5834
  ...(resettingTurnId ? ["正在 Reset 到所选轮次,请稍候…"] : []),
@@ -5912,6 +6038,7 @@ export class ProxySessionController {
5912
6038
  const record = this.requireCurrentSession(contextKey);
5913
6039
  const filePath = resolveUserPath(requestedFilePath, record.cwd);
5914
6040
  await this.assertSendableFile(filePath);
6041
+ await this.outbound.sendText(contextKey, SEND_FILE_STARTED_MESSAGE);
5915
6042
  await this.outbound.sendFile(contextKey, filePath);
5916
6043
  }
5917
6044
 
@@ -6216,7 +6343,7 @@ export class ProxySessionController {
6216
6343
 
6217
6344
  private async resolveRemoteCodexSession(
6218
6345
  reference: string,
6219
- options: { forFork?: boolean } = {},
6346
+ options: { forFork?: boolean; view?: "metadata" | "latest" | "latest-full" } = {},
6220
6347
  ): Promise<AgentRemoteSession> {
6221
6348
  const scoped = parseRemoteSessionReference(reference);
6222
6349
  const candidates = scoped
@@ -6234,7 +6361,7 @@ export class ProxySessionController {
6234
6361
  runtime,
6235
6362
  remote: options.forFork && runtime.readRemoteForkSource
6236
6363
  ? await runtime.readRemoteForkSource(remoteSessionId)
6237
- : await runtime.readRemoteSession(remoteSessionId),
6364
+ : await runtime.readRemoteSession(remoteSessionId, options.view ?? "latest"),
6238
6365
  } satisfies AgentRemoteSession;
6239
6366
  }));
6240
6367
  const matches = reads
@@ -6260,6 +6387,7 @@ export class ProxySessionController {
6260
6387
  if (direct?.status === "closed") {
6261
6388
  throw new Error(`找不到任务:${taskId}`);
6262
6389
  }
6390
+ await this.outbound.sendText(contextKey, SWITCH_TASK_STARTED_MESSAGE);
6263
6391
  const existing = direct ?? this.findStoredSessionByReference(taskId);
6264
6392
  if (existing) {
6265
6393
  const runtime = this.runtimes.forAgent(existing.agentName);
@@ -6368,6 +6496,10 @@ export class ProxySessionController {
6368
6496
  throw new Error("任务仍有排队消息,无法归档。请先处理或取消排队消息。");
6369
6497
  }
6370
6498
 
6499
+ if (options.source !== "dismiss") {
6500
+ await this.outbound.sendText(contextKey, ARCHIVE_TASK_STARTED_MESSAGE);
6501
+ }
6502
+
6371
6503
  const remoteReference = local
6372
6504
  ? remoteSessionReference(local.agentName, local.remoteSessionId!)
6373
6505
  : taskId;
@@ -6599,27 +6731,28 @@ export class ProxySessionController {
6599
6731
  current = context.currentSessionId ? this.store.getSession(context.currentSessionId) : undefined;
6600
6732
  }
6601
6733
 
6602
- if (current && current.status === "running") {
6603
- try {
6604
- const runtime = this.runtimes.forAgent(current.agentName);
6605
- if (runtime.getSession(current.localSessionId)) {
6606
- await runtime.synchronizeSession(current.localSessionId);
6734
+ let remote: RemoteSessionSummary | undefined;
6735
+ if (current?.status === "running") {
6736
+ const runtime = this.runtimes.forAgent(current.agentName);
6737
+ if (runtime.getSession(current.localSessionId)) {
6738
+ try {
6739
+ remote = (await runtime.synchronizeSession(current.localSessionId)).remoteSummary;
6607
6740
  current = this.store.getSession(current.localSessionId) ?? current;
6741
+ } catch (error) {
6742
+ if (error instanceof CodexVersionError) throw error;
6743
+ this.logger.warn({ error, sessionId: current.localSessionId }, "Failed to synchronize task status.");
6608
6744
  }
6609
- } catch (error) {
6610
- this.logger.warn({ error, sessionId: current.localSessionId }, "Failed to synchronize task status.");
6611
6745
  }
6612
6746
  }
6613
-
6614
6747
  const localCurrent = current;
6615
- let remote: RemoteSessionSummary | undefined;
6616
6748
  let goal: RuntimeGoal | undefined;
6617
6749
  if (current?.remoteSessionId) {
6618
6750
  const runtime = this.runtimes.forAgent(current.agentName);
6619
- if (runtime.readRemoteSession) {
6751
+ if (!remote && runtime.readRemoteSession) {
6620
6752
  try {
6621
- remote = await runtime.readRemoteSession(current.remoteSessionId);
6753
+ remote = await runtime.readRemoteSession(current.remoteSessionId, "latest-full");
6622
6754
  } catch (error) {
6755
+ if (error instanceof CodexVersionError) throw error;
6623
6756
  this.logger.warn({ error, sessionId: current.localSessionId }, "Failed to inspect App Server task status.");
6624
6757
  }
6625
6758
  }
@@ -6730,7 +6863,7 @@ export class ProxySessionController {
6730
6863
  reference: string,
6731
6864
  options: StatusCardOptions = {},
6732
6865
  ): Promise<void> {
6733
- const { agentName, remote } = await this.resolveRemoteCodexSession(reference);
6866
+ const { agentName, remote } = await this.resolveRemoteCodexSession(reference, { view: "latest-full" });
6734
6867
  const actionReference = remoteSessionReference(agentName, remote.id);
6735
6868
  const sections: CardSection[] = [
6736
6869
  {
@@ -6892,9 +7025,10 @@ export class ProxySessionController {
6892
7025
  return agent;
6893
7026
  }
6894
7027
 
6895
- private async persistAgentExecutionDefaults(localSessionId: string): Promise<void> {
6896
- const session = this.store.getSession(localSessionId);
6897
- if (!session) throw new Error(`找不到任务:${localSessionId}`);
7028
+ private async persistAgentExecutionDefaults(localSessionId: string, candidate?: RuntimeSession): Promise<void> {
7029
+ const record = this.store.getSession(localSessionId);
7030
+ if (!record) throw new Error(`找不到任务:${localSessionId}`);
7031
+ const session = candidate ?? record;
6898
7032
  const agent = this.ensureAgent(session.agentName);
6899
7033
  const observed: AgentExecutionDefaults = {
6900
7034
  ...(session.modelProvider?.trim() ? { modelProvider: session.modelProvider } : {}),
@@ -7329,6 +7463,7 @@ export class ProxySessionController {
7329
7463
  const lockPath = runtime.getThreadWriterLockPath?.(threadId);
7330
7464
  if (!lockPath) throw new Error("当前 Agent 不支持自动关闭占用程序。");
7331
7465
 
7466
+ await this.outbound.sendText(contextKey, CLOSE_WRITER_STARTED_MESSAGE);
7332
7467
  let owners = await this.threadWriterProcesses.inspect(lockPath);
7333
7468
  const expectedOwner = owners.length === 1 && threadWriterMatches(
7334
7469
  owners[0],
@@ -7788,11 +7923,6 @@ function runtimeErrorMessage(error: unknown): string {
7788
7923
  return error instanceof Error ? error.message : String(error);
7789
7924
  }
7790
7925
 
7791
- function isMissingRolloutError(error: unknown): boolean {
7792
- const message = error instanceof Error ? error.message : String(error);
7793
- return /no rollout found|rollout[^\n]*(?:not found|missing)|thread\/resume failed/i.test(message);
7794
- }
7795
-
7796
7926
  function isUnmaterializedCodexThreadError(error: unknown): boolean {
7797
7927
  const message = error instanceof Error ? error.message : String(error);
7798
7928
  return /thread\/read failed:[^\n]*(?:not materialized|not loaded|includeTurns is unavailable before first user message)/i.test(message);