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

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 (113) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +82 -55
  3. package/README.zh.md +82 -55
  4. package/config.example.yaml +5 -1
  5. package/dist/cli/AutostartManager.js +431 -0
  6. package/dist/cli/AutostartManager.js.map +1 -0
  7. package/dist/cli/FeishuAppConfiguration.js +17 -13
  8. package/dist/cli/FeishuAppConfiguration.js.map +1 -1
  9. package/dist/cli/Initializer.js +35 -0
  10. package/dist/cli/Initializer.js.map +1 -1
  11. package/dist/cli/SelfUpdateRunner.js +552 -0
  12. package/dist/cli/SelfUpdateRunner.js.map +1 -0
  13. package/dist/cli/SelfUpdater.js +431 -0
  14. package/dist/cli/SelfUpdater.js.map +1 -0
  15. package/dist/cli/autostartOutput.js +63 -0
  16. package/dist/cli/autostartOutput.js.map +1 -0
  17. package/dist/cli/controlProtocol.js.map +1 -1
  18. package/dist/cli/help.js +92 -18
  19. package/dist/cli/help.js.map +1 -1
  20. package/dist/cli/taskGroupOptions.js +14 -11
  21. package/dist/cli/taskGroupOptions.js.map +1 -1
  22. package/dist/cli/taskListOutput.js +3 -0
  23. package/dist/cli/taskListOutput.js.map +1 -1
  24. package/dist/cli/taskTarget.js +99 -0
  25. package/dist/cli/taskTarget.js.map +1 -0
  26. package/dist/cli.js +643 -58
  27. package/dist/cli.js.map +1 -1
  28. package/dist/codex/CodexRuntime.js +16 -5
  29. package/dist/codex/CodexRuntime.js.map +1 -1
  30. package/dist/commands/CommandRouter.js +20 -1
  31. package/dist/commands/CommandRouter.js.map +1 -1
  32. package/dist/config/loadConfig.js +2 -0
  33. package/dist/config/loadConfig.js.map +1 -1
  34. package/dist/config/schema.js +2 -0
  35. package/dist/config/schema.js.map +1 -1
  36. package/dist/console/ConsoleTurnPresenter.js +2 -0
  37. package/dist/console/ConsoleTurnPresenter.js.map +1 -1
  38. package/dist/feishu/CardRenderer.js +584 -16
  39. package/dist/feishu/CardRenderer.js.map +1 -1
  40. package/dist/feishu/FeishuConnector.js +29 -8
  41. package/dist/feishu/FeishuConnector.js.map +1 -1
  42. package/dist/feishu/FeishuMarkdown.js +25 -1
  43. package/dist/feishu/FeishuMarkdown.js.map +1 -1
  44. package/dist/feishu/FeishuMessageClient.js +43 -0
  45. package/dist/feishu/FeishuMessageClient.js.map +1 -1
  46. package/dist/feishu/FeishuTurnPresenter.js +29 -1
  47. package/dist/feishu/FeishuTurnPresenter.js.map +1 -1
  48. package/dist/feishu/ownerAccess.js +7 -0
  49. package/dist/feishu/ownerAccess.js.map +1 -0
  50. package/dist/index.js +148 -19
  51. package/dist/index.js.map +1 -1
  52. package/dist/presentation/OutboundRouter.js +19 -1
  53. package/dist/presentation/OutboundRouter.js.map +1 -1
  54. package/dist/proxy/ProxySessionController.js +1444 -66
  55. package/dist/proxy/ProxySessionController.js.map +1 -1
  56. package/dist/startup/StartupNotifier.js +65 -16
  57. package/dist/startup/StartupNotifier.js.map +1 -1
  58. package/dist/startup/startFeishu.js +2 -2
  59. package/dist/startup/startFeishu.js.map +1 -1
  60. package/dist/state/StateStore.js +268 -67
  61. package/dist/state/StateStore.js.map +1 -1
  62. package/dist/state/migrations.js +26 -0
  63. package/dist/state/migrations.js.map +1 -1
  64. package/dist/supervision/SafeRestartNotifier.js +17 -16
  65. package/dist/supervision/SafeRestartNotifier.js.map +1 -1
  66. package/dist/supervision/SafeRestartScheduler.js +12 -7
  67. package/dist/supervision/SafeRestartScheduler.js.map +1 -1
  68. package/dist/supervision/replacementSupervisor.js +45 -4
  69. package/dist/supervision/replacementSupervisor.js.map +1 -1
  70. package/dist/supervisor.js +30 -18
  71. package/dist/supervisor.js.map +1 -1
  72. package/docs/technical-reference.md +33 -10
  73. package/docs/technical-reference.zh.md +33 -10
  74. package/npm-shrinkwrap.json +2 -2
  75. package/package.json +2 -2
  76. package/skills/agent-bot/SKILL.md +107 -182
  77. package/src/cli/AutostartManager.ts +579 -0
  78. package/src/cli/FeishuAppConfiguration.ts +22 -7
  79. package/src/cli/Initializer.ts +57 -0
  80. package/src/cli/SelfUpdateRunner.ts +817 -0
  81. package/src/cli/SelfUpdater.ts +688 -0
  82. package/src/cli/autostartOutput.ts +75 -0
  83. package/src/cli/controlProtocol.ts +111 -1
  84. package/src/cli/help.ts +92 -18
  85. package/src/cli/taskGroupOptions.ts +27 -17
  86. package/src/cli/taskListOutput.ts +10 -0
  87. package/src/cli/taskTarget.ts +168 -0
  88. package/src/cli.ts +781 -65
  89. package/src/codex/CodexRuntime.ts +17 -5
  90. package/src/commands/CommandRouter.ts +19 -1
  91. package/src/commands/commandTypes.ts +3 -0
  92. package/src/config/loadConfig.ts +2 -0
  93. package/src/config/schema.ts +2 -0
  94. package/src/console/ConsoleTurnPresenter.ts +4 -1
  95. package/src/feishu/CardRenderer.ts +716 -16
  96. package/src/feishu/FeishuConnector.ts +44 -10
  97. package/src/feishu/FeishuMarkdown.ts +27 -1
  98. package/src/feishu/FeishuMessageClient.ts +58 -0
  99. package/src/feishu/FeishuTurnPresenter.ts +35 -2
  100. package/src/feishu/ownerAccess.ts +7 -0
  101. package/src/feishu/types.ts +7 -0
  102. package/src/index.ts +192 -22
  103. package/src/presentation/OutboundRouter.ts +28 -3
  104. package/src/proxy/ProxySessionController.ts +1707 -70
  105. package/src/runtime/types.ts +1 -0
  106. package/src/startup/StartupNotifier.ts +67 -17
  107. package/src/startup/startFeishu.ts +5 -3
  108. package/src/state/StateStore.ts +413 -65
  109. package/src/state/migrations.ts +26 -0
  110. package/src/supervision/SafeRestartNotifier.ts +17 -17
  111. package/src/supervision/SafeRestartScheduler.ts +13 -6
  112. package/src/supervision/replacementSupervisor.ts +50 -4
  113. 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]",
@@ -110,6 +141,11 @@ const HELP_COMMAND_SECTIONS: Array<{
110
141
  requiresArgument: true,
111
142
  },
112
143
  { command: "/sessions", usage: "[关键词]", description: "查找本机任务" },
144
+ {
145
+ command: "/archive",
146
+ usage: "[序号或任务 ID]",
147
+ description: "归档当前或指定任务",
148
+ },
113
149
  {
114
150
  command: "/switch",
115
151
  usage: "[序号或任务 ID]",
@@ -174,6 +210,11 @@ const HELP_COMMAND_SECTIONS: Array<{
174
210
  usage: "[--force]",
175
211
  description: "默认安全重启;--force 立即重启",
176
212
  },
213
+ {
214
+ command: "/mute",
215
+ usage: "[on|off]",
216
+ description: "开启或关闭当前群的仅 @ 响应模式",
217
+ },
177
218
  { command: "/help", description: "显示本帮助" },
178
219
  ],
179
220
  },
@@ -202,6 +243,11 @@ interface SessionsCardOptions {
202
243
  page?: number;
203
244
  }
204
245
 
