@keyou007/agent-bot 0.1.14-alpha.0 → 0.1.14-alpha.1

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 (74) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +41 -44
  3. package/README.zh.md +41 -44
  4. package/config.example.yaml +4 -0
  5. package/dist/codex/CodexRuntime.js +8 -3
  6. package/dist/codex/CodexRuntime.js.map +1 -1
  7. package/dist/commands/CommandRouter.js +13 -0
  8. package/dist/commands/CommandRouter.js.map +1 -1
  9. package/dist/config/loadConfig.js +2 -0
  10. package/dist/config/loadConfig.js.map +1 -1
  11. package/dist/config/schema.js +2 -0
  12. package/dist/config/schema.js.map +1 -1
  13. package/dist/console/ConsoleTurnPresenter.js +2 -0
  14. package/dist/console/ConsoleTurnPresenter.js.map +1 -1
  15. package/dist/feishu/CardRenderer.js +428 -9
  16. package/dist/feishu/CardRenderer.js.map +1 -1
  17. package/dist/feishu/FeishuConnector.js +29 -8
  18. package/dist/feishu/FeishuConnector.js.map +1 -1
  19. package/dist/feishu/FeishuMessageClient.js +43 -0
  20. package/dist/feishu/FeishuMessageClient.js.map +1 -1
  21. package/dist/feishu/FeishuTurnPresenter.js +29 -1
  22. package/dist/feishu/FeishuTurnPresenter.js.map +1 -1
  23. package/dist/feishu/ownerAccess.js +7 -0
  24. package/dist/feishu/ownerAccess.js.map +1 -0
  25. package/dist/index.js +14 -17
  26. package/dist/index.js.map +1 -1
  27. package/dist/presentation/OutboundRouter.js +19 -1
  28. package/dist/presentation/OutboundRouter.js.map +1 -1
  29. package/dist/proxy/ProxySessionController.js +1012 -58
  30. package/dist/proxy/ProxySessionController.js.map +1 -1
  31. package/dist/startup/StartupNotifier.js +65 -16
  32. package/dist/startup/StartupNotifier.js.map +1 -1
  33. package/dist/startup/startFeishu.js +2 -2
  34. package/dist/startup/startFeishu.js.map +1 -1
  35. package/dist/state/StateStore.js +250 -67
  36. package/dist/state/StateStore.js.map +1 -1
  37. package/dist/state/migrations.js +26 -0
  38. package/dist/state/migrations.js.map +1 -1
  39. package/dist/supervision/SafeRestartNotifier.js +17 -16
  40. package/dist/supervision/SafeRestartNotifier.js.map +1 -1
  41. package/dist/supervision/SafeRestartScheduler.js +12 -7
  42. package/dist/supervision/SafeRestartScheduler.js.map +1 -1
  43. package/dist/supervision/replacementSupervisor.js +45 -4
  44. package/dist/supervision/replacementSupervisor.js.map +1 -1
  45. package/dist/supervisor.js +30 -18
  46. package/dist/supervisor.js.map +1 -1
  47. package/docs/technical-reference.md +18 -5
  48. package/docs/technical-reference.zh.md +18 -5
  49. package/npm-shrinkwrap.json +2 -2
  50. package/package.json +1 -1
  51. package/skills/agent-bot/SKILL.md +8 -2
  52. package/src/codex/CodexRuntime.ts +9 -3
  53. package/src/commands/CommandRouter.ts +13 -0
  54. package/src/commands/commandTypes.ts +2 -0
  55. package/src/config/loadConfig.ts +2 -0
  56. package/src/config/schema.ts +2 -0
  57. package/src/console/ConsoleTurnPresenter.ts +4 -1
  58. package/src/feishu/CardRenderer.ts +533 -9
  59. package/src/feishu/FeishuConnector.ts +44 -10
  60. package/src/feishu/FeishuMessageClient.ts +58 -0
  61. package/src/feishu/FeishuTurnPresenter.ts +35 -2
  62. package/src/feishu/ownerAccess.ts +7 -0
  63. package/src/feishu/types.ts +7 -0
  64. package/src/index.ts +20 -20
  65. package/src/presentation/OutboundRouter.ts +28 -3
  66. package/src/proxy/ProxySessionController.ts +1177 -57
  67. package/src/startup/StartupNotifier.ts +67 -17
  68. package/src/startup/startFeishu.ts +5 -3
  69. package/src/state/StateStore.ts +394 -65
  70. package/src/state/migrations.ts +26 -0
  71. package/src/supervision/SafeRestartNotifier.ts +17 -17
  72. package/src/supervision/SafeRestartScheduler.ts +13 -6
  73. package/src/supervision/replacementSupervisor.ts +50 -4
  74. package/src/supervisor.ts +35 -16
@@ -1,3 +1,4 @@
1
+ import { promises as fs } from "node:fs";
1
2
  import path from "node:path";
2
3
  import os from "node:os";
3
4
  import { createProjectlessWorkspace, detectProjectlessWorkspace } from "../codex/ProjectlessWorkspace.js";
@@ -6,6 +7,7 @@ import { CommandRouter } from "../commands/CommandRouter.js";
6
7
  import { baseChatContextKey, isThreadContextKey } from "../feishu/contextKey.js";
7
8
  import { CardRenderer, } from "../feishu/CardRenderer.js";
8
9
  import { generateGroupAvatarPng, resolveGroupAvatarProjectName } from "../feishu/GroupAvatarGenerator.js";
10
+ import { allowsFeishuUser } from "../feishu/ownerAccess.js";
9
11
  import { buildTurnGraphRows } from "../presentation/turnGraph.js";
10
12
  import { createId } from "../utils/id.js";
11
13
  import { asInlineCode, codeBlock, truncateMiddle, truncateText } from "../utils/markdown.js";
@@ -16,8 +18,22 @@ const MESSAGE_COMPLETED_REACTION = "DONE";
16
18
  const MESSAGE_FAILED_REACTION = "ERROR";
17
19
  const MESSAGE_CANCELLED_REACTION = "CrossMark";
18
20
  const SESSION_PAGE_SIZE = 5;
21
+ const DIRECTORY_PAGE_SIZE = 15;
19
22
  const RESET_HISTORY_PAGE_SIZE = 10;
23
+ const MAX_LLM_TURN_RETRIES = 3;
24
+ const RECOVERY_ACTIVITY_WINDOW_MS = 5 * 60 * 1_000;
25
+ const RECOVERY_HEARTBEAT_INTERVAL_MS = 60 * 1_000;
20
26
  const REMOTE_SESSION_REFERENCE_PREFIX = "agent-runtime:";
