@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, type Dirent } from "node:fs";
1
2
  import path from "node:path";
2
3
  import os from "node:os";
3
4
  import type { Logger } from "pino";
@@ -16,13 +17,16 @@ import type {
16
17
  import {
17
18
  CardRenderer,
18
19
  type CardSection,
20
+ type DirectoryBrowserCardEntry,
19
21
  type ExecutionSettingsTab,
20
22
  type HelpCardSection,
21
23
  type ResetHistoryCardEntry,
24
+ type SessionTaskCardEntry,
25
+ type SessionTaskCardGroup,
22
26
  type TaskListCardAction,
23
- type TaskListCardEntry,
24
27
  } from "../feishu/CardRenderer.js";
25
28
  import { generateGroupAvatarPng, resolveGroupAvatarProjectName } from "../feishu/GroupAvatarGenerator.js";
29
+ import { allowsFeishuUser } from "../feishu/ownerAccess.js";
26
30
  import type { OutboundRouter } from "../presentation/OutboundRouter.js";
27
31
  import type { TurnActivity, TurnViewState } from "../presentation/turnViewTypes.js";
28
32
  import { buildTurnGraphRows } from "../presentation/turnGraph.js";
@@ -44,6 +48,7 @@ import {
44
48
  type MessageReactionStatus,
45
49
  type QueuedPromptRecord,
46
50
  type SessionRecord,
51
+ type TurnAttemptRecord,
47
52
  type TurnAnchorRecord,
48
53
  } from "../state/StateStore.js";
49
54
  import { createId } from "../utils/id.js";
@@ -60,13 +65,34 @@ interface LoadedSession {
60
65
  session: RuntimeSession;
61
66
  }
62
67
 
68
+ interface StartTurnOptions {
69
+ attemptId?: string;
70
+ messageId?: string;
71
+ displayPrompt?: string;
72
+ preserveAttemptOnFailure?: boolean;
73
+ }
74
+
63
75
  const MESSAGE_RECEIVED_REACTION = "OnIt";
64
76
  const MESSAGE_COMPLETED_REACTION = "DONE";
65
77
  const MESSAGE_FAILED_REACTION = "ERROR";
66
78
  const MESSAGE_CANCELLED_REACTION = "CrossMark";
67
79
  const SESSION_PAGE_SIZE = 5;
80
+ const DIRECTORY_PAGE_SIZE = 15;
68
81
  const RESET_HISTORY_PAGE_SIZE = 10;
82
+ const MAX_LLM_TURN_RETRIES = 3;
83
+ const RECOVERY_ACTIVITY_WINDOW_MS = 5 * 60 * 1_000;
84
+ const RECOVERY_HEARTBEAT_INTERVAL_MS = 60 * 1_000;
69
85
  const REMOTE_SESSION_REFERENCE_PREFIX = "agent-runtime:";
86
+ const WINDOWS_DRIVES_DIRECTORY = "agentbot://windows-drives";
87
+ const IMAGE_FILE_EXTENSIONS = new Set([
88
+ ".avif", ".bmp", ".gif", ".heic", ".heif", ".ico", ".jpeg", ".jpg", ".png", ".svg", ".tif", ".tiff", ".webp",
89
+ ]);
90
+ const BINARY_FILE_EXTENSIONS = new Set([
91
+ ".7z", ".a", ".apk", ".avi", ".bin", ".bz2", ".class", ".dat", ".db", ".deb", ".dll", ".dmg", ".doc", ".docx",
92
+ ".dylib", ".eot", ".exe", ".flac", ".gz", ".ico", ".ipa", ".iso", ".jar", ".lib", ".mdb", ".mkv", ".mov", ".mp3",
93
+ ".mp4", ".msi", ".o", ".obj", ".ogg", ".otf", ".pdf", ".ppt", ".pptx", ".rar", ".rpm", ".so", ".sqlite", ".tar",
94
+ ".ttf", ".wav", ".webm", ".woff", ".woff2", ".xls", ".xlsx", ".xz", ".zip", ".zst",
95
+ ]);
70
96
 
71
97
  interface HelpCommandDefinition {
72
98
  command: string;
@@ -92,6 +118,11 @@ const HELP_COMMAND_SECTIONS: Array<{
92
118
  usage: "[title] [--dir &#60;cwd&#62; | --nodir]",
93
119
  description: "创建飞书群和新任务",
94
120
  },
121
+ {
122
+ command: "/dir",
123
+ usage: "[目录]",
124
+ description: "浏览、发送文件,并从选定目录创建任务或群",
125
+ },
95
126
  {
96
127
  command: "/forkgroup",
97
128
  usage: "[title]",
@@ -174,6 +205,11 @@ const HELP_COMMAND_SECTIONS: Array<{
174
205
  usage: "[--force]",
175
206
  description: "默认安全重启;--force 立即重启",
176
207
  },
208
+ {
209
+ command: "/mute",
210
+ usage: "[on|off]",
211
+ description: "开启或关闭当前群的仅 @ 响应模式",
212
+ },
177
213
  { command: "/help", description: "显示本帮助" },
178
214
  ],
179
215
  },
@@ -202,6 +238,11 @@ interface SessionsCardOptions {
202
238
  page?: number;
203
239
  }
204
240
 
241
+ interface DirectoryBrowserOptions {
242
+ updateMessageId?: string;
243
+ page?: number;
244
+ }
245
+
205
246
  interface StatusCardOptions {
206
247
  updateMessageId?: string;
207
248
  forceSwitchTaskId?: string;
@@ -301,6 +342,13 @@ export interface ProxyLifecycle {
301
342
  }
302
343
 
303
344
  export type ShellCommandExecutor = (command: string, cwd: string) => Promise<ShellCommandResult>;
345
+ export interface WindowsDriveInfo {
346
+ root: string;
347
+ label?: string;
348
+ driveType?: string;
349
+ }
350
+
351
+ export type WindowsDriveLister = () => Promise<WindowsDriveInfo[]>;
304
352
 
305
353
  export class ProxySessionController {
306
354
  private readonly router = new CommandRouter();
@@ -313,7 +361,13 @@ export class ProxySessionController {
313
361
  private readonly sessionResets = new Map<string, Promise<void>>();
314
362
  private readonly lastSessionListings = new Map<string, string[]>();
315
363
  private readonly threadInitializations = new Map<string, Promise<void>>();
364
+ private readonly llmRetryingSessions = new Set<string>();
365
+ private readonly retriedFailureTurnIds = new Set<string>();
366
+ private readonly recoveryRetryTimers = new Map<string, NodeJS.Timeout>();
316
367
  private readonly unsubscribe: Array<() => void> = [];
368
+ private readonly recoveryActivityHeartbeat: NodeJS.Timeout;
369
+ private startupRecovery?: Promise<void>;
370
+ private queuedPromptsRestored = false;
317
371
 
318
372
  constructor(
319
373
  private readonly config: AppConfig,
@@ -323,18 +377,8 @@ export class ProxySessionController {
323
377
  private readonly logger: Logger,
324
378
  private readonly lifecycle?: ProxyLifecycle,
325
379
  private readonly shellCommandExecutor: ShellCommandExecutor = executeShellCommand,
380
+ private readonly windowsDriveLister: WindowsDriveLister = listWindowsDriveRoots,
326
381
  ) {
327
- const interruptedAcpSessions = this.store.reconcileInterruptedAcpSessions(
328
- Object.entries(this.config.agents)
329
- .filter(([, agent]) => agent.kind === "acp")
330
- .map(([name]) => name),
331
- );
332
- if (interruptedAcpSessions.length > 0) {
333
- this.logger.warn(
334
- { sessionIds: interruptedAcpSessions.map((session) => session.localSessionId) },
335
- "Marked interrupted ACP sessions as failed after process restart.",
336
- );
337
- }
338
382
  for (const [, runtime] of this.runtimes.entries()) {
339
383
  this.unsubscribe.push(
340
384
  runtime.onEvent((event) => {
@@ -345,15 +389,37 @@ export class ProxySessionController {
345
389
  );
346
390
  }
347
391
  this.restorePersistedSessionRoutes();
348
- this.restorePersistedQueuedPrompts();
349
392
  void this.restorePersistedMessageReactions().catch((error: unknown) => {
350
393
  this.logger.warn({ error }, "Failed to restore persisted message reaction statuses.");
351
394
  });
395
+ this.recoveryActivityHeartbeat = setInterval(
396
+ () => this.persistActiveTurnHeartbeats(),
397
+ RECOVERY_HEARTBEAT_INTERVAL_MS,
398
+ );
399
+ this.recoveryActivityHeartbeat.unref?.();
352
400
  }
353
401
 
354
402
  async onMessage(message: IncomingMessage): Promise<void> {
355
- // The durable deduplication claim is the only operation allowed before acknowledgement.
356
- // It prevents event retries from adding duplicate reactions.
403
+ if (!message.contextKey.startsWith("console:") && !allowsFeishuUser(this.config, message.userId)) {
404
+ this.logger.debug(
405
+ { messageId: message.messageId },
406
+ "Ignored a Feishu message from a non-owner before acknowledgement.",
407
+ );
408
+ return;
409
+ }
410
+ if (
411
+ message.chatType === "group"
412
+ && this.store.chatRequiresMention(baseChatContextKey(message.contextKey))
413
+ && message.mentionedBot !== true
414
+ ) {
415
+ this.logger.debug(
416
+ { messageId: message.messageId, contextKey: message.contextKey },
417
+ "Ignored an unmentioned message from a muted Lark group.",
418
+ );
419
+ return;
420
+ }
421
+ // For accepted messages, claim durable deduplication before acknowledgement so event
422
+ // retries cannot add duplicate reactions.
357
423
  if (!this.store.claimInboundEvent(message.messageId, "message")) return;
358
424
  try {
359
425
  const reactionId = await this.outbound.addReaction(
@@ -473,6 +539,13 @@ export class ProxySessionController {
473
539
  }
474
540
 
475
541
  async onCardAction(action: CardAction): Promise<void> {
542
+ if (!allowsFeishuUser(this.config, action.userId)) {
543
+ this.logger.debug(
544
+ { actionId: action.actionId },
545
+ "Ignored a Feishu card action from a non-owner.",
546
+ );
547
+ return;
548
+ }
476
549
  if (!this.store.claimInboundEvent(action.actionId, "card_action")) return;
477
550
  const contextKey = this.cardActionContextKey(action);
478
551
  const scopedAction = contextKey === action.contextKey ? action : { ...action, contextKey };
@@ -533,6 +606,32 @@ export class ProxySessionController {
533
606
  if (!cancelled) {
534
607
  await this.outbound.sendText(contextKey, "该安全重启计划已失效,请查看最新状态卡片。");
535
608
  }
609
+ } else if (kind === "directory_open" || kind === "directory_page") {
610
+ await this.openDirectoryBrowser(
611
+ contextKey,
612
+ directoryActionPath(scopedAction.value.directory),
613
+ {
614
+ updateMessageId: requiredCardMessageId(scopedAction.messageId),
615
+ page: kind === "directory_page" ? directoryPageValue(scopedAction.value.page) : 0,
616
+ },
617
+ );
618
+ } else if (kind === "directory_send_file") {
619
+ const filePath = directoryFileActionPath(scopedAction.value.filePath);
620
+ await this.assertSendableFile(filePath);
621
+ await this.outbound.sendFile(contextKey, filePath);
622
+ } else if (kind === "directory_new" || kind === "directory_new_group") {
623
+ const directory = directoryActionPath(scopedAction.value.directory);
624
+ await this.assertBrowsableDirectory(directory);
625
+ await this.execute(
626
+ contextKey,
627
+ kind === "directory_new"
628
+ ? { type: "new", cwd: directory }
629
+ : { type: "newgroup", cwd: directory },
630
+ undefined,
631
+ replyTarget,
632
+ undefined,
633
+ scopedAction.userId,
634
+ );
536
635
  } else if (kind === "session_page") {
537
636
  await this.refreshSessionsCardFromAction(scopedAction);
538
637
  } else if (kind === "session_switch") {
@@ -692,6 +791,9 @@ export class ProxySessionController {
692
791
  this.queuedPromptCards.clear();
693
792
  this.queuedPromptCardWrites.clear();
694
793
  this.sessionResets.clear();
794
+ for (const timer of this.recoveryRetryTimers.values()) clearTimeout(timer);
795
+ this.recoveryRetryTimers.clear();
796
+ clearInterval(this.recoveryActivityHeartbeat);
695
797
  }
696
798
 
697
799
  async controlStopTask(localSessionId: string): Promise<string> {
@@ -916,6 +1018,9 @@ export class ProxySessionController {
916
1018
  case "shell":
917
1019
  await this.runShellCommand(contextKey, command.command);
918
1020
  return;
1021
+ case "dir":
1022
+ await this.openDirectoryBrowser(contextKey, command.directory);
1023
+ return;
919
1024
  case "new":
920
1025
  if (command.projectless && this.ensureAgent(context.defaultAgent).kind !== "app-server") {
921
1026
  throw new Error("/new --nodir 仅支持 App Server Agent。");
@@ -984,6 +1089,9 @@ export class ProxySessionController {
984
1089
  if (!this.lifecycle) throw new Error("当前运行方式不支持自动重启。");
985
1090
  await this.lifecycle.restart(contextKey, command.force === true, replyTarget);
986
1091
  return;
1092
+ case "mute":
1093
+ await this.setGroupMute(contextKey, command.enabled);
1094
+ return;
987
1095
  case "turns":
988
1096
  await this.openResetHistory(contextKey);
989
1097
  return;
@@ -1016,6 +1124,7 @@ export class ProxySessionController {
1016
1124
  const turnContextKey = session.lastTurnId
1017
1125
  ? this.store.getTurnContextKey(session.lastTurnId) ?? context.contextKey
1018
1126
  : context.contextKey;
1127
+ if (!this.outbound.canRoute(turnContextKey)) continue;
1019
1128
  this.outbound.registerSession(
1020
1129
  session.localSessionId,
1021
1130
  turnContextKey,
@@ -1028,16 +1137,386 @@ export class ProxySessionController {
1028
1137
  this.logger.warn({ error, sessionId: session.localSessionId }, "Failed to restore persisted turn delivery.");
1029
1138
  });
1030
1139
  }
1140
+ for (const turn of this.store.listUndeliveredCompletedTurns()) {
1141
+ const contextKey = turn.contextKey ?? this.store.getSession(turn.localSessionId)?.contextKey;
1142
+ if (!contextKey || !this.outbound.canRoute(contextKey)) continue;
1143
+ void this.outbound.resumeDelivery(turn.localSessionId, contextKey, turn.turnId).catch((error: unknown) => {
1144
+ this.logger.warn(
1145
+ { error, sessionId: turn.localSessionId, turnId: turn.turnId },
1146
+ "Failed to resume an undelivered completed turn.",
1147
+ );
1148
+ });
1149
+ }
1031
1150
  }
1032
1151
 
1033
1152
  private restorePersistedQueuedPrompts(): void {
1153
+ if (this.queuedPromptsRestored) return;
1154
+ this.queuedPromptsRestored = true;
1034
1155
  for (const sessionId of this.store.listQueuedPromptSessionIds()) {
1156
+ const firstPrompt = this.store.listQueuedPrompts(sessionId)[0];
1157
+ if (firstPrompt && !this.outbound.canRoute(firstPrompt.contextKey)) continue;
1035
1158
  void this.scheduleNextQueuedPrompt(sessionId).catch((error: unknown) => {
1036
1159
  this.logger.warn({ error, sessionId }, "Failed to resume a persisted prompt queue.");
1037
1160
  });
1038
1161
  }
1039
1162
  }
1040
1163
 
1164
+ async recoverInterruptedTasks(): Promise<void> {
1165
+ if (this.startupRecovery) return this.startupRecovery;
1166
+ const recovery = this.runStartupRecovery();
1167
+ this.startupRecovery = recovery;
1168
+ return recovery;
1169
+ }
1170
+
1171
+ private async runStartupRecovery(): Promise<void> {
1172
+ this.backfillTurnAttemptsForUpgrade();
1173
+ const attempts = this.store.listIncompleteTurnAttempts();
1174
+ const recoveryCutoff = Date.now() - RECOVERY_ACTIVITY_WINDOW_MS;
1175
+ const recentAttempts = attempts.filter((attempt) => isRecoveryAttemptRecent(attempt, recoveryCutoff));
1176
+ if (recentAttempts.length > 0) {
1177
+ this.logger.warn(
1178
+ { attemptIds: recentAttempts.map((attempt) => attempt.attemptId) },
1179
+ "Recovering unfinished Agent Bot tasks after startup.",
1180
+ );
1181
+ }
1182
+ for (const attempt of attempts) {
1183
+ if (!isRecoveryAttemptRecent(attempt, recoveryCutoff)) {
1184
+ await this.expireStaleTurnAttempt(attempt);
1185
+ continue;
1186
+ }
1187
+ if (!this.outbound.canRoute(attempt.contextKey)) continue;
1188
+ try {
1189
+ await this.recoverTurnAttempt(attempt);
1190
+ } catch (error) {
1191
+ this.logger.error(
1192
+ { error, attemptId: attempt.attemptId, sessionId: attempt.localSessionId },
1193
+ "Failed to recover an unfinished task; it will be retried while the server is running.",
1194
+ );
1195
+ this.scheduleRecoveryRetry(attempt.attemptId);
1196
+ }
1197
+ }
1198
+ this.restorePersistedQueuedPrompts();
1199
+ }
1200
+
1201
+ private backfillTurnAttemptsForUpgrade(): void {
1202
+ for (const session of this.store.listAllSessions()) {
1203
+ if (session.status !== "starting" && session.status !== "running" && session.status !== "ready") continue;
1204
+ if (this.store.findIncompleteTurnAttemptForSession(session.localSessionId)) continue;
1205
+ const lastTurn = session.lastTurnId
1206
+ ? {
1207
+ turnId: session.lastTurnId,
1208
+ localSessionId: session.localSessionId,
1209
+ contextKey: this.store.getTurnContextKey(session.lastTurnId),
1210
+ snapshot: this.store.getTurnSnapshot(session.lastTurnId),
1211
+ updatedAt: session.updatedAt,
1212
+ }
1213
+ : undefined;
1214
+ const lastTurnSnapshot = turnViewSnapshot(lastTurn?.snapshot);
1215
+ const latestPersisted = this.store.findLatestTurnSnapshotForSession(session.localSessionId);
1216
+ const latestPersistedSnapshot = turnViewSnapshot(latestPersisted?.snapshot);
1217
+ const latest = lastTurnSnapshot && !isTerminalTurnViewStatus(lastTurnSnapshot.status)
1218
+ ? lastTurn
1219
+ : latestPersistedSnapshot && !isTerminalTurnViewStatus(latestPersistedSnapshot.status)
1220
+ ? latestPersisted
1221
+ : session.status === "ready"
1222
+ ? undefined
1223
+ : lastTurn;
1224
+ if (!latest && session.status === "ready") continue;
1225
+ const snapshot = turnViewSnapshot(latest?.snapshot);
1226
+ if (snapshot && isTerminalTurnViewStatus(snapshot.status)) {
1227
+ this.store.updateSession(session.localSessionId, {
1228
+ status: snapshot.status === "failed" ? "failed" : "ready",
1229
+ });
1230
+ continue;
1231
+ }
1232
+ const persistedTurnId = latest?.turnId;
1233
+ const pendingTurnId = persistedTurnId?.startsWith("pending_") ? persistedTurnId : undefined;
1234
+ const turnId = pendingTurnId ? undefined : persistedTurnId ?? session.lastTurnId;
1235
+ const replyTarget = snapshot?.replyTarget?.replyInThread ? snapshot.replyTarget : undefined;
1236
+ this.store.createTurnAttempt({
1237
+ attemptId: createId("attempt"),
1238
+ localSessionId: session.localSessionId,
1239
+ contextKey: latest?.contextKey ?? session.contextKey,
1240
+ promptText: snapshot?.prompt?.trim() || "继续完成重启前尚未完成的任务。",
1241
+ messageId: turnId ? this.store.findMessageIdForTurn(turnId) : undefined,
1242
+ replyMessageId: replyTarget?.messageId,
1243
+ pendingTurnId,
1244
+ turnId,
1245
+ status: turnId ? "running" : "accepted",
1246
+ createdAt: latest?.updatedAt ?? session.updatedAt,
1247
+ updatedAt: latest?.updatedAt ?? session.updatedAt,
1248
+ });
1249
+ }
1250
+ }
1251
+
1252
+ private async expireStaleTurnAttempt(attempt: TurnAttemptRecord): Promise<void> {
1253
+ const session = this.store.getSession(attempt.localSessionId);
1254
+ const oldCardTurnId = attempt.pendingTurnId ?? attempt.turnId;
1255
+ if (session && oldCardTurnId && this.outbound.canRoute(attempt.contextKey)) {
1256
+ await this.outbound.interruptTurnForRecovery(
1257
+ session.localSessionId,
1258
+ attempt.contextKey,
1259
+ oldCardTurnId,
1260
+ "执行中断已超过 5 分钟,未自动恢复。",
1261
+ ).catch((error: unknown) => {
1262
+ this.logger.warn(
1263
+ { error, attemptId: attempt.attemptId, turnId: oldCardTurnId },
1264
+ "Failed to close an expired interrupted thinking card.",
1265
+ );
1266
+ });
1267
+ }
1268
+ this.store.updateTurnAttempt(attempt.attemptId, { status: "interrupted" });
1269
+ if (!session || session.status === "closed") return;
1270
+ if (attempt.turnId && session.lastTurnId === attempt.turnId) {
1271
+ this.store.updateRuntimeSession(session.localSessionId, { lastTurnStatus: "cancelled" });
1272
+ }
1273
+ if (session.status === "starting" || session.status === "running") {
1274
+ this.store.updateSession(session.localSessionId, { status: "ready" });
1275
+ }
1276
+ if (attempt.turnId && this.outbound.canRoute(attempt.contextKey)) {
1277
+ await this.finalizeTurnMessageReactions(attempt.turnId, "cancelled").catch((error: unknown) => {
1278
+ this.logger.warn(
1279
+ { error, attemptId: attempt.attemptId, turnId: attempt.turnId },
1280
+ "Failed to finalize the message reaction for an expired interrupted turn.",
1281
+ );
1282
+ });
1283
+ }
1284
+ }
1285
+
1286
+ private persistActiveTurnHeartbeats(): void {
1287
+ for (const attempt of this.store.listIncompleteTurnAttempts()) {
1288
+ if (!attempt.turnId) continue;
1289
+ const session = this.store.getSession(attempt.localSessionId);
1290
+ if (!session) continue;
1291
+ const activeTurnId = this.runtimes.forAgent(session.agentName).getSession(session.localSessionId)?.activeTurnId;
1292
+ if (activeTurnId === attempt.turnId) this.store.touchTurnAttempt(attempt.turnId);
1293
+ }
1294
+ }
1295
+
1296
+ private async recoverTurnAttempt(attempt: TurnAttemptRecord, announce = true): Promise<void> {
1297
+ const session = this.store.getSession(attempt.localSessionId);
1298
+ if (!session || session.status === "closed") {
1299
+ this.store.updateTurnAttempt(attempt.attemptId, { status: "interrupted" });
1300
+ return;
1301
+ }
1302
+ const contextKey = attempt.contextKey || session.contextKey;
1303
+ const replyTarget = attempt.replyMessageId
1304
+ ? { messageId: attempt.replyMessageId, replyInThread: true as const }
1305
+ : undefined;
1306
+ this.outbound.registerSession(
1307
+ session.localSessionId,
1308
+ contextKey,
1309
+ session.title,
1310
+ session.cwd,
1311
+ this.agentLabel(session.agentName),
1312
+ );
1313
+ if (announce) {
1314
+ await this.outbound.withReplyTarget(contextKey, replyTarget, () =>
1315
+ this.outbound.sendText(contextKey, "检测到任务在 Agent Bot 重启前尚未完成,正在自动恢复。"),
1316
+ ).catch((error: unknown) => {
1317
+ this.logger.warn(
1318
+ { error, attemptId: attempt.attemptId, contextKey },
1319
+ "Failed to send the task recovery notification.",
1320
+ );
1321
+ return undefined;
1322
+ });
1323
+ }
1324
+
1325
+ const runtime = this.runtimes.forAgent(session.agentName);
1326
+ const sourceTurnId = attempt.turnId;
1327
+ if (runtime.kind === "acp") {
1328
+ await this.continueInterruptedAttempt(attempt, session, sourceTurnId, replyTarget);
1329
+ return;
1330
+ }
1331
+
1332
+ let remote: RemoteSessionSummary | undefined;
1333
+ if (session.remoteSessionId && runtime.readRemoteSession) {
1334
+ try {
1335
+ remote = await runtime.readRemoteSession(session.remoteSessionId);
1336
+ } catch (error) {
1337
+ this.logger.warn(
1338
+ { error, attemptId: attempt.attemptId, remoteSessionId: session.remoteSessionId },
1339
+ "Failed to read the remote task during startup recovery; treating the old local execution as interrupted.",
1340
+ );
1341
+ }
1342
+ }
1343
+ const remoteTurnBelongsToAttempt = remoteTurnMatchesAttempt(remote, attempt);
1344
+ if (remote && remoteTurnBelongsToAttempt && remote.lastTurnStatus === "completed") {
1345
+ await this.reconcileRecoveredRemoteTurn(attempt, session, remote.lastTurnId);
1346
+ return;
1347
+ }
1348
+ if (remote && remoteTurnBelongsToAttempt && remote.lastTurnStatus === "failed") {
1349
+ await this.reconcileRecoveredRemoteTurn(attempt, session, remote.lastTurnId);
1350
+ return;
1351
+ }
1352
+ if (remote && remoteTurnBelongsToAttempt && isRemoteSessionActive(remote) && remote.lastTurnId) {
1353
+ await this.reattachActiveRecoveredTurn(attempt, session, remote.lastTurnId, replyTarget);
1354
+ return;
1355
+ }
1356
+ if (remote && isRemoteSessionActive(remote) && !remoteTurnBelongsToAttempt) {
1357
+ this.store.updateTurnAttempt(attempt.attemptId, { status: "recovering" });
1358
+ if (announce) {
1359
+ await this.outbound.withReplyTarget(contextKey, replyTarget, () =>
1360
+ this.outbound.sendText(contextKey, "任务中检测到另一轮仍在执行;本次恢复将在它结束后重试。"),
1361
+ );
1362
+ }
1363
+ this.scheduleRecoveryRetry(attempt.attemptId);
1364
+ return;
1365
+ }
1366
+ await this.continueInterruptedAttempt(attempt, session, sourceTurnId, replyTarget);
1367
+ }
1368
+
1369
+ private async reconcileRecoveredRemoteTurn(
1370
+ attempt: TurnAttemptRecord,
1371
+ session: SessionRecord,
1372
+ remoteTurnId: string | undefined,
1373
+ ): Promise<void> {
1374
+ if (remoteTurnId && !attempt.turnId) {
1375
+ this.store.updateTurnAttempt(attempt.attemptId, { turnId: remoteTurnId, status: "running" });
1376
+ this.store.updateRuntimeSession(session.localSessionId, {
1377
+ lastTurnId: remoteTurnId,
1378
+ lastTurnStatus: "running",
1379
+ });
1380
+ }
1381
+ const current = this.store.getSession(session.localSessionId) ?? session;
1382
+ const loaded = await this.loadSession({
1383
+ ...current,
1384
+ contextKey: attempt.contextKey,
1385
+ status: "running",
1386
+ lastTurnStatus: "running",
1387
+ });
1388
+ const synchronized = await loaded.runtime.synchronizeSession(session.localSessionId);
1389
+ if (synchronized.activeTurnId) this.scheduleRecoveryRetry(attempt.attemptId, synchronized.activeTurnId);
1390
+ }
1391
+
1392
+ private scheduleRecoveryRetry(attemptId: string, activeTurnId?: string): void {
1393
+ if (this.recoveryRetryTimers.has(attemptId)) return;
1394
+ const timer = setTimeout(() => {
1395
+ this.recoveryRetryTimers.delete(attemptId);
1396
+ const attempt = this.store.getTurnAttempt(attemptId);
1397
+ if (!attempt || !isIncompleteTurnAttemptStatus(attempt.status)) return;
1398
+ const retry = async (): Promise<void> => {
1399
+ let recoveryAttempt = attempt;
1400
+ if (activeTurnId) {
1401
+ const session = this.store.getSession(attempt.localSessionId);
1402
+ if (session) {
1403
+ const runtime = this.runtimes.forAgent(session.agentName);
1404
+ const synchronized = await runtime.synchronizeSession(session.localSessionId);
1405
+ if (synchronized.activeTurnId === activeTurnId) {
1406
+ this.scheduleRecoveryRetry(attemptId, activeTurnId);
1407
+ return;
1408
+ }
1409
+ const current = this.store.getTurnAttempt(attemptId);
1410
+ if (!current || !isIncompleteTurnAttemptStatus(current.status)) return;
1411
+ recoveryAttempt = current;
1412
+ }
1413
+ }
1414
+ await this.recoverTurnAttempt(recoveryAttempt, false);
1415
+ };
1416
+ void retry().catch((error: unknown) => {
1417
+ this.logger.warn(
1418
+ { error, attemptId, sessionId: attempt.localSessionId },
1419
+ "Failed to retry an unfinished task recovery.",
1420
+ );
1421
+ this.scheduleRecoveryRetry(attemptId, activeTurnId);
1422
+ });
1423
+ }, 5_000);
1424
+ timer.unref?.();
1425
+ this.recoveryRetryTimers.set(attemptId, timer);
1426
+ }
1427
+
1428
+ private async reattachActiveRecoveredTurn(
1429
+ attempt: TurnAttemptRecord,
1430
+ session: SessionRecord,
1431
+ turnId: string,
1432
+ replyTarget?: MessageReplyTarget,
1433
+ ): Promise<void> {
1434
+ const oldCardTurnId = attempt.pendingTurnId ?? attempt.turnId;
1435
+ if (oldCardTurnId) {
1436
+ await this.outbound.interruptTurnForRecovery(
1437
+ session.localSessionId,
1438
+ attempt.contextKey,
1439
+ oldCardTurnId,
1440
+ "Agent Bot 已重启,执行仍在继续;进度已转移到新的思考卡片。",
1441
+ );
1442
+ }
1443
+ const pendingTurnId = await this.outbound.startPendingTurn(
1444
+ session.localSessionId,
1445
+ attempt.contextKey,
1446
+ session.title,
1447
+ replyTarget,
1448
+ recoveryCardPrompt(attempt.promptText),
1449
+ );
1450
+ this.store.updateTurnAttempt(attempt.attemptId, {
1451
+ pendingTurnId: pendingTurnId ?? null,
1452
+ turnId,
1453
+ status: "running",
1454
+ });
1455
+ await this.outbound.onEvent({
1456
+ type: "turn_started",
1457
+ sessionId: session.localSessionId,
1458
+ turnId,
1459
+ startedAt: Date.now(),
1460
+ });
1461
+ this.store.updateTurnAttempt(attempt.attemptId, { pendingTurnId: null });
1462
+ const current = this.store.getSession(session.localSessionId) ?? session;
1463
+ const loaded = await this.loadSession({
1464
+ ...current,
1465
+ contextKey: attempt.contextKey,
1466
+ status: "running",
1467
+ lastTurnId: turnId,
1468
+ lastTurnStatus: "running",
1469
+ });
1470
+ const synchronized = await loaded.runtime.synchronizeSession(session.localSessionId);
1471
+ if (synchronized.activeTurnId) this.scheduleRecoveryRetry(attempt.attemptId, synchronized.activeTurnId);
1472
+ }
1473
+
1474
+ private async continueInterruptedAttempt(
1475
+ attempt: TurnAttemptRecord,
1476
+ session: SessionRecord,
1477
+ sourceTurnId: string | undefined,
1478
+ replyTarget?: MessageReplyTarget,
1479
+ ): Promise<void> {
1480
+ const oldCardTurnId = attempt.pendingTurnId ?? sourceTurnId;
1481
+ if (oldCardTurnId) {
1482
+ await this.outbound.interruptTurnForRecovery(
1483
+ session.localSessionId,
1484
+ attempt.contextKey,
1485
+ oldCardTurnId,
1486
+ "Agent Bot 重启中断了这次执行,正在新的思考卡片中继续。",
1487
+ ).catch((error: unknown) => {
1488
+ this.logger.warn(
1489
+ { error, attemptId: attempt.attemptId, turnId: oldCardTurnId },
1490
+ "Failed to close the interrupted thinking card before recovery.",
1491
+ );
1492
+ });
1493
+ }
1494
+ this.store.prepareTurnAttemptRecovery(attempt.attemptId, oldCardTurnId);
1495
+ this.store.updateSession(session.localSessionId, { status: "ready" });
1496
+ if (sourceTurnId) {
1497
+ this.store.updateRuntimeSession(session.localSessionId, {
1498
+ lastTurnId: sourceTurnId,
1499
+ lastTurnStatus: "cancelled",
1500
+ });
1501
+ }
1502
+ const current = this.store.getSession(session.localSessionId) ?? session;
1503
+ const loaded = await this.loadSession({ ...current, contextKey: attempt.contextKey });
1504
+ const recoveryText = recoveryRuntimePrompt(attempt.promptText);
1505
+ const turnId = await this.startTurn(
1506
+ loaded,
1507
+ recoveryText,
1508
+ replyTarget,
1509
+ attempt.localImagePaths,
1510
+ {
1511
+ attemptId: attempt.attemptId,
1512
+ messageId: attempt.messageId,
1513
+ displayPrompt: recoveryCardPrompt(attempt.promptText),
1514
+ preserveAttemptOnFailure: true,
1515
+ },
1516
+ );
1517
+ if (attempt.messageId) await this.bindMessageReactionToTurn(attempt.messageId, session.localSessionId, turnId);
1518
+ }
1519
+
1041
1520
  private async restorePersistedMessageReactions(): Promise<void> {
1042
1521
  const pending = this.store.listPendingMessageReactions();
1043
1522
  const terminalTurns = new Map<string, "completed" | "failed" | "cancelled">();
@@ -1086,6 +1565,19 @@ export class ProxySessionController {
1086
1565
  replyTarget?: MessageReplyTarget,
1087
1566
  localImagePaths?: string[],
1088
1567
  ): Promise<void> {
1568
+ if (this.llmRetryingSessions.has(record.localSessionId)) {
1569
+ const queued = this.persistQueuedPrompt(record.localSessionId, contextKey, text, {
1570
+ localImagePaths,
1571
+ messageId,
1572
+ replyTarget,
1573
+ });
1574
+ this.store.audit(contextKey, "queued_prompt_added_during_llm_retry", {
1575
+ promptId: queued.promptId,
1576
+ localSessionId: record.localSessionId,
1577
+ });
1578
+ await this.presentPromptQueueCard(record.localSessionId, contextKey);
1579
+ return;
1580
+ }
1089
1581
  const configuredRuntime = this.runtimes.forAgent(record.agentName);
1090
1582
  if (!configuredRuntime.getSession(record.localSessionId)) {
1091
1583
  this.outbound.registerSession(
@@ -1143,7 +1635,7 @@ export class ProxySessionController {
1143
1635
  this.logger.warn({ error: syncError, sessionId: record.localSessionId }, "Failed to synchronize after steering failure.");
1144
1636
  }
1145
1637
  if (!current?.activeTurnId) {
1146
- const turnId = await this.startTurn(loaded, text, replyTarget, localImagePaths);
1638
+ const turnId = await this.startTurn(loaded, text, replyTarget, localImagePaths, { messageId });
1147
1639
  if (messageId) await this.bindMessageReactionToTurn(messageId, record.localSessionId, turnId);
1148
1640
  return;
1149
1641
  }
@@ -1172,7 +1664,7 @@ export class ProxySessionController {
1172
1664
  return;
1173
1665
  }
1174
1666
  }
1175
- const turnId = await this.startTurn(loaded, text, replyTarget, localImagePaths);
1667
+ const turnId = await this.startTurn(loaded, text, replyTarget, localImagePaths, { messageId });
1176
1668
  if (messageId) await this.bindMessageReactionToTurn(messageId, record.localSessionId, turnId);
1177
1669
  }
1178
1670
 
@@ -1182,6 +1674,7 @@ export class ProxySessionController {
1182
1674
  text: string,
1183
1675
  messageId?: string,
1184
1676
  ): Promise<void> {
1677
+ this.store.touchTurnAttempt(turnId);
1185
1678
  try {
1186
1679
  await this.outbound.appendSteerMessage(localSessionId, turnId, text, messageId);
1187
1680
  } catch (error) {
@@ -1311,6 +1804,9 @@ export class ProxySessionController {
1311
1804
  if (!message.threadContext || !message.threadId || !message.chatId) return;
1312
1805
  const current = this.store.getUserContext(message.contextKey)?.currentSessionId;
1313
1806
  if (current && this.store.getSession(current)) return;
1807
+ const hasPersistedSourceTurn = threadForkAnchorMessageIds(message)
1808
+ .some((messageId) => this.store.findTurnAnchorByMessageId(messageId) !== undefined);
1809
+ if (!hasPersistedSourceTurn) return;
1314
1810
 
1315
1811
  const existing = this.threadInitializations.get(message.contextKey);
1316
1812
  if (existing) return existing;
@@ -1401,9 +1897,7 @@ export class ProxySessionController {
1401
1897
  if (!message.threadContext || !message.threadId || !message.chatId) {
1402
1898
  throw new Error("当前消息不属于可识别的飞书话题。");
1403
1899
  }
1404
- const anchorMessageIds = [message.rootMessageId, message.parentMessageId]
1405
- .filter((messageId): messageId is string => Boolean(messageId && messageId !== message.messageId));
1406
- const anchor = [...new Set(anchorMessageIds)]
1900
+ const anchor = threadForkAnchorMessageIds(message)
1407
1901
  .map((messageId) => this.store.findTurnAnchorByMessageId(messageId))
1408
1902
  .find((candidate) => candidate !== undefined);
1409
1903
  if (!anchor) {
@@ -1785,31 +2279,60 @@ export class ProxySessionController {
1785
2279
  text: string,
1786
2280
  replyTarget?: MessageReplyTarget,
1787
2281
  localImagePaths?: string[],
2282
+ options: StartTurnOptions = {},
1788
2283
  ): Promise<string> {
1789
2284
  const currentRecord = this.store.getSession(loaded.record.localSessionId);
1790
2285
  const title = currentRecord?.title ?? normalizeTaskTitle(text);
1791
2286
  if (!currentRecord?.title && title) this.store.updateRuntimeSession(loaded.record.localSessionId, { title });
1792
2287
  if (title) this.outbound.updateSessionTitle(loaded.record.localSessionId, title);
1793
- await this.outbound.startPendingTurn(
1794
- loaded.record.localSessionId,
1795
- loaded.record.contextKey,
1796
- title,
1797
- replyTarget,
1798
- text,
1799
- );
2288
+ const attemptId = options.attemptId ?? createId("attempt");
2289
+ if (options.attemptId) {
2290
+ this.store.updateTurnAttempt(attemptId, {
2291
+ messageId: options.messageId ?? undefined,
2292
+ replyMessageId: replyTarget?.messageId ?? undefined,
2293
+ status: "recovering",
2294
+ });
2295
+ } else {
2296
+ this.store.createTurnAttempt({
2297
+ attemptId,
2298
+ localSessionId: loaded.record.localSessionId,
2299
+ contextKey: loaded.record.contextKey,
2300
+ promptText: text,
2301
+ localImagePaths,
2302
+ messageId: options.messageId,
2303
+ replyMessageId: replyTarget?.messageId,
2304
+ });
2305
+ }
1800
2306
  let turnId: string;
1801
2307
  try {
2308
+ const pendingTurnId = await this.outbound.startPendingTurn(
2309
+ loaded.record.localSessionId,
2310
+ loaded.record.contextKey,
2311
+ title,
2312
+ replyTarget,
2313
+ options.displayPrompt ?? text,
2314
+ );
2315
+ this.store.updateTurnAttempt(attemptId, { pendingTurnId: pendingTurnId ?? null });
1802
2316
  turnId = await loaded.runtime.startTurn(
1803
2317
  loaded.record.localSessionId,
1804
2318
  runtimePrompt(text, localImagePaths),
1805
2319
  );
1806
2320
  } catch (error) {
2321
+ this.store.updateTurnAttempt(attemptId, {
2322
+ status: options.preserveAttemptOnFailure ? "recovering" : "failed",
2323
+ });
1807
2324
  await this.outbound.failPendingTurn(
1808
2325
  loaded.record.localSessionId,
1809
2326
  error instanceof Error ? error.message : String(error),
1810
2327
  );
1811
2328
  throw error;
1812
2329
  }
2330
+ const attempt = this.store.getTurnAttempt(attemptId);
2331
+ this.store.updateTurnAttempt(attemptId, {
2332
+ pendingTurnId: null,
2333
+ turnId,
2334
+ ...(attempt && isIncompleteTurnAttemptStatus(attempt.status) ? { status: "running" as const } : {}),
2335
+ });
1813
2336
  const latest = this.store.getSession(loaded.record.localSessionId);
1814
2337
  const alreadyTerminal = latest?.lastTurnId === turnId
1815
2338
  && ["completed", "cancelled", "failed"].includes(latest.lastTurnStatus ?? "");
@@ -2218,6 +2741,7 @@ export class ProxySessionController {
2218
2741
 
2219
2742
  private async handleRuntimeEvent(event: RuntimeEvent): Promise<void> {
2220
2743
  if ("turnId" in event) {
2744
+ this.store.touchTurnAttempt(event.turnId);
2221
2745
  const source = this.store.getSession(event.sessionId);
2222
2746
  if (source?.remoteSessionId && this.isCodexSession(source)) {
2223
2747
  this.store.saveTurnRuntimeOrigin(
@@ -2228,6 +2752,10 @@ export class ProxySessionController {
2228
2752
  );
2229
2753
  }
2230
2754
  if (event.type === "turn_started") {
2755
+ const attempt = this.store.bindTurnAttempt(event.sessionId, event.turnId);
2756
+ if (attempt?.recoveredFromTurnId && attempt.recoveredFromTurnId !== event.turnId) {
2757
+ this.store.rebindPendingTurnMessages(event.sessionId, attempt.recoveredFromTurnId, event.turnId);
2758
+ }
2231
2759
  this.store.saveTurnParent(
2232
2760
  event.turnId,
2233
2761
  event.sessionId,
@@ -2235,6 +2763,20 @@ export class ProxySessionController {
2235
2763
  );
2236
2764
  }
2237
2765
  }
2766
+ const failedAttempt = event.type === "turn_failed"
2767
+ ? this.store.findIncompleteTurnAttemptByTurnId(event.turnId)
2768
+ : undefined;
2769
+ if (event.type === "turn_failed") {
2770
+ if (this.retriedFailureTurnIds.has(event.turnId)) return;
2771
+ if (failedAttempt && isRetryableLlmTurnFailure(event.message) && failedAttempt.retryCount < MAX_LLM_TURN_RETRIES) {
2772
+ const retryAttempt = this.store.prepareTurnAttemptRetry(failedAttempt.attemptId, event.turnId);
2773
+ if (retryAttempt) {
2774
+ this.retriedFailureTurnIds.add(event.turnId);
2775
+ await this.retryFailedLlmTurn(event, retryAttempt);
2776
+ return;
2777
+ }
2778
+ }
2779
+ }
2238
2780
  if (event.type === "session_metadata_updated") {
2239
2781
  this.store.updateRuntimeSession(event.sessionId, { title: event.title });
2240
2782
  this.outbound.updateSessionTitle(event.sessionId, event.title);
@@ -2244,15 +2786,27 @@ export class ProxySessionController {
2244
2786
  this.store.updateSession(event.sessionId, { status: "running" });
2245
2787
  this.store.updateRuntimeSession(event.sessionId, { lastTurnId: event.turnId, lastTurnStatus: "running" });
2246
2788
  } else if (event.type === "turn_completed" || event.type === "turn_cancelled" || event.type === "turn_failed") {
2789
+ this.store.markTurnAttemptTerminal(
2790
+ event.turnId,
2791
+ event.type === "turn_completed" ? "completed" : event.type === "turn_cancelled" ? "cancelled" : "failed",
2792
+ );
2247
2793
  this.store.updateRuntimeSession(event.sessionId, {
2248
2794
  lastTurnId: event.turnId,
2249
2795
  lastTurnStatus: event.type === "turn_completed" ? "completed" : event.type === "turn_cancelled" ? "cancelled" : "failed",
2250
2796
  });
2251
2797
  }
2252
2798
 
2799
+ const presentationEvent = event.type === "turn_failed"
2800
+ && isRetryableLlmTurnFailure(event.message)
2801
+ && (failedAttempt?.retryCount ?? 0) >= MAX_LLM_TURN_RETRIES
2802
+ ? {
2803
+ ...event,
2804
+ message: `${event.message}\n\n已自动重试 ${MAX_LLM_TURN_RETRIES} 次,仍未成功。`,
2805
+ }
2806
+ : event;
2253
2807
  let presentationError: unknown;
2254
2808
  try {
2255
- await this.outbound.onEvent(event);
2809
+ await this.outbound.onEvent(presentationEvent);
2256
2810
  } catch (error) {
2257
2811
  presentationError = error;
2258
2812
  }
@@ -2285,6 +2839,80 @@ export class ProxySessionController {
2285
2839
  if (presentationError) throw presentationError;
2286
2840
  }
2287
2841
 
2842
+ private async retryFailedLlmTurn(
2843
+ event: Extract<RuntimeEvent, { type: "turn_failed" }>,
2844
+ attempt: TurnAttemptRecord,
2845
+ ): Promise<void> {
2846
+ const retryNumber = attempt.retryCount;
2847
+ const session = this.store.getSession(event.sessionId);
2848
+ if (!session || session.status === "closed") {
2849
+ this.store.updateTurnAttempt(attempt.attemptId, { status: "failed" });
2850
+ return;
2851
+ }
2852
+
2853
+ this.llmRetryingSessions.add(session.localSessionId);
2854
+ this.store.updateRuntimeSession(session.localSessionId, {
2855
+ lastTurnId: event.turnId,
2856
+ lastTurnStatus: "failed",
2857
+ });
2858
+ this.store.audit(attempt.contextKey, "llm_turn_retry_started", {
2859
+ attemptId: attempt.attemptId,
2860
+ failedTurnId: event.turnId,
2861
+ retryNumber,
2862
+ maxRetries: MAX_LLM_TURN_RETRIES,
2863
+ error: event.message,
2864
+ });
2865
+
2866
+ try {
2867
+ await this.outbound.onEvent({
2868
+ ...event,
2869
+ message: `${event.message}\n\n检测到临时模型服务错误,正在自动重试(${retryNumber}/${MAX_LLM_TURN_RETRIES})。`,
2870
+ });
2871
+ } catch (error) {
2872
+ this.logger.warn(
2873
+ { error, sessionId: session.localSessionId, turnId: event.turnId },
2874
+ "Failed to finalize the failed thinking card before an LLM retry.",
2875
+ );
2876
+ }
2877
+
2878
+ const replyTarget = attempt.replyMessageId
2879
+ ? { messageId: attempt.replyMessageId, replyInThread: true as const }
2880
+ : undefined;
2881
+ try {
2882
+ const current = this.store.getSession(session.localSessionId) ?? session;
2883
+ const loaded = await this.loadSession(current);
2884
+ const retryTurnId = await this.startTurn(
2885
+ loaded,
2886
+ llmRetryRuntimePrompt(attempt.promptText, retryNumber),
2887
+ replyTarget,
2888
+ attempt.localImagePaths,
2889
+ {
2890
+ attemptId: attempt.attemptId,
2891
+ messageId: attempt.messageId,
2892
+ displayPrompt: llmRetryCardPrompt(attempt.promptText, retryNumber),
2893
+ preserveAttemptOnFailure: true,
2894
+ },
2895
+ );
2896
+ this.store.rebindPendingTurnMessages(session.localSessionId, event.turnId, retryTurnId);
2897
+ } catch (error) {
2898
+ this.store.updateTurnAttempt(attempt.attemptId, { status: "failed" });
2899
+ this.store.updateSession(session.localSessionId, { status: "failed" });
2900
+ await this.finalizeTurnMessageReactions(event.turnId, "failed").catch((reactionError: unknown) => {
2901
+ this.logger.warn(
2902
+ { error: reactionError, sessionId: session.localSessionId, turnId: event.turnId },
2903
+ "Failed to finalize reactions after an LLM retry could not start.",
2904
+ );
2905
+ });
2906
+ this.logger.error(
2907
+ { error, sessionId: session.localSessionId, retryNumber },
2908
+ "Failed to start an automatic LLM turn retry.",
2909
+ );
2910
+ await this.scheduleNextQueuedPrompt(session.localSessionId);
2911
+ } finally {
2912
+ this.llmRetryingSessions.delete(session.localSessionId);
2913
+ }
2914
+ }
2915
+
2288
2916
  private scheduleNextQueuedPrompt(sessionId: string): Promise<void> {
2289
2917
  const previous = this.queuedPromptStarts.get(sessionId) ?? Promise.resolve();
2290
2918
  const next = previous.catch(() => undefined).then(() => this.startNextQueuedPromptIfIdle(sessionId));
@@ -2317,6 +2945,7 @@ export class ProxySessionController {
2317
2945
  prompt.text,
2318
2946
  prompt.replyMessageId ? { messageId: prompt.replyMessageId, replyInThread: true } : undefined,
2319
2947
  prompt.localImagePaths,
2948
+ { messageId: prompt.messageId },
2320
2949
  );
2321
2950
  if (prompt.messageId) await this.bindMessageReactionToTurn(prompt.messageId, sessionId, turnId);
2322
2951
  } catch (error) {
@@ -2333,7 +2962,9 @@ export class ProxySessionController {
2333
2962
  if (!source?.lastTurnId) return undefined;
2334
2963
  if (source.lastTurnStatus === "completed") return source.lastTurnId;
2335
2964
  const snapshot = turnViewSnapshot(this.store.getTurnSnapshot(source.lastTurnId));
2336
- return snapshot?.status === "completed" ? source.lastTurnId : undefined;
2965
+ return snapshot?.status === "completed"
2966
+ ? source.lastTurnId
2967
+ : this.store.findLatestCompletedTurnId(source.localSessionId, source.contextKey);
2337
2968
  }
2338
2969
 
2339
2970
  private async finalizeStandaloneMessageReaction(
@@ -3194,6 +3825,175 @@ export class ProxySessionController {
3194
3825
  }
3195
3826
  }
3196
3827
 
3828
+ private async openDirectoryBrowser(
3829
+ contextKey: string,
3830
+ requestedDirectory?: string,
3831
+ options: DirectoryBrowserOptions = {},
3832
+ ): Promise<void> {
3833
+ if (requestedDirectory === WINDOWS_DRIVES_DIRECTORY) {
3834
+ await this.openWindowsDriveBrowser(contextKey, options);
3835
+ return;
3836
+ }
3837
+ const baseDirectory = this.currentSession(contextKey)?.cwd
3838
+ ?? this.store.getUserContext(contextKey)?.boundProjectCwd
3839
+ ?? this.config.defaults.cwd;
3840
+ const directory = requestedDirectory === undefined
3841
+ ? path.resolve(baseDirectory)
3842
+ : resolveUserPath(requestedDirectory, baseDirectory);
3843
+ await this.assertBrowsableDirectory(directory);
3844
+
3845
+ let directoryEntries: Dirent[];
3846
+ try {
3847
+ directoryEntries = await fs.readdir(directory, { withFileTypes: true });
3848
+ } catch (error) {
3849
+ throw new Error(`无法读取目录 ${directory}:${runtimeErrorMessage(error)}`);
3850
+ }
3851
+ const entries = directoryEntries
3852
+ .map((entry) => ({
3853
+ name: entry.name,
3854
+ path: path.join(directory, entry.name),
3855
+ kind: directoryBrowserEntryKind(entry),
3856
+ }))
3857
+ .sort((left, right) => Number(right.kind === "directory") - Number(left.kind === "directory")
3858
+ || left.name.localeCompare(right.name, undefined, { numeric: true, sensitivity: "base" }));
3859
+ const parentDirectory = isWindowsDriveRoot(directory)
3860
+ ? WINDOWS_DRIVES_DIRECTORY
3861
+ : path.dirname(directory);
3862
+ const parentEntries: DirectoryBrowserCardEntry[] = parentDirectory === directory ? [] : [{
3863
+ name: "..",
3864
+ kind: "directory",
3865
+ openAction: {
3866
+ text: "..",
3867
+ value: {
3868
+ action: "directory_open",
3869
+ directory: parentDirectory,
3870
+ contextKey,
3871
+ },
3872
+ },
3873
+ }];
3874
+ const pageSize = DIRECTORY_PAGE_SIZE;
3875
+ const requestedPage = Math.max(0, Math.trunc(options.page ?? 0));
3876
+ const totalPages = Math.max(1, Math.ceil(entries.length / pageSize));
3877
+ const page = Math.min(requestedPage, totalPages - 1);
3878
+ const offset = page * pageSize;
3879
+ const visibleEntries: DirectoryBrowserCardEntry[] = [
3880
+ ...parentEntries,
3881
+ ...entries
3882
+ .slice(offset, offset + pageSize)
3883
+ .map((entry): DirectoryBrowserCardEntry => entry.kind === "directory"
3884
+ ? {
3885
+ name: entry.name,
3886
+ kind: entry.kind,
3887
+ openAction: {
3888
+ text: entry.name,
3889
+ value: {
3890
+ action: "directory_open",
3891
+ directory: entry.path,
3892
+ contextKey,
3893
+ },
3894
+ },
3895
+ actions: directoryCreationActions(entry.path, contextKey),
3896
+ }
3897
+ : {
3898
+ name: entry.name,
3899
+ kind: entry.kind,
3900
+ openAction: {
3901
+ text: entry.name,
3902
+ value: {
3903
+ action: "directory_send_file",
3904
+ filePath: entry.path,
3905
+ contextKey,
3906
+ },
3907
+ },
3908
+ }),
3909
+ ];
3910
+ const navigationActions = directoryPaginationActions(directory, page, totalPages, contextKey);
3911
+ const directoryCount = entries.filter((entry) => entry.kind === "directory").length;
3912
+ const card = this.cardRenderer.renderDirectoryBrowserCard({
3913
+ directory: abbreviateHomeDirectory(directory),
3914
+ entries: visibleEntries,
3915
+ currentActions: directoryCreationActions(directory, contextKey),
3916
+ navigationActions,
3917
+ footerLines: [
3918
+ `第 ${page + 1}/${totalPages} 页 · ${directoryCount} 个目录 · ${entries.length - directoryCount} 个文件`,
3919
+ "",
3920
+ "> 点击目录名称进入,点击文件名称发送到当前会话;**New** 在该目录创建任务,**NewGroup** 在该目录创建新群和任务。",
3921
+ ],
3922
+ });
3923
+ if (options.updateMessageId) {
3924
+ await this.outbound.updateInteractiveCard(contextKey, options.updateMessageId, card);
3925
+ } else {
3926
+ await this.outbound.sendInteractiveCard(contextKey, card);
3927
+ }
3928
+ }
3929
+
3930
+ private async openWindowsDriveBrowser(
3931
+ contextKey: string,
3932
+ options: DirectoryBrowserOptions,
3933
+ ): Promise<void> {
3934
+ const drives = await this.windowsDriveLister();
3935
+ const requestedPage = Math.max(0, Math.trunc(options.page ?? 0));
3936
+ const totalPages = Math.max(1, Math.ceil(drives.length / DIRECTORY_PAGE_SIZE));
3937
+ const page = Math.min(requestedPage, totalPages - 1);
3938
+ const offset = page * DIRECTORY_PAGE_SIZE;
3939
+ const entries: DirectoryBrowserCardEntry[] = drives
3940
+ .slice(offset, offset + DIRECTORY_PAGE_SIZE)
3941
+ .map((drive) => ({
3942
+ name: windowsDriveDisplayName(drive),
3943
+ kind: "drive",
3944
+ openAction: {
3945
+ text: windowsDriveDisplayName(drive),
3946
+ value: {
3947
+ action: "directory_open",
3948
+ directory: drive.root,
3949
+ contextKey,
3950
+ },
3951
+ },
3952
+ actions: directoryCreationActions(drive.root, contextKey),
3953
+ }));
3954
+ const card = this.cardRenderer.renderDirectoryBrowserCard({
3955
+ directory: "此电脑",
3956
+ entries,
3957
+ currentActions: [],
3958
+ navigationActions: directoryPaginationActions(
3959
+ WINDOWS_DRIVES_DIRECTORY,
3960
+ page,
3961
+ totalPages,
3962
+ contextKey,
3963
+ ),
3964
+ footerLines: [
3965
+ `第 ${page + 1}/${totalPages} 页 · ${drives.length} 个磁盘`,
3966
+ "",
3967
+ "> 点击磁盘名称进入;**New** 在磁盘根目录创建任务,**NewGroup** 在磁盘根目录创建新群和任务。",
3968
+ ],
3969
+ });
3970
+ if (options.updateMessageId) {
3971
+ await this.outbound.updateInteractiveCard(contextKey, options.updateMessageId, card);
3972
+ } else {
3973
+ await this.outbound.sendInteractiveCard(contextKey, card);
3974
+ }
3975
+ }
3976
+
3977
+ private async assertBrowsableDirectory(directory: string): Promise<void> {
3978
+ let stats;
3979
+ try {
3980
+ stats = await fs.stat(directory);
3981
+ } catch (error) {
3982
+ throw new Error(`目录不存在或无法访问:${directory}(${runtimeErrorMessage(error)})`);
3983
+ }
3984
+ if (!stats.isDirectory()) throw new Error(`这不是目录:${directory}`);
3985
+ }
3986
+
3987
+ private async assertSendableFile(filePath: string): Promise<void> {
3988
+ let stats;
3989
+ try {
3990
+ stats = await fs.stat(filePath);
3991
+ } catch (error) {
3992
+ throw new Error(`文件不存在或无法访问:${filePath}(${runtimeErrorMessage(error)})`);
3993
+ }
3994
+ if (!stats.isFile()) throw new Error(`这不是普通文件:${filePath}`);
3995
+ }
3996
+
3197
3997
  private async listSessions(
3198
3998
  contextKey: string,
3199
3999
  searchTerm?: string,
@@ -3224,23 +4024,33 @@ export class ProxySessionController {
3224
4024
  ? `部分 Agent 的任务读取失败:${remoteErrors.join(";")}`
3225
4025
  : undefined;
3226
4026
 
3227
- const entries = mergeTaskList(localSessions, remoteSessions, context.currentSessionId)
3228
- .filter((entry) => !normalizedSearch
4027
+ const entries = orderTaskListByProject(
4028
+ mergeTaskList(localSessions, remoteSessions, context.currentSessionId)
4029
+ .filter((entry) => !normalizedSearch
3229
4030
  || [entry.id, entry.title, entry.cwd, entry.agentName]
3230
- .some((value) => value.toLowerCase().includes(normalizedSearch)))
3231
- .sort((left, right) => Number(right.current) - Number(left.current)
3232
- || Number(right.active) - Number(left.active)
3233
- || right.updatedAt - left.updatedAt);
4031
+ .some((value) => value.toLowerCase().includes(normalizedSearch))),
4032
+ );
3234
4033
  const activeCount = entries.filter((entry) => entry.active).length;
3235
4034
  const lastKnownPage = Math.max(0, Math.ceil(entries.length / SESSION_PAGE_SIZE) - 1);
3236
4035
  const page = remoteHasMore ? requestedPage : Math.min(requestedPage, lastKnownPage);
3237
4036
  const offset = page * SESSION_PAGE_SIZE;
3238
4037
  const visibleEntries = entries.slice(offset, offset + SESSION_PAGE_SIZE);
4038
+ const projectActionSources = new Map<string, UnifiedTaskListEntry>();
4039
+ for (const entry of entries) {
4040
+ const project = taskProjectInfo(entry);
4041
+ if (!projectActionSources.has(project.key)) projectActionSources.set(project.key, entry);
4042
+ }
3239
4043
  const hasPrevious = page > 0;
3240
4044
  const hasNext = remoteHasMore || entries.length > offset + SESSION_PAGE_SIZE;
3241
4045
  this.lastSessionListings.set(contextKey, entries.map((entry) => entry.reference));
3242
- const cardEntries = visibleEntries.map((entry, index) => {
3243
- const marker = entry.current ? "✅" : entry.active ? "🟢 **活跃**" : "•";
4046
+ const cardEntries = visibleEntries.map((entry, index): {
4047
+ project: TaskProjectInfo;
4048
+ projectActions: TaskListCardAction[];
4049
+ cardEntry: SessionTaskCardEntry;
4050
+ } => {
4051
+ const project = taskProjectInfo(entry);
4052
+ const projectActionSource = projectActionSources.get(project.key) ?? entry;
4053
+ const marker = entry.current ? "✅" : entry.active ? "🟢" : "•";
3244
4054
  const showStop = entry.status === "外部执行中"
3245
4055
  && entry.reference !== options.forceSwitchTaskId
3246
4056
  && entry.id !== options.forceSwitchTaskId;
@@ -3255,26 +4065,25 @@ export class ProxySessionController {
3255
4065
  contextKey,
3256
4066
  },
3257
4067
  }];
3258
- actions.push({
4068
+ const projectActions: TaskListCardAction[] = [{
3259
4069
  text: "New",
3260
4070
  value: {
3261
4071
  action: "session_new",
3262
- sessionId: entry.reference,
4072
+ sessionId: projectActionSource.reference,
3263
4073
  ...(searchTerm ? { searchTerm } : {}),
3264
4074
  page: String(page),
3265
4075
  contextKey,
3266
4076
  },
3267
- });
3268
- actions.push({
4077
+ }, {
3269
4078
  text: "NewGroup",
3270
4079
  value: {
3271
4080
  action: "session_new_group",
3272
- sessionId: entry.reference,
4081
+ sessionId: projectActionSource.reference,
3273
4082
  ...(searchTerm ? { searchTerm } : {}),
3274
4083
  page: String(page),
3275
4084
  contextKey,
3276
4085
  },
3277
- });
4086
+ }];
3278
4087
  actions.push({
3279
4088
  text: "Fork",
3280
4089
  value: {
@@ -3303,24 +4112,48 @@ export class ProxySessionController {
3303
4112
  contextKey,
3304
4113
  },
3305
4114
  });
4115
+ const title = truncateText(entry.title.replace(/\s+/gu, " ").trim() || "未命名任务", 56);
3306
4116
  return {
3307
- lines: [
3308
- `**${offset + index + 1}.** ${marker} **${cardText(entry.title)}** ${cardText(entry.id)}`,
3309
- `${entry.status} · ${entry.updatedLabel} · ${cardText(entry.agentName)} · ${cardText(entry.cwd || "目录未知")}`,
3310
- ],
3311
- actions,
4117
+ project,
4118
+ projectActions,
4119
+ cardEntry: {
4120
+ reference: entry.reference,
4121
+ summary: `${offset + index + 1}. ${marker} ${title} · ${entry.agentName}`,
4122
+ detailLines: [
4123
+ `**状态 / 更新时间**:${cardText(entry.status)} / ${cardText(entry.updatedLabel)}`,
4124
+ `**Agent / 任务 ID**:${cardCode(entry.agentName)} / ${cardCode(entry.id)}`,
4125
+ `**目录**:${cardCode(entry.cwd || "目录未知")}`,
4126
+ ],
4127
+ actions,
4128
+ current: entry.current,
4129
+ },
3312
4130
  };
3313
4131
  });
3314
- const card = this.cardRenderer.renderTaskListCard(
3315
- searchTerm ? `App Server 任务:${searchTerm}` : "App Server 任务",
4132
+ const cardGroups: SessionTaskCardGroup[] = [];
4133
+ let currentProjectKey: string | undefined;
4134
+ for (const item of cardEntries) {
4135
+ const currentGroup = cardGroups.at(-1);
4136
+ if (currentGroup && currentProjectKey === item.project.key) {
4137
+ currentGroup.entries.push(item.cardEntry);
4138
+ } else {
4139
+ currentProjectKey = item.project.key;
4140
+ cardGroups.push({
4141
+ title: item.project.title,
4142
+ entries: [item.cardEntry],
4143
+ actions: item.projectActions,
4144
+ });
4145
+ }
4146
+ }
4147
+ const card = this.cardRenderer.renderSessionTaskListCard(
4148
+ searchTerm ? `任务列表:${searchTerm}` : "任务列表",
3316
4149
  activeCount > 0 ? `任务(${activeCount} 个活跃)` : "任务",
3317
- cardEntries,
4150
+ cardGroups,
3318
4151
  [
3319
4152
  ...(remoteHint ? [remoteHint] : []),
3320
4153
  `第 ${page + 1} 页 · 每页 ${SESSION_PAGE_SIZE} 个任务${hasNext ? "" : ` · 当前共 ${entries.length} 个任务`}`,
3321
4154
  "",
3322
- "> **Switch** 切换任务;**Stop** 停止运行;**Status** 查看状态。",
3323
- "> **New** / **Fork** 新建 / 分支任务;**NewGroup** / **ForkGroup** 新建 / 分支群。",
4155
+ "> 项目行:**New** 新建任务,**NewGroup** 新建群。",
4156
+ "> 任务详情:**Switch** 切换,**Stop** 停止,**Fork** / **ForkGroup** 创建分支,**Status** 查看状态。",
3324
4157
  ],
3325
4158
  [
3326
4159
  ...(hasPrevious ? [{
@@ -3839,6 +4672,21 @@ export class ProxySessionController {
3839
4672
  ));
3840
4673
  }
3841
4674
 
4675
+ private async setGroupMute(contextKey: string, enabled: boolean): Promise<void> {
4676
+ const chatContextKey = baseChatContextKey(contextKey);
4677
+ const chat = this.store.getChatContext(chatContextKey);
4678
+ if (chat?.chatType !== "group") {
4679
+ throw new Error("/mute 仅适用于群聊。");
4680
+ }
4681
+ this.store.setChatRequiresMention(chatContextKey, enabled);
4682
+ await this.outbound.sendText(
4683
+ contextKey,
4684
+ enabled
4685
+ ? "已开启当前群的静音模式。之后只有 @ 机器人的消息会被处理;发送 @机器人 /mute off 可恢复自动响应。"
4686
+ : "已关闭当前群的静音模式。机器人将恢复自动响应群消息。",
4687
+ );
4688
+ }
4689
+
3842
4690
  private ensureAgent(agentName: string) {
3843
4691
  const agent = this.config.agents[agentName];
3844
4692
  if (!agent) throw new Error(`未知 agent:${agentName}`);
@@ -3903,6 +4751,11 @@ export class ProxySessionController {
3903
4751
  }
3904
4752
  }
3905
4753
 
4754
+ function threadForkAnchorMessageIds(message: IncomingMessage): string[] {
4755
+ return [...new Set([message.rootMessageId, message.parentMessageId]
4756
+ .filter((messageId): messageId is string => Boolean(messageId && messageId !== message.messageId)))];
4757
+ }
4758
+
3906
4759
  function sessionStatusLabel(status: SessionRecord["status"], activeTurnId?: string): string {
3907
4760
  if (activeTurnId || status === "running") return "执行中";
3908
4761
  const labels: Record<SessionRecord["status"], string> = {
@@ -3957,6 +4810,55 @@ interface UnifiedTaskListEntry {
3957
4810
  updatedLabel: string;
3958
4811
  }
3959
4812
 
4813
+ interface TaskProjectInfo {
4814
+ key: string;
4815
+ title: string;
4816
+ }
4817
+
4818
+ function orderTaskListByProject(entries: UnifiedTaskListEntry[]): UnifiedTaskListEntry[] {
4819
+ const groups = new Map<string, { project: TaskProjectInfo; entries: UnifiedTaskListEntry[] }>();
4820
+ for (const entry of entries) {
4821
+ const project = taskProjectInfo(entry);
4822
+ const group = groups.get(project.key);
4823
+ if (group) group.entries.push(entry);
4824
+ else groups.set(project.key, { project, entries: [entry] });
4825
+ }
4826
+ const orderedGroups = [...groups.values()]
4827
+ .map((group) => ({
4828
+ ...group,
4829
+ current: group.entries.some((entry) => entry.current),
4830
+ active: group.entries.some((entry) => entry.active),
4831
+ updatedAt: Math.max(...group.entries.map((entry) => entry.updatedAt)),
4832
+ }))
4833
+ .sort((left, right) => Number(right.current) - Number(left.current)
4834
+ || Number(right.active) - Number(left.active)
4835
+ || right.updatedAt - left.updatedAt
4836
+ || left.project.title.localeCompare(right.project.title, undefined, { sensitivity: "base" }));
4837
+ return orderedGroups.flatMap((group) => group.entries.sort(compareTaskListEntries));
4838
+ }
4839
+
4840
+ function compareTaskListEntries(left: UnifiedTaskListEntry, right: UnifiedTaskListEntry): number {
4841
+ return Number(right.current) - Number(left.current)
4842
+ || Number(right.active) - Number(left.active)
4843
+ || right.updatedAt - left.updatedAt
4844
+ || left.title.localeCompare(right.title, undefined, { sensitivity: "base" });
4845
+ }
4846
+
4847
+ function taskProjectInfo(entry: UnifiedTaskListEntry): TaskProjectInfo {
4848
+ if (!entry.cwd) return { key: "unknown", title: "📁 目录未知" };
4849
+ if (detectProjectlessWorkspace(entry.cwd)) {
4850
+ return { key: "projectless", title: "🗒️ 未指定项目" };
4851
+ }
4852
+ const windowsPath = isWindowsAbsolutePath(entry.cwd);
4853
+ const normalized = windowsPath
4854
+ ? path.win32.normalize(entry.cwd).toLowerCase()
4855
+ : path.resolve(entry.cwd);
4856
+ return {
4857
+ key: `cwd:${normalized}`,
4858
+ title: `📁 ${abbreviateHomeDirectory(entry.cwd)}`,
4859
+ };
4860
+ }
4861
+
3960
4862
  function mergeTaskList(
3961
4863
  localSessions: SessionRecord[],
3962
4864
  remoteSessions: AgentRemoteSessionSummary[],
@@ -4055,6 +4957,14 @@ function turnViewSnapshot(value: unknown): TurnViewState | undefined {
4055
4957
  : undefined;
4056
4958
  }
4057
4959
 
4960
+ function isTerminalTurnViewStatus(status: TurnViewState["status"]): boolean {
4961
+ return status === "completed" || status === "cancelled" || status === "failed";
4962
+ }
4963
+
4964
+ function isIncompleteTurnAttemptStatus(status: TurnAttemptRecord["status"]): boolean {
4965
+ return status === "accepted" || status === "running" || status === "recovering";
4966
+ }
4967
+
4058
4968
  function executionDetailLines(
4059
4969
  record: SessionRecord,
4060
4970
  snapshot: TurnViewState | undefined,
@@ -4336,7 +5246,7 @@ function isBotOwnedActiveTurn(record: SessionRecord, remote: RemoteSessionSummar
4336
5246
  }
4337
5247
 
4338
5248
  function isQueueIndependentCommand(command: Command): boolean {
4339
- if (["stop", "status", "restart", "help", "sessions", "goal", "nosteer"].includes(command.type)) return true;
5249
+ if (["stop", "status", "restart", "mute", "help", "sessions", "dir", "goal", "nosteer"].includes(command.type)) return true;
4340
5250
  if (command.type === "agent") return command.agent === undefined;
4341
5251
  if (["model", "provider", "thinking", "permissions"].includes(command.type)) return true;
4342
5252
  return false;
@@ -4347,6 +5257,138 @@ function parseSessionPage(value: unknown): number {
4347
5257
  return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : 0;
4348
5258
  }
4349
5259
 
5260
+ function directoryPageValue(value: unknown): number {
5261
+ const page = Number(value);
5262
+ if (!Number.isSafeInteger(page) || page < 0) throw new Error("文件浏览卡片页码无效,请重新发送 /dir。");
5263
+ return page;
5264
+ }
5265
+
5266
+ function isWindowsDriveRoot(directory: string): boolean {
5267
+ return /^[a-z]:\\$/iu.test(path.win32.normalize(directory));
5268
+ }
5269
+
5270
+ function directoryBrowserEntryKind(entry: Dirent): DirectoryBrowserCardEntry["kind"] {
5271
+ if (entry.isDirectory()) return "directory";
5272
+ const extension = path.extname(entry.name).toLowerCase();
5273
+ if (IMAGE_FILE_EXTENSIONS.has(extension)) return "image";
5274
+ if (BINARY_FILE_EXTENSIONS.has(extension)) return "binary";
5275
+ return "file";
5276
+ }
5277
+
5278
+ function directoryActionPath(value: unknown): string {
5279
+ if (typeof value !== "string" || !value.trim()) {
5280
+ throw new Error("文件浏览卡片中的目录无效,请重新发送 /dir。");
5281
+ }
5282
+ return value === WINDOWS_DRIVES_DIRECTORY ? value : path.resolve(value);
5283
+ }
5284
+
5285
+ function directoryFileActionPath(value: unknown): string {
5286
+ if (typeof value !== "string" || !value.trim()) {
5287
+ throw new Error("文件浏览卡片中的文件无效,请重新发送 /dir。");
5288
+ }
5289
+ return path.resolve(value);
5290
+ }
5291
+
5292
+ function directoryCreationActions(directory: string, contextKey: string): TaskListCardAction[] {
5293
+ return [
5294
+ {
5295
+ text: "New",
5296
+ value: {
5297
+ action: "directory_new",
5298
+ directory,
5299
+ contextKey,
5300
+ },
5301
+ },
5302
+ {
5303
+ text: "NewGroup",
5304
+ value: {
5305
+ action: "directory_new_group",
5306
+ directory,
5307
+ contextKey,
5308
+ },
5309
+ },
5310
+ ];
5311
+ }
5312
+
5313
+ function directoryPaginationActions(
5314
+ directory: string,
5315
+ page: number,
5316
+ totalPages: number,
5317
+ contextKey: string,
5318
+ ): TaskListCardAction[] {
5319
+ return [
5320
+ ...(page > 0 ? [{
5321
+ text: "Previous",
5322
+ value: {
5323
+ action: "directory_page",
5324
+ directory,
5325
+ page: String(page - 1),
5326
+ contextKey,
5327
+ },
5328
+ }] : []),
5329
+ ...(page < totalPages - 1 ? [{
5330
+ text: "Next",
5331
+ value: {
5332
+ action: "directory_page",
5333
+ directory,
5334
+ page: String(page + 1),
5335
+ contextKey,
5336
+ },
5337
+ }] : []),
5338
+ ];
5339
+ }
5340
+
5341
+ function windowsDriveDisplayName(drive: WindowsDriveInfo): string {
5342
+ const driveLetter = drive.root.slice(0, 2).toUpperCase();
5343
+ const label = drive.label?.trim() || windowsDriveTypeLabel(drive.driveType);
5344
+ return `${label} (${driveLetter})`;
5345
+ }
5346
+
5347
+ function windowsDriveTypeLabel(driveType?: string): string {
5348
+ switch (driveType?.toLowerCase()) {
5349
+ case "fixed": return "本地磁盘";
5350
+ case "network": return "网络驱动器";
5351
+ case "removable": return "可移动磁盘";
5352
+ case "cdrom": return "光驱";
5353
+ case "ram": return "RAM 磁盘";
5354
+ default: return "磁盘";
5355
+ }
5356
+ }
5357
+
5358
+ async function listWindowsDriveRoots(): Promise<WindowsDriveInfo[]> {
5359
+ if (process.platform !== "win32") throw new Error("磁盘列表仅在 Windows 上可用。");
5360
+ const result = await executeShellCommand(
5361
+ "$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",
5362
+ os.homedir(),
5363
+ { timeoutMs: 10_000, maxOutputBytes: 16 * 1024, platform: "win32" },
5364
+ );
5365
+ if (result.timedOut || result.exitCode !== 0) {
5366
+ const detail = result.stderr.trim() || `退出码 ${result.exitCode ?? "未知"}`;
5367
+ throw new Error(`无法读取 Windows 磁盘列表:${detail}`);
5368
+ }
5369
+ let parsed: unknown;
5370
+ try {
5371
+ parsed = JSON.parse(result.stdout.trim().replace(/^\uFEFF/u, ""));
5372
+ } catch (error) {
5373
+ throw new Error(`无法解析 Windows 磁盘列表:${runtimeErrorMessage(error)}`);
5374
+ }
5375
+ const values = Array.isArray(parsed) ? parsed : [parsed];
5376
+ const drives = values.flatMap((value): WindowsDriveInfo[] => {
5377
+ if (typeof value !== "object" || value === null) return [];
5378
+ const record = value as Record<string, unknown>;
5379
+ if (typeof record.root !== "string") return [];
5380
+ const root = path.win32.normalize(record.root.trim());
5381
+ if (!isWindowsDriveRoot(root)) return [];
5382
+ return [{
5383
+ root,
5384
+ ...(typeof record.label === "string" && record.label.trim() ? { label: record.label.trim() } : {}),
5385
+ ...(typeof record.driveType === "string" ? { driveType: record.driveType } : {}),
5386
+ }];
5387
+ }).sort((left, right) => left.root.localeCompare(right.root, undefined, { sensitivity: "base" }));
5388
+ if (drives.length === 0) throw new Error("没有检测到可用的 Windows 磁盘。");
5389
+ return drives;
5390
+ }
5391
+
4350
5392
  function isPromptCommand(command: Command): boolean {
4351
5393
  return command.type === "prompt" || command.type === "nosteer";
4352
5394
  }
@@ -4355,6 +5397,75 @@ function runtimePrompt(text: string, localImagePaths?: string[]): RuntimePrompt
4355
5397
  return localImagePaths?.length ? { text, localImagePaths } : text;
4356
5398
  }
4357
5399
 
5400
+ function recoveryCardPrompt(prompt: string): string {
5401
+ return `恢复重启前的任务:${prompt}`;
5402
+ }
5403
+
5404
+ function recoveryRuntimePrompt(prompt: string): string {
5405
+ return [
5406
+ "Agent Bot restarted while the previous turn was still running.",
5407
+ "Continue the unfinished task in the current task and workspace.",
5408
+ "Inspect the existing conversation, files, and completed tool effects first. Do not repeat completed or irreversible actions.",
5409
+ "Finish the original request:",
5410
+ "",
5411
+ prompt,
5412
+ ].join("\n");
5413
+ }
5414
+
5415
+ function llmRetryCardPrompt(prompt: string, retryNumber: number): string {
5416
+ return `自动重试 ${retryNumber}/${MAX_LLM_TURN_RETRIES}:${prompt}`;
5417
+ }
5418
+
5419
+ function llmRetryRuntimePrompt(prompt: string, retryNumber: number): string {
5420
+ return [
5421
+ `Agent Bot is automatically retrying this request after a transient LLM service failure (${retryNumber}/${MAX_LLM_TURN_RETRIES}).`,
5422
+ "Continue in the current task and workspace. Inspect the conversation, files, and completed tool effects before acting.",
5423
+ "Do not repeat completed or irreversible actions. Finish the original request:",
5424
+ "",
5425
+ prompt,
5426
+ ].join("\n");
5427
+ }
5428
+
5429
+ function isRetryableLlmTurnFailure(message: string): boolean {
5430
+ const normalized = message.trim().toLowerCase();
5431
+ if (!normalized) return false;
5432
+ const permanentFailure = [
5433
+ /auth(?:entication|orization)?\b|unauthorized|forbidden|invalid api key|incorrect api key/u,
5434
+ /insufficient[_ -]?quota|quota (?:exceeded|exhausted)|usage limit|billing|credit balance/u,
5435
+ /context (?:length|window)|maximum context|too many tokens|token limit/u,
5436
+ /invalid request|bad request|invalid argument|malformed|unsupported|not supported/u,
5437
+ /model (?:not found|does not exist)|unknown model/u,
5438
+ /permission|approval|sandbox|policy violation|content policy/u,
5439
+ ].some((pattern) => pattern.test(normalized));
5440
+ if (permanentFailure) return false;
5441
+
5442
+ return [
5443
+ /(?:^|\D)429(?:\D|$)|rate[_ -]?limit|too many requests/u,
5444
+ /overload|high demand|at capacity|capacity limit|temporar(?:y|ily) unavailable/u,
5445
+ /service unavailable|internal server error|bad gateway|gateway timeout/u,
5446
+ /(?:^|\D)(?:500|502|503|504)(?:\D|$)|upstream (?:error|failure|timeout)/u,
5447
+ /deadline exceeded|request timed out|request timeout|response timeout/u,
5448
+ /connection (?:reset|closed|aborted)|network error|socket hang up/u,
5449
+ /stream (?:closed|disconnected|interrupted|error)/u,
5450
+ /\b(?:llm|model|provider|inference)\b.*\b(?:error|failed|failure|unavailable|timeout|timed out)\b/u,
5451
+ ].some((pattern) => pattern.test(normalized));
5452
+ }
5453
+
5454
+ function remoteTurnMatchesAttempt(remote: RemoteSessionSummary | undefined, attempt: TurnAttemptRecord): boolean {
5455
+ if (!remote?.lastTurnId) return false;
5456
+ if (attempt.turnId) return remote.lastTurnId === attempt.turnId;
5457
+ const remoteUpdatedAt = remote.updatedAt === undefined ? undefined : normalizeRemoteTimestamp(remote.updatedAt);
5458
+ const acceptedAt = Date.parse(attempt.createdAt);
5459
+ return remoteUpdatedAt !== undefined
5460
+ && Number.isFinite(acceptedAt)
5461
+ && remoteUpdatedAt >= acceptedAt - 1_000;
5462
+ }
5463
+
5464
+ function isRecoveryAttemptRecent(attempt: TurnAttemptRecord, cutoff: number): boolean {
5465
+ const updatedAt = Date.parse(attempt.updatedAt);
5466
+ return Number.isFinite(updatedAt) && updatedAt >= cutoff;
5467
+ }
5468
+
4358
5469
  function formatGroupDateSuffix(date: Date): string {
4359
5470
  const month = String(date.getMonth() + 1).padStart(2, "0");
4360
5471
  const day = String(date.getDate()).padStart(2, "0");
@@ -4395,10 +5506,19 @@ function formatGroupProjectDirectory(projectCwd: string): string {
4395
5506
  }
4396
5507
 
4397
5508
  function abbreviateHomeDirectory(value: string): string {
4398
- const relative = path.relative(os.homedir(), value);
5509
+ const homeDirectory = os.homedir();
5510
+ const valueIsWindows = isWindowsAbsolutePath(value);
5511
+ const homeIsWindows = isWindowsAbsolutePath(homeDirectory);
5512
+ if (valueIsWindows !== homeIsWindows && (valueIsWindows || path.posix.isAbsolute(value))) return value;
5513
+ const pathApi = valueIsWindows ? path.win32 : path;
5514
+ const relative = pathApi.relative(homeDirectory, value);
4399
5515
  if (relative === "") return "~";
4400
- if (path.isAbsolute(relative) || relative === ".." || relative.startsWith(`..${path.sep}`)) return value;
4401
- return `~${path.sep}${relative}`;
5516
+ if (pathApi.isAbsolute(relative) || relative === ".." || relative.startsWith(`..${pathApi.sep}`)) return value;
5517
+ return `~${pathApi.sep}${relative}`;
5518
+ }
5519
+
5520
+ function isWindowsAbsolutePath(value: string): boolean {
5521
+ return /^[a-z]:[\\/]|^\\\\/iu.test(value);
4402
5522
  }
4403
5523
 
4404
5524
  function fitTrailingPathLevels(levels: string[], maxLength: number, separator: string): string {