@keyou007/agent-bot 0.1.20-alpha.1 → 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 (63) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +13 -0
  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/feishu/CardRenderer.js +36 -11
  21. package/dist/feishu/CardRenderer.js.map +1 -1
  22. package/dist/feishu/FeishuConnector.js +17 -5
  23. package/dist/feishu/FeishuConnector.js.map +1 -1
  24. package/dist/feishu/InboundText.js +11 -0
  25. package/dist/feishu/InboundText.js.map +1 -1
  26. package/dist/feishu/MergedForwardMessage.js +17 -5
  27. package/dist/feishu/MergedForwardMessage.js.map +1 -1
  28. package/dist/index.js +1 -1
  29. package/dist/index.js.map +1 -1
  30. package/dist/local-files/LocalFileViewerServer.js +14 -4
  31. package/dist/local-files/LocalFileViewerServer.js.map +1 -1
  32. package/dist/local-files/MarkdownFileLinks.js +65 -0
  33. package/dist/local-files/MarkdownFileLinks.js.map +1 -0
  34. package/dist/proxy/ProxySessionController.js +399 -257
  35. package/dist/proxy/ProxySessionController.js.map +1 -1
  36. package/dist/state/StateStore.js +58 -65
  37. package/dist/state/StateStore.js.map +1 -1
  38. package/dist/utils/semanticVersion.js +39 -0
  39. package/dist/utils/semanticVersion.js.map +1 -0
  40. package/docs/technical-reference.md +5 -3
  41. package/docs/technical-reference.zh.md +5 -3
  42. package/npm-shrinkwrap.json +2 -2
  43. package/package.json +1 -1
  44. package/skills/agent-bot/SKILL.md +7 -1
  45. package/src/cli/AgentPrerequisites.ts +14 -40
  46. package/src/cli/DefaultAgentSelection.ts +1 -1
  47. package/src/cli.ts +13 -0
  48. package/src/codex/CodexLocalActivityDetector.ts +41 -9
  49. package/src/codex/CodexProcessManager.ts +44 -7
  50. package/src/codex/CodexRuntime.ts +244 -195
  51. package/src/codex/CodexVersion.ts +18 -0
  52. package/src/codex/ProviderSwitch.ts +36 -0
  53. package/src/feishu/CardRenderer.ts +37 -27
  54. package/src/feishu/FeishuConnector.ts +15 -5
  55. package/src/feishu/InboundText.ts +11 -0
  56. package/src/feishu/MergedForwardMessage.ts +15 -5
  57. package/src/index.ts +1 -1
  58. package/src/local-files/LocalFileViewerServer.ts +14 -4
  59. package/src/local-files/MarkdownFileLinks.ts +64 -0
  60. package/src/proxy/ProxySessionController.ts +369 -238
  61. package/src/runtime/types.ts +15 -2
  62. package/src/state/StateStore.ts +77 -90
  63. 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.",
@@ -2990,6 +3037,7 @@ export class ProxySessionController {
2990
3037
  }
2991
3038
 