27
+ const WINDOWS_DRIVES_DIRECTORY = "agentbot://windows-drives";
28
+ const IMAGE_FILE_EXTENSIONS = new Set([
29
+ ".avif", ".bmp", ".gif", ".heic", ".heif", ".ico", ".jpeg", ".jpg", ".png", ".svg", ".tif", ".tiff", ".webp",
30
+ ]);
31
+ const BINARY_FILE_EXTENSIONS = new Set([
32
+ ".7z", ".a", ".apk", ".avi", ".bin", ".bz2", ".class", ".dat", ".db", ".deb", ".dll", ".dmg", ".doc", ".docx",
33
+ ".dylib", ".eot", ".exe", ".flac", ".gz", ".ico", ".ipa", ".iso", ".jar", ".lib", ".mdb", ".mkv", ".mov", ".mp3",
34
+ ".mp4", ".msi", ".o", ".obj", ".ogg", ".otf", ".pdf", ".ppt", ".pptx", ".rar", ".rpm", ".so", ".sqlite", ".tar",
35
+ ".ttf", ".wav", ".webm", ".woff", ".woff2", ".xls", ".xlsx", ".xz", ".zip", ".zst",
36
+ ]);
21
37
  const HELP_COMMAND_SECTIONS = [
22
38
  {
23
39
  title: "任务管理",
@@ -32,6 +48,11 @@ const HELP_COMMAND_SECTIONS = [
32
48
  usage: "[title] [--dir <cwd> | --nodir]",
33
49
  description: "创建飞书群和新任务",
34
50
  },
51
+ {
52
+ command: "/dir",
53
+ usage: "[目录]",
54
+ description: "浏览、发送文件,并从选定目录创建任务或群",
55
+ },
35
56
  {
36
57
  command: "/forkgroup",
37
58
  usage: "[title]",
@@ -114,6 +135,11 @@ const HELP_COMMAND_SECTIONS = [
114
135
  usage: "[--force]",
115
136
  description: "默认安全重启;--force 立即重启",
116
137
  },
138
+ {
139
+ command: "/mute",
140
+ usage: "[on|off]",
141
+ description: "开启或关闭当前群的仅 @ 响应模式",
142
+ },
117
143
  { command: "/help", description: "显示本帮助" },
118
144
  ],
119
145
  },
@@ -129,6 +155,7 @@ export class ProxySessionController {
129
155
  logger;
130
156
  lifecycle;
131
157
  shellCommandExecutor;
158
+ windowsDriveLister;
132
159
  router = new CommandRouter();
133
160
  cardRenderer = new CardRenderer();
134
161
  messageQueues = new Map();
@@ -139,8 +166,14 @@ export class ProxySessionController {
139
166
  sessionResets = new Map();
140
167
  lastSessionListings = new Map();
141
168
  threadInitializations = new Map();
169
+ llmRetryingSessions = new Set();
170
+ retriedFailureTurnIds = new Set();
171
+ recoveryRetryTimers = new Map();
142
172
  unsubscribe = [];
143
- constructor(config, store, runtimes, outbound, logger, lifecycle, shellCommandExecutor = executeShellCommand) {
173
+ recoveryActivityHeartbeat;
174
+ startupRecovery;
175
+ queuedPromptsRestored = false;
176
+ constructor(config, store, runtimes, outbound, logger, lifecycle, shellCommandExecutor = executeShellCommand, windowsDriveLister = listWindowsDriveRoots) {
144
177
  this.config = config;
145
178
  this.store = store;
146
179
  this.runtimes = runtimes;
@@ -148,12 +181,7 @@ export class ProxySessionController {
148
181
  this.logger = logger;
149
182
  this.lifecycle = lifecycle;
150
183
  this.shellCommandExecutor = shellCommandExecutor;
151
- const interruptedAcpSessions = this.store.reconcileInterruptedAcpSessions(Object.entries(this.config.agents)
152
- .filter(([, agent]) => agent.kind === "acp")
153
- .map(([name]) => name));
154
- if (interruptedAcpSessions.length > 0) {
155
- this.logger.warn({ sessionIds: interruptedAcpSessions.map((session) => session.localSessionId) }, "Marked interrupted ACP sessions as failed after process restart.");
156
- }
184
+ this.windowsDriveLister = windowsDriveLister;
157
185
  for (const [, runtime] of this.runtimes.entries()) {
158
186
  this.unsubscribe.push(runtime.onEvent((event) => {
159
187
  void this.handleRuntimeEvent(event).catch((error) => {
@@ -162,14 +190,25 @@ export class ProxySessionController {
162
190
  }));
163
191
  }
164
192
  this.restorePersistedSessionRoutes();
165
- this.restorePersistedQueuedPrompts();
166
193
  void this.restorePersistedMessageReactions().catch((error) => {
167
194
  this.logger.warn({ error }, "Failed to restore persisted message reaction statuses.");
168
195
  });
196
+ this.recoveryActivityHeartbeat = setInterval(() => this.persistActiveTurnHeartbeats(), RECOVERY_HEARTBEAT_INTERVAL_MS);
197
+ this.recoveryActivityHeartbeat.unref?.();
169
198
  }
170
199
  async onMessage(message) {
171
- // The durable deduplication claim is the only operation allowed before acknowledgement.
172
- // It prevents event retries from adding duplicate reactions.
200
+ if (!message.contextKey.startsWith("console:") && !allowsFeishuUser(this.config, message.userId)) {
201
+ this.logger.debug({ messageId: message.messageId }, "Ignored a Feishu message from a non-owner before acknowledgement.");
202
+ return;
203
+ }
204
+ if (message.chatType === "group"
205
+ && this.store.chatRequiresMention(baseChatContextKey(message.contextKey))
206
+ && message.mentionedBot !== true) {
207
+ this.logger.debug({ messageId: message.messageId, contextKey: message.contextKey }, "Ignored an unmentioned message from a muted Lark group.");
208
+ return;
209
+ }
210
+ // For accepted messages, claim durable deduplication before acknowledgement so event
211
+ // retries cannot add duplicate reactions.
173
212
  if (!this.store.claimInboundEvent(message.messageId, "message"))
174
213
  return;
175
214
  try {
@@ -262,6 +301,10 @@ export class ProxySessionController {
262
301
  this.messageQueues.delete(message.contextKey);
263
302
  }
264
303
  async onCardAction(action) {
304
+ if (!allowsFeishuUser(this.config, action.userId)) {
305
+ this.logger.debug({ actionId: action.actionId }, "Ignored a Feishu card action from a non-owner.");
306
+ return;
307
+ }
265
308
  if (!this.store.claimInboundEvent(action.actionId, "card_action"))
266
309
  return;
267
310
  const contextKey = this.cardActionContextKey(action);
@@ -321,6 +364,24 @@ export class ProxySessionController {
321
364
  await this.outbound.sendText(contextKey, "该安全重启计划已失效,请查看最新状态卡片。");
322
365
  }
323
366
  }
367
+ else if (kind === "directory_open" || kind === "directory_page") {
368
+ await this.openDirectoryBrowser(contextKey, directoryActionPath(scopedAction.value.directory), {
369
+ updateMessageId: requiredCardMessageId(scopedAction.messageId),
370
+ page: kind === "directory_page" ? directoryPageValue(scopedAction.value.page) : 0,
371
+ });
372
+ }
373
+ else if (kind === "directory_send_file") {
374
+ const filePath = directoryFileActionPath(scopedAction.value.filePath);
375
+ await this.assertSendableFile(filePath);
376
+ await this.outbound.sendFile(contextKey, filePath);
377
+ }
378
+ else if (kind === "directory_new" || kind === "directory_new_group") {
379
+ const directory = directoryActionPath(scopedAction.value.directory);
380
+ await this.assertBrowsableDirectory(directory);
381
+ await this.execute(contextKey, kind === "directory_new"
382
+ ? { type: "new", cwd: directory }
383
+ : { type: "newgroup", cwd: directory }, undefined, replyTarget, undefined, scopedAction.userId);
384
+ }
324
385
  else if (kind === "session_page") {
325
386
  await this.refreshSessionsCardFromAction(scopedAction);
326
387
  }
@@ -484,6 +545,10 @@ export class ProxySessionController {
484
545
  this.queuedPromptCards.clear();
485
546
  this.queuedPromptCardWrites.clear();
486
547
  this.sessionResets.clear();
548
+ for (const timer of this.recoveryRetryTimers.values())
549
+ clearTimeout(timer);
550
+ this.recoveryRetryTimers.clear();
551
+ clearInterval(this.recoveryActivityHeartbeat);
487
552
  }
488
553
  async controlStopTask(localSessionId) {
489
554
  const record = this.store.getSession(localSessionId);
@@ -665,6 +730,9 @@ export class ProxySessionController {
665
730
  case "shell":
666
731
  await this.runShellCommand(contextKey, command.command);
667
732
  return;
733
+ case "dir":
734
+ await this.openDirectoryBrowser(contextKey, command.directory);
735
+ return;
668
736
  case "new":
669
737
  if (command.projectless && this.ensureAgent(context.defaultAgent).kind !== "app-server") {
670
738
  throw new Error("/new --nodir 仅支持 App Server Agent。");
@@ -719,6 +787,9 @@ export class ProxySessionController {
719
787
  throw new Error("当前运行方式不支持自动重启。");
720
788
  await this.lifecycle.restart(contextKey, command.force === true, replyTarget);
721
789
  return;
790
+ case "mute":
791
+ await this.setGroupMute(contextKey, command.enabled);
792
+ return;
722
793
  case "turns":
723
794
  await this.openResetHistory(contextKey);
724
795
  return;
@@ -752,6 +823,8 @@ export class ProxySessionController {
752
823
  const turnContextKey = session.lastTurnId
753
824
  ? this.store.getTurnContextKey(session.lastTurnId) ?? context.contextKey
754
825
  : context.contextKey;
826
+ if (!this.outbound.canRoute(turnContextKey))
827
+ continue;
755
828
  this.outbound.registerSession(session.localSessionId, turnContextKey, session.title, session.cwd, this.agentLabel(session.agentName));
756
829
  if (!session.lastTurnId)
757
830
  continue;
@@ -759,14 +832,314 @@ export class ProxySessionController {
759
832
  this.logger.warn({ error, sessionId: session.localSessionId }, "Failed to restore persisted turn delivery.");
760
833
  });
761
834
  }
835
+ for (const turn of this.store.listUndeliveredCompletedTurns()) {
836
+ const contextKey = turn.contextKey ?? this.store.getSession(turn.localSessionId)?.contextKey;
837
+ if (!contextKey || !this.outbound.canRoute(contextKey))
838
+ continue;
839
+ void this.outbound.resumeDelivery(turn.localSessionId, contextKey, turn.turnId).catch((error) => {
840
+ this.logger.warn({ error, sessionId: turn.localSessionId, turnId: turn.turnId }, "Failed to resume an undelivered completed turn.");
841
+ });
842
+ }
762
843
  }
763
844
  restorePersistedQueuedPrompts() {
845
+ if (this.queuedPromptsRestored)
846
+ return;
847
+ this.queuedPromptsRestored = true;
764
848
  for (const sessionId of this.store.listQueuedPromptSessionIds()) {
849
+ const firstPrompt = this.store.listQueuedPrompts(sessionId)[0];
850
+ if (firstPrompt && !this.outbound.canRoute(firstPrompt.contextKey))
851
+ continue;
765
852
  void this.scheduleNextQueuedPrompt(sessionId).catch((error) => {
766
853
  this.logger.warn({ error, sessionId }, "Failed to resume a persisted prompt queue.");
767
854
  });
768
855
  }
769
856
  }
857
+ async recoverInterruptedTasks() {
858
+ if (this.startupRecovery)
859
+ return this.startupRecovery;
860
+ const recovery = this.runStartupRecovery();
861
+ this.startupRecovery = recovery;
862
+ return recovery;
863
+ }
864
+ async runStartupRecovery() {
865
+ this.backfillTurnAttemptsForUpgrade();
866
+ const attempts = this.store.listIncompleteTurnAttempts();
867
+ const recoveryCutoff = Date.now() - RECOVERY_ACTIVITY_WINDOW_MS;
868
+ const recentAttempts = attempts.filter((attempt) => isRecoveryAttemptRecent(attempt, recoveryCutoff));
869
+ if (recentAttempts.length > 0) {
870
+ this.logger.warn({ attemptIds: recentAttempts.map((attempt) => attempt.attemptId) }, "Recovering unfinished Agent Bot tasks after startup.");
871
+ }
872
+ for (const attempt of attempts) {
873
+ if (!isRecoveryAttemptRecent(attempt, recoveryCutoff)) {
874
+ await this.expireStaleTurnAttempt(attempt);
875
+ continue;
876
+ }
877
+ if (!this.outbound.canRoute(attempt.contextKey))
878
+ continue;
879
+ try {
880
+ await this.recoverTurnAttempt(attempt);
881
+ }
882
+ catch (error) {
883
+ this.logger.error({ error, attemptId: attempt.attemptId, sessionId: attempt.localSessionId }, "Failed to recover an unfinished task; it will be retried while the server is running.");
884
+ this.scheduleRecoveryRetry(attempt.attemptId);
885
+ }
886
+ }
887
+ this.restorePersistedQueuedPrompts();
888
+ }
889
+ backfillTurnAttemptsForUpgrade() {
890
+ for (const session of this.store.listAllSessions()) {
891
+ if (session.status !== "starting" && session.status !== "running" && session.status !== "ready")
892
+ continue;
893
+ if (this.store.findIncompleteTurnAttemptForSession(session.localSessionId))
894
+ continue;
895
+ const lastTurn = session.lastTurnId
896
+ ? {
897
+ turnId: session.lastTurnId,
898
+ localSessionId: session.localSessionId,
899
+ contextKey: this.store.getTurnContextKey(session.lastTurnId),
900
+ snapshot: this.store.getTurnSnapshot(session.lastTurnId),
901
+ updatedAt: session.updatedAt,
902
+ }
903
+ : undefined;
904
+ const lastTurnSnapshot = turnViewSnapshot(lastTurn?.snapshot);
905
+ const latestPersisted = this.store.findLatestTurnSnapshotForSession(session.localSessionId);
906
+ const latestPersistedSnapshot = turnViewSnapshot(latestPersisted?.snapshot);
907
+ const latest = lastTurnSnapshot && !isTerminalTurnViewStatus(lastTurnSnapshot.status)
908
+ ? lastTurn
909
+ : latestPersistedSnapshot && !isTerminalTurnViewStatus(latestPersistedSnapshot.status)
910
+ ? latestPersisted
911
+ : session.status === "ready"
912
+ ? undefined
913
+ : lastTurn;
914
+ if (!latest && session.status === "ready")
915
+ continue;
916
+ const snapshot = turnViewSnapshot(latest?.snapshot);
917
+ if (snapshot && isTerminalTurnViewStatus(snapshot.status)) {
918
+ this.store.updateSession(session.localSessionId, {
919
+ status: snapshot.status === "failed" ? "failed" : "ready",
920
+ });
921
+ continue;
922
+ }
923
+ const persistedTurnId = latest?.turnId;
924
+ const pendingTurnId = persistedTurnId?.startsWith("pending_") ? persistedTurnId : undefined;
925
+ const turnId = pendingTurnId ? undefined : persistedTurnId ?? session.lastTurnId;
926
+ const replyTarget = snapshot?.replyTarget?.replyInThread ? snapshot.replyTarget : undefined;
927
+ this.store.createTurnAttempt({
928
+ attemptId: createId("attempt"),
929
+ localSessionId: session.localSessionId,
930
+ contextKey: latest?.contextKey ?? session.contextKey,
931
+ promptText: snapshot?.prompt?.trim() || "继续完成重启前尚未完成的任务。",
932
+ messageId: turnId ? this.store.findMessageIdForTurn(turnId) : undefined,
933
+ replyMessageId: replyTarget?.messageId,
934
+ pendingTurnId,
935
+ turnId,
936
+ status: turnId ? "running" : "accepted",
937
+ createdAt: latest?.updatedAt ?? session.updatedAt,
938
+ updatedAt: latest?.updatedAt ?? session.updatedAt,
939
+ });
940
+ }
941
+ }
942
+ async expireStaleTurnAttempt(attempt) {
943
+ const session = this.store.getSession(attempt.localSessionId);
944
+ const oldCardTurnId = attempt.pendingTurnId ?? attempt.turnId;
945
+ if (session && oldCardTurnId && this.outbound.canRoute(attempt.contextKey)) {
946
+ await this.outbound.interruptTurnForRecovery(session.localSessionId, attempt.contextKey, oldCardTurnId, "执行中断已超过 5 分钟,未自动恢复。").catch((error) => {
947
+ this.logger.warn({ error, attemptId: attempt.attemptId, turnId: oldCardTurnId }, "Failed to close an expired interrupted thinking card.");
948
+ });
949
+ }
950
+ this.store.updateTurnAttempt(attempt.attemptId, { status: "interrupted" });
951
+ if (!session || session.status === "closed")
952
+ return;
953
+ if (attempt.turnId && session.lastTurnId === attempt.turnId) {
954
+ this.store.updateRuntimeSession(session.localSessionId, { lastTurnStatus: "cancelled" });
955
+ }
956
+ if (session.status === "starting" || session.status === "running") {
957
+ this.store.updateSession(session.localSessionId, { status: "ready" });
958
+ }
959
+ if (attempt.turnId && this.outbound.canRoute(attempt.contextKey)) {
960
+ await this.finalizeTurnMessageReactions(attempt.turnId, "cancelled").catch((error) => {
961
+ this.logger.warn({ error, attemptId: attempt.attemptId, turnId: attempt.turnId }, "Failed to finalize the message reaction for an expired interrupted turn.");
962
+ });
963
+ }
964
+ }
965
+ persistActiveTurnHeartbeats() {
966
+ for (const attempt of this.store.listIncompleteTurnAttempts()) {
967
+ if (!attempt.turnId)
968
+ continue;
969
+ const session = this.store.getSession(attempt.localSessionId);
970
+ if (!session)
971
+ continue;
972
+ const activeTurnId = this.runtimes.forAgent(session.agentName).getSession(session.localSessionId)?.activeTurnId;
973
+ if (activeTurnId === attempt.turnId)
974
+ this.store.touchTurnAttempt(attempt.turnId);
975
+ }
976
+ }
977
+ async recoverTurnAttempt(attempt, announce = true) {
978
+ const session = this.store.getSession(attempt.localSessionId);
979
+ if (!session || session.status === "closed") {
980
+ this.store.updateTurnAttempt(attempt.attemptId, { status: "interrupted" });
981
+ return;
982
+ }
983
+ const contextKey = attempt.contextKey || session.contextKey;
984
+ const replyTarget = attempt.replyMessageId
985
+ ? { messageId: attempt.replyMessageId, replyInThread: true }
986
+ : undefined;
987
+ this.outbound.registerSession(session.localSessionId, contextKey, session.title, session.cwd, this.agentLabel(session.agentName));
988
+ if (announce) {
989
+ await this.outbound.withReplyTarget(contextKey, replyTarget, () => this.outbound.sendText(contextKey, "检测到任务在 Agent Bot 重启前尚未完成,正在自动恢复。")).catch((error) => {
990
+ this.logger.warn({ error, attemptId: attempt.attemptId, contextKey }, "Failed to send the task recovery notification.");
991
+ return undefined;
992
+ });
993
+ }
994
+ const runtime = this.runtimes.forAgent(session.agentName);
995
+ const sourceTurnId = attempt.turnId;
996
+ if (runtime.kind === "acp") {
997
+ await this.continueInterruptedAttempt(attempt, session, sourceTurnId, replyTarget);
998
+ return;
999
+ }
1000
+ let remote;
1001
+ if (session.remoteSessionId && runtime.readRemoteSession) {
1002
+ try {
1003
+ remote = await runtime.readRemoteSession(session.remoteSessionId);
1004
+ }
1005
+ catch (error) {
1006
+ this.logger.warn({ error, attemptId: attempt.attemptId, remoteSessionId: session.remoteSessionId }, "Failed to read the remote task during startup recovery; treating the old local execution as interrupted.");
1007
+ }
1008
+ }
1009
+ const remoteTurnBelongsToAttempt = remoteTurnMatchesAttempt(remote, attempt);
1010
+ if (remote && remoteTurnBelongsToAttempt && remote.lastTurnStatus === "completed") {
1011
+ await this.reconcileRecoveredRemoteTurn(attempt, session, remote.lastTurnId);
1012
+ return;
1013
+ }
1014
+ if (remote && remoteTurnBelongsToAttempt && remote.lastTurnStatus === "failed") {
1015
+ await this.reconcileRecoveredRemoteTurn(attempt, session, remote.lastTurnId);
1016
+ return;
1017
+ }
1018
+ if (remote && remoteTurnBelongsToAttempt && isRemoteSessionActive(remote) && remote.lastTurnId) {
1019
+ await this.reattachActiveRecoveredTurn(attempt, session, remote.lastTurnId, replyTarget);
1020
+ return;
1021
+ }
1022
+ if (remote && isRemoteSessionActive(remote) && !remoteTurnBelongsToAttempt) {
1023
+ this.store.updateTurnAttempt(attempt.attemptId, { status: "recovering" });
1024
+ if (announce) {
1025
+ await this.outbound.withReplyTarget(contextKey, replyTarget, () => this.outbound.sendText(contextKey, "任务中检测到另一轮仍在执行;本次恢复将在它结束后重试。"));
1026
+ }
1027
+ this.scheduleRecoveryRetry(attempt.attemptId);
1028
+ return;
1029
+ }
1030
+ await this.continueInterruptedAttempt(attempt, session, sourceTurnId, replyTarget);
1031
+ }
1032
+ async reconcileRecoveredRemoteTurn(attempt, session, remoteTurnId) {
1033
+ if (remoteTurnId && !attempt.turnId) {
1034
+ this.store.updateTurnAttempt(attempt.attemptId, { turnId: remoteTurnId, status: "running" });
1035
+ this.store.updateRuntimeSession(session.localSessionId, {
1036
+ lastTurnId: remoteTurnId,
1037
+ lastTurnStatus: "running",
1038
+ });
1039
+ }
1040
+ const current = this.store.getSession(session.localSessionId) ?? session;
1041
+ const loaded = await this.loadSession({
1042
+ ...current,
1043
+ contextKey: attempt.contextKey,
1044
+ status: "running",
1045
+ lastTurnStatus: "running",
1046
+ });
1047
+ const synchronized = await loaded.runtime.synchronizeSession(session.localSessionId);
1048
+ if (synchronized.activeTurnId)
1049
+ this.scheduleRecoveryRetry(attempt.attemptId, synchronized.activeTurnId);
1050
+ }
1051
+ scheduleRecoveryRetry(attemptId, activeTurnId) {
1052
+ if (this.recoveryRetryTimers.has(attemptId))
1053
+ return;
1054
+ const timer = setTimeout(() => {
1055
+ this.recoveryRetryTimers.delete(attemptId);
1056
+ const attempt = this.store.getTurnAttempt(attemptId);
1057
+ if (!attempt || !isIncompleteTurnAttemptStatus(attempt.status))
1058
+ return;
1059
+ const retry = async () => {
1060
+ let recoveryAttempt = attempt;
1061
+ if (activeTurnId) {
1062
+ const session = this.store.getSession(attempt.localSessionId);
1063
+ if (session) {
1064
+ const runtime = this.runtimes.forAgent(session.agentName);
1065
+ const synchronized = await runtime.synchronizeSession(session.localSessionId);
1066
+ if (synchronized.activeTurnId === activeTurnId) {
1067
+ this.scheduleRecoveryRetry(attemptId, activeTurnId);
1068
+ return;
1069
+ }
1070
+ const current = this.store.getTurnAttempt(attemptId);
1071
+ if (!current || !isIncompleteTurnAttemptStatus(current.status))
1072
+ return;
1073
+ recoveryAttempt = current;
1074
+ }
1075
+ }
1076
+ await this.recoverTurnAttempt(recoveryAttempt, false);
1077
+ };
1078
+ void retry().catch((error) => {
1079
+ this.logger.warn({ error, attemptId, sessionId: attempt.localSessionId }, "Failed to retry an unfinished task recovery.");
1080
+ this.scheduleRecoveryRetry(attemptId, activeTurnId);
1081
+ });
1082
+ }, 5_000);
1083
+ timer.unref?.();
1084
+ this.recoveryRetryTimers.set(attemptId, timer);
1085
+ }
1086
+ async reattachActiveRecoveredTurn(attempt, session, turnId, replyTarget) {
1087
+ const oldCardTurnId = attempt.pendingTurnId ?? attempt.turnId;
1088
+ if (oldCardTurnId) {
1089
+ await this.outbound.interruptTurnForRecovery(session.localSessionId, attempt.contextKey, oldCardTurnId, "Agent Bot 已重启,执行仍在继续;进度已转移到新的思考卡片。");
1090
+ }
1091
+ const pendingTurnId = await this.outbound.startPendingTurn(session.localSessionId, attempt.contextKey, session.title, replyTarget, recoveryCardPrompt(attempt.promptText));
1092
+ this.store.updateTurnAttempt(attempt.attemptId, {
1093
+ pendingTurnId: pendingTurnId ?? null,
1094
+ turnId,
1095
+ status: "running",
1096
+ });
1097
+ await this.outbound.onEvent({
1098
+ type: "turn_started",
1099
+ sessionId: session.localSessionId,
1100
+ turnId,
1101
+ startedAt: Date.now(),
1102
+ });
1103
+ this.store.updateTurnAttempt(attempt.attemptId, { pendingTurnId: null });
1104
+ const current = this.store.getSession(session.localSessionId) ?? session;
1105
+ const loaded = await this.loadSession({
1106
+ ...current,
1107
+ contextKey: attempt.contextKey,
1108
+ status: "running",
1109
+ lastTurnId: turnId,
1110
+ lastTurnStatus: "running",
1111
+ });
1112
+ const synchronized = await loaded.runtime.synchronizeSession(session.localSessionId);
1113
+ if (synchronized.activeTurnId)
1114
+ this.scheduleRecoveryRetry(attempt.attemptId, synchronized.activeTurnId);
1115
+ }
1116
+ async continueInterruptedAttempt(attempt, session, sourceTurnId, replyTarget) {
1117
+ const oldCardTurnId = attempt.pendingTurnId ?? sourceTurnId;
1118
+ if (oldCardTurnId) {
1119
+ await this.outbound.interruptTurnForRecovery(session.localSessionId, attempt.contextKey, oldCardTurnId, "Agent Bot 重启中断了这次执行,正在新的思考卡片中继续。").catch((error) => {
1120
+ this.logger.warn({ error, attemptId: attempt.attemptId, turnId: oldCardTurnId }, "Failed to close the interrupted thinking card before recovery.");
1121
+ });
1122
+ }
1123
+ this.store.prepareTurnAttemptRecovery(attempt.attemptId, oldCardTurnId);
1124
+ this.store.updateSession(session.localSessionId, { status: "ready" });
1125
+ if (sourceTurnId) {
1126
+ this.store.updateRuntimeSession(session.localSessionId, {
1127
+ lastTurnId: sourceTurnId,
1128
+ lastTurnStatus: "cancelled",
1129
+ });
1130
+ }
1131
+ const current = this.store.getSession(session.localSessionId) ?? session;
1132
+ const loaded = await this.loadSession({ ...current, contextKey: attempt.contextKey });
1133
+ const recoveryText = recoveryRuntimePrompt(attempt.promptText);
1134
+ const turnId = await this.startTurn(loaded, recoveryText, replyTarget, attempt.localImagePaths, {
1135
+ attemptId: attempt.attemptId,
1136
+ messageId: attempt.messageId,
1137
+ displayPrompt: recoveryCardPrompt(attempt.promptText),
1138
+ preserveAttemptOnFailure: true,
1139
+ });
1140
+ if (attempt.messageId)
1141
+ await this.bindMessageReactionToTurn(attempt.messageId, session.localSessionId, turnId);
1142
+ }
770
1143
  async restorePersistedMessageReactions() {
771
1144
  const pending = this.store.listPendingMessageReactions();
772
1145
  const terminalTurns = new Map();
@@ -795,6 +1168,19 @@ export class ProxySessionController {
795
1168
  await this.promptSession(record, contextKey, text, messageId, replyTarget, localImagePaths);
796
1169
  }
797
1170
  async promptSession(record, contextKey, text, messageId, replyTarget, localImagePaths) {
1171
+ if (this.llmRetryingSessions.has(record.localSessionId)) {
1172
+ const queued = this.persistQueuedPrompt(record.localSessionId, contextKey, text, {
1173
+ localImagePaths,
1174
+ messageId,
1175
+ replyTarget,
1176
+ });
1177
+ this.store.audit(contextKey, "queued_prompt_added_during_llm_retry", {
1178
+ promptId: queued.promptId,
1179
+ localSessionId: record.localSessionId,
1180
+ });
1181
+ await this.presentPromptQueueCard(record.localSessionId, contextKey);
1182
+ return;
1183
+ }
798
1184
  const configuredRuntime = this.runtimes.forAgent(record.agentName);
799
1185
  if (!configuredRuntime.getSession(record.localSessionId)) {
800
1186
  this.outbound.registerSession(record.localSessionId, contextKey, record.title, record.cwd, this.agentLabel(record.agentName));
@@ -852,7 +1238,7 @@ export class ProxySessionController {
852
1238
  this.logger.warn({ error: syncError, sessionId: record.localSessionId }, "Failed to synchronize after steering failure.");
853
1239
  }
854
1240
  if (!current?.activeTurnId) {
855
- const turnId = await this.startTurn(loaded, text, replyTarget, localImagePaths);
1241
+ const turnId = await this.startTurn(loaded, text, replyTarget, localImagePaths, { messageId });
856
1242
  if (messageId)
857
1243
  await this.bindMessageReactionToTurn(messageId, record.localSessionId, turnId);
858
1244
  return;
@@ -877,11 +1263,12 @@ export class ProxySessionController {
877
1263
  return;
878
1264
  }
879
1265
  }
880
- const turnId = await this.startTurn(loaded, text, replyTarget, localImagePaths);
1266
+ const turnId = await this.startTurn(loaded, text, replyTarget, localImagePaths, { messageId });
881
1267
  if (messageId)
882
1268
  await this.bindMessageReactionToTurn(messageId, record.localSessionId, turnId);
883
1269
  }
884
1270
  async presentSteerMessage(localSessionId, turnId, text, messageId) {
1271
+ this.store.touchTurnAttempt(turnId);
885
1272
  try {
886
1273
  await this.outbound.appendSteerMessage(localSessionId, turnId, text, messageId);
887
1274
  }
@@ -992,6 +1379,10 @@ export class ProxySessionController {
992
1379
  const current = this.store.getUserContext(message.contextKey)?.currentSessionId;
993
1380
  if (current && this.store.getSession(current))
994
1381
  return;
1382
+ const hasPersistedSourceTurn = threadForkAnchorMessageIds(message)
1383
+ .some((messageId) => this.store.findTurnAnchorByMessageId(messageId) !== undefined);
1384
+ if (!hasPersistedSourceTurn)
1385
+ return;
995
1386
  const existing = this.threadInitializations.get(message.contextKey);
996
1387
  if (existing)
997
1388
  return existing;
@@ -1074,9 +1465,7 @@ export class ProxySessionController {
1074
1465
  if (!message.threadContext || !message.threadId || !message.chatId) {
1075
1466
  throw new Error("当前消息不属于可识别的飞书话题。");
1076
1467
  }
1077
- const anchorMessageIds = [message.rootMessageId, message.parentMessageId]
1078
- .filter((messageId) => Boolean(messageId && messageId !== message.messageId));
1079
- const anchor = [...new Set(anchorMessageIds)]
1468
+ const anchor = threadForkAnchorMessageIds(message)
1080
1469
  .map((messageId) => this.store.findTurnAnchorByMessageId(messageId))
1081
1470
  .find((candidate) => candidate !== undefined);
1082
1471
  if (!anchor) {
@@ -1381,22 +1770,51 @@ export class ProxySessionController {
1381
1770
  executionSettings,
1382
1771
  };
1383
1772
  }
1384
- async startTurn(loaded, text, replyTarget, localImagePaths) {
1773
+ async startTurn(loaded, text, replyTarget, localImagePaths, options = {}) {
1385
1774
  const currentRecord = this.store.getSession(loaded.record.localSessionId);
1386
1775
  const title = currentRecord?.title ?? normalizeTaskTitle(text);
1387
1776
  if (!currentRecord?.title && title)
1388
1777
  this.store.updateRuntimeSession(loaded.record.localSessionId, { title });
1389
1778
  if (title)
1390
1779
  this.outbound.updateSessionTitle(loaded.record.localSessionId, title);
1391
- await this.outbound.startPendingTurn(loaded.record.localSessionId, loaded.record.contextKey, title, replyTarget, text);
1780
+ const attemptId = options.attemptId ?? createId("attempt");
1781
+ if (options.attemptId) {
1782
+ this.store.updateTurnAttempt(attemptId, {
1783
+ messageId: options.messageId ?? undefined,
1784
+ replyMessageId: replyTarget?.messageId ?? undefined,
1785
+ status: "recovering",
1786
+ });
1787
+ }
1788
+ else {
1789
+ this.store.createTurnAttempt({
1790
+ attemptId,
1791
+ localSessionId: loaded.record.localSessionId,
1792
+ contextKey: loaded.record.contextKey,
1793
+ promptText: text,
1794
+ localImagePaths,
1795
+ messageId: options.messageId,
1796
+ replyMessageId: replyTarget?.messageId,
1797
+ });
1798
+ }
1392
1799
  let turnId;
1393
1800
  try {
1801
+ const pendingTurnId = await this.outbound.startPendingTurn(loaded.record.localSessionId, loaded.record.contextKey, title, replyTarget, options.displayPrompt ?? text);
1802
+ this.store.updateTurnAttempt(attemptId, { pendingTurnId: pendingTurnId ?? null });
1394
1803
  turnId = await loaded.runtime.startTurn(loaded.record.localSessionId, runtimePrompt(text, localImagePaths));
1395
1804
  }
1396
1805
  catch (error) {
1806
+ this.store.updateTurnAttempt(attemptId, {
1807
+ status: options.preserveAttemptOnFailure ? "recovering" : "failed",
1808
+ });
1397
1809
  await this.outbound.failPendingTurn(loaded.record.localSessionId, error instanceof Error ? error.message : String(error));
1398
1810
  throw error;
1399
1811
  }
1812
+ const attempt = this.store.getTurnAttempt(attemptId);
1813
+ this.store.updateTurnAttempt(attemptId, {
1814
+ pendingTurnId: null,
1815
+ turnId,
1816
+ ...(attempt && isIncompleteTurnAttemptStatus(attempt.status) ? { status: "running" } : {}),
1817
+ });
1400
1818
  const latest = this.store.getSession(loaded.record.localSessionId);
1401
1819
  const alreadyTerminal = latest?.lastTurnId === turnId
1402
1820
  && ["completed", "cancelled", "failed"].includes(latest.lastTurnStatus ?? "");
@@ -1665,14 +2083,34 @@ export class ProxySessionController {
1665
2083
  }
1666
2084
  async handleRuntimeEvent(event) {
1667
2085
  if ("turnId" in event) {
2086
+ this.store.touchTurnAttempt(event.turnId);
1668
2087
  const source = this.store.getSession(event.sessionId);
1669
2088
  if (source?.remoteSessionId && this.isCodexSession(source)) {
1670
2089
  this.store.saveTurnRuntimeOrigin(event.turnId, source.localSessionId, source.agentName, source.remoteSessionId);
1671
2090
  }
1672
2091
  if (event.type === "turn_started") {
2092
+ const attempt = this.store.bindTurnAttempt(event.sessionId, event.turnId);
2093
+ if (attempt?.recoveredFromTurnId && attempt.recoveredFromTurnId !== event.turnId) {
2094
+ this.store.rebindPendingTurnMessages(event.sessionId, attempt.recoveredFromTurnId, event.turnId);
2095
+ }
1673
2096
  this.store.saveTurnParent(event.turnId, event.sessionId, this.completedParentTurnId(source));
1674
2097
  }
1675
2098
  }
2099
+ const failedAttempt = event.type === "turn_failed"
2100
+ ? this.store.findIncompleteTurnAttemptByTurnId(event.turnId)
2101
+ : undefined;
2102
+ if (event.type === "turn_failed") {
2103
+ if (this.retriedFailureTurnIds.has(event.turnId))
2104
+ return;
2105
+ if (failedAttempt && isRetryableLlmTurnFailure(event.message) && failedAttempt.retryCount < MAX_LLM_TURN_RETRIES) {
2106
+ const retryAttempt = this.store.prepareTurnAttemptRetry(failedAttempt.attemptId, event.turnId);
2107
+ if (retryAttempt) {
2108
+ this.retriedFailureTurnIds.add(event.turnId);
2109
+ await this.retryFailedLlmTurn(event, retryAttempt);
2110
+ return;
2111
+ }
2112
+ }
2113
+ }
1676
2114
  if (event.type === "session_metadata_updated") {
1677
2115
  this.store.updateRuntimeSession(event.sessionId, { title: event.title });
1678
2116
  this.outbound.updateSessionTitle(event.sessionId, event.title);
@@ -1683,14 +2121,23 @@ export class ProxySessionController {
1683
2121
  this.store.updateRuntimeSession(event.sessionId, { lastTurnId: event.turnId, lastTurnStatus: "running" });
1684
2122
  }
1685
2123
  else if (event.type === "turn_completed" || event.type === "turn_cancelled" || event.type === "turn_failed") {
2124
+ this.store.markTurnAttemptTerminal(event.turnId, event.type === "turn_completed" ? "completed" : event.type === "turn_cancelled" ? "cancelled" : "failed");
1686
2125
  this.store.updateRuntimeSession(event.sessionId, {
1687
2126
  lastTurnId: event.turnId,
1688
2127
  lastTurnStatus: event.type === "turn_completed" ? "completed" : event.type === "turn_cancelled" ? "cancelled" : "failed",
1689
2128
  });
1690
2129
  }
2130
+ const presentationEvent = event.type === "turn_failed"
2131
+ && isRetryableLlmTurnFailure(event.message)
2132
+ && (failedAttempt?.retryCount ?? 0) >= MAX_LLM_TURN_RETRIES
2133
+ ? {
2134
+ ...event,
2135
+ message: `${event.message}\n\n已自动重试 ${MAX_LLM_TURN_RETRIES} 次,仍未成功。`,
2136
+ }
2137
+ : event;
1691
2138
  let presentationError;
1692
2139
  try {
1693
- await this.outbound.onEvent(event);
2140
+ await this.outbound.onEvent(presentationEvent);
1694
2141
  }
1695
2142
  catch (error) {
1696
2143
  presentationError = error;
@@ -1719,6 +2166,61 @@ export class ProxySessionController {
1719
2166
  if (presentationError)
1720
2167
  throw presentationError;
1721
2168
  }
2169
+ async retryFailedLlmTurn(event, attempt) {
2170
+ const retryNumber = attempt.retryCount;
2171
+ const session = this.store.getSession(event.sessionId);
2172
+ if (!session || session.status === "closed") {
2173
+ this.store.updateTurnAttempt(attempt.attemptId, { status: "failed" });
2174
+ return;
2175
+ }
2176
+ this.llmRetryingSessions.add(session.localSessionId);
2177
+ this.store.updateRuntimeSession(session.localSessionId, {
2178
+ lastTurnId: event.turnId,
2179
+ lastTurnStatus: "failed",
2180
+ });
2181
+ this.store.audit(attempt.contextKey, "llm_turn_retry_started", {
2182
+ attemptId: attempt.attemptId,
2183
+ failedTurnId: event.turnId,
2184
+ retryNumber,
2185
+ maxRetries: MAX_LLM_TURN_RETRIES,
2186
+ error: event.message,
2187
+ });
2188
+ try {
2189
+ await this.outbound.onEvent({
2190
+ ...event,
2191
+ message: `${event.message}\n\n检测到临时模型服务错误,正在自动重试(${retryNumber}/${MAX_LLM_TURN_RETRIES})。`,
2192
+ });
2193
+ }
2194
+ catch (error) {
2195
+ this.logger.warn({ error, sessionId: session.localSessionId, turnId: event.turnId }, "Failed to finalize the failed thinking card before an LLM retry.");
2196
+ }
2197
+ const replyTarget = attempt.replyMessageId
2198
+ ? { messageId: attempt.replyMessageId, replyInThread: true }
2199
+ : undefined;
2200
+ try {
2201
+ const current = this.store.getSession(session.localSessionId) ?? session;
2202
+ const loaded = await this.loadSession(current);
2203
+ const retryTurnId = await this.startTurn(loaded, llmRetryRuntimePrompt(attempt.promptText, retryNumber), replyTarget, attempt.localImagePaths, {
2204
+ attemptId: attempt.attemptId,
2205
+ messageId: attempt.messageId,
2206
+ displayPrompt: llmRetryCardPrompt(attempt.promptText, retryNumber),
2207
+ preserveAttemptOnFailure: true,
2208
+ });
2209
+ this.store.rebindPendingTurnMessages(session.localSessionId, event.turnId, retryTurnId);
2210
+ }
2211
+ catch (error) {
2212
+ this.store.updateTurnAttempt(attempt.attemptId, { status: "failed" });
2213
+ this.store.updateSession(session.localSessionId, { status: "failed" });
2214
+ await this.finalizeTurnMessageReactions(event.turnId, "failed").catch((reactionError) => {
2215
+ this.logger.warn({ error: reactionError, sessionId: session.localSessionId, turnId: event.turnId }, "Failed to finalize reactions after an LLM retry could not start.");
2216
+ });
2217
+ this.logger.error({ error, sessionId: session.localSessionId, retryNumber }, "Failed to start an automatic LLM turn retry.");
2218
+ await this.scheduleNextQueuedPrompt(session.localSessionId);
2219
+ }
2220
+ finally {
2221
+ this.llmRetryingSessions.delete(session.localSessionId);
2222
+ }
2223
+ }
1722
2224
  scheduleNextQueuedPrompt(sessionId) {
1723
2225
  const previous = this.queuedPromptStarts.get(sessionId) ?? Promise.resolve();
1724
2226
  const next = previous.catch(() => undefined).then(() => this.startNextQueuedPromptIfIdle(sessionId));
@@ -1749,7 +2251,7 @@ export class ProxySessionController {
1749
2251
  const record = this.store.getSessionForContext(sessionId, prompt.contextKey) ?? baseRecord;
1750
2252
  loaded = { ...loaded, record };
1751
2253
  await this.refreshPromptQueueCards(sessionId);
1752
- const turnId = await this.startTurn(loaded, prompt.text, prompt.replyMessageId ? { messageId: prompt.replyMessageId, replyInThread: true } : undefined, prompt.localImagePaths);
2254
+ const turnId = await this.startTurn(loaded, prompt.text, prompt.replyMessageId ? { messageId: prompt.replyMessageId, replyInThread: true } : undefined, prompt.localImagePaths, { messageId: prompt.messageId });
1753
2255
  if (prompt.messageId)
1754
2256
  await this.bindMessageReactionToTurn(prompt.messageId, sessionId, turnId);
1755
2257
  }
@@ -1770,7 +2272,9 @@ export class ProxySessionController {
1770
2272
  if (source.lastTurnStatus === "completed")
1771
2273
  return source.lastTurnId;
1772
2274
  const snapshot = turnViewSnapshot(this.store.getTurnSnapshot(source.lastTurnId));
1773
- return snapshot?.status === "completed" ? source.lastTurnId : undefined;
2275
+ return snapshot?.status === "completed"
2276
+ ? source.lastTurnId
2277
+ : this.store.findLatestCompletedTurnId(source.localSessionId, source.contextKey);
1774
2278
  }
1775
2279
  async finalizeStandaloneMessageReaction(messageId, status) {
1776
2280
  const reaction = this.store.claimMessageReaction(messageId);
@@ -2497,6 +3001,165 @@ export class ProxySessionController {
2497
3001
  await this.outbound.sendInteractiveCard(contextKey, card);
2498
3002
  }
2499
3003
  }
3004
+ async openDirectoryBrowser(contextKey, requestedDirectory, options = {}) {
3005
+ if (requestedDirectory === WINDOWS_DRIVES_DIRECTORY) {
3006
+ await this.openWindowsDriveBrowser(contextKey, options);
3007
+ return;
3008
+ }
3009
+ const baseDirectory = this.currentSession(contextKey)?.cwd
3010
+ ?? this.store.getUserContext(contextKey)?.boundProjectCwd
3011
+ ?? this.config.defaults.cwd;
3012
+ const directory = requestedDirectory === undefined
3013
+ ? path.resolve(baseDirectory)
3014
+ : resolveUserPath(requestedDirectory, baseDirectory);
3015
+ await this.assertBrowsableDirectory(directory);
3016
+ let directoryEntries;
3017
+ try {
3018
+ directoryEntries = await fs.readdir(directory, { withFileTypes: true });
3019
+ }
3020
+ catch (error) {
3021
+ throw new Error(`无法读取目录 ${directory}:${runtimeErrorMessage(error)}`);
3022
+ }
3023
+ const entries = directoryEntries
3024
+ .map((entry) => ({
3025
+ name: entry.name,
3026
+ path: path.join(directory, entry.name),
3027
+ kind: directoryBrowserEntryKind(entry),
3028
+ }))
3029
+ .sort((left, right) => Number(right.kind === "directory") - Number(left.kind === "directory")
3030
+ || left.name.localeCompare(right.name, undefined, { numeric: true, sensitivity: "base" }));
3031
+ const parentDirectory = isWindowsDriveRoot(directory)
3032
+ ? WINDOWS_DRIVES_DIRECTORY
3033
+ : path.dirname(directory);
3034
+ const parentEntries = parentDirectory === directory ? [] : [{
3035
+ name: "..",
3036
+ kind: "directory",
3037
+ openAction: {
3038
+ text: "..",
3039
+ value: {
3040
+ action: "directory_open",
3041
+ directory: parentDirectory,
3042
+ contextKey,
3043
+ },
3044
+ },
3045
+ }];
3046
+ const pageSize = DIRECTORY_PAGE_SIZE;
3047
+ const requestedPage = Math.max(0, Math.trunc(options.page ?? 0));
3048
+ const totalPages = Math.max(1, Math.ceil(entries.length / pageSize));
3049
+ const page = Math.min(requestedPage, totalPages - 1);
3050
+ const offset = page * pageSize;
3051
+ const visibleEntries = [
3052
+ ...parentEntries,
3053
+ ...entries
3054
+ .slice(offset, offset + pageSize)
3055
+ .map((entry) => entry.kind === "directory"
3056
+ ? {
3057
+ name: entry.name,
3058
+ kind: entry.kind,
3059
+ openAction: {
3060
+ text: entry.name,
3061
+ value: {
3062
+ action: "directory_open",
3063
+ directory: entry.path,
3064
+ contextKey,
3065
+ },
3066
+ },
3067
+ actions: directoryCreationActions(entry.path, contextKey),
3068
+ }
3069
+ : {
3070
+ name: entry.name,
3071
+ kind: entry.kind,
3072
+ openAction: {
3073
+ text: entry.name,
3074
+ value: {
3075
+ action: "directory_send_file",
3076
+ filePath: entry.path,
3077
+ contextKey,
3078
+ },
3079
+ },
3080
+ }),
3081
+ ];
3082
+ const navigationActions = directoryPaginationActions(directory, page, totalPages, contextKey);
3083
+ const directoryCount = entries.filter((entry) => entry.kind === "directory").length;
3084
+ const card = this.cardRenderer.renderDirectoryBrowserCard({
3085
+ directory: abbreviateHomeDirectory(directory),
3086
+ entries: visibleEntries,
3087
+ currentActions: directoryCreationActions(directory, contextKey),
3088
+ navigationActions,
3089
+ footerLines: [
3090
+ `第 ${page + 1}/${totalPages} 页 · ${directoryCount} 个目录 · ${entries.length - directoryCount} 个文件`,
3091
+ "",
3092
+ "> 点击目录名称进入,点击文件名称发送到当前会话;**New** 在该目录创建任务,**NewGroup** 在该目录创建新群和任务。",
3093
+ ],
3094
+ });
3095
+ if (options.updateMessageId) {
3096
+ await this.outbound.updateInteractiveCard(contextKey, options.updateMessageId, card);
3097
+ }
3098
+ else {
3099
+ await this.outbound.sendInteractiveCard(contextKey, card);
3100
+ }
3101
+ }
3102
+ async openWindowsDriveBrowser(contextKey, options) {
3103
+ const drives = await this.windowsDriveLister();
3104
+ const requestedPage = Math.max(0, Math.trunc(options.page ?? 0));
3105
+ const totalPages = Math.max(1, Math.ceil(drives.length / DIRECTORY_PAGE_SIZE));
3106
+ const page = Math.min(requestedPage, totalPages - 1);
3107
+ const offset = page * DIRECTORY_PAGE_SIZE;
3108
+ const entries = drives
3109
+ .slice(offset, offset + DIRECTORY_PAGE_SIZE)
3110
+ .map((drive) => ({
3111
+ name: windowsDriveDisplayName(drive),
3112
+ kind: "drive",
3113
+ openAction: {
3114
+ text: windowsDriveDisplayName(drive),
3115
+ value: {
3116
+ action: "directory_open",
3117
+ directory: drive.root,
3118
+ contextKey,
3119
+ },
3120
+ },
3121
+ actions: directoryCreationActions(drive.root, contextKey),
3122
+ }));
3123
+ const card = this.cardRenderer.renderDirectoryBrowserCard({
3124
+ directory: "此电脑",
3125
+ entries,
3126
+ currentActions: [],
3127
+ navigationActions: directoryPaginationActions(WINDOWS_DRIVES_DIRECTORY, page, totalPages, contextKey),
3128
+ footerLines: [
3129
+ `第 ${page + 1}/${totalPages} 页 · ${drives.length} 个磁盘`,
3130
+ "",
3131
+ "> 点击磁盘名称进入;**New** 在磁盘根目录创建任务,**NewGroup** 在磁盘根目录创建新群和任务。",
3132
+ ],
3133
+ });
3134
+ if (options.updateMessageId) {
3135
+ await this.outbound.updateInteractiveCard(contextKey, options.updateMessageId, card);
3136
+ }
3137
+ else {
3138
+ await this.outbound.sendInteractiveCard(contextKey, card);
3139
+ }
3140
+ }
3141
+ async assertBrowsableDirectory(directory) {
3142
+ let stats;
3143
+ try {
3144
+ stats = await fs.stat(directory);
3145
+ }
3146
+ catch (error) {
3147
+ throw new Error(`目录不存在或无法访问:${directory}(${runtimeErrorMessage(error)})`);
3148
+ }
3149
+ if (!stats.isDirectory())
3150
+ throw new Error(`这不是目录:${directory}`);
3151
+ }
3152
+ async assertSendableFile(filePath) {
3153
+ let stats;
3154
+ try {
3155
+ stats = await fs.stat(filePath);
3156
+ }
3157
+ catch (error) {
3158
+ throw new Error(`文件不存在或无法访问:${filePath}(${runtimeErrorMessage(error)})`);
3159
+ }
3160
+ if (!stats.isFile())
3161
+ throw new Error(`这不是普通文件:${filePath}`);
3162
+ }
2500
3163
  async listSessions(contextKey, searchTerm, options = {}) {
2501
3164
  const context = this.store.getOrCreateUserContext(contextKey, this.config.defaults.agent);
2502
3165
  const normalizedSearch = searchTerm?.trim().toLowerCase();
@@ -2522,23 +3185,28 @@ export class ProxySessionController {
2522
3185
  const remoteHint = remoteErrors.length > 0
2523
3186
  ? `部分 Agent 的任务读取失败:${remoteErrors.join(";")}`
2524
3187
  : undefined;
2525
- const entries = mergeTaskList(localSessions, remoteSessions, context.currentSessionId)
3188
+ const entries = orderTaskListByProject(mergeTaskList(localSessions, remoteSessions, context.currentSessionId)
2526
3189
  .filter((entry) => !normalizedSearch
2527
3190
  || [entry.id, entry.title, entry.cwd, entry.agentName]
2528
- .some((value) => value.toLowerCase().includes(normalizedSearch)))
2529
- .sort((left, right) => Number(right.current) - Number(left.current)
2530
- || Number(right.active) - Number(left.active)
2531
- || right.updatedAt - left.updatedAt);
3191
+ .some((value) => value.toLowerCase().includes(normalizedSearch))));
2532
3192
  const activeCount = entries.filter((entry) => entry.active).length;
2533
3193
  const lastKnownPage = Math.max(0, Math.ceil(entries.length / SESSION_PAGE_SIZE) - 1);
2534
3194
  const page = remoteHasMore ? requestedPage : Math.min(requestedPage, lastKnownPage);
2535
3195
  const offset = page * SESSION_PAGE_SIZE;
2536
3196
  const visibleEntries = entries.slice(offset, offset + SESSION_PAGE_SIZE);
3197
+ const projectActionSources = new Map();
3198
+ for (const entry of entries) {
3199
+ const project = taskProjectInfo(entry);
3200
+ if (!projectActionSources.has(project.key))
3201
+ projectActionSources.set(project.key, entry);
3202
+ }
2537
3203
  const hasPrevious = page > 0;
2538
3204
  const hasNext = remoteHasMore || entries.length > offset + SESSION_PAGE_SIZE;
2539
3205
  this.lastSessionListings.set(contextKey, entries.map((entry) => entry.reference));
2540
3206
  const cardEntries = visibleEntries.map((entry, index) => {
2541
- const marker = entry.current ? "✅" : entry.active ? "🟢 **活跃**" : "•";
3207
+ const project = taskProjectInfo(entry);
3208
+ const projectActionSource = projectActionSources.get(project.key) ?? entry;
3209
+ const marker = entry.current ? "✅" : entry.active ? "🟢" : "•";
2542
3210
  const showStop = entry.status === "外部执行中"
2543
3211
  && entry.reference !== options.forceSwitchTaskId
2544
3212
  && entry.id !== options.forceSwitchTaskId;
@@ -2553,26 +3221,25 @@ export class ProxySessionController {
2553
3221
  contextKey,
2554
3222
  },
2555
3223
  }];
2556
- actions.push({
2557
- text: "New",
2558
- value: {
2559
- action: "session_new",
2560
- sessionId: entry.reference,
2561
- ...(searchTerm ? { searchTerm } : {}),
2562
- page: String(page),
2563
- contextKey,
2564
- },
2565
- });
2566
- actions.push({
2567
- text: "NewGroup",
2568
- value: {
2569
- action: "session_new_group",
2570
- sessionId: entry.reference,
2571
- ...(searchTerm ? { searchTerm } : {}),
2572
- page: String(page),
2573
- contextKey,
2574
- },
2575
- });
3224
+ const projectActions = [{
3225
+ text: "New",
3226
+ value: {
3227
+ action: "session_new",
3228
+ sessionId: projectActionSource.reference,
3229
+ ...(searchTerm ? { searchTerm } : {}),
3230
+ page: String(page),
3231
+ contextKey,
3232
+ },
3233
+ }, {
3234
+ text: "NewGroup",
3235
+ value: {
3236
+ action: "session_new_group",
3237
+ sessionId: projectActionSource.reference,
3238
+ ...(searchTerm ? { searchTerm } : {}),
3239
+ page: String(page),
3240
+ contextKey,
3241
+ },
3242
+ }];
2576
3243
  actions.push({
2577
3244
  text: "Fork",
2578
3245
  value: {
@@ -2601,20 +3268,45 @@ export class ProxySessionController {
2601
3268
  contextKey,
2602
3269
  },
2603
3270
  });
3271
+ const title = truncateText(entry.title.replace(/\s+/gu, " ").trim() || "未命名任务", 56);
2604
3272
  return {
2605
- lines: [
2606
- `**${offset + index + 1}.** ${marker} **${cardText(entry.title)}** ${cardText(entry.id)}`,
2607
- `${entry.status} · ${entry.updatedLabel} · ${cardText(entry.agentName)} · ${cardText(entry.cwd || "目录未知")}`,
2608
- ],
2609
- actions,
3273
+ project,
3274
+ projectActions,
3275
+ cardEntry: {
3276
+ reference: entry.reference,
3277
+ summary: `${offset + index + 1}. ${marker} ${title} · ${entry.agentName}`,
3278
+ detailLines: [
3279
+ `**状态 / 更新时间**:${cardText(entry.status)} / ${cardText(entry.updatedLabel)}`,
3280
+ `**Agent / 任务 ID**:${cardCode(entry.agentName)} / ${cardCode(entry.id)}`,
3281
+ `**目录**:${cardCode(entry.cwd || "目录未知")}`,
3282
+ ],
3283
+ actions,
3284
+ current: entry.current,
3285
+ },
2610
3286
  };
2611
3287
  });
2612
- const card = this.cardRenderer.renderTaskListCard(searchTerm ? `App Server 任务:${searchTerm}` : "App Server 任务", activeCount > 0 ? `任务(${activeCount} 个活跃)` : "任务", cardEntries, [
3288
+ const cardGroups = [];
3289
+ let currentProjectKey;
3290
+ for (const item of cardEntries) {
3291
+ const currentGroup = cardGroups.at(-1);
3292
+ if (currentGroup && currentProjectKey === item.project.key) {
3293
+ currentGroup.entries.push(item.cardEntry);
3294
+ }
3295
+ else {
3296
+ currentProjectKey = item.project.key;
3297
+ cardGroups.push({
3298
+ title: item.project.title,
3299
+ entries: [item.cardEntry],
3300
+ actions: item.projectActions,
3301
+ });
3302
+ }
3303
+ }
3304
+ const card = this.cardRenderer.renderSessionTaskListCard(searchTerm ? `任务列表:${searchTerm}` : "任务列表", activeCount > 0 ? `任务(${activeCount} 个活跃)` : "任务", cardGroups, [
2613
3305
  ...(remoteHint ? [remoteHint] : []),
2614
3306
  `第 ${page + 1} 页 · 每页 ${SESSION_PAGE_SIZE} 个任务${hasNext ? "" : ` · 当前共 ${entries.length} 个任务`}`,
2615
3307
  "",
2616
- "> **Switch** 切换任务;**Stop** 停止运行;**Status** 查看状态。",
2617
- "> **New** / **Fork** 新建 / 分支任务;**NewGroup** / **ForkGroup** 新建 / 分支群。",
3308
+ "> 项目行:**New** 新建任务,**NewGroup** 新建群。",
3309
+ "> 任务详情:**Switch** 切换,**Stop** 停止,**Fork** / **ForkGroup** 创建分支,**Status** 查看状态。",
2618
3310
  ], [
2619
3311
  ...(hasPrevious ? [{
2620
3312
  text: "Previous",
@@ -3067,6 +3759,17 @@ export class ProxySessionController {
3067
3759
  "**[参数]** 可选 **&#60;参数&#62;** 必填",
3068
3760
  ], sections));
3069
3761
  }
3762
+ async setGroupMute(contextKey, enabled) {
3763
+ const chatContextKey = baseChatContextKey(contextKey);
3764
+ const chat = this.store.getChatContext(chatContextKey);
3765
+ if (chat?.chatType !== "group") {
3766
+ throw new Error("/mute 仅适用于群聊。");
3767
+ }
3768
+ this.store.setChatRequiresMention(chatContextKey, enabled);
3769
+ await this.outbound.sendText(contextKey, enabled
3770
+ ? "已开启当前群的静音模式。之后只有 @ 机器人的消息会被处理;发送 @机器人 /mute off 可恢复自动响应。"
3771
+ : "已关闭当前群的静音模式。机器人将恢复自动响应群消息。");
3772
+ }
3070
3773
  ensureAgent(agentName) {
3071
3774
  const agent = this.config.agents[agentName];
3072
3775
  if (!agent)
@@ -3127,6 +3830,10 @@ export class ProxySessionController {
3127
3830
  await this.outbound.sendText(contextKey, error instanceof Error ? error.message : String(error));
3128
3831
  }
3129
3832
  }
3833
+ function threadForkAnchorMessageIds(message) {
3834
+ return [...new Set([message.rootMessageId, message.parentMessageId]
3835
+ .filter((messageId) => Boolean(messageId && messageId !== message.messageId)))];
3836
+ }
3130
3837
  function sessionStatusLabel(status, activeTurnId) {
3131
3838
  if (activeTurnId || status === "running")
3132
3839
  return "执行中";
@@ -3157,6 +3864,50 @@ function statusRefreshAction(sessionId, contextKey) {
3157
3864
  },
3158
3865
  };
3159
3866
  }
3867
+ function orderTaskListByProject(entries) {
3868
+ const groups = new Map();
3869
+ for (const entry of entries) {
3870
+ const project = taskProjectInfo(entry);
3871
+ const group = groups.get(project.key);
3872
+ if (group)
3873
+ group.entries.push(entry);
3874
+ else
3875
+ groups.set(project.key, { project, entries: [entry] });
3876
+ }
3877
+ const orderedGroups = [...groups.values()]
3878
+ .map((group) => ({
3879
+ ...group,
3880
+ current: group.entries.some((entry) => entry.current),
3881
+ active: group.entries.some((entry) => entry.active),
3882
+ updatedAt: Math.max(...group.entries.map((entry) => entry.updatedAt)),
3883
+ }))
3884
+ .sort((left, right) => Number(right.current) - Number(left.current)
3885
+ || Number(right.active) - Number(left.active)
3886
+ || right.updatedAt - left.updatedAt
3887
+ || left.project.title.localeCompare(right.project.title, undefined, { sensitivity: "base" }));
3888
+ return orderedGroups.flatMap((group) => group.entries.sort(compareTaskListEntries));
3889
+ }
3890
+ function compareTaskListEntries(left, right) {
3891
+ return Number(right.current) - Number(left.current)
3892
+ || Number(right.active) - Number(left.active)
3893
+ || right.updatedAt - left.updatedAt
3894
+ || left.title.localeCompare(right.title, undefined, { sensitivity: "base" });
3895
+ }
3896
+ function taskProjectInfo(entry) {
3897
+ if (!entry.cwd)
3898
+ return { key: "unknown", title: "📁 目录未知" };
3899
+ if (detectProjectlessWorkspace(entry.cwd)) {
3900
+ return { key: "projectless", title: "🗒️ 未指定项目" };
3901
+ }
3902
+ const windowsPath = isWindowsAbsolutePath(entry.cwd);
3903
+ const normalized = windowsPath
3904
+ ? path.win32.normalize(entry.cwd).toLowerCase()
3905
+ : path.resolve(entry.cwd);
3906
+ return {
3907
+ key: `cwd:${normalized}`,
3908
+ title: `📁 ${abbreviateHomeDirectory(entry.cwd)}`,
3909
+ };
3910
+ }
3160
3911
  function mergeTaskList(localSessions, remoteSessions, currentLocalSessionId) {
3161
3912
  const localByRemoteId = new Map(localSessions
3162
3913
  .filter((session) => session.runtimeKind === "codex" && session.remoteSessionId)
@@ -3246,6 +3997,12 @@ function turnViewSnapshot(value) {
3246
3997
  ? candidate
3247
3998
  : undefined;
3248
3999
  }
4000
+ function isTerminalTurnViewStatus(status) {
4001
+ return status === "completed" || status === "cancelled" || status === "failed";
4002
+ }
4003
+ function isIncompleteTurnAttemptStatus(status) {
4004
+ return status === "accepted" || status === "running" || status === "recovering";
4005
+ }
3249
4006
  function executionDetailLines(record, snapshot, remote, activeTurnId, queued) {
3250
4007
  const remoteActive = isRemoteSessionActive(remote);
3251
4008
  const turnId = remoteActive
@@ -3519,7 +4276,7 @@ function isBotOwnedActiveTurn(record, remote) {
3519
4276
  && record.lastTurnId === remote.lastTurnId;
3520
4277
  }
3521
4278
  function isQueueIndependentCommand(command) {
3522
- if (["stop", "status", "restart", "help", "sessions", "goal", "nosteer"].includes(command.type))
4279
+ if (["stop", "status", "restart", "mute", "help", "sessions", "dir", "goal", "nosteer"].includes(command.type))
3523
4280
  return true;
3524
4281
  if (command.type === "agent")
3525
4282
  return command.agent === undefined;
@@ -3531,12 +4288,200 @@ function parseSessionPage(value) {
3531
4288
  const parsed = typeof value === "string" || typeof value === "number" ? Number(value) : Number.NaN;
3532
4289
  return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : 0;
3533
4290
  }
4291
+ function directoryPageValue(value) {
4292
+ const page = Number(value);
4293
+ if (!Number.isSafeInteger(page) || page < 0)
4294
+ throw new Error("文件浏览卡片页码无效,请重新发送 /dir。");
4295
+ return page;
4296
+ }
4297
+ function isWindowsDriveRoot(directory) {
4298
+ return /^[a-z]:\\$/iu.test(path.win32.normalize(directory));
4299
+ }
4300
+ function directoryBrowserEntryKind(entry) {
4301
+ if (entry.isDirectory())
4302
+ return "directory";
4303
+ const extension = path.extname(entry.name).toLowerCase();
4304
+ if (IMAGE_FILE_EXTENSIONS.has(extension))
4305
+ return "image";
4306
+ if (BINARY_FILE_EXTENSIONS.has(extension))
4307
+ return "binary";
4308
+ return "file";
4309
+ }
4310
+ function directoryActionPath(value) {
4311
+ if (typeof value !== "string" || !value.trim()) {
4312
+ throw new Error("文件浏览卡片中的目录无效,请重新发送 /dir。");
4313
+ }
4314
+ return value === WINDOWS_DRIVES_DIRECTORY ? value : path.resolve(value);
4315
+ }
4316
+ function directoryFileActionPath(value) {
4317
+ if (typeof value !== "string" || !value.trim()) {
4318
+ throw new Error("文件浏览卡片中的文件无效,请重新发送 /dir。");
4319
+ }
4320
+ return path.resolve(value);
4321
+ }
4322
+ function directoryCreationActions(directory, contextKey) {
4323
+ return [
4324
+ {
4325
+ text: "New",
4326
+ value: {
4327
+ action: "directory_new",
4328
+ directory,
4329
+ contextKey,
4330
+ },
4331
+ },
4332
+ {
4333
+ text: "NewGroup",
4334
+ value: {
4335
+ action: "directory_new_group",
4336
+ directory,
4337
+ contextKey,
4338
+ },
4339
+ },
4340
+ ];
4341
+ }
4342
+ function directoryPaginationActions(directory, page, totalPages, contextKey) {
4343
+ return [
4344
+ ...(page > 0 ? [{
4345
+ text: "Previous",
4346
+ value: {
4347
+ action: "directory_page",
4348
+ directory,
4349
+ page: String(page - 1),
4350
+ contextKey,
4351
+ },
4352
+ }] : []),
4353
+ ...(page < totalPages - 1 ? [{
4354
+ text: "Next",
4355
+ value: {
4356
+ action: "directory_page",
4357
+ directory,
4358
+ page: String(page + 1),
4359
+ contextKey,
4360
+ },
4361
+ }] : []),
4362
+ ];
4363
+ }
4364
+ function windowsDriveDisplayName(drive) {
4365
+ const driveLetter = drive.root.slice(0, 2).toUpperCase();
4366
+ const label = drive.label?.trim() || windowsDriveTypeLabel(drive.driveType);
4367
+ return `${label} (${driveLetter})`;
4368
+ }
4369
+ function windowsDriveTypeLabel(driveType) {
4370
+ switch (driveType?.toLowerCase()) {
4371
+ case "fixed": return "本地磁盘";
4372
+ case "network": return "网络驱动器";
4373
+ case "removable": return "可移动磁盘";
4374
+ case "cdrom": return "光驱";
4375
+ case "ram": return "RAM 磁盘";
4376
+ default: return "磁盘";
4377
+ }
4378
+ }
4379
+ async function listWindowsDriveRoots() {
4380
+ if (process.platform !== "win32")
4381
+ throw new Error("磁盘列表仅在 Windows 上可用。");
4382
+ const result = await executeShellCommand("$drives = @([System.IO.DriveInfo]::GetDrives() | ForEach-Object { $label = ''; if ($_.IsReady) { try { $label = $_.VolumeLabel } catch {} }; [PSCustomObject]@{ root = $_.Name; label = $label; driveType = $_.DriveType.ToString() } }); ConvertTo-Json -Compress -InputObject $drives", os.homedir(), { timeoutMs: 10_000, maxOutputBytes: 16 * 1024, platform: "win32" });
4383
+ if (result.timedOut || result.exitCode !== 0) {
4384
+ const detail = result.stderr.trim() || `退出码 ${result.exitCode ?? "未知"}`;
4385
+ throw new Error(`无法读取 Windows 磁盘列表:${detail}`);
4386
+ }
4387
+ let parsed;
4388
+ try {
4389
+ parsed = JSON.parse(result.stdout.trim().replace(/^\uFEFF/u, ""));
4390
+ }
4391
+ catch (error) {
4392
+ throw new Error(`无法解析 Windows 磁盘列表:${runtimeErrorMessage(error)}`);
4393
+ }
4394
+ const values = Array.isArray(parsed) ? parsed : [parsed];
4395
+ const drives = values.flatMap((value) => {
4396
+ if (typeof value !== "object" || value === null)
4397
+ return [];
4398
+ const record = value;
4399
+ if (typeof record.root !== "string")
4400
+ return [];
4401
+ const root = path.win32.normalize(record.root.trim());
4402
+ if (!isWindowsDriveRoot(root))
4403
+ return [];
4404
+ return [{
4405
+ root,
4406
+ ...(typeof record.label === "string" && record.label.trim() ? { label: record.label.trim() } : {}),
4407
+ ...(typeof record.driveType === "string" ? { driveType: record.driveType } : {}),
4408
+ }];
4409
+ }).sort((left, right) => left.root.localeCompare(right.root, undefined, { sensitivity: "base" }));
4410
+ if (drives.length === 0)
4411
+ throw new Error("没有检测到可用的 Windows 磁盘。");
4412
+ return drives;
4413
+ }
3534
4414
  function isPromptCommand(command) {
3535
4415
  return command.type === "prompt" || command.type === "nosteer";
3536
4416
  }
3537
4417
  function runtimePrompt(text, localImagePaths) {
3538
4418
  return localImagePaths?.length ? { text, localImagePaths } : text;
3539
4419
  }
4420
+ function recoveryCardPrompt(prompt) {
4421
+ return `恢复重启前的任务:${prompt}`;
4422
+ }
4423
+ function recoveryRuntimePrompt(prompt) {
4424
+ return [
4425
+ "Agent Bot restarted while the previous turn was still running.",
4426
+ "Continue the unfinished task in the current task and workspace.",
4427
+ "Inspect the existing conversation, files, and completed tool effects first. Do not repeat completed or irreversible actions.",
4428
+ "Finish the original request:",
4429
+ "",
4430
+ prompt,
4431
+ ].join("\n");
4432
+ }
4433
+ function llmRetryCardPrompt(prompt, retryNumber) {
4434
+ return `自动重试 ${retryNumber}/${MAX_LLM_TURN_RETRIES}:${prompt}`;
4435
+ }
4436
+ function llmRetryRuntimePrompt(prompt, retryNumber) {
4437
+ return [
4438
+ `Agent Bot is automatically retrying this request after a transient LLM service failure (${retryNumber}/${MAX_LLM_TURN_RETRIES}).`,
4439
+ "Continue in the current task and workspace. Inspect the conversation, files, and completed tool effects before acting.",
4440
+ "Do not repeat completed or irreversible actions. Finish the original request:",
4441
+ "",
4442
+ prompt,
4443
+ ].join("\n");
4444
+ }
4445
+ function isRetryableLlmTurnFailure(message) {
4446
+ const normalized = message.trim().toLowerCase();
4447
+ if (!normalized)
4448
+ return false;
4449
+ const permanentFailure = [
4450
+ /auth(?:entication|orization)?\b|unauthorized|forbidden|invalid api key|incorrect api key/u,
4451
+ /insufficient[_ -]?quota|quota (?:exceeded|exhausted)|usage limit|billing|credit balance/u,
4452
+ /context (?:length|window)|maximum context|too many tokens|token limit/u,
4453
+ /invalid request|bad request|invalid argument|malformed|unsupported|not supported/u,
4454
+ /model (?:not found|does not exist)|unknown model/u,
4455
+ /permission|approval|sandbox|policy violation|content policy/u,
4456
+ ].some((pattern) => pattern.test(normalized));
4457
+ if (permanentFailure)
4458
+ return false;
4459
+ return [
4460
+ /(?:^|\D)429(?:\D|$)|rate[_ -]?limit|too many requests/u,
4461
+ /overload|high demand|at capacity|capacity limit|temporar(?:y|ily) unavailable/u,
4462
+ /service unavailable|internal server error|bad gateway|gateway timeout/u,
4463
+ /(?:^|\D)(?:500|502|503|504)(?:\D|$)|upstream (?:error|failure|timeout)/u,
4464
+ /deadline exceeded|request timed out|request timeout|response timeout/u,
4465
+ /connection (?:reset|closed|aborted)|network error|socket hang up/u,
4466
+ /stream (?:closed|disconnected|interrupted|error)/u,
4467
+ /\b(?:llm|model|provider|inference)\b.*\b(?:error|failed|failure|unavailable|timeout|timed out)\b/u,
4468
+ ].some((pattern) => pattern.test(normalized));
4469
+ }
4470
+ function remoteTurnMatchesAttempt(remote, attempt) {
4471
+ if (!remote?.lastTurnId)
4472
+ return false;
4473
+ if (attempt.turnId)
4474
+ return remote.lastTurnId === attempt.turnId;
4475
+ const remoteUpdatedAt = remote.updatedAt === undefined ? undefined : normalizeRemoteTimestamp(remote.updatedAt);
4476
+ const acceptedAt = Date.parse(attempt.createdAt);
4477
+ return remoteUpdatedAt !== undefined
4478
+ && Number.isFinite(acceptedAt)
4479
+ && remoteUpdatedAt >= acceptedAt - 1_000;
4480
+ }
4481
+ function isRecoveryAttemptRecent(attempt, cutoff) {
4482
+ const updatedAt = Date.parse(attempt.updatedAt);
4483
+ return Number.isFinite(updatedAt) && updatedAt >= cutoff;
4484
+ }
3540
4485
  function formatGroupDateSuffix(date) {
3541
4486
  const month = String(date.getMonth() + 1).padStart(2, "0");
3542
4487
  const day = String(date.getDate()).padStart(2, "0");
@@ -3567,12 +4512,21 @@ function formatGroupProjectDirectory(projectCwd) {
3567
4512
  return `[${fitTrailingPathLevels(levels, GROUP_PROJECT_DIRECTORY_MAX_LENGTH, path.sep)}]`;
3568
4513
  }
3569
4514
  function abbreviateHomeDirectory(value) {
3570
- const relative = path.relative(os.homedir(), value);
4515
+ const homeDirectory = os.homedir();
4516
+ const valueIsWindows = isWindowsAbsolutePath(value);
4517
+ const homeIsWindows = isWindowsAbsolutePath(homeDirectory);
4518
+ if (valueIsWindows !== homeIsWindows && (valueIsWindows || path.posix.isAbsolute(value)))
4519
+ return value;
4520
+ const pathApi = valueIsWindows ? path.win32 : path;
4521
+ const relative = pathApi.relative(homeDirectory, value);
3571
4522
  if (relative === "")
3572
4523
  return "~";
3573
- if (path.isAbsolute(relative) || relative === ".." || relative.startsWith(`..${path.sep}`))
4524
+ if (pathApi.isAbsolute(relative) || relative === ".." || relative.startsWith(`..${pathApi.sep}`))
3574
4525
  return value;
3575
- return `~${path.sep}${relative}`;
4526
+ return `~${pathApi.sep}${relative}`;
4527
+ }
4528
+ function isWindowsAbsolutePath(value) {
4529
+ return /^[a-z]:[\\/]|^\\\\/iu.test(value);
3576
4530
  }
3577
4531
  function fitTrailingPathLevels(levels, maxLength, separator) {
3578
4532
  if (levels.length === 0)