246
+ interface DirectoryBrowserOptions {
247
+ updateMessageId?: string;
248
+ page?: number;
249
+ }
250
+
205
251
  interface StatusCardOptions {
206
252
  updateMessageId?: string;
207
253
  forceSwitchTaskId?: string;
@@ -301,6 +347,13 @@ export interface ProxyLifecycle {
301
347
  }
302
348
 
303
349
  export type ShellCommandExecutor = (command: string, cwd: string) => Promise<ShellCommandResult>;
350
+ export interface WindowsDriveInfo {
351
+ root: string;
352
+ label?: string;
353
+ driveType?: string;
354
+ }
355
+
356
+ export type WindowsDriveLister = () => Promise<WindowsDriveInfo[]>;
304
357
 
305
358
  export class ProxySessionController {
306
359
  private readonly router = new CommandRouter();
@@ -313,7 +366,13 @@ export class ProxySessionController {
313
366
  private readonly sessionResets = new Map<string, Promise<void>>();
314
367
  private readonly lastSessionListings = new Map<string, string[]>();
315
368
  private readonly threadInitializations = new Map<string, Promise<void>>();
369
+ private readonly llmRetryingSessions = new Set<string>();
370
+ private readonly retriedFailureTurnIds = new Set<string>();
371
+ private readonly recoveryRetryTimers = new Map<string, NodeJS.Timeout>();
316
372
  private readonly unsubscribe: Array<() => void> = [];
373
+ private readonly recoveryActivityHeartbeat: NodeJS.Timeout;
374
+ private startupRecovery?: Promise<void>;
375
+ private queuedPromptsRestored = false;
317
376
 
318
377
  constructor(
319
378
  private readonly config: AppConfig,
@@ -323,18 +382,8 @@ export class ProxySessionController {
323
382
  private readonly logger: Logger,
324
383
  private readonly lifecycle?: ProxyLifecycle,
325
384
  private readonly shellCommandExecutor: ShellCommandExecutor = executeShellCommand,
385
+ private readonly windowsDriveLister: WindowsDriveLister = listWindowsDriveRoots,
326
386
  ) {
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
387
  for (const [, runtime] of this.runtimes.entries()) {
339
388
  this.unsubscribe.push(
340
389
  runtime.onEvent((event) => {
@@ -345,15 +394,37 @@ export class ProxySessionController {
345
394
  );
346
395
  }
347
396
  this.restorePersistedSessionRoutes();
348
- this.restorePersistedQueuedPrompts();
349
397
  void this.restorePersistedMessageReactions().catch((error: unknown) => {
350
398
  this.logger.warn({ error }, "Failed to restore persisted message reaction statuses.");
351
399
  });
400
+ this.recoveryActivityHeartbeat = setInterval(
401
+ () => this.persistActiveTurnHeartbeats(),
402
+ RECOVERY_HEARTBEAT_INTERVAL_MS,
403
+ );
404
+ this.recoveryActivityHeartbeat.unref?.();
352
405
  }
353
406
 
354
407
  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.
408
+ if (!message.contextKey.startsWith("console:") && !allowsFeishuUser(this.config, message.userId)) {
409
+ this.logger.debug(
410
+ { messageId: message.messageId },
411
+ "Ignored a Feishu message from a non-owner before acknowledgement.",
412
+ );
413
+ return;
414
+ }
415
+ if (
416
+ message.chatType === "group"
417
+ && this.store.chatRequiresMention(baseChatContextKey(message.contextKey))
418
+ && message.mentionedBot !== true
419
+ ) {
420
+ this.logger.debug(
421
+ { messageId: message.messageId, contextKey: message.contextKey },
422
+ "Ignored an unmentioned message from a muted Lark group.",
423
+ );
424
+ return;
425
+ }
426
+ // For accepted messages, claim durable deduplication before acknowledgement so event
427
+ // retries cannot add duplicate reactions.
357
428
  if (!this.store.claimInboundEvent(message.messageId, "message")) return;
358
429
  try {
359
430
  const reactionId = await this.outbound.addReaction(
@@ -473,6 +544,13 @@ export class ProxySessionController {
473
544
  }
474
545
 
475
546
  async onCardAction(action: CardAction): Promise<void> {
547
+ if (!allowsFeishuUser(this.config, action.userId)) {
548
+ this.logger.debug(
549
+ { actionId: action.actionId },
550
+ "Ignored a Feishu card action from a non-owner.",
551
+ );
552
+ return;
553
+ }
476
554
  if (!this.store.claimInboundEvent(action.actionId, "card_action")) return;
477
555
  const contextKey = this.cardActionContextKey(action);
478
556
  const scopedAction = contextKey === action.contextKey ? action : { ...action, contextKey };
@@ -533,6 +611,32 @@ export class ProxySessionController {
533
611
  if (!cancelled) {
534
612
  await this.outbound.sendText(contextKey, "该安全重启计划已失效,请查看最新状态卡片。");
535
613
  }
614
+ } else if (kind === "directory_open" || kind === "directory_page") {
615
+ await this.openDirectoryBrowser(
616
+ contextKey,
617
+ directoryActionPath(scopedAction.value.directory),
618
+ {
619
+ updateMessageId: requiredCardMessageId(scopedAction.messageId),
620
+ page: kind === "directory_page" ? directoryPageValue(scopedAction.value.page) : 0,
621
+ },
622
+ );
623
+ } else if (kind === "directory_send_file") {
624
+ const filePath = directoryFileActionPath(scopedAction.value.filePath);
625
+ await this.assertSendableFile(filePath);
626
+ await this.outbound.sendFile(contextKey, filePath);
627
+ } else if (kind === "directory_new" || kind === "directory_new_group") {
628
+ const directory = directoryActionPath(scopedAction.value.directory);
629
+ await this.assertBrowsableDirectory(directory);
630
+ await this.execute(
631
+ contextKey,
632
+ kind === "directory_new"
633
+ ? { type: "new", cwd: directory }
634
+ : { type: "newgroup", cwd: directory },
635
+ undefined,
636
+ replyTarget,
637
+ undefined,
638
+ scopedAction.userId,
639
+ );
536
640
  } else if (kind === "session_page") {
537
641
  await this.refreshSessionsCardFromAction(scopedAction);
538
642
  } else if (kind === "session_switch") {
@@ -556,6 +660,13 @@ export class ProxySessionController {
556
660
  const sessionId = String(scopedAction.value.sessionId ?? "");
557
661
  await this.createFeishuGroupFromReference(contextKey, sessionId, scopedAction.userId);
558
662
  await this.refreshSessionsCardFromAction(scopedAction);
663
+ } else if (kind === "session_archive") {
664
+ const sessionId = String(scopedAction.value.sessionId ?? "");
665
+ await this.archiveSessionReference(contextKey, sessionId, {
666
+ announce: true,
667
+ source: "sessions_card",
668
+ });
669
+ await this.refreshSessionsCardFromAction(scopedAction);
559
670
  } else if (kind === "session_stop") {
560
671
  const sessionId = String(scopedAction.value.sessionId ?? "");
561
672
  await this.stopSessionReference(contextKey, sessionId);
@@ -692,6 +803,9 @@ export class ProxySessionController {
692
803
  this.queuedPromptCards.clear();
693
804
  this.queuedPromptCardWrites.clear();
694
805
  this.sessionResets.clear();
806
+ for (const timer of this.recoveryRetryTimers.values()) clearTimeout(timer);
807
+ this.recoveryRetryTimers.clear();
808
+ clearInterval(this.recoveryActivityHeartbeat);
695
809
  }
696
810
 
697
811
  async controlStopTask(localSessionId: string): Promise<string> {
@@ -701,6 +815,18 @@ export class ProxySessionController {
701
815
  return `Task stop requested: ${record.title ?? record.remoteSessionId ?? record.localSessionId}`;
702
816
  }
703
817
 
818
+ async controlArchiveTask(localSessionId: string): Promise<{
819
+ localSessionId: string;
820
+ remoteSessionId: string;
821
+ title: string;
822
+ }> {
823
+ const record = this.requireControlSession(localSessionId);
824
+ return this.archiveSessionReference(this.controlSessionContextKey(record), localSessionId, {
825
+ announce: false,
826
+ source: "cli",
827
+ });
828
+ }
829
+
704
830
  async controlGetTaskStatus(localSessionId: string): Promise<{
705
831
  session: SessionRecord;
706
832
  snapshot?: TurnViewState;
@@ -882,6 +1008,387 @@ export class ProxySessionController {
882
1008
  };
883
1009
  }
884
1010
 
1011
+ async controlCreateTask(
1012
+ localSessionId: string,
1013
+ requestedTitle?: string,
1014
+ requestedProjectCwd?: string,
1015
+ forceProjectless = false,
1016
+ requestedAgentName?: string,
1017
+ ): Promise<SessionRecord> {
1018
+ const source = this.requireControlSession(localSessionId);
1019
+ const contextKey = this.controlSessionContextKey(source);
1020
+ const context = this.store.getOrCreateUserContext(contextKey, this.config.defaults.agent!);
1021
+ const agentName = requestedAgentName?.trim() || context.defaultAgent;
1022
+ const agent = this.ensureAgent(agentName);
1023
+ if (forceProjectless && agent.kind !== "app-server") {
1024
+ throw new Error("task new --nodir is only available for App Server agents.");
1025
+ }
1026
+ const replyTarget = this.controlSessionReplyTarget(source);
1027
+ const cwd = forceProjectless
1028
+ ? undefined
1029
+ : requestedProjectCwd === undefined
1030
+ ? detectProjectlessWorkspace(source.cwd)
1031
+ ? agent.kind === "app-server" ? undefined : createProjectlessWorkspace().cwd
1032
+ : source.cwd
1033
+ : resolveUserPath(requestedProjectCwd, source.cwd);
1034
+ const settings: SessionExecutionSettings = source.agentName === agentName
1035
+ ? {
1036
+ modelProvider: source.modelProvider,
1037
+ model: source.model,
1038
+ reasoningEffort: source.reasoningEffort,
1039
+ permissionMode: source.permissionMode,
1040
+ }
1041
+ : {};
1042
+ return this.outbound.withReplyTarget(contextKey, replyTarget, () =>
1043
+ this.createSession(
1044
+ contextKey,
1045
+ agentName,
1046
+ cwd,
1047
+ true,
1048
+ false,
1049
+ undefined,
1050
+ undefined,
1051
+ requestedTitle,
1052
+ settings,
1053
+ ));
1054
+ }
1055
+
1056
+ async controlForkTask(localSessionId: string): Promise<{
1057
+ sourceLocalSessionId: string;
1058
+ sourceTurnId: string;
1059
+ task: SessionRecord;
1060
+ }> {
1061
+ const source = this.requireControlSession(localSessionId);
1062
+ const contextKey = this.controlSessionContextKey(source);
1063
+ const replyTarget = this.controlSessionReplyTarget(source);
1064
+ const plan = await this.prepareForkSession(contextKey, source.localSessionId);
1065
+ const forked = await this.outbound.withReplyTarget(contextKey, replyTarget, async () => {
1066
+ const result = await this.forkSessionIntoContext(contextKey, plan);
1067
+ await this.outbound.sendText(
1068
+ contextKey,
1069
+ `已从指定任务${plan.forkedFromHistoricalTurn ? "最近已完成轮次" : ""}创建分支并切换到新任务:${
1070
+ result.session.title
1071
+ ? `${result.session.title}(${result.session.remoteSessionId})`
1072
+ : result.session.remoteSessionId
1073
+ }`,
1074
+ );
1075
+ return result;
1076
+ });
1077
+ return {
1078
+ sourceLocalSessionId: source.localSessionId,
1079
+ sourceTurnId: plan.lastTurnId,
1080
+ task: this.store.getSession(forked.record.localSessionId) ?? forked.record,
1081
+ };
1082
+ }
1083
+
1084
+ async controlSwitchTask(
1085
+ localSessionId: string,
1086
+ targetLocalSessionId?: string,
1087
+ previous = false,
1088
+ ): Promise<SessionRecord> {
1089
+ const anchor = this.requireControlSession(localSessionId);
1090
+ const contextKey = this.controlSessionContextKey(anchor);
1091
+ const replyTarget = this.controlSessionReplyTarget(anchor);
1092
+ const target = previous ? undefined : targetLocalSessionId ?? anchor.localSessionId;
1093
+ await this.outbound.withReplyTarget(contextKey, replyTarget, () => this.switchSession(contextKey, target));
1094
+ return this.requireCurrentSession(contextKey);
1095
+ }
1096
+
1097
+ async controlQueueTaskPrompt(localSessionId: string, text: string): Promise<{ promptId: string; queued: number }> {
1098
+ const promptText = text.trim();
1099
+ if (!promptText) throw new Error("The queued Prompt cannot be empty.");
1100
+ const record = this.requireControlSession(localSessionId);
1101
+ const contextKey = this.controlSessionContextKey(record);
1102
+ const replyTarget = this.controlSessionReplyTarget(record);
1103
+ const queued = this.persistQueuedPrompt(record.localSessionId, contextKey, promptText, { replyTarget });
1104
+ this.store.audit(contextKey, "queued_prompt_added", {
1105
+ promptId: queued.promptId,
1106
+ localSessionId: record.localSessionId,
1107
+ source: "cli",
1108
+ });
1109
+ const queuedCount = this.store.countQueuedPrompts(record.localSessionId);
1110
+ await this.scheduleNextQueuedPrompt(record.localSessionId);
1111
+ return { promptId: queued.promptId, queued: queuedCount };
1112
+ }
1113
+
1114
+ controlTaskAgent(localSessionId: string, agentName?: string): {
1115
+ current: string;
1116
+ agents: Array<{ name: string; title: string }>;
1117
+ } {
1118
+ const record = this.requireControlSession(localSessionId);
1119
+ const contextKey = this.controlSessionContextKey(record);
1120
+ const context = this.store.getOrCreateUserContext(contextKey, this.config.defaults.agent!);
1121
+ if (agentName) {
1122
+ this.ensureAgent(agentName);
1123
+ this.store.setDefaultAgent(contextKey, agentName);
1124
+ }
1125
+ return {
1126
+ current: agentName ?? context.defaultAgent,
1127
+ agents: Object.entries(this.config.agents).map(([name, agent]) => ({ name, title: agent.title })),
1128
+ };
1129
+ }
1130
+
1131
+ async controlTaskSettings(
1132
+ localSessionId: string,
1133
+ setting?: "provider" | "model" | "thinking" | "permissions",
1134
+ value?: string,
1135
+ ): Promise<{
1136
+ session: SessionRecord;
1137
+ providers: Awaited<ReturnType<NonNullable<AgentRuntime["listModelProviders"]>>>;
1138
+ models: Awaited<ReturnType<AgentRuntime["listModels"]>>;
1139
+ reasoningOptions: Awaited<ReturnType<AgentRuntime["listModels"]>>[number]["supportedReasoningEfforts"];
1140
+ permissionModes: PermissionMode[];
1141
+ }> {
1142
+ const record = this.requireControlSession(localSessionId);
1143
+ const loaded = await this.loadSession(record);
1144
+ if (setting && !value?.trim()) throw new Error(`task ${setting} requires a value.`);
1145
+ const nextValue = value?.trim();
1146
+ if (setting === "provider" && nextValue) {
1147
+ await this.assertModelProvider(loaded, nextValue);
1148
+ const models = await loaded.runtime.listModels();
1149
+ const model = models.find((candidate) => candidate.id === loaded.session.model)
1150
+ ?? models.find((candidate) => candidate.isDefault);
1151
+ if (!model) throw new Error("The current runtime has no model available for a Provider change.");
1152
+ const effort = model.supportedReasoningEfforts.some(
1153
+ (candidate) => candidate.value === loaded.session.reasoningEffort,
1154
+ )
1155
+ ? loaded.session.reasoningEffort
1156
+ : model.defaultReasoningEffort ?? model.supportedReasoningEfforts[0]?.value;
1157
+ if (!effort) throw new Error(`Model ${model.id} has no reasoning effort available for a Provider change.`);
1158
+ if (!loaded.runtime.setExecutionSettings) throw new Error("The current runtime does not support Provider settings.");
1159
+ const updated = await loaded.runtime.setExecutionSettings(record.localSessionId, {
1160
+ modelProvider: nextValue,
1161
+ model: model.id,
1162
+ reasoningEffort: effort,
1163
+ permissionMode: loaded.session.permissionMode,
1164
+ });
1165
+ this.store.updateRuntimeSession(record.localSessionId, {
1166
+ modelProvider: updated.modelProvider ?? nextValue,
1167
+ model: updated.model ?? model.id,
1168
+ reasoningEffort: updated.reasoningEffort ?? effort,
1169
+ permissionMode: updated.permissionMode,
1170
+ });
1171
+ } else if (setting === "model" && nextValue) {
1172
+ const models = await loaded.runtime.listModels();
1173
+ const selected = models.find((candidate) => candidate.id === nextValue);
1174
+ if (!selected) throw new Error(`Unknown model: ${nextValue}`);
1175
+ const currentEffort = loaded.session.reasoningEffort;
1176
+ const nextEffort = currentEffort && selected.supportedReasoningEfforts.some(
1177
+ (candidate) => candidate.value === currentEffort,
1178
+ )
1179
+ ? currentEffort
1180
+ : selected.defaultReasoningEffort;
1181
+ await loaded.runtime.setModel(record.localSessionId, nextValue);
1182
+ if (nextEffort && nextEffort !== currentEffort) {
1183
+ await loaded.runtime.setReasoningEffort(record.localSessionId, nextEffort);
1184
+ }
1185
+ this.store.updateRuntimeSession(record.localSessionId, { model: nextValue, reasoningEffort: nextEffort });
1186
+ } else if (setting === "thinking" && nextValue) {
1187
+ const models = await loaded.runtime.listModels();
1188
+ const currentModel = models.find((candidate) => candidate.id === loaded.session.model)
1189
+ ?? models.find((candidate) => candidate.isDefault);
1190
+ if (!currentModel) throw new Error("The current runtime has no model with configurable reasoning effort.");
1191
+ if (!currentModel.supportedReasoningEfforts.some((candidate) => candidate.value === nextValue)) {
1192
+ const supported = currentModel.supportedReasoningEfforts.map((candidate) => candidate.value).join(", ") || "none";
1193
+ throw new Error(`Unsupported reasoning effort: ${nextValue}. Supported values: ${supported}.`);
1194
+ }
1195
+ await loaded.runtime.setReasoningEffort(record.localSessionId, nextValue);
1196
+ this.store.updateRuntimeSession(record.localSessionId, { reasoningEffort: nextValue });
1197
+ } else if (setting === "permissions" && nextValue) {
1198
+ if (nextValue !== "auto" && nextValue !== "confirm") {
1199
+ throw new Error("task permissions accepts auto or confirm.");
1200
+ }
1201
+ await loaded.runtime.setPermissionMode(record.localSessionId, nextValue);
1202
+ this.store.updateRuntimeSession(record.localSessionId, { permissionMode: nextValue });
1203
+ }
1204
+ const session = this.store.getSession(record.localSessionId) ?? record;
1205
+ const models = await loaded.runtime.listModels();
1206
+ const currentModel = models.find((candidate) => candidate.id === session.model)
1207
+ ?? models.find((candidate) => candidate.isDefault);
1208
+ const providers = loaded.runtime.listModelProviders
1209
+ ? await loaded.runtime.listModelProviders()
1210
+ : [];
1211
+ return {
1212
+ session,
1213
+ providers,
1214
+ models,
1215
+ reasoningOptions: currentModel?.supportedReasoningEfforts ?? [],
1216
+ permissionModes: ["auto", "confirm"],
1217
+ };
1218
+ }
1219
+
1220
+ async controlTaskGoal(
1221
+ localSessionId: string,
1222
+ action: "show" | "set" | "edit" | "pause" | "resume" | "clear",
1223
+ objective?: string,
1224
+ ): Promise<{ goal?: RuntimeGoal; cleared?: boolean }> {
1225
+ const record = this.requireControlSession(localSessionId);
1226
+ const loaded = await this.loadSession(record);
1227
+ if (loaded.runtime.kind !== "codex" || !loaded.runtime.getGoal || !loaded.runtime.setGoal || !loaded.runtime.clearGoal) {
1228
+ throw new Error("The current Agent does not support Goal mode.");
1229
+ }
1230
+ if (action === "show") return { goal: await loaded.runtime.getGoal(record.localSessionId) };
1231
+ if (action === "clear") return { cleared: await loaded.runtime.clearGoal(record.localSessionId) };
1232
+ const current = await loaded.runtime.getGoal(record.localSessionId);
1233
+ if (action === "pause" || action === "resume") {
1234
+ if (!current) throw new Error("The task has no Goal.");
1235
+ return { goal: await loaded.runtime.setGoal(record.localSessionId, { status: action === "pause" ? "paused" : "active" }) };
1236
+ }
1237
+ const nextObjective = objective?.trim();
1238
+ if (!nextObjective) throw new Error(`task goal ${action} requires an objective.`);
1239
+ validateGoalObjective(nextObjective);
1240
+ if (action === "set" && current && current.status !== "complete") {
1241
+ throw new Error("The task already has an unfinished Goal. Use task goal <task> edit <objective>, or clear it first.");
1242
+ }
1243
+ if (action === "edit" && !current) throw new Error("The task has no Goal to edit.");
1244
+ return {
1245
+ goal: await loaded.runtime.setGoal(record.localSessionId, {
1246
+ objective: nextObjective,
1247
+ status: action === "edit" ? current!.status : "active",
1248
+ ...(action === "edit" ? { tokenBudget: current!.tokenBudget } : {}),
1249
+ }),
1250
+ };
1251
+ }
1252
+
1253
+ controlListTaskTurns(localSessionId: string): {
1254
+ session: SessionRecord;
1255
+ turns: Array<{
1256
+ sequence: number;
1257
+ turnId: string;
1258
+ parentTurnId?: string;
1259
+ prompt?: string;
1260
+ startedAt?: number;
1261
+ completedAt?: number;
1262
+ current: boolean;
1263
+ }>;
1264
+ } {
1265
+ const session = this.requireControlSession(localSessionId);
1266
+ if (!session.remoteSessionId || !this.isCodexSession(session)) {
1267
+ throw new Error("The task is not an App Server task that can be reset.");
1268
+ }
1269
+ const contextKey = this.controlSessionContextKey(session);
1270
+ const rows = this.store.listCompletedTurnGraph(session.localSessionId, contextKey);
1271
+ const graph = buildTurnGraphRows(rows.map((turn) => ({ turnId: turn.turnId, parentTurnId: turn.parentTurnId })));
1272
+ return {
1273
+ session,
1274
+ turns: rows.flatMap((turn, index) => {
1275
+ const snapshot = turnViewSnapshot(turn.snapshot);
1276
+ if (!snapshot) return [];
1277
+ return [{
1278
+ sequence: graph[index]!.sequence,
1279
+ turnId: turn.turnId,
1280
+ parentTurnId: turn.parentTurnId,
1281
+ prompt: snapshot.prompt ?? snapshot.taskTitle,
1282
+ startedAt: snapshot.startedAt,
1283
+ completedAt: snapshot.completedAt,
1284
+ current: session.lastTurnId === turn.turnId && session.lastTurnStatus === "completed",
1285
+ }];
1286
+ }),
1287
+ };
1288
+ }
1289
+
1290
+ async controlResetTaskToTurn(localSessionId: string, turnId: string): Promise<SessionRecord> {
1291
+ const record = this.requireControlSession(localSessionId);
1292
+ const contextKey = this.controlSessionContextKey(record);
1293
+ const previous = this.sessionResets.get(record.localSessionId) ?? Promise.resolve();
1294
+ const reset = previous.catch(() => undefined).then(() =>
1295
+ this.performSessionReset(contextKey, record, turnId, false));
1296
+ this.sessionResets.set(record.localSessionId, reset);
1297
+ try {
1298
+ await reset;
1299
+ } finally {
1300
+ if (this.sessionResets.get(record.localSessionId) === reset) this.sessionResets.delete(record.localSessionId);
1301
+ }
1302
+ return this.store.getSession(record.localSessionId) ?? record;
1303
+ }
1304
+
1305
+ controlTaskMute(localSessionId: string, enabled?: boolean): { contextKey: string; enabled: boolean } {
1306
+ const record = this.requireControlSession(localSessionId);
1307
+ const contextKey = baseChatContextKey(this.controlSessionContextKey(record));
1308
+ const chat = this.store.getChatContext(contextKey);
1309
+ if (chat?.chatType !== "group") throw new Error("task mute is only available for group chats.");
1310
+ const next = enabled ?? Boolean(chat.requiresMention);
1311
+ if (enabled !== undefined) this.store.setChatRequiresMention(contextKey, enabled);
1312
+ return { contextKey, enabled: next };
1313
+ }
1314
+
1315
+ async controlRunTaskShell(localSessionId: string, command: string): Promise<ShellCommandResult & {
1316
+ cwd: string;
1317
+ command: string;
1318
+ }> {
1319
+ const record = this.requireControlSession(localSessionId);
1320
+ const normalized = command.trim();
1321
+ if (!normalized) throw new Error("task shell requires a command.");
1322
+ return { ...await this.shellCommandExecutor(normalized, record.cwd), cwd: record.cwd, command: normalized };
1323
+ }
1324
+
1325
+ async controlListTaskDirectory(
1326
+ localSessionId: string,
1327
+ requestedDirectory?: string,
1328
+ requestedPage = 0,
1329
+ ): Promise<{
1330
+ directory: string;
1331
+ parentDirectory?: string;
1332
+ page: number;
1333
+ totalPages: number;
1334
+ totalEntries: number;
1335
+ entries: Array<{
1336
+ name: string;
1337
+ path: string;
1338
+ kind: "directory" | "image" | "binary" | "file" | "drive";
1339
+ }>;
1340
+ }> {
1341
+ const record = this.requireControlSession(localSessionId);
1342
+ if (requestedDirectory === WINDOWS_DRIVES_DIRECTORY) {
1343
+ const drives = await this.windowsDriveLister();
1344
+ const totalPages = Math.max(1, Math.ceil(drives.length / DIRECTORY_PAGE_SIZE));
1345
+ const page = Math.min(Math.max(0, Math.trunc(requestedPage)), totalPages - 1);
1346
+ return {
1347
+ directory: WINDOWS_DRIVES_DIRECTORY,
1348
+ page,
1349
+ totalPages,
1350
+ totalEntries: drives.length,
1351
+ entries: drives.slice(page * DIRECTORY_PAGE_SIZE, (page + 1) * DIRECTORY_PAGE_SIZE).map((drive) => ({
1352
+ name: windowsDriveDisplayName(drive),
1353
+ path: drive.root,
1354
+ kind: "drive" as const,
1355
+ })),
1356
+ };
1357
+ }
1358
+ const directory = requestedDirectory === undefined
1359
+ ? path.resolve(record.cwd)
1360
+ : resolveUserPath(requestedDirectory, record.cwd);
1361
+ await this.assertBrowsableDirectory(directory);
1362
+ const entries = (await fs.readdir(directory, { withFileTypes: true }))
1363
+ .map((entry) => ({
1364
+ name: entry.name,
1365
+ path: path.join(directory, entry.name),
1366
+ kind: directoryBrowserEntryKind(entry),
1367
+ }))
1368
+ .sort((left, right) => Number(right.kind === "directory") - Number(left.kind === "directory")
1369
+ || left.name.localeCompare(right.name, undefined, { numeric: true, sensitivity: "base" }));
1370
+ const totalPages = Math.max(1, Math.ceil(entries.length / DIRECTORY_PAGE_SIZE));
1371
+ const page = Math.min(Math.max(0, Math.trunc(requestedPage)), totalPages - 1);
1372
+ const parentDirectory = isWindowsDriveRoot(directory) ? WINDOWS_DRIVES_DIRECTORY : path.dirname(directory);
1373
+ return {
1374
+ directory,
1375
+ ...(parentDirectory !== directory ? { parentDirectory } : {}),
1376
+ page,
1377
+ totalPages,
1378
+ totalEntries: entries.length,
1379
+ entries: entries.slice(page * DIRECTORY_PAGE_SIZE, (page + 1) * DIRECTORY_PAGE_SIZE),
1380
+ };
1381
+ }
1382
+
1383
+ async controlSendTaskFile(localSessionId: string, requestedFilePath: string): Promise<string | undefined> {
1384
+ const record = this.requireControlSession(localSessionId);
1385
+ const filePath = resolveUserPath(requestedFilePath, record.cwd);
1386
+ await this.assertSendableFile(filePath);
1387
+ const contextKey = this.controlSessionContextKey(record);
1388
+ const replyTarget = this.controlSessionReplyTarget(record);
1389
+ return this.outbound.withReplyTarget(contextKey, replyTarget, () => this.outbound.sendFile(contextKey, filePath));
1390
+ }
1391
+
885
1392
  private cardActionContextKey(action: CardAction): string {
886
1393
  const explicit = typeof action.value.contextKey === "string" ? action.value.contextKey : undefined;
887
1394
  if (explicit && baseChatContextKey(explicit) === baseChatContextKey(action.contextKey)) return explicit;
@@ -916,6 +1423,9 @@ export class ProxySessionController {
916
1423
  case "shell":
917
1424
  await this.runShellCommand(contextKey, command.command);
918
1425
  return;
1426
+ case "dir":
1427
+ await this.openDirectoryBrowser(contextKey, command.directory);
1428
+ return;
919
1429
  case "new":
920
1430
  if (command.projectless && this.ensureAgent(context.defaultAgent).kind !== "app-server") {
921
1431
  throw new Error("/new --nodir 仅支持 App Server Agent。");
@@ -974,6 +1484,15 @@ export class ProxySessionController {
974
1484
  if (command.agent) await this.setDefaultAgent(contextKey, command.agent);
975
1485
  else await this.openAgentSettings(contextKey);
976
1486
  return;
1487
+ case "archive": {
1488
+ const sessionId = command.sessionId ?? context.currentSessionId;
1489
+ if (!sessionId) throw new Error("当前会话没有可归档的任务。请使用 /sessions 查找任务。");
1490
+ await this.archiveSessionReference(contextKey, sessionId, {
1491
+ announce: true,
1492
+ source: "command",
1493
+ });
1494
+ return;
1495
+ }
977
1496
  case "status":
978
1497
  await this.status(contextKey, command.sessionId);
979
1498
  return;
@@ -984,6 +1503,9 @@ export class ProxySessionController {
984
1503
  if (!this.lifecycle) throw new Error("当前运行方式不支持自动重启。");
985
1504
  await this.lifecycle.restart(contextKey, command.force === true, replyTarget);
986
1505
  return;
1506
+ case "mute":
1507
+ await this.setGroupMute(contextKey, command.enabled);
1508
+ return;
987
1509
  case "turns":
988
1510
  await this.openResetHistory(contextKey);
989
1511
  return;
@@ -1016,6 +1538,7 @@ export class ProxySessionController {
1016
1538
  const turnContextKey = session.lastTurnId
1017
1539
  ? this.store.getTurnContextKey(session.lastTurnId) ?? context.contextKey
1018
1540
  : context.contextKey;
1541
+ if (!this.outbound.canRoute(turnContextKey)) continue;
1019
1542
  this.outbound.registerSession(
1020
1543
  session.localSessionId,
1021
1544
  turnContextKey,
@@ -1028,16 +1551,386 @@ export class ProxySessionController {
1028
1551
  this.logger.warn({ error, sessionId: session.localSessionId }, "Failed to restore persisted turn delivery.");
1029
1552
  });
1030
1553
  }
1554
+ for (const turn of this.store.listUndeliveredCompletedTurns()) {
1555
+ const contextKey = turn.contextKey ?? this.store.getSession(turn.localSessionId)?.contextKey;
1556
+ if (!contextKey || !this.outbound.canRoute(contextKey)) continue;
1557
+ void this.outbound.resumeDelivery(turn.localSessionId, contextKey, turn.turnId).catch((error: unknown) => {
1558
+ this.logger.warn(
1559
+ { error, sessionId: turn.localSessionId, turnId: turn.turnId },
1560
+ "Failed to resume an undelivered completed turn.",
1561
+ );
1562
+ });
1563
+ }
1031
1564
  }
1032
1565
 
1033
1566
  private restorePersistedQueuedPrompts(): void {
1567
+ if (this.queuedPromptsRestored) return;
1568
+ this.queuedPromptsRestored = true;
1034
1569
  for (const sessionId of this.store.listQueuedPromptSessionIds()) {
1570
+ const firstPrompt = this.store.listQueuedPrompts(sessionId)[0];
1571
+ if (firstPrompt && !this.outbound.canRoute(firstPrompt.contextKey)) continue;
1035
1572
  void this.scheduleNextQueuedPrompt(sessionId).catch((error: unknown) => {
1036
1573
  this.logger.warn({ error, sessionId }, "Failed to resume a persisted prompt queue.");
1037
1574
  });
1038
1575
  }
1039
1576
  }
1040
1577
 
1578
+ async recoverInterruptedTasks(): Promise<void> {
1579
+ if (this.startupRecovery) return this.startupRecovery;
1580
+ const recovery = this.runStartupRecovery();
1581
+ this.startupRecovery = recovery;
1582
+ return recovery;
1583
+ }
1584
+
1585
+ private async runStartupRecovery(): Promise<void> {
1586
+ this.backfillTurnAttemptsForUpgrade();
1587
+ const attempts = this.store.listIncompleteTurnAttempts();
1588
+ const recoveryCutoff = Date.now() - RECOVERY_ACTIVITY_WINDOW_MS;
1589
+ const recentAttempts = attempts.filter((attempt) => isRecoveryAttemptRecent(attempt, recoveryCutoff));
1590
+ if (recentAttempts.length > 0) {
1591
+ this.logger.warn(
1592
+ { attemptIds: recentAttempts.map((attempt) => attempt.attemptId) },
1593
+ "Recovering unfinished Agent Bot tasks after startup.",
1594
+ );
1595
+ }
1596
+ for (const attempt of attempts) {
1597
+ if (!isRecoveryAttemptRecent(attempt, recoveryCutoff)) {
1598
+ await this.expireStaleTurnAttempt(attempt);
1599
+ continue;
1600
+ }
1601
+ if (!this.outbound.canRoute(attempt.contextKey)) continue;
1602
+ try {
1603
+ await this.recoverTurnAttempt(attempt);
1604
+ } catch (error) {
1605
+ this.logger.error(
1606
+ { error, attemptId: attempt.attemptId, sessionId: attempt.localSessionId },
1607
+ "Failed to recover an unfinished task; it will be retried while the server is running.",
1608
+ );
1609
+ this.scheduleRecoveryRetry(attempt.attemptId);
1610
+ }
1611
+ }
1612
+ this.restorePersistedQueuedPrompts();
1613
+ }
1614
+
1615
+ private backfillTurnAttemptsForUpgrade(): void {
1616
+ for (const session of this.store.listAllSessions()) {
1617
+ if (session.status !== "starting" && session.status !== "running" && session.status !== "ready") continue;
1618
+ if (this.store.findIncompleteTurnAttemptForSession(session.localSessionId)) continue;
1619
+ const lastTurn = session.lastTurnId
1620
+ ? {
1621
+ turnId: session.lastTurnId,
1622
+ localSessionId: session.localSessionId,
1623
+ contextKey: this.store.getTurnContextKey(session.lastTurnId),
1624
+ snapshot: this.store.getTurnSnapshot(session.lastTurnId),
1625
+ updatedAt: session.updatedAt,
1626
+ }
1627
+ : undefined;
1628
+ const lastTurnSnapshot = turnViewSnapshot(lastTurn?.snapshot);
1629
+ const latestPersisted = this.store.findLatestTurnSnapshotForSession(session.localSessionId);
1630
+ const latestPersistedSnapshot = turnViewSnapshot(latestPersisted?.snapshot);
1631
+ const latest = lastTurnSnapshot && !isTerminalTurnViewStatus(lastTurnSnapshot.status)
1632
+ ? lastTurn
1633
+ : latestPersistedSnapshot && !isTerminalTurnViewStatus(latestPersistedSnapshot.status)
1634
+ ? latestPersisted
1635
+ : session.status === "ready"
1636
+ ? undefined
1637
+ : lastTurn;
1638
+ if (!latest && session.status === "ready") continue;
1639
+ const snapshot = turnViewSnapshot(latest?.snapshot);
1640
+ if (snapshot && isTerminalTurnViewStatus(snapshot.status)) {
1641
+ this.store.updateSession(session.localSessionId, {
1642
+ status: snapshot.status === "failed" ? "failed" : "ready",
1643
+ });
1644
+ continue;
1645
+ }
1646
+ const persistedTurnId = latest?.turnId;
1647
+ const pendingTurnId = persistedTurnId?.startsWith("pending_") ? persistedTurnId : undefined;
1648
+ const turnId = pendingTurnId ? undefined : persistedTurnId ?? session.lastTurnId;
1649
+ const replyTarget = snapshot?.replyTarget?.replyInThread ? snapshot.replyTarget : undefined;
1650
+ this.store.createTurnAttempt({
1651
+ attemptId: createId("attempt"),
1652
+ localSessionId: session.localSessionId,
1653
+ contextKey: latest?.contextKey ?? session.contextKey,
1654
+ promptText: snapshot?.prompt?.trim() || "继续完成重启前尚未完成的任务。",
1655
+ messageId: turnId ? this.store.findMessageIdForTurn(turnId) : undefined,
1656
+ replyMessageId: replyTarget?.messageId,
1657
+ pendingTurnId,
1658
+ turnId,
1659
+ status: turnId ? "running" : "accepted",
1660
+ createdAt: latest?.updatedAt ?? session.updatedAt,
1661
+ updatedAt: latest?.updatedAt ?? session.updatedAt,
1662
+ });
1663
+ }
1664
+ }
1665
+
1666
+ private async expireStaleTurnAttempt(attempt: TurnAttemptRecord): Promise<void> {
1667
+ const session = this.store.getSession(attempt.localSessionId);
1668
+ const oldCardTurnId = attempt.pendingTurnId ?? attempt.turnId;
1669
+ if (session && oldCardTurnId && this.outbound.canRoute(attempt.contextKey)) {
1670
+ await this.outbound.interruptTurnForRecovery(
1671
+ session.localSessionId,
1672
+ attempt.contextKey,
1673
+ oldCardTurnId,
1674
+ "执行中断已超过 5 分钟,未自动恢复。",
1675
+ ).catch((error: unknown) => {
1676
+ this.logger.warn(
1677
+ { error, attemptId: attempt.attemptId, turnId: oldCardTurnId },
1678
+ "Failed to close an expired interrupted thinking card.",
1679
+ );
1680
+ });
1681
+ }
1682
+ this.store.updateTurnAttempt(attempt.attemptId, { status: "interrupted" });
1683
+ if (!session || session.status === "closed") return;
1684
+ if (attempt.turnId && session.lastTurnId === attempt.turnId) {
1685
+ this.store.updateRuntimeSession(session.localSessionId, { lastTurnStatus: "cancelled" });
1686
+ }
1687
+ if (session.status === "starting" || session.status === "running") {
1688
+ this.store.updateSession(session.localSessionId, { status: "ready" });
1689
+ }
1690
+ if (attempt.turnId && this.outbound.canRoute(attempt.contextKey)) {
1691
+ await this.finalizeTurnMessageReactions(attempt.turnId, "cancelled").catch((error: unknown) => {
1692
+ this.logger.warn(
1693
+ { error, attemptId: attempt.attemptId, turnId: attempt.turnId },
1694
+ "Failed to finalize the message reaction for an expired interrupted turn.",
1695
+ );
1696
+ });
1697
+ }
1698
+ }
1699
+
1700
+ private persistActiveTurnHeartbeats(): void {
1701
+ for (const attempt of this.store.listIncompleteTurnAttempts()) {
1702
+ if (!attempt.turnId) continue;
1703
+ const session = this.store.getSession(attempt.localSessionId);
1704
+ if (!session) continue;
1705
+ const activeTurnId = this.runtimes.forAgent(session.agentName).getSession(session.localSessionId)?.activeTurnId;
1706
+ if (activeTurnId === attempt.turnId) this.store.touchTurnAttempt(attempt.turnId);
1707
+ }
1708
+ }
1709
+
1710
+ private async recoverTurnAttempt(attempt: TurnAttemptRecord, announce = true): Promise<void> {
1711
+ const session = this.store.getSession(attempt.localSessionId);
1712
+ if (!session || session.status === "closed") {
1713
+ this.store.updateTurnAttempt(attempt.attemptId, { status: "interrupted" });
1714
+ return;
1715
+ }
1716
+ const contextKey = attempt.contextKey || session.contextKey;
1717
+ const replyTarget = attempt.replyMessageId
1718
+ ? { messageId: attempt.replyMessageId, replyInThread: true as const }
1719
+ : undefined;
1720
+ this.outbound.registerSession(
1721
+ session.localSessionId,
1722
+ contextKey,
1723
+ session.title,
1724
+ session.cwd,
1725
+ this.agentLabel(session.agentName),
1726
+ );
1727
+ if (announce) {
1728
+ await this.outbound.withReplyTarget(contextKey, replyTarget, () =>
1729
+ this.outbound.sendText(contextKey, "检测到任务在 Agent Bot 重启前尚未完成,正在自动恢复。"),
1730
+ ).catch((error: unknown) => {
1731
+ this.logger.warn(
1732
+ { error, attemptId: attempt.attemptId, contextKey },
1733
+ "Failed to send the task recovery notification.",
1734
+ );
1735
+ return undefined;
1736
+ });
1737
+ }
1738
+
1739
+ const runtime = this.runtimes.forAgent(session.agentName);
1740
+ const sourceTurnId = attempt.turnId;
1741
+ if (runtime.kind === "acp") {
1742
+ await this.continueInterruptedAttempt(attempt, session, sourceTurnId, replyTarget);
1743
+ return;
1744
+ }
1745
+
1746
+ let remote: RemoteSessionSummary | undefined;
1747
+ if (session.remoteSessionId && runtime.readRemoteSession) {
1748
+ try {
1749
+ remote = await runtime.readRemoteSession(session.remoteSessionId);
1750
+ } catch (error) {
1751
+ this.logger.warn(
1752
+ { error, attemptId: attempt.attemptId, remoteSessionId: session.remoteSessionId },
1753
+ "Failed to read the remote task during startup recovery; treating the old local execution as interrupted.",
1754
+ );
1755
+ }
1756
+ }
1757
+ const remoteTurnBelongsToAttempt = remoteTurnMatchesAttempt(remote, attempt);
1758
+ if (remote && remoteTurnBelongsToAttempt && remote.lastTurnStatus === "completed") {
1759
+ await this.reconcileRecoveredRemoteTurn(attempt, session, remote.lastTurnId);
1760
+ return;
1761
+ }
1762
+ if (remote && remoteTurnBelongsToAttempt && remote.lastTurnStatus === "failed") {
1763
+ await this.reconcileRecoveredRemoteTurn(attempt, session, remote.lastTurnId);
1764
+ return;
1765
+ }
1766
+ if (remote && remoteTurnBelongsToAttempt && isRemoteSessionActive(remote) && remote.lastTurnId) {
1767
+ await this.reattachActiveRecoveredTurn(attempt, session, remote.lastTurnId, replyTarget);
1768
+ return;
1769
+ }
1770
+ if (remote && isRemoteSessionActive(remote) && !remoteTurnBelongsToAttempt) {
1771
+ this.store.updateTurnAttempt(attempt.attemptId, { status: "recovering" });
1772
+ if (announce) {
1773
+ await this.outbound.withReplyTarget(contextKey, replyTarget, () =>
1774
+ this.outbound.sendText(contextKey, "任务中检测到另一轮仍在执行;本次恢复将在它结束后重试。"),
1775
+ );
1776
+ }
1777
+ this.scheduleRecoveryRetry(attempt.attemptId);
1778
+ return;
1779
+ }
1780
+ await this.continueInterruptedAttempt(attempt, session, sourceTurnId, replyTarget);
1781
+ }
1782
+
1783
+ private async reconcileRecoveredRemoteTurn(
1784
+ attempt: TurnAttemptRecord,
1785
+ session: SessionRecord,
1786
+ remoteTurnId: string | undefined,
1787
+ ): Promise<void> {
1788
+ if (remoteTurnId && !attempt.turnId) {
1789
+ this.store.updateTurnAttempt(attempt.attemptId, { turnId: remoteTurnId, status: "running" });
1790
+ this.store.updateRuntimeSession(session.localSessionId, {
1791
+ lastTurnId: remoteTurnId,
1792
+ lastTurnStatus: "running",
1793
+ });
1794
+ }
1795
+ const current = this.store.getSession(session.localSessionId) ?? session;
1796
+ const loaded = await this.loadSession({
1797
+ ...current,
1798
+ contextKey: attempt.contextKey,
1799
+ status: "running",
1800
+ lastTurnStatus: "running",
1801
+ });
1802
+ const synchronized = await loaded.runtime.synchronizeSession(session.localSessionId);
1803
+ if (synchronized.activeTurnId) this.scheduleRecoveryRetry(attempt.attemptId, synchronized.activeTurnId);
1804
+ }
1805
+
1806
+ private scheduleRecoveryRetry(attemptId: string, activeTurnId?: string): void {
1807
+ if (this.recoveryRetryTimers.has(attemptId)) return;
1808
+ const timer = setTimeout(() => {
1809
+ this.recoveryRetryTimers.delete(attemptId);
1810
+ const attempt = this.store.getTurnAttempt(attemptId);
1811
+ if (!attempt || !isIncompleteTurnAttemptStatus(attempt.status)) return;
1812
+ const retry = async (): Promise<void> => {
1813
+ let recoveryAttempt = attempt;
1814
+ if (activeTurnId) {
1815
+ const session = this.store.getSession(attempt.localSessionId);
1816
+ if (session) {
1817
+ const runtime = this.runtimes.forAgent(session.agentName);
1818
+ const synchronized = await runtime.synchronizeSession(session.localSessionId);
1819
+ if (synchronized.activeTurnId === activeTurnId) {
1820
+ this.scheduleRecoveryRetry(attemptId, activeTurnId);
1821
+ return;
1822
+ }
1823
+ const current = this.store.getTurnAttempt(attemptId);
1824
+ if (!current || !isIncompleteTurnAttemptStatus(current.status)) return;
1825
+ recoveryAttempt = current;
1826
+ }
1827
+ }
1828
+ await this.recoverTurnAttempt(recoveryAttempt, false);
1829
+ };
1830
+ void retry().catch((error: unknown) => {
1831
+ this.logger.warn(
1832
+ { error, attemptId, sessionId: attempt.localSessionId },
1833
+ "Failed to retry an unfinished task recovery.",
1834
+ );
1835
+ this.scheduleRecoveryRetry(attemptId, activeTurnId);
1836
+ });
1837
+ }, 5_000);
1838
+ timer.unref?.();
1839
+ this.recoveryRetryTimers.set(attemptId, timer);
1840
+ }
1841
+
1842
+ private async reattachActiveRecoveredTurn(
1843
+ attempt: TurnAttemptRecord,
1844
+ session: SessionRecord,
1845
+ turnId: string,
1846
+ replyTarget?: MessageReplyTarget,
1847
+ ): Promise<void> {
1848
+ const oldCardTurnId = attempt.pendingTurnId ?? attempt.turnId;
1849
+ if (oldCardTurnId) {
1850
+ await this.outbound.interruptTurnForRecovery(
1851
+ session.localSessionId,
1852
+ attempt.contextKey,
1853
+ oldCardTurnId,
1854
+ "Agent Bot 已重启,执行仍在继续;进度已转移到新的思考卡片。",
1855
+ );
1856
+ }
1857
+ const pendingTurnId = await this.outbound.startPendingTurn(
1858
+ session.localSessionId,
1859
+ attempt.contextKey,
1860
+ session.title,
1861
+ replyTarget,
1862
+ recoveryCardPrompt(attempt.promptText),
1863
+ );
1864
+ this.store.updateTurnAttempt(attempt.attemptId, {
1865
+ pendingTurnId: pendingTurnId ?? null,
1866
+ turnId,
1867
+ status: "running",
1868
+ });
1869
+ await this.outbound.onEvent({
1870
+ type: "turn_started",
1871
+ sessionId: session.localSessionId,
1872
+ turnId,
1873
+ startedAt: Date.now(),
1874
+ });
1875
+ this.store.updateTurnAttempt(attempt.attemptId, { pendingTurnId: null });
1876
+ const current = this.store.getSession(session.localSessionId) ?? session;
1877
+ const loaded = await this.loadSession({
1878
+ ...current,
1879
+ contextKey: attempt.contextKey,
1880
+ status: "running",
1881
+ lastTurnId: turnId,
1882
+ lastTurnStatus: "running",
1883
+ });
1884
+ const synchronized = await loaded.runtime.synchronizeSession(session.localSessionId);
1885
+ if (synchronized.activeTurnId) this.scheduleRecoveryRetry(attempt.attemptId, synchronized.activeTurnId);
1886
+ }
1887
+
1888
+ private async continueInterruptedAttempt(
1889
+ attempt: TurnAttemptRecord,
1890
+ session: SessionRecord,
1891
+ sourceTurnId: string | undefined,
1892
+ replyTarget?: MessageReplyTarget,
1893
+ ): Promise<void> {
1894
+ const oldCardTurnId = attempt.pendingTurnId ?? sourceTurnId;
1895
+ if (oldCardTurnId) {
1896
+ await this.outbound.interruptTurnForRecovery(
1897
+ session.localSessionId,
1898
+ attempt.contextKey,
1899
+ oldCardTurnId,
1900
+ "Agent Bot 重启中断了这次执行,正在新的思考卡片中继续。",
1901
+ ).catch((error: unknown) => {
1902
+ this.logger.warn(
1903
+ { error, attemptId: attempt.attemptId, turnId: oldCardTurnId },
1904
+ "Failed to close the interrupted thinking card before recovery.",
1905
+ );
1906
+ });
1907
+ }
1908
+ this.store.prepareTurnAttemptRecovery(attempt.attemptId, oldCardTurnId);
1909
+ this.store.updateSession(session.localSessionId, { status: "ready" });
1910
+ if (sourceTurnId) {
1911
+ this.store.updateRuntimeSession(session.localSessionId, {
1912
+ lastTurnId: sourceTurnId,
1913
+ lastTurnStatus: "cancelled",
1914
+ });
1915
+ }
1916
+ const current = this.store.getSession(session.localSessionId) ?? session;
1917
+ const loaded = await this.loadSession({ ...current, contextKey: attempt.contextKey });
1918
+ const recoveryText = recoveryRuntimePrompt(attempt.promptText);
1919
+ const turnId = await this.startTurn(
1920
+ loaded,
1921
+ recoveryText,
1922
+ replyTarget,
1923
+ attempt.localImagePaths,
1924
+ {
1925
+ attemptId: attempt.attemptId,
1926
+ messageId: attempt.messageId,
1927
+ displayPrompt: recoveryCardPrompt(attempt.promptText),
1928
+ preserveAttemptOnFailure: true,
1929
+ },
1930
+ );
1931
+ if (attempt.messageId) await this.bindMessageReactionToTurn(attempt.messageId, session.localSessionId, turnId);
1932
+ }
1933
+
1041
1934
  private async restorePersistedMessageReactions(): Promise<void> {
1042
1935
  const pending = this.store.listPendingMessageReactions();
1043
1936
  const terminalTurns = new Map<string, "completed" | "failed" | "cancelled">();
@@ -1086,6 +1979,19 @@ export class ProxySessionController {
1086
1979
  replyTarget?: MessageReplyTarget,
1087
1980
  localImagePaths?: string[],
1088
1981
  ): Promise<void> {
1982
+ if (this.llmRetryingSessions.has(record.localSessionId)) {
1983
+ const queued = this.persistQueuedPrompt(record.localSessionId, contextKey, text, {
1984
+ localImagePaths,
1985
+ messageId,
1986
+ replyTarget,
1987
+ });
1988
+ this.store.audit(contextKey, "queued_prompt_added_during_llm_retry", {
1989
+ promptId: queued.promptId,
1990
+ localSessionId: record.localSessionId,
1991
+ });
1992
+ await this.presentPromptQueueCard(record.localSessionId, contextKey);
1993
+ return;
1994
+ }
1089
1995
  const configuredRuntime = this.runtimes.forAgent(record.agentName);
1090
1996
  if (!configuredRuntime.getSession(record.localSessionId)) {
1091
1997
  this.outbound.registerSession(
@@ -1143,7 +2049,7 @@ export class ProxySessionController {
1143
2049
  this.logger.warn({ error: syncError, sessionId: record.localSessionId }, "Failed to synchronize after steering failure.");
1144
2050
  }
1145
2051
  if (!current?.activeTurnId) {
1146
- const turnId = await this.startTurn(loaded, text, replyTarget, localImagePaths);
2052
+ const turnId = await this.startTurn(loaded, text, replyTarget, localImagePaths, { messageId });
1147
2053
  if (messageId) await this.bindMessageReactionToTurn(messageId, record.localSessionId, turnId);
1148
2054
  return;
1149
2055
  }
@@ -1172,7 +2078,7 @@ export class ProxySessionController {
1172
2078
  return;
1173
2079
  }
1174
2080
  }
1175
- const turnId = await this.startTurn(loaded, text, replyTarget, localImagePaths);
2081
+ const turnId = await this.startTurn(loaded, text, replyTarget, localImagePaths, { messageId });
1176
2082
  if (messageId) await this.bindMessageReactionToTurn(messageId, record.localSessionId, turnId);
1177
2083
  }
1178
2084
 
@@ -1182,6 +2088,7 @@ export class ProxySessionController {
1182
2088
  text: string,
1183
2089
  messageId?: string,
1184
2090
  ): Promise<void> {
2091
+ this.store.touchTurnAttempt(turnId);
1185
2092
  try {
1186
2093
  await this.outbound.appendSteerMessage(localSessionId, turnId, text, messageId);
1187
2094
  } catch (error) {
@@ -1311,6 +2218,9 @@ export class ProxySessionController {
1311
2218
  if (!message.threadContext || !message.threadId || !message.chatId) return;
1312
2219
  const current = this.store.getUserContext(message.contextKey)?.currentSessionId;
1313
2220
  if (current && this.store.getSession(current)) return;
2221
+ const hasPersistedSourceTurn = threadForkAnchorMessageIds(message)
2222
+ .some((messageId) => this.store.findTurnAnchorByMessageId(messageId) !== undefined);
2223
+ if (!hasPersistedSourceTurn) return;
1314
2224
 
1315
2225
  const existing = this.threadInitializations.get(message.contextKey);
1316
2226
  if (existing) return existing;
@@ -1401,9 +2311,7 @@ export class ProxySessionController {
1401
2311
  if (!message.threadContext || !message.threadId || !message.chatId) {
1402
2312
  throw new Error("当前消息不属于可识别的飞书话题。");
1403
2313
  }
1404
- const anchorMessageIds = [message.rootMessageId, message.parentMessageId]
1405
- .filter((messageId): messageId is string => Boolean(messageId && messageId !== message.messageId));
1406
- const anchor = [...new Set(anchorMessageIds)]
2314
+ const anchor = threadForkAnchorMessageIds(message)
1407
2315
  .map((messageId) => this.store.findTurnAnchorByMessageId(messageId))
1408
2316
  .find((candidate) => candidate !== undefined);
1409
2317
  if (!anchor) {
@@ -1785,31 +2693,60 @@ export class ProxySessionController {
1785
2693
  text: string,
1786
2694
  replyTarget?: MessageReplyTarget,
1787
2695
  localImagePaths?: string[],
2696
+ options: StartTurnOptions = {},
1788
2697
  ): Promise<string> {
1789
2698
  const currentRecord = this.store.getSession(loaded.record.localSessionId);
1790
2699
  const title = currentRecord?.title ?? normalizeTaskTitle(text);
1791
2700
  if (!currentRecord?.title && title) this.store.updateRuntimeSession(loaded.record.localSessionId, { title });
1792
2701
  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
- );
2702
+ const attemptId = options.attemptId ?? createId("attempt");
2703
+ if (options.attemptId) {
2704
+ this.store.updateTurnAttempt(attemptId, {
2705
+ messageId: options.messageId ?? undefined,
2706
+ replyMessageId: replyTarget?.messageId ?? undefined,
2707
+ status: "recovering",
2708
+ });
2709
+ } else {
2710
+ this.store.createTurnAttempt({
2711
+ attemptId,
2712
+ localSessionId: loaded.record.localSessionId,
2713
+ contextKey: loaded.record.contextKey,
2714
+ promptText: text,
2715
+ localImagePaths,
2716
+ messageId: options.messageId,
2717
+ replyMessageId: replyTarget?.messageId,
2718
+ });
2719
+ }
1800
2720
  let turnId: string;
1801
2721
  try {
2722
+ const pendingTurnId = await this.outbound.startPendingTurn(
2723
+ loaded.record.localSessionId,
2724
+ loaded.record.contextKey,
2725
+ title,
2726
+ replyTarget,
2727
+ options.displayPrompt ?? text,
2728
+ );
2729
+ this.store.updateTurnAttempt(attemptId, { pendingTurnId: pendingTurnId ?? null });
1802
2730
  turnId = await loaded.runtime.startTurn(
1803
2731
  loaded.record.localSessionId,
1804
2732
  runtimePrompt(text, localImagePaths),
1805
2733
  );
1806
2734
  } catch (error) {
2735
+ this.store.updateTurnAttempt(attemptId, {
2736
+ status: options.preserveAttemptOnFailure ? "recovering" : "failed",
2737
+ });
1807
2738
  await this.outbound.failPendingTurn(
1808
2739
  loaded.record.localSessionId,
1809
2740
  error instanceof Error ? error.message : String(error),
1810
2741
  );
1811
2742
  throw error;
1812
2743
  }
2744
+ const attempt = this.store.getTurnAttempt(attemptId);
2745
+ this.store.updateTurnAttempt(attemptId, {
2746
+ pendingTurnId: null,
2747
+ turnId,
2748
+ ...(attempt && isIncompleteTurnAttemptStatus(attempt.status) ? { status: "running" as const } : {}),
2749
+ });
1813
2750
  const latest = this.store.getSession(loaded.record.localSessionId);
1814
2751
  const alreadyTerminal = latest?.lastTurnId === turnId
1815
2752
  && ["completed", "cancelled", "failed"].includes(latest.lastTurnStatus ?? "");
@@ -2218,6 +3155,7 @@ export class ProxySessionController {
2218
3155
 
2219
3156
  private async handleRuntimeEvent(event: RuntimeEvent): Promise<void> {
2220
3157
  if ("turnId" in event) {
3158
+ this.store.touchTurnAttempt(event.turnId);
2221
3159
  const source = this.store.getSession(event.sessionId);
2222
3160
  if (source?.remoteSessionId && this.isCodexSession(source)) {
2223
3161
  this.store.saveTurnRuntimeOrigin(
@@ -2228,6 +3166,10 @@ export class ProxySessionController {
2228
3166
  );
2229
3167
  }
2230
3168
  if (event.type === "turn_started") {
3169
+ const attempt = this.store.bindTurnAttempt(event.sessionId, event.turnId);
3170
+ if (attempt?.recoveredFromTurnId && attempt.recoveredFromTurnId !== event.turnId) {
3171
+ this.store.rebindPendingTurnMessages(event.sessionId, attempt.recoveredFromTurnId, event.turnId);
3172
+ }
2231
3173
  this.store.saveTurnParent(
2232
3174
  event.turnId,
2233
3175
  event.sessionId,
@@ -2235,6 +3177,20 @@ export class ProxySessionController {
2235
3177
  );
2236
3178
  }
2237
3179
  }
3180
+ const failedAttempt = event.type === "turn_failed"
3181
+ ? this.store.findIncompleteTurnAttemptByTurnId(event.turnId)
3182
+ : undefined;
3183
+ if (event.type === "turn_failed") {
3184
+ if (this.retriedFailureTurnIds.has(event.turnId)) return;
3185
+ if (failedAttempt && isRetryableLlmTurnFailure(event.message) && failedAttempt.retryCount < MAX_LLM_TURN_RETRIES) {
3186
+ const retryAttempt = this.store.prepareTurnAttemptRetry(failedAttempt.attemptId, event.turnId);
3187
+ if (retryAttempt) {
3188
+ this.retriedFailureTurnIds.add(event.turnId);
3189
+ await this.retryFailedLlmTurn(event, retryAttempt);
3190
+ return;
3191
+ }
3192
+ }
3193
+ }
2238
3194
  if (event.type === "session_metadata_updated") {
2239
3195
  this.store.updateRuntimeSession(event.sessionId, { title: event.title });
2240
3196
  this.outbound.updateSessionTitle(event.sessionId, event.title);
@@ -2244,15 +3200,27 @@ export class ProxySessionController {
2244
3200
  this.store.updateSession(event.sessionId, { status: "running" });
2245
3201
  this.store.updateRuntimeSession(event.sessionId, { lastTurnId: event.turnId, lastTurnStatus: "running" });
2246
3202
  } else if (event.type === "turn_completed" || event.type === "turn_cancelled" || event.type === "turn_failed") {
3203
+ this.store.markTurnAttemptTerminal(
3204
+ event.turnId,
3205
+ event.type === "turn_completed" ? "completed" : event.type === "turn_cancelled" ? "cancelled" : "failed",
3206
+ );
2247
3207
  this.store.updateRuntimeSession(event.sessionId, {
2248
3208
  lastTurnId: event.turnId,
2249
3209
  lastTurnStatus: event.type === "turn_completed" ? "completed" : event.type === "turn_cancelled" ? "cancelled" : "failed",
2250
3210
  });
2251
3211
  }
2252
3212
 
3213
+ const presentationEvent = event.type === "turn_failed"
3214
+ && isRetryableLlmTurnFailure(event.message)
3215
+ && (failedAttempt?.retryCount ?? 0) >= MAX_LLM_TURN_RETRIES
3216
+ ? {
3217
+ ...event,
3218
+ message: `${event.message}\n\n已自动重试 ${MAX_LLM_TURN_RETRIES} 次,仍未成功。`,
3219
+ }
3220
+ : event;
2253
3221
  let presentationError: unknown;
2254
3222
  try {
2255
- await this.outbound.onEvent(event);
3223
+ await this.outbound.onEvent(presentationEvent);
2256
3224
  } catch (error) {
2257
3225
  presentationError = error;
2258
3226
  }
@@ -2285,6 +3253,80 @@ export class ProxySessionController {
2285
3253
  if (presentationError) throw presentationError;
2286
3254
  }
2287
3255
 
3256
+ private async retryFailedLlmTurn(
3257
+ event: Extract<RuntimeEvent, { type: "turn_failed" }>,
3258
+ attempt: TurnAttemptRecord,
3259
+ ): Promise<void> {
3260
+ const retryNumber = attempt.retryCount;
3261
+ const session = this.store.getSession(event.sessionId);
3262
+ if (!session || session.status === "closed") {
3263
+ this.store.updateTurnAttempt(attempt.attemptId, { status: "failed" });
3264
+ return;
3265
+ }
3266
+
3267
+ this.llmRetryingSessions.add(session.localSessionId);
3268
+ this.store.updateRuntimeSession(session.localSessionId, {
3269
+ lastTurnId: event.turnId,
3270
+ lastTurnStatus: "failed",
3271
+ });
3272
+ this.store.audit(attempt.contextKey, "llm_turn_retry_started", {
3273
+ attemptId: attempt.attemptId,
3274
+ failedTurnId: event.turnId,
3275
+ retryNumber,
3276
+ maxRetries: MAX_LLM_TURN_RETRIES,
3277
+ error: event.message,
3278
+ });
3279
+
3280
+ try {
3281
+ await this.outbound.onEvent({
3282
+ ...event,
3283
+ message: `${event.message}\n\n检测到临时模型服务错误,正在自动重试(${retryNumber}/${MAX_LLM_TURN_RETRIES})。`,
3284
+ });
3285
+ } catch (error) {
3286
+ this.logger.warn(
3287
+ { error, sessionId: session.localSessionId, turnId: event.turnId },
3288
+ "Failed to finalize the failed thinking card before an LLM retry.",
3289
+ );
3290
+ }
3291
+
3292
+ const replyTarget = attempt.replyMessageId
3293
+ ? { messageId: attempt.replyMessageId, replyInThread: true as const }
3294
+ : undefined;
3295
+ try {
3296
+ const current = this.store.getSession(session.localSessionId) ?? session;
3297
+ const loaded = await this.loadSession(current);
3298
+ const retryTurnId = await this.startTurn(
3299
+ loaded,
3300
+ llmRetryRuntimePrompt(attempt.promptText, retryNumber),
3301
+ replyTarget,
3302
+ attempt.localImagePaths,
3303
+ {
3304
+ attemptId: attempt.attemptId,
3305
+ messageId: attempt.messageId,
3306
+ displayPrompt: llmRetryCardPrompt(attempt.promptText, retryNumber),
3307
+ preserveAttemptOnFailure: true,
3308
+ },
3309
+ );
3310
+ this.store.rebindPendingTurnMessages(session.localSessionId, event.turnId, retryTurnId);
3311
+ } catch (error) {
3312
+ this.store.updateTurnAttempt(attempt.attemptId, { status: "failed" });
3313
+ this.store.updateSession(session.localSessionId, { status: "failed" });
3314
+ await this.finalizeTurnMessageReactions(event.turnId, "failed").catch((reactionError: unknown) => {
3315
+ this.logger.warn(
3316
+ { error: reactionError, sessionId: session.localSessionId, turnId: event.turnId },
3317
+ "Failed to finalize reactions after an LLM retry could not start.",
3318
+ );
3319
+ });
3320
+ this.logger.error(
3321
+ { error, sessionId: session.localSessionId, retryNumber },
3322
+ "Failed to start an automatic LLM turn retry.",
3323
+ );
3324
+ await this.scheduleNextQueuedPrompt(session.localSessionId);
3325
+ } finally {
3326
+ this.llmRetryingSessions.delete(session.localSessionId);
3327
+ }
3328
+ }
3329
+
2288
3330
  private scheduleNextQueuedPrompt(sessionId: string): Promise<void> {
2289
3331
  const previous = this.queuedPromptStarts.get(sessionId) ?? Promise.resolve();
2290
3332
  const next = previous.catch(() => undefined).then(() => this.startNextQueuedPromptIfIdle(sessionId));
@@ -2317,6 +3359,7 @@ export class ProxySessionController {
2317
3359
  prompt.text,
2318
3360
  prompt.replyMessageId ? { messageId: prompt.replyMessageId, replyInThread: true } : undefined,
2319
3361
  prompt.localImagePaths,
3362
+ { messageId: prompt.messageId },
2320
3363
  );
2321
3364
  if (prompt.messageId) await this.bindMessageReactionToTurn(prompt.messageId, sessionId, turnId);
2322
3365
  } catch (error) {
@@ -2333,7 +3376,9 @@ export class ProxySessionController {
2333
3376
  if (!source?.lastTurnId) return undefined;
2334
3377
  if (source.lastTurnStatus === "completed") return source.lastTurnId;
2335
3378
  const snapshot = turnViewSnapshot(this.store.getTurnSnapshot(source.lastTurnId));
2336
- return snapshot?.status === "completed" ? source.lastTurnId : undefined;
3379
+ return snapshot?.status === "completed"
3380
+ ? source.lastTurnId
3381
+ : this.store.findLatestCompletedTurnId(source.localSessionId, source.contextKey);
2337
3382
  }
2338
3383
 
2339
3384
  private async finalizeStandaloneMessageReaction(
@@ -2476,16 +3521,25 @@ export class ProxySessionController {
2476
3521
  if (current.localSessionId !== sessionId) {
2477
3522
  throw new Error("这张思考卡片不属于当前任务。请先切换回对应任务,再点击 Reset。");
2478
3523
  }
3524
+ await this.performSessionReset(contextKey, current, turnId, true);
3525
+ }
3526
+
3527
+ private async performSessionReset(
3528
+ contextKey: string,
3529
+ current: SessionRecord,
3530
+ turnId: string,
3531
+ announce: boolean,
3532
+ ): Promise<void> {
2479
3533
  if (!current.remoteSessionId || !this.isCodexSession(current)) {
2480
3534
  throw new Error("当前任务不是可 Reset 的 App Server 任务。");
2481
3535
  }
2482
3536
 
2483
3537
  const snapshot = turnViewSnapshot(this.store.getTurnSnapshot(turnId));
2484
- if (!snapshot || snapshot.sessionId !== sessionId || snapshot.status !== "completed") {
3538
+ if (!snapshot || snapshot.sessionId !== current.localSessionId || snapshot.status !== "completed") {
2485
3539
  throw new Error("只能将当前任务 Reset 到已成功完成的轮次。");
2486
3540
  }
2487
3541
  if (current.lastTurnId === turnId && current.lastTurnStatus === "completed") {
2488
- await this.outbound.sendText(contextKey, "当前任务已经处于本轮完成后的对话状态,无需 Reset。");
3542
+ if (announce) await this.outbound.sendText(contextKey, "当前任务已经处于本轮完成后的对话状态,无需 Reset。");
2489
3543
  return;
2490
3544
  }
2491
3545
 
@@ -2502,7 +3556,7 @@ export class ProxySessionController {
2502
3556
  }
2503
3557
 
2504
3558
  const origin = this.store.getTurnRuntimeOrigin(turnId);
2505
- if (origin && (origin.localSessionId !== sessionId || origin.agentName !== current.agentName)) {
3559
+ if (origin && (origin.localSessionId !== current.localSessionId || origin.agentName !== current.agentName)) {
2506
3560
  throw new Error("这张思考卡片与当前任务不匹配,已拒绝 Reset。");
2507
3561
  }
2508
3562
  const sourceRemoteSessionId = origin?.remoteSessionId ?? current.remoteSessionId;
@@ -2543,16 +3597,18 @@ export class ProxySessionController {
2543
3597
  100,
2544
3598
  ) || "未记录对话内容";
2545
3599
  const targetTime = snapshot.completedAt ?? snapshot.startedAt;
2546
- await this.outbound.sendText(
2547
- contextKey,
2548
- [
2549
- "已将当前任务重置到:",
2550
- targetSummary,
2551
- `完成时间:${targetTime === undefined ? "未知" : formatResetTurnTime(targetTime)}`,
2552
- `Turn ID:${turnId}`,
2553
- "后续对话将从该轮完成后的状态继续;本地文件没有回退。",
2554
- ].join("\n"),
2555
- );
3600
+ if (announce) {
3601
+ await this.outbound.sendText(
3602
+ contextKey,
3603
+ [
3604
+ "已将当前任务重置到:",
3605
+ targetSummary,
3606
+ `完成时间:${targetTime === undefined ? "未知" : formatResetTurnTime(targetTime)}`,
3607
+ `Turn ID:${turnId}`,
3608
+ "后续对话将从该轮完成后的状态继续;本地文件没有回退。",
3609
+ ].join("\n"),
3610
+ );
3611
+ }
2556
3612
  }
2557
3613
 
2558
3614
  private async resolveApproval(action: CardAction): Promise<void> {
@@ -3194,6 +4250,175 @@ export class ProxySessionController {
3194
4250
  }
3195
4251
  }
3196
4252
 
4253
+ private async openDirectoryBrowser(
4254
+ contextKey: string,
4255
+ requestedDirectory?: string,
4256
+ options: DirectoryBrowserOptions = {},
4257
+ ): Promise<void> {
4258
+ if (requestedDirectory === WINDOWS_DRIVES_DIRECTORY) {
4259
+ await this.openWindowsDriveBrowser(contextKey, options);
4260
+ return;
4261
+ }
4262
+ const baseDirectory = this.currentSession(contextKey)?.cwd
4263
+ ?? this.store.getUserContext(contextKey)?.boundProjectCwd
4264
+ ?? this.config.defaults.cwd;
4265
+ const directory = requestedDirectory === undefined
4266
+ ? path.resolve(baseDirectory)
4267
+ : resolveUserPath(requestedDirectory, baseDirectory);
4268
+ await this.assertBrowsableDirectory(directory);
4269
+
4270
+ let directoryEntries: Dirent[];
4271
+ try {
4272
+ directoryEntries = await fs.readdir(directory, { withFileTypes: true });
4273
+ } catch (error) {
4274
+ throw new Error(`无法读取目录 ${directory}:${runtimeErrorMessage(error)}`);
4275
+ }
4276
+ const entries = directoryEntries
4277
+ .map((entry) => ({
4278
+ name: entry.name,
4279
+ path: path.join(directory, entry.name),
4280
+ kind: directoryBrowserEntryKind(entry),
4281
+ }))
4282
+ .sort((left, right) => Number(right.kind === "directory") - Number(left.kind === "directory")
4283
+ || left.name.localeCompare(right.name, undefined, { numeric: true, sensitivity: "base" }));
4284
+ const parentDirectory = isWindowsDriveRoot(directory)
4285
+ ? WINDOWS_DRIVES_DIRECTORY
4286
+ : path.dirname(directory);
4287
+ const parentEntries: DirectoryBrowserCardEntry[] = parentDirectory === directory ? [] : [{
4288
+ name: "..",
4289
+ kind: "directory",
4290
+ openAction: {
4291
+ text: "..",
4292
+ value: {
4293
+ action: "directory_open",
4294
+ directory: parentDirectory,
4295
+ contextKey,
4296
+ },
4297
+ },
4298
+ }];
4299
+ const pageSize = DIRECTORY_PAGE_SIZE;
4300
+ const requestedPage = Math.max(0, Math.trunc(options.page ?? 0));
4301
+ const totalPages = Math.max(1, Math.ceil(entries.length / pageSize));
4302
+ const page = Math.min(requestedPage, totalPages - 1);
4303
+ const offset = page * pageSize;
4304
+ const visibleEntries: DirectoryBrowserCardEntry[] = [
4305
+ ...parentEntries,
4306
+ ...entries
4307
+ .slice(offset, offset + pageSize)
4308
+ .map((entry): DirectoryBrowserCardEntry => entry.kind === "directory"
4309
+ ? {
4310
+ name: entry.name,
4311
+ kind: entry.kind,
4312
+ openAction: {
4313
+ text: entry.name,
4314
+ value: {
4315
+ action: "directory_open",
4316
+ directory: entry.path,
4317
+ contextKey,
4318
+ },
4319
+ },
4320
+ actions: directoryCreationActions(entry.path, contextKey),
4321
+ }
4322
+ : {
4323
+ name: entry.name,
4324
+ kind: entry.kind,
4325
+ openAction: {
4326
+ text: entry.name,
4327
+ value: {
4328
+ action: "directory_send_file",
4329
+ filePath: entry.path,
4330
+ contextKey,
4331
+ },
4332
+ },
4333
+ }),
4334
+ ];
4335
+ const navigationActions = directoryPaginationActions(directory, page, totalPages, contextKey);
4336
+ const directoryCount = entries.filter((entry) => entry.kind === "directory").length;
4337
+ const card = this.cardRenderer.renderDirectoryBrowserCard({
4338
+ directory: abbreviateHomeDirectory(directory),
4339
+ entries: visibleEntries,
4340
+ currentActions: directoryCreationActions(directory, contextKey),
4341
+ navigationActions,
4342
+ footerLines: [
4343
+ `第 ${page + 1}/${totalPages} 页 · ${directoryCount} 个目录 · ${entries.length - directoryCount} 个文件`,
4344
+ "",
4345
+ "> 点击目录名称进入,点击文件名称发送到当前会话;**New** 在该目录创建任务,**NewGroup** 在该目录创建新群和任务。",
4346
+ ],
4347
+ });
4348
+ if (options.updateMessageId) {
4349
+ await this.outbound.updateInteractiveCard(contextKey, options.updateMessageId, card);
4350
+ } else {
4351
+ await this.outbound.sendInteractiveCard(contextKey, card);
4352
+ }
4353
+ }
4354
+
4355
+ private async openWindowsDriveBrowser(
4356
+ contextKey: string,
4357
+ options: DirectoryBrowserOptions,
4358
+ ): Promise<void> {
4359
+ const drives = await this.windowsDriveLister();
4360
+ const requestedPage = Math.max(0, Math.trunc(options.page ?? 0));
4361
+ const totalPages = Math.max(1, Math.ceil(drives.length / DIRECTORY_PAGE_SIZE));
4362
+ const page = Math.min(requestedPage, totalPages - 1);
4363
+ const offset = page * DIRECTORY_PAGE_SIZE;
4364
+ const entries: DirectoryBrowserCardEntry[] = drives
4365
+ .slice(offset, offset + DIRECTORY_PAGE_SIZE)
4366
+ .map((drive) => ({
4367
+ name: windowsDriveDisplayName(drive),
4368
+ kind: "drive",
4369
+ openAction: {
4370
+ text: windowsDriveDisplayName(drive),
4371
+ value: {
4372
+ action: "directory_open",
4373
+ directory: drive.root,
4374
+ contextKey,
4375
+ },
4376
+ },
4377
+ actions: directoryCreationActions(drive.root, contextKey),
4378
+ }));
4379
+ const card = this.cardRenderer.renderDirectoryBrowserCard({
4380
+ directory: "此电脑",
4381
+ entries,
4382
+ currentActions: [],
4383
+ navigationActions: directoryPaginationActions(
4384
+ WINDOWS_DRIVES_DIRECTORY,
4385
+ page,
4386
+ totalPages,
4387
+ contextKey,
4388
+ ),
4389
+ footerLines: [
4390
+ `第 ${page + 1}/${totalPages} 页 · ${drives.length} 个磁盘`,
4391
+ "",
4392
+ "> 点击磁盘名称进入;**New** 在磁盘根目录创建任务,**NewGroup** 在磁盘根目录创建新群和任务。",
4393
+ ],
4394
+ });
4395
+ if (options.updateMessageId) {
4396
+ await this.outbound.updateInteractiveCard(contextKey, options.updateMessageId, card);
4397
+ } else {
4398
+ await this.outbound.sendInteractiveCard(contextKey, card);
4399
+ }
4400
+ }
4401
+
4402
+ private async assertBrowsableDirectory(directory: string): Promise<void> {
4403
+ let stats;
4404
+ try {
4405
+ stats = await fs.stat(directory);
4406
+ } catch (error) {
4407
+ throw new Error(`目录不存在或无法访问:${directory}(${runtimeErrorMessage(error)})`);
4408
+ }
4409
+ if (!stats.isDirectory()) throw new Error(`这不是目录:${directory}`);
4410
+ }
4411
+
4412
+ private async assertSendableFile(filePath: string): Promise<void> {
4413
+ let stats;
4414
+ try {
4415
+ stats = await fs.stat(filePath);
4416
+ } catch (error) {
4417
+ throw new Error(`文件不存在或无法访问:${filePath}(${runtimeErrorMessage(error)})`);
4418
+ }
4419
+ if (!stats.isFile()) throw new Error(`这不是普通文件:${filePath}`);
4420
+ }
4421
+
3197
4422
  private async listSessions(
3198
4423
  contextKey: string,
3199
4424
  searchTerm?: string,
@@ -3224,23 +4449,33 @@ export class ProxySessionController {
3224
4449
  ? `部分 Agent 的任务读取失败:${remoteErrors.join(";")}`
3225
4450
  : undefined;
3226
4451
 
3227
- const entries = mergeTaskList(localSessions, remoteSessions, context.currentSessionId)
3228
- .filter((entry) => !normalizedSearch
4452
+ const entries = orderTaskListByProject(
4453
+ mergeTaskList(localSessions, remoteSessions, context.currentSessionId)
4454
+ .filter((entry) => !normalizedSearch
3229
4455
  || [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);
4456
+ .some((value) => value.toLowerCase().includes(normalizedSearch))),
4457
+ );
3234
4458
  const activeCount = entries.filter((entry) => entry.active).length;
3235
4459
  const lastKnownPage = Math.max(0, Math.ceil(entries.length / SESSION_PAGE_SIZE) - 1);
3236
4460
  const page = remoteHasMore ? requestedPage : Math.min(requestedPage, lastKnownPage);
3237
4461
  const offset = page * SESSION_PAGE_SIZE;
3238
4462
  const visibleEntries = entries.slice(offset, offset + SESSION_PAGE_SIZE);
4463
+ const projectActionSources = new Map<string, UnifiedTaskListEntry>();
4464
+ for (const entry of entries) {
4465
+ const project = taskProjectInfo(entry);
4466
+ if (!projectActionSources.has(project.key)) projectActionSources.set(project.key, entry);
4467
+ }
3239
4468
  const hasPrevious = page > 0;
3240
4469
  const hasNext = remoteHasMore || entries.length > offset + SESSION_PAGE_SIZE;
3241
4470
  this.lastSessionListings.set(contextKey, entries.map((entry) => entry.reference));
3242
- const cardEntries = visibleEntries.map((entry, index) => {
3243
- const marker = entry.current ? "✅" : entry.active ? "🟢 **活跃**" : "•";
4471
+ const cardEntries = visibleEntries.map((entry, index): {
4472
+ project: TaskProjectInfo;
4473
+ projectActions: TaskListCardAction[];
4474
+ cardEntry: SessionTaskCardEntry;
4475
+ } => {
4476
+ const project = taskProjectInfo(entry);
4477
+ const projectActionSource = projectActionSources.get(project.key) ?? entry;
4478
+ const marker = entry.current ? "✅" : entry.active ? "🟢" : "•";
3244
4479
  const showStop = entry.status === "外部执行中"
3245
4480
  && entry.reference !== options.forceSwitchTaskId
3246
4481
  && entry.id !== options.forceSwitchTaskId;
@@ -3255,26 +4490,25 @@ export class ProxySessionController {
3255
4490
  contextKey,
3256
4491
  },
3257
4492
  }];
3258
- actions.push({
4493
+ const projectActions: TaskListCardAction[] = [{
3259
4494
  text: "New",
3260
4495
  value: {
3261
4496
  action: "session_new",
3262
- sessionId: entry.reference,
4497
+ sessionId: projectActionSource.reference,
3263
4498
  ...(searchTerm ? { searchTerm } : {}),
3264
4499
  page: String(page),
3265
4500
  contextKey,
3266
4501
  },
3267
- });
3268
- actions.push({
4502
+ }, {
3269
4503
  text: "NewGroup",
3270
4504
  value: {
3271
4505
  action: "session_new_group",
3272
- sessionId: entry.reference,
4506
+ sessionId: projectActionSource.reference,
3273
4507
  ...(searchTerm ? { searchTerm } : {}),
3274
4508
  page: String(page),
3275
4509
  contextKey,
3276
4510
  },
3277
- });
4511
+ }];
3278
4512
  actions.push({
3279
4513
  text: "Fork",
3280
4514
  value: {
@@ -3303,24 +4537,58 @@ export class ProxySessionController {
3303
4537
  contextKey,
3304
4538
  },
3305
4539
  });
4540
+ actions.push({
4541
+ text: "Archive",
4542
+ value: {
4543
+ action: "session_archive",
4544
+ sessionId: entry.reference,
4545
+ ...(searchTerm ? { searchTerm } : {}),
4546
+ page: String(page),
4547
+ contextKey,
4548
+ },
4549
+ });
4550
+ const title = truncateText(entry.title.replace(/\s+/gu, " ").trim() || "未命名任务", 56);
3306
4551
  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,
4552
+ project,
4553
+ projectActions,
4554
+ cardEntry: {
4555
+ reference: entry.reference,
4556
+ summary: `${offset + index + 1}. ${marker} ${title} · ${entry.agentName}`,
4557
+ detailLines: [
4558
+ `**状态 / 更新时间**:${cardText(entry.status)} / ${cardText(entry.updatedLabel)}`,
4559
+ `**Agent / 任务 ID**:${cardCode(entry.agentName)} / ${cardCode(entry.id)}`,
4560
+ `**目录**:${cardCode(entry.cwd || "目录未知")}`,
4561
+ ],
4562
+ actions,
4563
+ current: entry.current,
4564
+ },
3312
4565
  };
3313
4566
  });
3314
- const card = this.cardRenderer.renderTaskListCard(
3315
- searchTerm ? `App Server 任务:${searchTerm}` : "App Server 任务",
4567
+ const cardGroups: SessionTaskCardGroup[] = [];
4568
+ let currentProjectKey: string | undefined;
4569
+ for (const item of cardEntries) {
4570
+ const currentGroup = cardGroups.at(-1);
4571
+ if (currentGroup && currentProjectKey === item.project.key) {
4572
+ currentGroup.entries.push(item.cardEntry);
4573
+ } else {
4574
+ currentProjectKey = item.project.key;
4575
+ cardGroups.push({
4576
+ title: item.project.title,
4577
+ entries: [item.cardEntry],
4578
+ actions: item.projectActions,
4579
+ });
4580
+ }
4581
+ }
4582
+ const card = this.cardRenderer.renderSessionTaskListCard(
4583
+ searchTerm ? `任务列表:${searchTerm}` : "任务列表",
3316
4584
  activeCount > 0 ? `任务(${activeCount} 个活跃)` : "任务",
3317
- cardEntries,
4585
+ cardGroups,
3318
4586
  [
3319
4587
  ...(remoteHint ? [remoteHint] : []),
3320
4588
  `第 ${page + 1} 页 · 每页 ${SESSION_PAGE_SIZE} 个任务${hasNext ? "" : ` · 当前共 ${entries.length} 个任务`}`,
3321
4589
  "",
3322
- "> **Switch** 切换任务;**Stop** 停止运行;**Status** 查看状态。",
3323
- "> **New** / **Fork** 新建 / 分支任务;**NewGroup** / **ForkGroup** 新建 / 分支群。",
4590
+ "> 项目行:**New** 新建任务,**NewGroup** 新建群。",
4591
+ "> 任务详情:**Switch** 切换,**Stop** 停止,**Fork** / **ForkGroup** 创建分支,**Status** 查看状态,**Archive** 归档。",
3324
4592
  ],
3325
4593
  [
3326
4594
  ...(hasPrevious ? [{
@@ -3509,6 +4777,67 @@ export class ProxySessionController {
3509
4777
  await this.outbound.sendText(contextKey, `已向 Agent 发送 Interrupt 请求:${turnId}`);
3510
4778
  }
3511
4779
 
4780
+ private async archiveSessionReference(
4781
+ contextKey: string,
4782
+ reference: string,
4783
+ options: { announce: boolean; source: "cli" | "command" | "sessions_card" },
4784
+ ): Promise<{ localSessionId: string; remoteSessionId: string; title: string }> {
4785
+ const taskId = this.resolveSessionReference(contextKey, reference);
4786
+ const direct = this.store.getSession(taskId);
4787
+ if (direct?.status === "closed") throw new Error(`任务已经归档:${direct.title ?? taskId}`);
4788
+ const local = direct ?? this.findStoredSessionByReference(taskId);
4789
+ if (local && (!local.remoteSessionId || !this.isCodexSession(local))) {
4790
+ throw new Error("当前 Agent 不支持归档任务。");
4791
+ }
4792
+ if (local && (
4793
+ local.status === "running"
4794
+ || Boolean(this.runtimes.forAgent(local.agentName).getSession(local.localSessionId)?.activeTurnId)
4795
+ )) {
4796
+ throw new Error("任务正在执行,无法归档。请先停止任务或等待本轮完成。");
4797
+ }
4798
+ if (local && this.store.countQueuedPrompts(local.localSessionId) > 0) {
4799
+ throw new Error("任务仍有排队消息,无法归档。请先处理或取消排队消息。");
4800
+ }
4801
+
4802
+ const remoteReference = local
4803
+ ? remoteSessionReference(local.agentName, local.remoteSessionId!)
4804
+ : taskId;
4805
+ const { agentName, runtime, remote } = await this.resolveRemoteCodexSession(remoteReference);
4806
+ if (isRemoteSessionActive(remote)) {
4807
+ throw new Error("任务正在执行,无法归档。请先停止任务或等待本轮完成。");
4808
+ }
4809
+ if (!runtime.archiveRemoteSession) {
4810
+ throw new Error(`Agent ${agentName} 不支持归档任务。`);
4811
+ }
4812
+
4813
+ const wasCurrent = Boolean(local && this.store.getUserContext(contextKey)?.currentSessionId === local.localSessionId);
4814
+ await runtime.archiveRemoteSession(remote.id);
4815
+ const title = local?.title ?? remote.title ?? remote.preview ?? remote.id;
4816
+ if (local) {
4817
+ this.store.archiveSession(local.localSessionId);
4818
+ this.outbound.unregisterSession(local.localSessionId);
4819
+ }
4820
+ this.store.audit(contextKey, "session_archived", {
4821
+ ...(local ? { localSessionId: local.localSessionId } : {}),
4822
+ remoteSessionId: remote.id,
4823
+ agentName,
4824
+ source: options.source,
4825
+ });
4826
+ if (options.announce) {
4827
+ await this.outbound.sendText(
4828
+ contextKey,
4829
+ wasCurrent
4830
+ ? `已归档任务:${title}\n当前会话已没有绑定任务,直接发送消息即可创建新任务。`
4831
+ : `已归档任务:${title}`,
4832
+ );
4833
+ }
4834
+ return {
4835
+ localSessionId: local?.localSessionId ?? "",
4836
+ remoteSessionId: remote.id,
4837
+ title,
4838
+ };
4839
+ }
4840
+
3512
4841
  private resolveSessionReference(contextKey: string, reference?: string): string {
3513
4842
  if (reference === undefined) {
3514
4843
  const previous = this.store.getOrCreateUserContext(contextKey, this.config.defaults.agent!).previousSessionId;
@@ -3839,6 +5168,42 @@ export class ProxySessionController {
3839
5168
  ));
3840
5169
  }
3841
5170
 
5171
+ private async setGroupMute(contextKey: string, enabled: boolean): Promise<void> {
5172
+ const chatContextKey = baseChatContextKey(contextKey);
5173
+ const chat = this.store.getChatContext(chatContextKey);
5174
+ if (chat?.chatType !== "group") {
5175
+ throw new Error("/mute 仅适用于群聊。");
5176
+ }
5177
+ this.store.setChatRequiresMention(chatContextKey, enabled);
5178
+ await this.outbound.sendText(
5179
+ contextKey,
5180
+ enabled
5181
+ ? "已开启当前群的静音模式。之后只有 @ 机器人的消息会被处理;发送 @机器人 /mute off 可恢复自动响应。"
5182
+ : "已关闭当前群的静音模式。机器人将恢复自动响应群消息。",
5183
+ );
5184
+ }
5185
+
5186
+ private requireControlSession(localSessionId: string): SessionRecord {
5187
+ const record = this.store.getSession(localSessionId);
5188
+ if (!record || record.status === "closed") throw new Error(`Task not found: ${localSessionId}`);
5189
+ return record;
5190
+ }
5191
+
5192
+ private controlSessionContextKey(record: SessionRecord): string {
5193
+ return this.outbound.getSessionContextKey(record.localSessionId)
5194
+ ?? (record.lastTurnId ? this.store.getTurnContextKey(record.lastTurnId) : undefined)
5195
+ ?? record.contextKey;
5196
+ }
5197
+
5198
+ private controlSessionReplyTarget(record: SessionRecord): MessageReplyTarget | undefined {
5199
+ const routed = this.outbound.getSessionReplyTarget(record.localSessionId);
5200
+ if (routed) return routed;
5201
+ const snapshot = record.lastTurnId
5202
+ ? turnViewSnapshot(this.store.getTurnSnapshot(record.lastTurnId))
5203
+ : undefined;
5204
+ return snapshot?.replyTarget;
5205
+ }
5206
+
3842
5207
  private ensureAgent(agentName: string) {
3843
5208
  const agent = this.config.agents[agentName];
3844
5209
  if (!agent) throw new Error(`未知 agent:${agentName}`);
@@ -3903,6 +5268,11 @@ export class ProxySessionController {
3903
5268
  }
3904
5269
  }
3905
5270
 
5271
+ function threadForkAnchorMessageIds(message: IncomingMessage): string[] {
5272
+ return [...new Set([message.rootMessageId, message.parentMessageId]
5273
+ .filter((messageId): messageId is string => Boolean(messageId && messageId !== message.messageId)))];
5274
+ }
5275
+
3906
5276
  function sessionStatusLabel(status: SessionRecord["status"], activeTurnId?: string): string {
3907
5277
  if (activeTurnId || status === "running") return "执行中";
3908
5278
  const labels: Record<SessionRecord["status"], string> = {
@@ -3957,6 +5327,55 @@ interface UnifiedTaskListEntry {
3957
5327
  updatedLabel: string;
3958
5328
  }
3959
5329
 
5330
+ interface TaskProjectInfo {
5331
+ key: string;
5332
+ title: string;
5333
+ }
5334
+
5335
+ function orderTaskListByProject(entries: UnifiedTaskListEntry[]): UnifiedTaskListEntry[] {
5336
+ const groups = new Map<string, { project: TaskProjectInfo; entries: UnifiedTaskListEntry[] }>();
5337
+ for (const entry of entries) {
5338
+ const project = taskProjectInfo(entry);
5339
+ const group = groups.get(project.key);
5340
+ if (group) group.entries.push(entry);
5341
+ else groups.set(project.key, { project, entries: [entry] });
5342
+ }
5343
+ const orderedGroups = [...groups.values()]
5344
+ .map((group) => ({
5345
+ ...group,
5346
+ current: group.entries.some((entry) => entry.current),
5347
+ active: group.entries.some((entry) => entry.active),
5348
+ updatedAt: Math.max(...group.entries.map((entry) => entry.updatedAt)),
5349
+ }))
5350
+ .sort((left, right) => Number(right.current) - Number(left.current)
5351
+ || Number(right.active) - Number(left.active)
5352
+ || right.updatedAt - left.updatedAt
5353
+ || left.project.title.localeCompare(right.project.title, undefined, { sensitivity: "base" }));
5354
+ return orderedGroups.flatMap((group) => group.entries.sort(compareTaskListEntries));
5355
+ }
5356
+
5357
+ function compareTaskListEntries(left: UnifiedTaskListEntry, right: UnifiedTaskListEntry): number {
5358
+ return Number(right.current) - Number(left.current)
5359
+ || Number(right.active) - Number(left.active)
5360
+ || right.updatedAt - left.updatedAt
5361
+ || left.title.localeCompare(right.title, undefined, { sensitivity: "base" });
5362
+ }
5363
+
5364
+ function taskProjectInfo(entry: UnifiedTaskListEntry): TaskProjectInfo {
5365
+ if (!entry.cwd) return { key: "unknown", title: "📁 目录未知" };
5366
+ if (detectProjectlessWorkspace(entry.cwd)) {
5367
+ return { key: "projectless", title: "🗒️ 未指定项目" };
5368
+ }
5369
+ const windowsPath = isWindowsAbsolutePath(entry.cwd);
5370
+ const normalized = windowsPath
5371
+ ? path.win32.normalize(entry.cwd).toLowerCase()
5372
+ : path.resolve(entry.cwd);
5373
+ return {
5374
+ key: `cwd:${normalized}`,
5375
+ title: `📁 ${abbreviateHomeDirectory(entry.cwd)}`,
5376
+ };
5377
+ }
5378
+
3960
5379
  function mergeTaskList(
3961
5380
  localSessions: SessionRecord[],
3962
5381
  remoteSessions: AgentRemoteSessionSummary[],
@@ -4055,6 +5474,14 @@ function turnViewSnapshot(value: unknown): TurnViewState | undefined {
4055
5474
  : undefined;
4056
5475
  }
4057
5476
 
5477
+ function isTerminalTurnViewStatus(status: TurnViewState["status"]): boolean {
5478
+ return status === "completed" || status === "cancelled" || status === "failed";
5479
+ }
5480
+
5481
+ function isIncompleteTurnAttemptStatus(status: TurnAttemptRecord["status"]): boolean {
5482
+ return status === "accepted" || status === "running" || status === "recovering";
5483
+ }
5484
+
4058
5485
  function executionDetailLines(
4059
5486
  record: SessionRecord,
4060
5487
  snapshot: TurnViewState | undefined,
@@ -4336,7 +5763,7 @@ function isBotOwnedActiveTurn(record: SessionRecord, remote: RemoteSessionSummar
4336
5763
  }
4337
5764
 
4338
5765
  function isQueueIndependentCommand(command: Command): boolean {
4339
- if (["stop", "status", "restart", "help", "sessions", "goal", "nosteer"].includes(command.type)) return true;
5766
+ if (["archive", "stop", "status", "restart", "mute", "help", "sessions", "dir", "goal", "nosteer"].includes(command.type)) return true;
4340
5767
  if (command.type === "agent") return command.agent === undefined;
4341
5768
  if (["model", "provider", "thinking", "permissions"].includes(command.type)) return true;
4342
5769
  return false;
@@ -4347,6 +5774,138 @@ function parseSessionPage(value: unknown): number {
4347
5774
  return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : 0;
4348
5775
  }
4349
5776
 
5777
+ function directoryPageValue(value: unknown): number {
5778
+ const page = Number(value);
5779
+ if (!Number.isSafeInteger(page) || page < 0) throw new Error("文件浏览卡片页码无效,请重新发送 /dir。");
5780
+ return page;
5781
+ }
5782
+
5783
+ function isWindowsDriveRoot(directory: string): boolean {
5784
+ return /^[a-z]:\\$/iu.test(path.win32.normalize(directory));
5785
+ }
5786
+
5787
+ function directoryBrowserEntryKind(entry: Dirent): DirectoryBrowserCardEntry["kind"] {
5788
+ if (entry.isDirectory()) return "directory";
5789
+ const extension = path.extname(entry.name).toLowerCase();
5790
+ if (IMAGE_FILE_EXTENSIONS.has(extension)) return "image";
5791
+ if (BINARY_FILE_EXTENSIONS.has(extension)) return "binary";
5792
+ return "file";
5793
+ }
5794
+
5795
+ function directoryActionPath(value: unknown): string {
5796
+ if (typeof value !== "string" || !value.trim()) {
5797
+ throw new Error("文件浏览卡片中的目录无效,请重新发送 /dir。");
5798
+ }
5799
+ return value === WINDOWS_DRIVES_DIRECTORY ? value : path.resolve(value);
5800
+ }
5801
+
5802
+ function directoryFileActionPath(value: unknown): string {
5803
+ if (typeof value !== "string" || !value.trim()) {
5804
+ throw new Error("文件浏览卡片中的文件无效,请重新发送 /dir。");
5805
+ }
5806
+ return path.resolve(value);
5807
+ }
5808
+
5809
+ function directoryCreationActions(directory: string, contextKey: string): TaskListCardAction[] {
5810
+ return [
5811
+ {
5812
+ text: "New",
5813
+ value: {
5814
+ action: "directory_new",
5815
+ directory,
5816
+ contextKey,
5817
+ },
5818
+ },
5819
+ {
5820
+ text: "NewGroup",
5821
+ value: {
5822
+ action: "directory_new_group",
5823
+ directory,
5824
+ contextKey,
5825
+ },
5826
+ },
5827
+ ];
5828
+ }
5829
+
5830
+ function directoryPaginationActions(
5831
+ directory: string,
5832
+ page: number,
5833
+ totalPages: number,
5834
+ contextKey: string,
5835
+ ): TaskListCardAction[] {
5836
+ return [
5837
+ ...(page > 0 ? [{
5838
+ text: "Previous",
5839
+ value: {
5840
+ action: "directory_page",
5841
+ directory,
5842
+ page: String(page - 1),
5843
+ contextKey,
5844
+ },
5845
+ }] : []),
5846
+ ...(page < totalPages - 1 ? [{
5847
+ text: "Next",
5848
+ value: {
5849
+ action: "directory_page",
5850
+ directory,
5851
+ page: String(page + 1),
5852
+ contextKey,
5853
+ },
5854
+ }] : []),
5855
+ ];
5856
+ }
5857
+
5858
+ function windowsDriveDisplayName(drive: WindowsDriveInfo): string {
5859
+ const driveLetter = drive.root.slice(0, 2).toUpperCase();
5860
+ const label = drive.label?.trim() || windowsDriveTypeLabel(drive.driveType);
5861
+ return `${label} (${driveLetter})`;
5862
+ }
5863
+
5864
+ function windowsDriveTypeLabel(driveType?: string): string {
5865
+ switch (driveType?.toLowerCase()) {
5866
+ case "fixed": return "本地磁盘";
5867
+ case "network": return "网络驱动器";
5868
+ case "removable": return "可移动磁盘";
5869
+ case "cdrom": return "光驱";
5870
+ case "ram": return "RAM 磁盘";
5871
+ default: return "磁盘";
5872
+ }
5873
+ }
5874
+
5875
+ async function listWindowsDriveRoots(): Promise<WindowsDriveInfo[]> {
5876
+ if (process.platform !== "win32") throw new Error("磁盘列表仅在 Windows 上可用。");
5877
+ const result = await executeShellCommand(
5878
+ "$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",
5879
+ os.homedir(),
5880
+ { timeoutMs: 10_000, maxOutputBytes: 16 * 1024, platform: "win32" },
5881
+ );
5882
+ if (result.timedOut || result.exitCode !== 0) {
5883
+ const detail = result.stderr.trim() || `退出码 ${result.exitCode ?? "未知"}`;
5884
+ throw new Error(`无法读取 Windows 磁盘列表:${detail}`);
5885
+ }
5886
+ let parsed: unknown;
5887
+ try {
5888
+ parsed = JSON.parse(result.stdout.trim().replace(/^\uFEFF/u, ""));
5889
+ } catch (error) {
5890
+ throw new Error(`无法解析 Windows 磁盘列表:${runtimeErrorMessage(error)}`);
5891
+ }
5892
+ const values = Array.isArray(parsed) ? parsed : [parsed];
5893
+ const drives = values.flatMap((value): WindowsDriveInfo[] => {
5894
+ if (typeof value !== "object" || value === null) return [];
5895
+ const record = value as Record<string, unknown>;
5896
+ if (typeof record.root !== "string") return [];
5897
+ const root = path.win32.normalize(record.root.trim());
5898
+ if (!isWindowsDriveRoot(root)) return [];
5899
+ return [{
5900
+ root,
5901
+ ...(typeof record.label === "string" && record.label.trim() ? { label: record.label.trim() } : {}),
5902
+ ...(typeof record.driveType === "string" ? { driveType: record.driveType } : {}),
5903
+ }];
5904
+ }).sort((left, right) => left.root.localeCompare(right.root, undefined, { sensitivity: "base" }));
5905
+ if (drives.length === 0) throw new Error("没有检测到可用的 Windows 磁盘。");
5906
+ return drives;
5907
+ }
5908
+
4350
5909
  function isPromptCommand(command: Command): boolean {
4351
5910
  return command.type === "prompt" || command.type === "nosteer";
4352
5911
  }
@@ -4355,6 +5914,75 @@ function runtimePrompt(text: string, localImagePaths?: string[]): RuntimePrompt
4355
5914
  return localImagePaths?.length ? { text, localImagePaths } : text;
4356
5915
  }
4357
5916
 
5917
+ function recoveryCardPrompt(prompt: string): string {
5918
+ return `恢复重启前的任务:${prompt}`;
5919
+ }
5920
+
5921
+ function recoveryRuntimePrompt(prompt: string): string {
5922
+ return [
5923
+ "Agent Bot restarted while the previous turn was still running.",
5924
+ "Continue the unfinished task in the current task and workspace.",
5925
+ "Inspect the existing conversation, files, and completed tool effects first. Do not repeat completed or irreversible actions.",
5926
+ "Finish the original request:",
5927
+ "",
5928
+ prompt,
5929
+ ].join("\n");
5930
+ }
5931
+
5932
+ function llmRetryCardPrompt(prompt: string, retryNumber: number): string {
5933
+ return `自动重试 ${retryNumber}/${MAX_LLM_TURN_RETRIES}:${prompt}`;
5934
+ }
5935
+
5936
+ function llmRetryRuntimePrompt(prompt: string, retryNumber: number): string {
5937
+ return [
5938
+ `Agent Bot is automatically retrying this request after a transient LLM service failure (${retryNumber}/${MAX_LLM_TURN_RETRIES}).`,
5939
+ "Continue in the current task and workspace. Inspect the conversation, files, and completed tool effects before acting.",
5940
+ "Do not repeat completed or irreversible actions. Finish the original request:",
5941
+ "",
5942
+ prompt,
5943
+ ].join("\n");
5944
+ }
5945
+
5946
+ function isRetryableLlmTurnFailure(message: string): boolean {
5947
+ const normalized = message.trim().toLowerCase();
5948
+ if (!normalized) return false;
5949
+ const permanentFailure = [
5950
+ /auth(?:entication|orization)?\b|unauthorized|forbidden|invalid api key|incorrect api key/u,
5951
+ /insufficient[_ -]?quota|quota (?:exceeded|exhausted)|usage limit|billing|credit balance/u,
5952
+ /context (?:length|window)|maximum context|too many tokens|token limit/u,
5953
+ /invalid request|bad request|invalid argument|malformed|unsupported|not supported/u,
5954
+ /model (?:not found|does not exist)|unknown model/u,
5955
+ /permission|approval|sandbox|policy violation|content policy/u,
5956
+ ].some((pattern) => pattern.test(normalized));
5957
+ if (permanentFailure) return false;
5958
+
5959
+ return [
5960
+ /(?:^|\D)429(?:\D|$)|rate[_ -]?limit|too many requests/u,
5961
+ /overload|high demand|at capacity|capacity limit|temporar(?:y|ily) unavailable/u,
5962
+ /service unavailable|internal server error|bad gateway|gateway timeout/u,
5963
+ /(?:^|\D)(?:500|502|503|504)(?:\D|$)|upstream (?:error|failure|timeout)/u,
5964
+ /deadline exceeded|request timed out|request timeout|response timeout/u,
5965
+ /connection (?:reset|closed|aborted)|network error|socket hang up/u,
5966
+ /stream (?:closed|disconnected|interrupted|error)/u,
5967
+ /\b(?:llm|model|provider|inference)\b.*\b(?:error|failed|failure|unavailable|timeout|timed out)\b/u,
5968
+ ].some((pattern) => pattern.test(normalized));
5969
+ }
5970
+
5971
+ function remoteTurnMatchesAttempt(remote: RemoteSessionSummary | undefined, attempt: TurnAttemptRecord): boolean {
5972
+ if (!remote?.lastTurnId) return false;
5973
+ if (attempt.turnId) return remote.lastTurnId === attempt.turnId;
5974
+ const remoteUpdatedAt = remote.updatedAt === undefined ? undefined : normalizeRemoteTimestamp(remote.updatedAt);
5975
+ const acceptedAt = Date.parse(attempt.createdAt);
5976
+ return remoteUpdatedAt !== undefined
5977
+ && Number.isFinite(acceptedAt)
5978
+ && remoteUpdatedAt >= acceptedAt - 1_000;
5979
+ }
5980
+
5981
+ function isRecoveryAttemptRecent(attempt: TurnAttemptRecord, cutoff: number): boolean {
5982
+ const updatedAt = Date.parse(attempt.updatedAt);
5983
+ return Number.isFinite(updatedAt) && updatedAt >= cutoff;
5984
+ }
5985
+
4358
5986
  function formatGroupDateSuffix(date: Date): string {
4359
5987
  const month = String(date.getMonth() + 1).padStart(2, "0");
4360
5988
  const day = String(date.getDate()).padStart(2, "0");
@@ -4395,10 +6023,19 @@ function formatGroupProjectDirectory(projectCwd: string): string {
4395
6023
  }
4396
6024
 
4397
6025
  function abbreviateHomeDirectory(value: string): string {
4398
- const relative = path.relative(os.homedir(), value);
6026
+ const homeDirectory = os.homedir();
6027
+ const valueIsWindows = isWindowsAbsolutePath(value);
6028
+ const homeIsWindows = isWindowsAbsolutePath(homeDirectory);
6029
+ if (valueIsWindows !== homeIsWindows && (valueIsWindows || path.posix.isAbsolute(value))) return value;
6030
+ const pathApi = valueIsWindows ? path.win32 : path;
6031
+ const relative = pathApi.relative(homeDirectory, value);
4399
6032
  if (relative === "") return "~";
4400
- if (path.isAbsolute(relative) || relative === ".." || relative.startsWith(`..${path.sep}`)) return value;
4401
- return `~${path.sep}${relative}`;
6033
+ if (pathApi.isAbsolute(relative) || relative === ".." || relative.startsWith(`..${pathApi.sep}`)) return value;
6034
+ return `~${pathApi.sep}${relative}`;
6035
+ }
6036
+
6037
+ function isWindowsAbsolutePath(value: string): boolean {
6038
+ return /^[a-z]:[\\/]|^\\\\/iu.test(value);
4402
6039
  }
4403
6040
 
4404
6041
  function fitTrailingPathLevels(levels: string[], maxLength: number, separator: string): string {