2992
3039
  private async forkSessionReference(contextKey: string, reference?: string): Promise<void> {
3040
+ await this.outbound.sendText(contextKey, FORK_TASK_STARTED_MESSAGE);
2993
3041
  const plan = await this.prepareForkSession(contextKey, reference);
2994
3042
  const forked = await this.forkSessionIntoContext(contextKey, plan);
2995
3043
  const forkSourceLabel = plan.forkedFromHistoricalTurn
@@ -3050,35 +3098,24 @@ export class ProxySessionController {
3050
3098
  if (!runtime.readRemoteSession && !source) {
3051
3099
  throw new Error("当前 App Server Agent 不支持读取指定任务。");
3052
3100
  }
3053
- const persistedCompletedTurns = source ? this.localForkCompletedTurns(source) : [];
3101
+ const persistedCompletedTurnId = source
3102
+ ? this.store.findLatestCompletedTurnId(source.localSessionId)
3103
+ : undefined;
3054
3104
  const sourceLastSnapshot = turnViewSnapshot(
3055
3105
  source?.lastTurnId ? this.store.getTurnSnapshot(source.lastTurnId) : undefined,
3056
3106
  );
3057
- const localCompletedTurns = persistedCompletedTurns.length > 0
3058
- ? persistedCompletedTurns
3059
- : source?.lastTurnId
3107
+ const localCompletedTurnId = persistedCompletedTurnId
3108
+ ?? (source?.lastTurnId
3060
3109
  && (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
- }
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);
3080
3117
  }
3081
- const latestTurnId = remote?.lastTurnId ?? source?.lastTurnId ?? localCompletedTurns.at(-1)?.id;
3118
+ const latestTurnId = remote?.lastTurnId ?? source?.lastTurnId ?? localCompletedTurnId;
3082
3119
  const latestSnapshot = turnViewSnapshot(latestTurnId ? this.store.getTurnSnapshot(latestTurnId) : undefined);
3083
3120
  const isRunning = remote
3084
3121
  ? isRemoteSessionActive(remote)
@@ -3089,7 +3126,7 @@ export class ProxySessionController {
3089
3126
  );
3090
3127
  const lastTurnId = remote?.lastCompletedTurnId
3091
3128
  ?? (remote?.lastTurnStatus === "completed" ? remote.lastTurnId : undefined)
3092
- ?? localCompletedTurns.at(-1)?.id;
3129
+ ?? localCompletedTurnId;
3093
3130
  if (!lastTurnId) {
3094
3131
  if (isRunning) {
3095
3132
  throw new Error(`${sourceLabel}正在执行,且还没有已完成轮次可供 fork。请等待当前轮次完成后重试。`);
@@ -3098,8 +3135,7 @@ export class ProxySessionController {
3098
3135
  }
3099
3136
  const snapshot = turnViewSnapshot(this.store.getTurnSnapshot(lastTurnId));
3100
3137
  const forkedFromHistoricalTurn = lastTurnId !== latestTurnId;
3101
- const completedTurn = (remote?.completedTurns ?? localCompletedTurns)
3102
- .find((turn) => turn.id === lastTurnId);
3138
+ const completedTurn = remote?.completedTurns?.find((turn) => turn.id === lastTurnId);
3103
3139
 
3104
3140
  const cwd = remote?.cwd || source?.cwd;
3105
3141
  if (!cwd) throw new Error("指定的 App Server 任务没有可用的工作目录,暂时不能 fork。");
@@ -3145,29 +3181,11 @@ export class ProxySessionController {
3145
3181
  sourceTurnPrompt,
3146
3182
  sourceTurnStartedAt: snapshot?.startedAt ?? sourceTurnCompletedAt,
3147
3183
  sourceTurnCompletedAt,
3148
- sourceCompletedTurns: remote?.completedTurns
3149
- ?? (persistedCompletedTurns.length > 0 ? [] : localCompletedTurns),
3150
3184
  sourceWasRunning: isRunning,
3151
3185
  forkedFromHistoricalTurn,
3152
3186
  };
3153
3187
  }
3154
3188
 
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
3189
  private async prepareForkGroupSession(
3172
3190
  contextKey: string,
3173
3191
  requestedTitle: string | undefined,
@@ -3270,7 +3288,6 @@ export class ProxySessionController {
3270
3288
  contextKey: string,
3271
3289
  plan: ForkSessionPlan,
3272
3290
  ): Promise<ForkSessionResult> {
3273
- const sourceCompletedTurns = await this.resolveForkSourceCompletedTurns(plan);
3274
3291
  const localSessionId = createId("sess");
3275
3292
  const record = this.store.createSession({
3276
3293
  localSessionId,
@@ -3313,12 +3330,6 @@ export class ProxySessionController {
3313
3330
  plan.cwd,
3314
3331
  this.agentLabel(plan.agentName),
3315
3332
  );
3316
- const sourceTurnHistoryCount = this.persistForkSourceHistory(
3317
- contextKey,
3318
- localSessionId,
3319
- plan,
3320
- sourceCompletedTurns,
3321
- );
3322
3333
  this.store.audit(contextKey, "session_forked", {
3323
3334
  sourceLocalSessionId: plan.source?.localSessionId,
3324
3335
  sourceRemoteSessionId: plan.remoteSessionId,
@@ -3326,7 +3337,6 @@ export class ProxySessionController {
3326
3337
  sourceTurnPrompt: plan.sourceTurnPrompt,
3327
3338
  sourceTurnStartedAt: plan.sourceTurnStartedAt,
3328
3339
  sourceTurnCompletedAt: plan.sourceTurnCompletedAt,
3329
- sourceTurnHistoryCount,
3330
3340
  sourceWasRunning: plan.sourceWasRunning,
3331
3341
  forkedLocalSessionId: localSessionId,
3332
3342
  forkedRemoteSessionId: forked.remoteSessionId,
@@ -3342,54 +3352,6 @@ export class ProxySessionController {
3342
3352
  }
3343
3353
  }
3344
3354
 
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
3355
  private persistCompletedTurnHistory(input: {
3394
3356
  localSessionId: string;
3395
3357
  contextKey: string;
@@ -3397,6 +3359,7 @@ export class ProxySessionController {
3397
3359
  remoteSessionId: string;
3398
3360
  completedTurns: RemoteCompletedTurnSummary[];
3399
3361
  fallbackStart: number;
3362
+ replaceParents?: boolean;
3400
3363
  }): number {
3401
3364
  try {
3402
3365
  this.store.importCompletedTurnHistory({
@@ -3404,6 +3367,7 @@ export class ProxySessionController {
3404
3367
  contextKey: input.contextKey,
3405
3368
  agentName: input.agentName,
3406
3369
  remoteSessionId: input.remoteSessionId,
3370
+ replaceParents: input.replaceParents,
3407
3371
  turns: input.completedTurns.map((turn, index) => {
3408
3372
  const startedAt = turn.startedAt ?? input.fallbackStart + index;
3409
3373
  const completedAt = turn.completedAt ?? startedAt;
@@ -3437,82 +3401,202 @@ export class ProxySessionController {
3437
3401
  }
3438
3402
  }
3439
3403
 
3440
- 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(
3441
3427
  contextKey: string,
3442
3428
  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;
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;
3448
3484
  const runtime = this.runtimes.forAgent(current.agentName);
3449
- if (runtime.kind !== "codex" || !runtime.readRemoteSession) return;
3485
+ if (runtime.kind !== "codex" || !runtime.listRemoteTurnSummaries) return false;
3486
+ const seenCursors = new Set<string>();
3450
3487
  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) {
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) {
3470
3532
  this.store.audit(contextKey, "fork_turn_history_imported", {
3471
3533
  forkedLocalSessionId: current.localSessionId,
3472
3534
  sourceRemoteSessionId: source.sourceRemoteSessionId,
3473
3535
  sourceTurnId: source.sourceTurnId,
3474
- importedTurnCount: imported,
3475
3536
  });
3476
3537
  }
3538
+ return !state.complete;
3477
3539
  } catch (error) {
3540
+ if (error instanceof CodexVersionError) throw error;
3478
3541
  this.logger.warn(
3479
3542
  { error, localSessionId: current.localSessionId, sourceRemoteSessionId: source.sourceRemoteSessionId },
3480
- "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.",
3481
3544
  );
3482
3545
  }
3546
+ return true;
3483
3547
  }
3484
3548
 
3485
3549
  private async hydrateRemoteTurnHistory(
3486
3550
  contextKey: string,
3487
3551
  current: SessionRecord,
3488
- ): Promise<void> {
3489
- 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"]);
3490
3556
  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;
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 };
3495
3560
  const runtime = this.runtimes.forAgent(current.agentName);
3496
- if (runtime.kind !== "codex" || !runtime.readRemoteSession) return;
3561
+ if (runtime.kind !== "codex" || !runtime.listRemoteTurnSummaries) return false;
3562
+ const seen = new Set<string>();
3497
3563
  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
- });
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;
3510
3592
  } catch (error) {
3593
+ if (error instanceof CodexVersionError) throw error;
3511
3594
  this.logger.warn(
3512
3595
  { error, localSessionId: current.localSessionId, remoteSessionId: current.remoteSessionId },
3513
3596
  "Failed to hydrate App Server Turn history; rendering the locally available history.",
3514
3597
  );
3515
3598
  }
3599
+ return true;
3516
3600
  }
3517
3601
 
3518
3602
  private async createProjectSessionFromReference(contextKey: string, reference: string): Promise<void> {
@@ -3559,7 +3643,7 @@ export class ProxySessionController {
3559
3643
  agentName = source.agentName;
3560
3644
  runtime = this.runtimes.forAgent(agentName);
3561
3645
  } else {
3562
- const resolved = await this.resolveRemoteCodexSession(taskId);
3646
+ const resolved = await this.resolveRemoteCodexSession(taskId, { view: "metadata" });
3563
3647
  agentName = resolved.agentName;
3564
3648
  runtime = resolved.runtime;
3565
3649
  remote = resolved.remote;
@@ -3568,9 +3652,9 @@ export class ProxySessionController {
3568
3652
  const remoteSessionId = source?.remoteSessionId ?? remote?.id ?? taskId;
3569
3653
  if (!remote && runtime.readRemoteSession && remoteSessionId) {
3570
3654
  try {
3571
- remote = await runtime.readRemoteSession(remoteSessionId);
3655
+ remote = await runtime.readRemoteSession(remoteSessionId, "metadata");
3572
3656
  } catch (error) {
3573
- if (!source) throw error;
3657
+ if (!source || error instanceof CodexVersionError) throw error;
3574
3658
  this.logger.warn(
3575
3659
  { error, contextKey, taskId: remoteSessionId },
3576
3660
  "Failed to refresh the source task before creating a project task; using the local project path.",
@@ -3907,6 +3991,7 @@ export class ProxySessionController {
3907
3991
  if (!userId?.startsWith("ou_")) {
3908
3992
  throw new Error("/forkgroup 只能由具有 open_id 的飞书用户消息触发。");
3909
3993
  }
3994
+ await this.outbound.sendText(sourceContextKey, FORK_GROUP_STARTED_MESSAGE);
3910
3995
  const prepared = await this.prepareForkGroupSession(sourceContextKey, requestedTitle, incomingMessage);
3911
3996
  await this.forkPreparedSessionToFeishuGroup(
3912
3997
  sourceContextKey,
@@ -3924,6 +4009,7 @@ export class ProxySessionController {
3924
4009
  if (!userId?.startsWith("ou_")) {
3925
4010
  throw new Error("ForkGroup 只能由具有 open_id 的飞书用户触发。");
3926
4011
  }
4012
+ await this.outbound.sendText(sourceContextKey, FORK_GROUP_STARTED_MESSAGE);
3927
4013
  const plan = await this.prepareForkSession(sourceContextKey, reference);
3928
4014
  await this.forkPreparedSessionToFeishuGroup(
3929
4015
  sourceContextKey,
@@ -4076,7 +4162,8 @@ export class ProxySessionController {
4076
4162
  });
4077
4163
  } catch (error) {
4078
4164
  if (activeWriterThreadId(error)) throw error;
4079
- 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;
4080
4167
  this.logger.warn({ error, sessionId: record.localSessionId }, "App Server task has no rollout; creating a replacement task.");
4081
4168
  session = await runtime.createSession({
4082
4169
  localSessionId: record.localSessionId,
@@ -4554,6 +4641,7 @@ export class ProxySessionController {
4554
4641
 
4555
4642
  private async cancel(contextKey: string): Promise<void> {
4556
4643
  const record = this.requireCurrentSession(contextKey);
4644
+ await this.outbound.sendText(contextKey, STOP_TASK_STARTED_MESSAGE);
4557
4645
  await this.cancelSession(record);
4558
4646
  }
4559
4647
 
@@ -4566,6 +4654,7 @@ export class ProxySessionController {
4566
4654
  const goal = await runtime.getGoal(record.localSessionId);
4567
4655
  if (goal?.status === "active") await runtime.setGoal(record.localSessionId, { status: "paused" });
4568
4656
  } catch (error) {
4657
+ if (error instanceof CodexVersionError) throw error;
4569
4658
  this.logger.warn(
4570
4659
  { error, sessionId: record.localSessionId },
4571
4660
  "Failed to pause the active Agent goal before interrupting its turn.",
@@ -4580,6 +4669,7 @@ export class ProxySessionController {
4580
4669
  ? remote.lastTurnId
4581
4670
  : undefined;
4582
4671
  } catch (error) {
4672
+ if (error instanceof CodexVersionError) throw error;
4583
4673
  this.logger.warn(
4584
4674
  { error, sessionId: record.localSessionId, remoteSessionId: record.remoteSessionId },
4585
4675
  "Failed to inspect the current App Server turn before interrupting; using the locally tracked turn.",
@@ -4627,9 +4717,15 @@ export class ProxySessionController {
4627
4717
  turnId: string,
4628
4718
  ): Promise<void> {
4629
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> {
4630
4727
  const previous = this.sessionResets.get(sessionId) ?? Promise.resolve();
4631
- const reset = previous.catch(() => undefined).then(() =>
4632
- this.performCurrentSessionReset(contextKey, sessionId, turnId));
4728
+ const reset = previous.catch(() => undefined).then(operation);
4633
4729
  this.sessionResets.set(sessionId, reset);
4634
4730
  try {
4635
4731
  await reset;
@@ -4638,6 +4734,19 @@ export class ProxySessionController {
4638
4734
  }
4639
4735
  }
4640
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
+
4641
4750
  private async resetFromHistoryCard(
4642
4751
  contextKey: string,
4643
4752
  sessionId: string,
@@ -4654,16 +4763,11 @@ export class ProxySessionController {
4654
4763
 
4655
4764
  this.resetHistoryOperations.set(sessionId, turnId);
4656
4765
  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);
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
+ });
4667
4771
  } catch (error) {
4668
4772
  if (this.resetHistoryOperations.get(sessionId) === turnId) {
4669
4773
  this.resetHistoryOperations.delete(sessionId);
@@ -4707,9 +4811,9 @@ export class ProxySessionController {
4707
4811
  throw new Error("当前任务不是可 Reset 的 App Server 任务。");
4708
4812
  }
4709
4813
 
4710
- const historyTurn = this.store.listTaskTurnGraph(current.localSessionId)
4814
+ const historyTurn = this.store.listTaskTurnGraphIndex(current.localSessionId)
4711
4815
  .find((turn) => turn.turnId === turnId);
4712
- const snapshot = turnViewSnapshot(historyTurn?.snapshot);
4816
+ const snapshot = historyTurn ? this.store.getTurnPromptSummary(turnId) : undefined;
4713
4817
  if (!snapshot || snapshot.status !== "completed") {
4714
4818
  throw new Error("只能将当前任务 Reset 到已成功完成的轮次。");
4715
4819
  }
@@ -4893,6 +4997,7 @@ export class ProxySessionController {
4893
4997
  const record = options.sessionId
4894
4998
  ? this.requireSession(contextKey, options.sessionId)
4895
4999
  : this.requireCurrentSession(contextKey);
5000
+ await this.outbound.sendText(contextKey, `正在切换到 Provider ${modelProvider},请稍后。`);
4896
5001
  const loaded = await this.loadSession(record);
4897
5002
  await this.assertModelProvider(loaded, modelProvider);
4898
5003
  const models = await loaded.runtime.listModels();
@@ -4910,7 +5015,7 @@ export class ProxySessionController {
4910
5015
  model: model.id,
4911
5016
  effort,
4912
5017
  mode: loaded.session.permissionMode,
4913
- }, options);
5018
+ }, options, false);
4914
5019
  }
4915
5020
 
4916
5021
  private async openProviderSelector(
@@ -5003,28 +5108,22 @@ export class ProxySessionController {
5003
5108
  contextKey: string,
5004
5109
  settings: { provider: string; model: string; effort: string; mode: PermissionMode },
5005
5110
  options: ModelCardOptions = {},
5111
+ announceStart = true,
5006
5112
  ): Promise<void> {
5007
5113
  const record = options.sessionId
5008
5114
  ? this.requireSession(contextKey, options.sessionId)
5009
5115
  : this.requireCurrentSession(contextKey);
5116
+ if (announceStart) {
5117
+ await this.outbound.sendText(contextKey, `正在切换到 Provider ${settings.provider},请稍后。`);
5118
+ }
5010
5119
  const loaded = await this.loadSession(record);
5011
5120
  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, {
5121
+ const session = await this.changeProviderSettings(loaded, {
5016
5122
  modelProvider: settings.provider,
5017
5123
  model: settings.model,
5018
5124
  reasoningEffort: settings.effort,
5019
5125
  permissionMode: settings.mode,
5020
5126
  });
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
5127
  const notice = [
5029
5128
  `Provider 已切换为 ${cardCode(session.modelProvider ?? settings.provider)}`,
5030
5129
  `模型 ${cardCode(session.model ?? settings.model)}`,
@@ -5043,6 +5142,30 @@ export class ProxySessionController {
5043
5142
  }
5044
5143
  }
5045
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
+
5046
5169
  private async assertProviderModelSettings(
5047
5170
  loaded: LoadedSession,
5048
5171
  modelProvider: string,
@@ -5463,7 +5586,9 @@ export class ProxySessionController {
5463
5586
  private async setTitle(contextKey: string, title: string): Promise<void> {
5464
5587
  const normalizedTitle = normalizeTaskTitle(title);
5465
5588
  if (!normalizedTitle) throw new Error("任务标题不能为空。");
5466
- 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);
5467
5592
  if (loaded.runtime.setTitle) await loaded.runtime.setTitle(loaded.record.localSessionId, normalizedTitle);
5468
5593
  else loaded.session.title = normalizedTitle;
5469
5594
  this.store.updateRuntimeSession(loaded.record.localSessionId, { title: normalizedTitle });
@@ -5596,9 +5721,9 @@ export class ProxySessionController {
5596
5721
  if (!current.remoteSessionId || !this.isCodexSession(current)) {
5597
5722
  throw new Error("当前任务不是可 Reset 的 App Server 任务。");
5598
5723
  }
5599
- await this.hydrateLegacyForkTurnHistory(contextKey, current);
5600
- await this.hydrateRemoteTurnHistory(contextKey, current);
5601
- 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);
5602
5727
  const completedTurnIds = new Set(completedTurns.map((turn) => turn.turnId));
5603
5728
  const runningTurnId = current.lastTurnStatus === "running" && current.lastTurnId
5604
5729
  && !completedTurnIds.has(current.lastTurnId)
@@ -5640,13 +5765,13 @@ export class ProxySessionController {
5640
5765
  parentTurnId: turn.parentTurnId,
5641
5766
  })));
5642
5767
  const total = allTurns.length;
5643
- 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);
5644
5769
  const page = Math.max(0, Math.min(Math.trunc(options.page ?? 0), totalPages - 1));
5645
5770
  const offset = page * RESET_HISTORY_PAGE_SIZE;
5646
5771
  const turns = allTurns.slice(offset, offset + RESET_HISTORY_PAGE_SIZE);
5647
5772
  const resettingTurnId = this.resetHistoryOperations.get(current.localSessionId);
5648
5773
  const entries: ResetHistoryCardEntry[] = turns.flatMap((turn, index) => {
5649
- const snapshot = turnViewSnapshot(turn.snapshot);
5774
+ const snapshot = turn.turnId === runningTurnId ? runningSnapshot : this.store.getTurnPromptSummary(turn.turnId);
5650
5775
  if (!snapshot) return [];
5651
5776
  const graph = graphRows[offset + index]!;
5652
5777
  const summary = truncateText(
@@ -5701,7 +5826,9 @@ export class ProxySessionController {
5701
5826
  const card = this.cardRenderer.renderResetHistoryCard({
5702
5827
  entries,
5703
5828
  footerLines: [
5704
- runningTurn
5829
+ hasMoreHistory
5830
+ ? `第 ${page + 1} 页 · 已加载 ${total} 个 turn`
5831
+ : runningTurn
5705
5832
  ? `第 ${page + 1}/${totalPages} 页 · 共 ${total} 个 turn(${completedTurns.length} 个已完成,1 个运行中)`
5706
5833
  : `第 ${page + 1}/${totalPages} 页 · 共 ${total} 个已完成 turn`,
5707
5834
  ...(resettingTurnId ? ["正在 Reset 到所选轮次,请稍候…"] : []),
@@ -5911,6 +6038,7 @@ export class ProxySessionController {
5911
6038
  const record = this.requireCurrentSession(contextKey);
5912
6039
  const filePath = resolveUserPath(requestedFilePath, record.cwd);
5913
6040
  await this.assertSendableFile(filePath);
6041
+ await this.outbound.sendText(contextKey, SEND_FILE_STARTED_MESSAGE);
5914
6042
  await this.outbound.sendFile(contextKey, filePath);
5915
6043
  }
5916
6044
 
@@ -6215,7 +6343,7 @@ export class ProxySessionController {
6215
6343
 
6216
6344
  private async resolveRemoteCodexSession(
6217
6345
  reference: string,
6218
- options: { forFork?: boolean } = {},
6346
+ options: { forFork?: boolean; view?: "metadata" | "latest" | "latest-full" } = {},
6219
6347
  ): Promise<AgentRemoteSession> {
6220
6348
  const scoped = parseRemoteSessionReference(reference);
6221
6349
  const candidates = scoped
@@ -6233,7 +6361,7 @@ export class ProxySessionController {
6233
6361
  runtime,
6234
6362
  remote: options.forFork && runtime.readRemoteForkSource
6235
6363
  ? await runtime.readRemoteForkSource(remoteSessionId)
6236
- : await runtime.readRemoteSession(remoteSessionId),
6364
+ : await runtime.readRemoteSession(remoteSessionId, options.view ?? "latest"),
6237
6365
  } satisfies AgentRemoteSession;
6238
6366
  }));
6239
6367
  const matches = reads
@@ -6259,6 +6387,7 @@ export class ProxySessionController {
6259
6387
  if (direct?.status === "closed") {
6260
6388
  throw new Error(`找不到任务:${taskId}`);
6261
6389
  }
6390
+ await this.outbound.sendText(contextKey, SWITCH_TASK_STARTED_MESSAGE);
6262
6391
  const existing = direct ?? this.findStoredSessionByReference(taskId);
6263
6392
  if (existing) {
6264
6393
  const runtime = this.runtimes.forAgent(existing.agentName);
@@ -6367,6 +6496,10 @@ export class ProxySessionController {
6367
6496
  throw new Error("任务仍有排队消息,无法归档。请先处理或取消排队消息。");
6368
6497
  }
6369
6498
 
6499
+ if (options.source !== "dismiss") {
6500
+ await this.outbound.sendText(contextKey, ARCHIVE_TASK_STARTED_MESSAGE);
6501
+ }
6502
+
6370
6503
  const remoteReference = local
6371
6504
  ? remoteSessionReference(local.agentName, local.remoteSessionId!)
6372
6505
  : taskId;
@@ -6598,27 +6731,28 @@ export class ProxySessionController {
6598
6731
  current = context.currentSessionId ? this.store.getSession(context.currentSessionId) : undefined;
6599
6732
  }
6600
6733
 
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);
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;
6606
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.");
6607
6744
  }
6608
- } catch (error) {
6609
- this.logger.warn({ error, sessionId: current.localSessionId }, "Failed to synchronize task status.");
6610
6745
  }
6611
6746
  }
6612
-
6613
6747
  const localCurrent = current;
6614
- let remote: RemoteSessionSummary | undefined;
6615
6748
  let goal: RuntimeGoal | undefined;
6616
6749
  if (current?.remoteSessionId) {
6617
6750
  const runtime = this.runtimes.forAgent(current.agentName);
6618
- if (runtime.readRemoteSession) {
6751
+ if (!remote && runtime.readRemoteSession) {
6619
6752
  try {
6620
- remote = await runtime.readRemoteSession(current.remoteSessionId);
6753
+ remote = await runtime.readRemoteSession(current.remoteSessionId, "latest-full");
6621
6754
  } catch (error) {
6755
+ if (error instanceof CodexVersionError) throw error;
6622
6756
  this.logger.warn({ error, sessionId: current.localSessionId }, "Failed to inspect App Server task status.");
6623
6757
  }
6624
6758
  }
@@ -6729,7 +6863,7 @@ export class ProxySessionController {
6729
6863
  reference: string,
6730
6864
  options: StatusCardOptions = {},
6731
6865
  ): Promise<void> {
6732
- const { agentName, remote } = await this.resolveRemoteCodexSession(reference);
6866
+ const { agentName, remote } = await this.resolveRemoteCodexSession(reference, { view: "latest-full" });
6733
6867
  const actionReference = remoteSessionReference(agentName, remote.id);
6734
6868
  const sections: CardSection[] = [
6735
6869
  {
@@ -6891,9 +7025,10 @@ export class ProxySessionController {
6891
7025
  return agent;
6892
7026
  }
6893
7027
 
6894
- private async persistAgentExecutionDefaults(localSessionId: string): Promise<void> {
6895
- const session = this.store.getSession(localSessionId);
6896
- 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;
6897
7032
  const agent = this.ensureAgent(session.agentName);
6898
7033
  const observed: AgentExecutionDefaults = {
6899
7034
  ...(session.modelProvider?.trim() ? { modelProvider: session.modelProvider } : {}),
@@ -7328,6 +7463,7 @@ export class ProxySessionController {
7328
7463
  const lockPath = runtime.getThreadWriterLockPath?.(threadId);
7329
7464
  if (!lockPath) throw new Error("当前 Agent 不支持自动关闭占用程序。");
7330
7465
 
7466
+ await this.outbound.sendText(contextKey, CLOSE_WRITER_STARTED_MESSAGE);
7331
7467
  let owners = await this.threadWriterProcesses.inspect(lockPath);
7332
7468
  const expectedOwner = owners.length === 1 && threadWriterMatches(
7333
7469
  owners[0],
@@ -7787,11 +7923,6 @@ function runtimeErrorMessage(error: unknown): string {
7787
7923
  return error instanceof Error ? error.message : String(error);
7788
7924
  }
7789
7925
 
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
7926
  function isUnmaterializedCodexThreadError(error: unknown): boolean {
7796
7927
  const message = error instanceof Error ? error.message : String(error);
7797
7928
  return /thread\/read failed:[^\n]*(?:not materialized|not loaded|includeTurns is unavailable before first user message)/i.test(message);