@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 } from "node:fs";
1
2
  import path from "node:path";
2
3
  import os from "node:os";
3
4
  import { createProjectlessWorkspace, detectProjectlessWorkspace } from "../codex/ProjectlessWorkspace.js";
@@ -6,6 +7,7 @@ import { CommandRouter } from "../commands/CommandRouter.js";
6
7
  import { baseChatContextKey, isThreadContextKey } from "../feishu/contextKey.js";
7
8
  import { CardRenderer, } from "../feishu/CardRenderer.js";
8
9
  import { generateGroupAvatarPng, resolveGroupAvatarProjectName } from "../feishu/GroupAvatarGenerator.js";
10
+ import { allowsFeishuUser } from "../feishu/ownerAccess.js";
9
11
  import { buildTurnGraphRows } from "../presentation/turnGraph.js";
10
12
  import { createId } from "../utils/id.js";
11
13
  import { asInlineCode, codeBlock, truncateMiddle, truncateText } from "../utils/markdown.js";
@@ -16,8 +18,22 @@ const MESSAGE_COMPLETED_REACTION = "DONE";
16
18
  const MESSAGE_FAILED_REACTION = "ERROR";
17
19
  const MESSAGE_CANCELLED_REACTION = "CrossMark";
18
20
  const SESSION_PAGE_SIZE = 5;
21
+ const DIRECTORY_PAGE_SIZE = 15;
19
22
  const RESET_HISTORY_PAGE_SIZE = 10;
23
+ const MAX_LLM_TURN_RETRIES = 3;
24
+ const RECOVERY_ACTIVITY_WINDOW_MS = 5 * 60 * 1_000;
25
+ const RECOVERY_HEARTBEAT_INTERVAL_MS = 60 * 1_000;
20
26
  const REMOTE_SESSION_REFERENCE_PREFIX = "agent-runtime:";
27
+ const WINDOWS_DRIVES_DIRECTORY = "agentbot://windows-drives";
28
+ const IMAGE_FILE_EXTENSIONS = new Set([
29
+ ".avif", ".bmp", ".gif", ".heic", ".heif", ".ico", ".jpeg", ".jpg", ".png", ".svg", ".tif", ".tiff", ".webp",
30
+ ]);
31
+ const BINARY_FILE_EXTENSIONS = new Set([
32
+ ".7z", ".a", ".apk", ".avi", ".bin", ".bz2", ".class", ".dat", ".db", ".deb", ".dll", ".dmg", ".doc", ".docx",
33
+ ".dylib", ".eot", ".exe", ".flac", ".gz", ".ico", ".ipa", ".iso", ".jar", ".lib", ".mdb", ".mkv", ".mov", ".mp3",
34
+ ".mp4", ".msi", ".o", ".obj", ".ogg", ".otf", ".pdf", ".ppt", ".pptx", ".rar", ".rpm", ".so", ".sqlite", ".tar",
35
+ ".ttf", ".wav", ".webm", ".woff", ".woff2", ".xls", ".xlsx", ".xz", ".zip", ".zst",
36
+ ]);
21
37
  const HELP_COMMAND_SECTIONS = [
22
38
  {
23
39
  title: "任务管理",
@@ -32,6 +48,11 @@ const HELP_COMMAND_SECTIONS = [
32
48
  usage: "[title] [--dir <cwd> | --nodir]",
33
49
  description: "创建飞书群和新任务",
34
50
  },
51
+ {
52
+ command: "/dir",
53
+ usage: "[目录]",
54
+ description: "浏览、发送文件,并从选定目录创建任务或群",
55
+ },
35
56
  {
36
57
  command: "/forkgroup",
37
58
  usage: "[title]",
@@ -50,6 +71,11 @@ const HELP_COMMAND_SECTIONS = [
50
71
  requiresArgument: true,
51
72
  },
52
73
  { command: "/sessions", usage: "[关键词]", description: "查找本机任务" },
74
+ {
75
+ command: "/archive",
76
+ usage: "[序号或任务 ID]",
77
+ description: "归档当前或指定任务",
78
+ },
53
79
  {
54
80
  command: "/switch",
55
81
  usage: "[序号或任务 ID]",
@@ -114,6 +140,11 @@ const HELP_COMMAND_SECTIONS = [
114
140
  usage: "[--force]",
115
141
  description: "默认安全重启;--force 立即重启",
116
142
  },
143
+ {
144
+ command: "/mute",
145
+ usage: "[on|off]",
146
+ description: "开启或关闭当前群的仅 @ 响应模式",
147
+ },
117
148
  { command: "/help", description: "显示本帮助" },
118
149
  ],
119
150
  },
@@ -129,6 +160,7 @@ export class ProxySessionController {
129
160
  logger;
130
161
  lifecycle;
131
162
  shellCommandExecutor;
163
+ windowsDriveLister;
132
164
  router = new CommandRouter();
133
165
  cardRenderer = new CardRenderer();
134
166
  messageQueues = new Map();
@@ -139,8 +171,14 @@ export class ProxySessionController {
139
171
  sessionResets = new Map();
140
172
  lastSessionListings = new Map();
141
173
  threadInitializations = new Map();
174
+ llmRetryingSessions = new Set();
175
+ retriedFailureTurnIds = new Set();
176
+ recoveryRetryTimers = new Map();
142
177
  unsubscribe = [];
143
- constructor(config, store, runtimes, outbound, logger, lifecycle, shellCommandExecutor = executeShellCommand) {
178
+ recoveryActivityHeartbeat;
179
+ startupRecovery;
180
+ queuedPromptsRestored = false;
181
+ constructor(config, store, runtimes, outbound, logger, lifecycle, shellCommandExecutor = executeShellCommand, windowsDriveLister = listWindowsDriveRoots) {
144
182
  this.config = config;
145
183
  this.store = store;
146
184
  this.runtimes = runtimes;
@@ -148,12 +186,7 @@ export class ProxySessionController {
148
186
  this.logger = logger;
149
187
  this.lifecycle = lifecycle;
150
188
  this.shellCommandExecutor = shellCommandExecutor;
151
- const interruptedAcpSessions = this.store.reconcileInterruptedAcpSessions(Object.entries(this.config.agents)
152
- .filter(([, agent]) => agent.kind === "acp")
153
- .map(([name]) => name));
154
- if (interruptedAcpSessions.length > 0) {
155
- this.logger.warn({ sessionIds: interruptedAcpSessions.map((session) => session.localSessionId) }, "Marked interrupted ACP sessions as failed after process restart.");
156
- }
189
+ this.windowsDriveLister = windowsDriveLister;
157
190
  for (const [, runtime] of this.runtimes.entries()) {
158
191
  this.unsubscribe.push(runtime.onEvent((event) => {
159
192
  void this.handleRuntimeEvent(event).catch((error) => {
@@ -162,14 +195,25 @@ export class ProxySessionController {
162
195
  }));
163
196
  }
164
197
  this.restorePersistedSessionRoutes();
165
- this.restorePersistedQueuedPrompts();
166
198
  void this.restorePersistedMessageReactions().catch((error) => {
167
199
  this.logger.warn({ error }, "Failed to restore persisted message reaction statuses.");
168
200
  });
201
+ this.recoveryActivityHeartbeat = setInterval(() => this.persistActiveTurnHeartbeats(), RECOVERY_HEARTBEAT_INTERVAL_MS);
202
+ this.recoveryActivityHeartbeat.unref?.();
169
203
  }
170
204
  async onMessage(message) {
171
- // The durable deduplication claim is the only operation allowed before acknowledgement.
172
- // It prevents event retries from adding duplicate reactions.
205
+ if (!message.contextKey.startsWith("console:") && !allowsFeishuUser(this.config, message.userId)) {
206
+ this.logger.debug({ messageId: message.messageId }, "Ignored a Feishu message from a non-owner before acknowledgement.");
207
+ return;
208
+ }
209
+ if (message.chatType === "group"
210
+ && this.store.chatRequiresMention(baseChatContextKey(message.contextKey))
211
+ && message.mentionedBot !== true) {
212
+ this.logger.debug({ messageId: message.messageId, contextKey: message.contextKey }, "Ignored an unmentioned message from a muted Lark group.");
213
+ return;
214
+ }
215
+ // For accepted messages, claim durable deduplication before acknowledgement so event
216
+ // retries cannot add duplicate reactions.
173
217
  if (!this.store.claimInboundEvent(message.messageId, "message"))
174
218
  return;
175
219
  try {
@@ -262,6 +306,10 @@ export class ProxySessionController {
262
306
  this.messageQueues.delete(message.contextKey);
263
307
  }
264
308
  async onCardAction(action) {
309
+ if (!allowsFeishuUser(this.config, action.userId)) {
310
+ this.logger.debug({ actionId: action.actionId }, "Ignored a Feishu card action from a non-owner.");
311
+ return;
312
+ }
265
313
  if (!this.store.claimInboundEvent(action.actionId, "card_action"))
266
314
  return;
267
315
  const contextKey = this.cardActionContextKey(action);
@@ -321,6 +369,24 @@ export class ProxySessionController {
321
369
  await this.outbound.sendText(contextKey, "该安全重启计划已失效,请查看最新状态卡片。");
322
370
  }
323
371
  }
372
+ else if (kind === "directory_open" || kind === "directory_page") {
373
+ await this.openDirectoryBrowser(contextKey, directoryActionPath(scopedAction.value.directory), {
374
+ updateMessageId: requiredCardMessageId(scopedAction.messageId),
375
+ page: kind === "directory_page" ? directoryPageValue(scopedAction.value.page) : 0,
376
+ });
377
+ }
378
+ else if (kind === "directory_send_file") {
379
+ const filePath = directoryFileActionPath(scopedAction.value.filePath);
380
+ await this.assertSendableFile(filePath);
381
+ await this.outbound.sendFile(contextKey, filePath);
382
+ }
383
+ else if (kind === "directory_new" || kind === "directory_new_group") {
384
+ const directory = directoryActionPath(scopedAction.value.directory);
385
+ await this.assertBrowsableDirectory(directory);
386
+ await this.execute(contextKey, kind === "directory_new"
387
+ ? { type: "new", cwd: directory }
388
+ : { type: "newgroup", cwd: directory }, undefined, replyTarget, undefined, scopedAction.userId);
389
+ }
324
390
  else if (kind === "session_page") {
325
391
  await this.refreshSessionsCardFromAction(scopedAction);
326
392
  }
@@ -352,6 +418,14 @@ export class ProxySessionController {
352
418
  await this.createFeishuGroupFromReference(contextKey, sessionId, scopedAction.userId);
353
419
  await this.refreshSessionsCardFromAction(scopedAction);
354
420
  }
421
+ else if (kind === "session_archive") {
422
+ const sessionId = String(scopedAction.value.sessionId ?? "");
423
+ await this.archiveSessionReference(contextKey, sessionId, {
424
+ announce: true,
425
+ source: "sessions_card",
426
+ });
427
+ await this.refreshSessionsCardFromAction(scopedAction);
428
+ }
355
429
  else if (kind === "session_stop") {
356
430
  const sessionId = String(scopedAction.value.sessionId ?? "");
357
431
  await this.stopSessionReference(contextKey, sessionId);
@@ -484,6 +558,10 @@ export class ProxySessionController {
484
558
  this.queuedPromptCards.clear();
485
559
  this.queuedPromptCardWrites.clear();
486
560
  this.sessionResets.clear();
561
+ for (const timer of this.recoveryRetryTimers.values())
562
+ clearTimeout(timer);
563
+ this.recoveryRetryTimers.clear();
564
+ clearInterval(this.recoveryActivityHeartbeat);
487
565
  }
488
566
  async controlStopTask(localSessionId) {
489
567
  const record = this.store.getSession(localSessionId);
@@ -492,6 +570,13 @@ export class ProxySessionController {
492
570
  await this.cancelSession(record);
493
571
  return `Task stop requested: ${record.title ?? record.remoteSessionId ?? record.localSessionId}`;
494
572
  }
573
+ async controlArchiveTask(localSessionId) {
574
+ const record = this.requireControlSession(localSessionId);
575
+ return this.archiveSessionReference(this.controlSessionContextKey(record), localSessionId, {
576
+ announce: false,
577
+ source: "cli",
578
+ });
579
+ }
495
580
  async controlGetTaskStatus(localSessionId) {
496
581
  const record = this.store.getSession(localSessionId);
497
582
  if (!record)
@@ -641,6 +726,314 @@ export class ProxySessionController {
641
726
  task: forked.task,
642
727
  };
643
728
  }
729
+ async controlCreateTask(localSessionId, requestedTitle, requestedProjectCwd, forceProjectless = false, requestedAgentName) {
730
+ const source = this.requireControlSession(localSessionId);
731
+ const contextKey = this.controlSessionContextKey(source);
732
+ const context = this.store.getOrCreateUserContext(contextKey, this.config.defaults.agent);
733
+ const agentName = requestedAgentName?.trim() || context.defaultAgent;
734
+ const agent = this.ensureAgent(agentName);
735
+ if (forceProjectless && agent.kind !== "app-server") {
736
+ throw new Error("task new --nodir is only available for App Server agents.");
737
+ }
738
+ const replyTarget = this.controlSessionReplyTarget(source);
739
+ const cwd = forceProjectless
740
+ ? undefined
741
+ : requestedProjectCwd === undefined
742
+ ? detectProjectlessWorkspace(source.cwd)
743
+ ? agent.kind === "app-server" ? undefined : createProjectlessWorkspace().cwd
744
+ : source.cwd
745
+ : resolveUserPath(requestedProjectCwd, source.cwd);
746
+ const settings = source.agentName === agentName
747
+ ? {
748
+ modelProvider: source.modelProvider,
749
+ model: source.model,
750
+ reasoningEffort: source.reasoningEffort,
751
+ permissionMode: source.permissionMode,
752
+ }
753
+ : {};
754
+ return this.outbound.withReplyTarget(contextKey, replyTarget, () => this.createSession(contextKey, agentName, cwd, true, false, undefined, undefined, requestedTitle, settings));
755
+ }
756
+ async controlForkTask(localSessionId) {
757
+ const source = this.requireControlSession(localSessionId);
758
+ const contextKey = this.controlSessionContextKey(source);
759
+ const replyTarget = this.controlSessionReplyTarget(source);
760
+ const plan = await this.prepareForkSession(contextKey, source.localSessionId);
761
+ const forked = await this.outbound.withReplyTarget(contextKey, replyTarget, async () => {
762
+ const result = await this.forkSessionIntoContext(contextKey, plan);
763
+ await this.outbound.sendText(contextKey, `已从指定任务${plan.forkedFromHistoricalTurn ? "最近已完成轮次" : ""}创建分支并切换到新任务:${result.session.title
764
+ ? `${result.session.title}(${result.session.remoteSessionId})`
765
+ : result.session.remoteSessionId}`);
766
+ return result;
767
+ });
768
+ return {
769
+ sourceLocalSessionId: source.localSessionId,
770
+ sourceTurnId: plan.lastTurnId,
771
+ task: this.store.getSession(forked.record.localSessionId) ?? forked.record,
772
+ };
773
+ }
774
+ async controlSwitchTask(localSessionId, targetLocalSessionId, previous = false) {
775
+ const anchor = this.requireControlSession(localSessionId);
776
+ const contextKey = this.controlSessionContextKey(anchor);
777
+ const replyTarget = this.controlSessionReplyTarget(anchor);
778
+ const target = previous ? undefined : targetLocalSessionId ?? anchor.localSessionId;
779
+ await this.outbound.withReplyTarget(contextKey, replyTarget, () => this.switchSession(contextKey, target));
780
+ return this.requireCurrentSession(contextKey);
781
+ }
782
+ async controlQueueTaskPrompt(localSessionId, text) {
783
+ const promptText = text.trim();
784
+ if (!promptText)
785
+ throw new Error("The queued Prompt cannot be empty.");
786
+ const record = this.requireControlSession(localSessionId);
787
+ const contextKey = this.controlSessionContextKey(record);
788
+ const replyTarget = this.controlSessionReplyTarget(record);
789
+ const queued = this.persistQueuedPrompt(record.localSessionId, contextKey, promptText, { replyTarget });
790
+ this.store.audit(contextKey, "queued_prompt_added", {
791
+ promptId: queued.promptId,
792
+ localSessionId: record.localSessionId,
793
+ source: "cli",
794
+ });
795
+ const queuedCount = this.store.countQueuedPrompts(record.localSessionId);
796
+ await this.scheduleNextQueuedPrompt(record.localSessionId);
797
+ return { promptId: queued.promptId, queued: queuedCount };
798
+ }
799
+ controlTaskAgent(localSessionId, agentName) {
800
+ const record = this.requireControlSession(localSessionId);
801
+ const contextKey = this.controlSessionContextKey(record);
802
+ const context = this.store.getOrCreateUserContext(contextKey, this.config.defaults.agent);
803
+ if (agentName) {
804
+ this.ensureAgent(agentName);
805
+ this.store.setDefaultAgent(contextKey, agentName);
806
+ }
807
+ return {
808
+ current: agentName ?? context.defaultAgent,
809
+ agents: Object.entries(this.config.agents).map(([name, agent]) => ({ name, title: agent.title })),
810
+ };
811
+ }
812
+ async controlTaskSettings(localSessionId, setting, value) {
813
+ const record = this.requireControlSession(localSessionId);
814
+ const loaded = await this.loadSession(record);
815
+ if (setting && !value?.trim())
816
+ throw new Error(`task ${setting} requires a value.`);
817
+ const nextValue = value?.trim();
818
+ if (setting === "provider" && nextValue) {
819
+ await this.assertModelProvider(loaded, nextValue);
820
+ const models = await loaded.runtime.listModels();
821
+ const model = models.find((candidate) => candidate.id === loaded.session.model)
822
+ ?? models.find((candidate) => candidate.isDefault);
823
+ if (!model)
824
+ throw new Error("The current runtime has no model available for a Provider change.");
825
+ const effort = model.supportedReasoningEfforts.some((candidate) => candidate.value === loaded.session.reasoningEffort)
826
+ ? loaded.session.reasoningEffort
827
+ : model.defaultReasoningEffort ?? model.supportedReasoningEfforts[0]?.value;
828
+ if (!effort)
829
+ throw new Error(`Model ${model.id} has no reasoning effort available for a Provider change.`);
830
+ if (!loaded.runtime.setExecutionSettings)
831
+ throw new Error("The current runtime does not support Provider settings.");
832
+ const updated = await loaded.runtime.setExecutionSettings(record.localSessionId, {
833
+ modelProvider: nextValue,
834
+ model: model.id,
835
+ reasoningEffort: effort,
836
+ permissionMode: loaded.session.permissionMode,
837
+ });
838
+ this.store.updateRuntimeSession(record.localSessionId, {
839
+ modelProvider: updated.modelProvider ?? nextValue,
840
+ model: updated.model ?? model.id,
841
+ reasoningEffort: updated.reasoningEffort ?? effort,
842
+ permissionMode: updated.permissionMode,
843
+ });
844
+ }
845
+ else if (setting === "model" && nextValue) {
846
+ const models = await loaded.runtime.listModels();
847
+ const selected = models.find((candidate) => candidate.id === nextValue);
848
+ if (!selected)
849
+ throw new Error(`Unknown model: ${nextValue}`);
850
+ const currentEffort = loaded.session.reasoningEffort;
851
+ const nextEffort = currentEffort && selected.supportedReasoningEfforts.some((candidate) => candidate.value === currentEffort)
852
+ ? currentEffort
853
+ : selected.defaultReasoningEffort;
854
+ await loaded.runtime.setModel(record.localSessionId, nextValue);
855
+ if (nextEffort && nextEffort !== currentEffort) {
856
+ await loaded.runtime.setReasoningEffort(record.localSessionId, nextEffort);
857
+ }
858
+ this.store.updateRuntimeSession(record.localSessionId, { model: nextValue, reasoningEffort: nextEffort });
859
+ }
860
+ else if (setting === "thinking" && nextValue) {
861
+ const models = await loaded.runtime.listModels();
862
+ const currentModel = models.find((candidate) => candidate.id === loaded.session.model)
863
+ ?? models.find((candidate) => candidate.isDefault);
864
+ if (!currentModel)
865
+ throw new Error("The current runtime has no model with configurable reasoning effort.");
866
+ if (!currentModel.supportedReasoningEfforts.some((candidate) => candidate.value === nextValue)) {
867
+ const supported = currentModel.supportedReasoningEfforts.map((candidate) => candidate.value).join(", ") || "none";
868
+ throw new Error(`Unsupported reasoning effort: ${nextValue}. Supported values: ${supported}.`);
869
+ }
870
+ await loaded.runtime.setReasoningEffort(record.localSessionId, nextValue);
871
+ this.store.updateRuntimeSession(record.localSessionId, { reasoningEffort: nextValue });
872
+ }
873
+ else if (setting === "permissions" && nextValue) {
874
+ if (nextValue !== "auto" && nextValue !== "confirm") {
875
+ throw new Error("task permissions accepts auto or confirm.");
876
+ }
877
+ await loaded.runtime.setPermissionMode(record.localSessionId, nextValue);
878
+ this.store.updateRuntimeSession(record.localSessionId, { permissionMode: nextValue });
879
+ }
880
+ const session = this.store.getSession(record.localSessionId) ?? record;
881
+ const models = await loaded.runtime.listModels();
882
+ const currentModel = models.find((candidate) => candidate.id === session.model)
883
+ ?? models.find((candidate) => candidate.isDefault);
884
+ const providers = loaded.runtime.listModelProviders
885
+ ? await loaded.runtime.listModelProviders()
886
+ : [];
887
+ return {
888
+ session,
889
+ providers,
890
+ models,
891
+ reasoningOptions: currentModel?.supportedReasoningEfforts ?? [],
892
+ permissionModes: ["auto", "confirm"],
893
+ };
894
+ }
895
+ async controlTaskGoal(localSessionId, action, objective) {
896
+ const record = this.requireControlSession(localSessionId);
897
+ const loaded = await this.loadSession(record);
898
+ if (loaded.runtime.kind !== "codex" || !loaded.runtime.getGoal || !loaded.runtime.setGoal || !loaded.runtime.clearGoal) {
899
+ throw new Error("The current Agent does not support Goal mode.");
900
+ }
901
+ if (action === "show")
902
+ return { goal: await loaded.runtime.getGoal(record.localSessionId) };
903
+ if (action === "clear")
904
+ return { cleared: await loaded.runtime.clearGoal(record.localSessionId) };
905
+ const current = await loaded.runtime.getGoal(record.localSessionId);
906
+ if (action === "pause" || action === "resume") {
907
+ if (!current)
908
+ throw new Error("The task has no Goal.");
909
+ return { goal: await loaded.runtime.setGoal(record.localSessionId, { status: action === "pause" ? "paused" : "active" }) };
910
+ }
911
+ const nextObjective = objective?.trim();
912
+ if (!nextObjective)
913
+ throw new Error(`task goal ${action} requires an objective.`);
914
+ validateGoalObjective(nextObjective);
915
+ if (action === "set" && current && current.status !== "complete") {
916
+ throw new Error("The task already has an unfinished Goal. Use task goal <task> edit <objective>, or clear it first.");
917
+ }
918
+ if (action === "edit" && !current)
919
+ throw new Error("The task has no Goal to edit.");
920
+ return {
921
+ goal: await loaded.runtime.setGoal(record.localSessionId, {
922
+ objective: nextObjective,
923
+ status: action === "edit" ? current.status : "active",
924
+ ...(action === "edit" ? { tokenBudget: current.tokenBudget } : {}),
925
+ }),
926
+ };
927
+ }
928
+ controlListTaskTurns(localSessionId) {
929
+ const session = this.requireControlSession(localSessionId);
930
+ if (!session.remoteSessionId || !this.isCodexSession(session)) {
931
+ throw new Error("The task is not an App Server task that can be reset.");
932
+ }
933
+ const contextKey = this.controlSessionContextKey(session);
934
+ const rows = this.store.listCompletedTurnGraph(session.localSessionId, contextKey);
935
+ const graph = buildTurnGraphRows(rows.map((turn) => ({ turnId: turn.turnId, parentTurnId: turn.parentTurnId })));
936
+ return {
937
+ session,
938
+ turns: rows.flatMap((turn, index) => {
939
+ const snapshot = turnViewSnapshot(turn.snapshot);
940
+ if (!snapshot)
941
+ return [];
942
+ return [{
943
+ sequence: graph[index].sequence,
944
+ turnId: turn.turnId,
945
+ parentTurnId: turn.parentTurnId,
946
+ prompt: snapshot.prompt ?? snapshot.taskTitle,
947
+ startedAt: snapshot.startedAt,
948
+ completedAt: snapshot.completedAt,
949
+ current: session.lastTurnId === turn.turnId && session.lastTurnStatus === "completed",
950
+ }];
951
+ }),
952
+ };
953
+ }
954
+ async controlResetTaskToTurn(localSessionId, turnId) {
955
+ const record = this.requireControlSession(localSessionId);
956
+ const contextKey = this.controlSessionContextKey(record);
957
+ const previous = this.sessionResets.get(record.localSessionId) ?? Promise.resolve();
958
+ const reset = previous.catch(() => undefined).then(() => this.performSessionReset(contextKey, record, turnId, false));
959
+ this.sessionResets.set(record.localSessionId, reset);
960
+ try {
961
+ await reset;
962
+ }
963
+ finally {
964
+ if (this.sessionResets.get(record.localSessionId) === reset)
965
+ this.sessionResets.delete(record.localSessionId);
966
+ }
967
+ return this.store.getSession(record.localSessionId) ?? record;
968
+ }
969
+ controlTaskMute(localSessionId, enabled) {
970
+ const record = this.requireControlSession(localSessionId);
971
+ const contextKey = baseChatContextKey(this.controlSessionContextKey(record));
972
+ const chat = this.store.getChatContext(contextKey);
973
+ if (chat?.chatType !== "group")
974
+ throw new Error("task mute is only available for group chats.");
975
+ const next = enabled ?? Boolean(chat.requiresMention);
976
+ if (enabled !== undefined)
977
+ this.store.setChatRequiresMention(contextKey, enabled);
978
+ return { contextKey, enabled: next };
979
+ }
980
+ async controlRunTaskShell(localSessionId, command) {
981
+ const record = this.requireControlSession(localSessionId);
982
+ const normalized = command.trim();
983
+ if (!normalized)
984
+ throw new Error("task shell requires a command.");
985
+ return { ...await this.shellCommandExecutor(normalized, record.cwd), cwd: record.cwd, command: normalized };
986
+ }
987
+ async controlListTaskDirectory(localSessionId, requestedDirectory, requestedPage = 0) {
988
+ const record = this.requireControlSession(localSessionId);
989
+ if (requestedDirectory === WINDOWS_DRIVES_DIRECTORY) {
990
+ const drives = await this.windowsDriveLister();
991
+ const totalPages = Math.max(1, Math.ceil(drives.length / DIRECTORY_PAGE_SIZE));
992
+ const page = Math.min(Math.max(0, Math.trunc(requestedPage)), totalPages - 1);
993
+ return {
994
+ directory: WINDOWS_DRIVES_DIRECTORY,
995
+ page,
996
+ totalPages,
997
+ totalEntries: drives.length,
998
+ entries: drives.slice(page * DIRECTORY_PAGE_SIZE, (page + 1) * DIRECTORY_PAGE_SIZE).map((drive) => ({
999
+ name: windowsDriveDisplayName(drive),
1000
+ path: drive.root,
1001
+ kind: "drive",
1002
+ })),
1003
+ };
1004
+ }
1005
+ const directory = requestedDirectory === undefined
1006
+ ? path.resolve(record.cwd)
1007
+ : resolveUserPath(requestedDirectory, record.cwd);
1008
+ await this.assertBrowsableDirectory(directory);
1009
+ const entries = (await fs.readdir(directory, { withFileTypes: true }))
1010
+ .map((entry) => ({
1011
+ name: entry.name,
1012
+ path: path.join(directory, entry.name),
1013
+ kind: directoryBrowserEntryKind(entry),
1014
+ }))
1015
+ .sort((left, right) => Number(right.kind === "directory") - Number(left.kind === "directory")
1016
+ || left.name.localeCompare(right.name, undefined, { numeric: true, sensitivity: "base" }));
1017
+ const totalPages = Math.max(1, Math.ceil(entries.length / DIRECTORY_PAGE_SIZE));
1018
+ const page = Math.min(Math.max(0, Math.trunc(requestedPage)), totalPages - 1);
1019
+ const parentDirectory = isWindowsDriveRoot(directory) ? WINDOWS_DRIVES_DIRECTORY : path.dirname(directory);
1020
+ return {
1021
+ directory,
1022
+ ...(parentDirectory !== directory ? { parentDirectory } : {}),
1023
+ page,
1024
+ totalPages,
1025
+ totalEntries: entries.length,
1026
+ entries: entries.slice(page * DIRECTORY_PAGE_SIZE, (page + 1) * DIRECTORY_PAGE_SIZE),
1027
+ };
1028
+ }
1029
+ async controlSendTaskFile(localSessionId, requestedFilePath) {
1030
+ const record = this.requireControlSession(localSessionId);
1031
+ const filePath = resolveUserPath(requestedFilePath, record.cwd);
1032
+ await this.assertSendableFile(filePath);
1033
+ const contextKey = this.controlSessionContextKey(record);
1034
+ const replyTarget = this.controlSessionReplyTarget(record);
1035
+ return this.outbound.withReplyTarget(contextKey, replyTarget, () => this.outbound.sendFile(contextKey, filePath));
1036
+ }
644
1037
  cardActionContextKey(action) {
645
1038
  const explicit = typeof action.value.contextKey === "string" ? action.value.contextKey : undefined;
646
1039
  if (explicit && baseChatContextKey(explicit) === baseChatContextKey(action.contextKey))
@@ -665,6 +1058,9 @@ export class ProxySessionController {
665
1058
  case "shell":
666
1059
  await this.runShellCommand(contextKey, command.command);
667
1060
  return;
1061
+ case "dir":
1062
+ await this.openDirectoryBrowser(contextKey, command.directory);
1063
+ return;
668
1064
  case "new":
669
1065
  if (command.projectless && this.ensureAgent(context.defaultAgent).kind !== "app-server") {
670
1066
  throw new Error("/new --nodir 仅支持 App Server Agent。");
@@ -708,6 +1104,16 @@ export class ProxySessionController {
708
1104
  else
709
1105
  await this.openAgentSettings(contextKey);
710
1106
  return;
1107
+ case "archive": {
1108
+ const sessionId = command.sessionId ?? context.currentSessionId;
1109
+ if (!sessionId)
1110
+ throw new Error("当前会话没有可归档的任务。请使用 /sessions 查找任务。");
1111
+ await this.archiveSessionReference(contextKey, sessionId, {
1112
+ announce: true,
1113
+ source: "command",
1114
+ });
1115
+ return;
1116
+ }
711
1117
  case "status":
712
1118
  await this.status(contextKey, command.sessionId);
713
1119
  return;
@@ -719,6 +1125,9 @@ export class ProxySessionController {
719
1125
  throw new Error("当前运行方式不支持自动重启。");
720
1126
  await this.lifecycle.restart(contextKey, command.force === true, replyTarget);
721
1127
  return;
1128
+ case "mute":
1129
+ await this.setGroupMute(contextKey, command.enabled);
1130
+ return;
722
1131
  case "turns":
723
1132
  await this.openResetHistory(contextKey);
724
1133
  return;
@@ -752,6 +1161,8 @@ export class ProxySessionController {
752
1161
  const turnContextKey = session.lastTurnId
753
1162
  ? this.store.getTurnContextKey(session.lastTurnId) ?? context.contextKey
754
1163
  : context.contextKey;
1164
+ if (!this.outbound.canRoute(turnContextKey))
1165
+ continue;
755
1166
  this.outbound.registerSession(session.localSessionId, turnContextKey, session.title, session.cwd, this.agentLabel(session.agentName));
756
1167
  if (!session.lastTurnId)
757
1168
  continue;
@@ -759,14 +1170,314 @@ export class ProxySessionController {
759
1170
  this.logger.warn({ error, sessionId: session.localSessionId }, "Failed to restore persisted turn delivery.");
760
1171
  });
761
1172
  }
1173
+ for (const turn of this.store.listUndeliveredCompletedTurns()) {
1174
+ const contextKey = turn.contextKey ?? this.store.getSession(turn.localSessionId)?.contextKey;
1175
+ if (!contextKey || !this.outbound.canRoute(contextKey))
1176
+ continue;
1177
+ void this.outbound.resumeDelivery(turn.localSessionId, contextKey, turn.turnId).catch((error) => {
1178
+ this.logger.warn({ error, sessionId: turn.localSessionId, turnId: turn.turnId }, "Failed to resume an undelivered completed turn.");
1179
+ });
1180
+ }
762
1181
  }
763
1182
  restorePersistedQueuedPrompts() {
1183
+ if (this.queuedPromptsRestored)
1184
+ return;
1185
+ this.queuedPromptsRestored = true;
764
1186
  for (const sessionId of this.store.listQueuedPromptSessionIds()) {
1187
+ const firstPrompt = this.store.listQueuedPrompts(sessionId)[0];
1188
+ if (firstPrompt && !this.outbound.canRoute(firstPrompt.contextKey))
1189
+ continue;
765
1190
  void this.scheduleNextQueuedPrompt(sessionId).catch((error) => {
766
1191
  this.logger.warn({ error, sessionId }, "Failed to resume a persisted prompt queue.");
767
1192
  });
768
1193
  }
769
1194
  }
1195
+ async recoverInterruptedTasks() {
1196
+ if (this.startupRecovery)
1197
+ return this.startupRecovery;
1198
+ const recovery = this.runStartupRecovery();
1199
+ this.startupRecovery = recovery;
1200
+ return recovery;
1201
+ }
1202
+ async runStartupRecovery() {
1203
+ this.backfillTurnAttemptsForUpgrade();
1204
+ const attempts = this.store.listIncompleteTurnAttempts();
1205
+ const recoveryCutoff = Date.now() - RECOVERY_ACTIVITY_WINDOW_MS;
1206
+ const recentAttempts = attempts.filter((attempt) => isRecoveryAttemptRecent(attempt, recoveryCutoff));
1207
+ if (recentAttempts.length > 0) {
1208
+ this.logger.warn({ attemptIds: recentAttempts.map((attempt) => attempt.attemptId) }, "Recovering unfinished Agent Bot tasks after startup.");
1209
+ }
1210
+ for (const attempt of attempts) {
1211
+ if (!isRecoveryAttemptRecent(attempt, recoveryCutoff)) {
1212
+ await this.expireStaleTurnAttempt(attempt);
1213
+ continue;
1214
+ }
1215
+ if (!this.outbound.canRoute(attempt.contextKey))
1216
+ continue;
1217
+ try {
1218
+ await this.recoverTurnAttempt(attempt);
1219
+ }
1220
+ catch (error) {
1221
+ this.logger.error({ error, attemptId: attempt.attemptId, sessionId: attempt.localSessionId }, "Failed to recover an unfinished task; it will be retried while the server is running.");
1222
+ this.scheduleRecoveryRetry(attempt.attemptId);
1223
+ }
1224
+ }
1225
+ this.restorePersistedQueuedPrompts();
1226
+ }
1227
+ backfillTurnAttemptsForUpgrade() {
1228
+ for (const session of this.store.listAllSessions()) {
1229
+ if (session.status !== "starting" && session.status !== "running" && session.status !== "ready")
1230
+ continue;
1231
+ if (this.store.findIncompleteTurnAttemptForSession(session.localSessionId))
1232
+ continue;
1233
+ const lastTurn = session.lastTurnId
1234
+ ? {
1235
+ turnId: session.lastTurnId,
1236
+ localSessionId: session.localSessionId,
1237
+ contextKey: this.store.getTurnContextKey(session.lastTurnId),
1238
+ snapshot: this.store.getTurnSnapshot(session.lastTurnId),
1239
+ updatedAt: session.updatedAt,
1240
+ }
1241
+ : undefined;
1242
+ const lastTurnSnapshot = turnViewSnapshot(lastTurn?.snapshot);
1243
+ const latestPersisted = this.store.findLatestTurnSnapshotForSession(session.localSessionId);
1244
+ const latestPersistedSnapshot = turnViewSnapshot(latestPersisted?.snapshot);
1245
+ const latest = lastTurnSnapshot && !isTerminalTurnViewStatus(lastTurnSnapshot.status)
1246
+ ? lastTurn
1247
+ : latestPersistedSnapshot && !isTerminalTurnViewStatus(latestPersistedSnapshot.status)
1248
+ ? latestPersisted
1249
+ : session.status === "ready"
1250
+ ? undefined
1251
+ : lastTurn;
1252
+ if (!latest && session.status === "ready")
1253
+ continue;
1254
+ const snapshot = turnViewSnapshot(latest?.snapshot);
1255
+ if (snapshot && isTerminalTurnViewStatus(snapshot.status)) {
1256
+ this.store.updateSession(session.localSessionId, {
1257
+ status: snapshot.status === "failed" ? "failed" : "ready",
1258
+ });
1259
+ continue;
1260
+ }
1261
+ const persistedTurnId = latest?.turnId;
1262
+ const pendingTurnId = persistedTurnId?.startsWith("pending_") ? persistedTurnId : undefined;
1263
+ const turnId = pendingTurnId ? undefined : persistedTurnId ?? session.lastTurnId;
1264
+ const replyTarget = snapshot?.replyTarget?.replyInThread ? snapshot.replyTarget : undefined;
1265
+ this.store.createTurnAttempt({
1266
+ attemptId: createId("attempt"),
1267
+ localSessionId: session.localSessionId,
1268
+ contextKey: latest?.contextKey ?? session.contextKey,
1269
+ promptText: snapshot?.prompt?.trim() || "继续完成重启前尚未完成的任务。",
1270
+ messageId: turnId ? this.store.findMessageIdForTurn(turnId) : undefined,
1271
+ replyMessageId: replyTarget?.messageId,
1272
+ pendingTurnId,
1273
+ turnId,
1274
+ status: turnId ? "running" : "accepted",
1275
+ createdAt: latest?.updatedAt ?? session.updatedAt,
1276
+ updatedAt: latest?.updatedAt ?? session.updatedAt,
1277
+ });
1278
+ }
1279
+ }
1280
+ async expireStaleTurnAttempt(attempt) {
1281
+ const session = this.store.getSession(attempt.localSessionId);
1282
+ const oldCardTurnId = attempt.pendingTurnId ?? attempt.turnId;
1283
+ if (session && oldCardTurnId && this.outbound.canRoute(attempt.contextKey)) {
1284
+ await this.outbound.interruptTurnForRecovery(session.localSessionId, attempt.contextKey, oldCardTurnId, "执行中断已超过 5 分钟,未自动恢复。").catch((error) => {
1285
+ this.logger.warn({ error, attemptId: attempt.attemptId, turnId: oldCardTurnId }, "Failed to close an expired interrupted thinking card.");
1286
+ });
1287
+ }
1288
+ this.store.updateTurnAttempt(attempt.attemptId, { status: "interrupted" });
1289
+ if (!session || session.status === "closed")
1290
+ return;
1291
+ if (attempt.turnId && session.lastTurnId === attempt.turnId) {
1292
+ this.store.updateRuntimeSession(session.localSessionId, { lastTurnStatus: "cancelled" });
1293
+ }
1294
+ if (session.status === "starting" || session.status === "running") {
1295
+ this.store.updateSession(session.localSessionId, { status: "ready" });
1296
+ }
1297
+ if (attempt.turnId && this.outbound.canRoute(attempt.contextKey)) {
1298
+ await this.finalizeTurnMessageReactions(attempt.turnId, "cancelled").catch((error) => {
1299
+ this.logger.warn({ error, attemptId: attempt.attemptId, turnId: attempt.turnId }, "Failed to finalize the message reaction for an expired interrupted turn.");
1300
+ });
1301
+ }
1302
+ }
1303
+ persistActiveTurnHeartbeats() {
1304
+ for (const attempt of this.store.listIncompleteTurnAttempts()) {
1305
+ if (!attempt.turnId)
1306
+ continue;
1307
+ const session = this.store.getSession(attempt.localSessionId);
1308
+ if (!session)
1309
+ continue;
1310
+ const activeTurnId = this.runtimes.forAgent(session.agentName).getSession(session.localSessionId)?.activeTurnId;
1311
+ if (activeTurnId === attempt.turnId)
1312
+ this.store.touchTurnAttempt(attempt.turnId);
1313
+ }
1314
+ }
1315
+ async recoverTurnAttempt(attempt, announce = true) {
1316
+ const session = this.store.getSession(attempt.localSessionId);
1317
+ if (!session || session.status === "closed") {
1318
+ this.store.updateTurnAttempt(attempt.attemptId, { status: "interrupted" });
1319
+ return;
1320
+ }
1321
+ const contextKey = attempt.contextKey || session.contextKey;
1322
+ const replyTarget = attempt.replyMessageId
1323
+ ? { messageId: attempt.replyMessageId, replyInThread: true }
1324
+ : undefined;
1325
+ this.outbound.registerSession(session.localSessionId, contextKey, session.title, session.cwd, this.agentLabel(session.agentName));
1326
+ if (announce) {
1327
+ await this.outbound.withReplyTarget(contextKey, replyTarget, () => this.outbound.sendText(contextKey, "检测到任务在 Agent Bot 重启前尚未完成,正在自动恢复。")).catch((error) => {
1328
+ this.logger.warn({ error, attemptId: attempt.attemptId, contextKey }, "Failed to send the task recovery notification.");
1329
+ return undefined;
1330
+ });
1331
+ }
1332
+ const runtime = this.runtimes.forAgent(session.agentName);
1333
+ const sourceTurnId = attempt.turnId;
1334
+ if (runtime.kind === "acp") {
1335
+ await this.continueInterruptedAttempt(attempt, session, sourceTurnId, replyTarget);
1336
+ return;
1337
+ }
1338
+ let remote;
1339
+ if (session.remoteSessionId && runtime.readRemoteSession) {
1340
+ try {
1341
+ remote = await runtime.readRemoteSession(session.remoteSessionId);
1342
+ }
1343
+ catch (error) {
1344
+ this.logger.warn({ error, attemptId: attempt.attemptId, remoteSessionId: session.remoteSessionId }, "Failed to read the remote task during startup recovery; treating the old local execution as interrupted.");
1345
+ }
1346
+ }
1347
+ const remoteTurnBelongsToAttempt = remoteTurnMatchesAttempt(remote, attempt);
1348
+ if (remote && remoteTurnBelongsToAttempt && remote.lastTurnStatus === "completed") {
1349
+ await this.reconcileRecoveredRemoteTurn(attempt, session, remote.lastTurnId);
1350
+ return;
1351
+ }
1352
+ if (remote && remoteTurnBelongsToAttempt && remote.lastTurnStatus === "failed") {
1353
+ await this.reconcileRecoveredRemoteTurn(attempt, session, remote.lastTurnId);
1354
+ return;
1355
+ }
1356
+ if (remote && remoteTurnBelongsToAttempt && isRemoteSessionActive(remote) && remote.lastTurnId) {
1357
+ await this.reattachActiveRecoveredTurn(attempt, session, remote.lastTurnId, replyTarget);
1358
+ return;
1359
+ }
1360
+ if (remote && isRemoteSessionActive(remote) && !remoteTurnBelongsToAttempt) {
1361
+ this.store.updateTurnAttempt(attempt.attemptId, { status: "recovering" });
1362
+ if (announce) {
1363
+ await this.outbound.withReplyTarget(contextKey, replyTarget, () => this.outbound.sendText(contextKey, "任务中检测到另一轮仍在执行;本次恢复将在它结束后重试。"));
1364
+ }
1365
+ this.scheduleRecoveryRetry(attempt.attemptId);
1366
+ return;
1367
+ }
1368
+ await this.continueInterruptedAttempt(attempt, session, sourceTurnId, replyTarget);
1369
+ }
1370
+ async reconcileRecoveredRemoteTurn(attempt, session, remoteTurnId) {
1371
+ if (remoteTurnId && !attempt.turnId) {
1372
+ this.store.updateTurnAttempt(attempt.attemptId, { turnId: remoteTurnId, status: "running" });
1373
+ this.store.updateRuntimeSession(session.localSessionId, {
1374
+ lastTurnId: remoteTurnId,
1375
+ lastTurnStatus: "running",
1376
+ });
1377
+ }
1378
+ const current = this.store.getSession(session.localSessionId) ?? session;
1379
+ const loaded = await this.loadSession({
1380
+ ...current,
1381
+ contextKey: attempt.contextKey,
1382
+ status: "running",
1383
+ lastTurnStatus: "running",
1384
+ });
1385
+ const synchronized = await loaded.runtime.synchronizeSession(session.localSessionId);
1386
+ if (synchronized.activeTurnId)
1387
+ this.scheduleRecoveryRetry(attempt.attemptId, synchronized.activeTurnId);
1388
+ }
1389
+ scheduleRecoveryRetry(attemptId, activeTurnId) {
1390
+ if (this.recoveryRetryTimers.has(attemptId))
1391
+ return;
1392
+ const timer = setTimeout(() => {
1393
+ this.recoveryRetryTimers.delete(attemptId);
1394
+ const attempt = this.store.getTurnAttempt(attemptId);
1395
+ if (!attempt || !isIncompleteTurnAttemptStatus(attempt.status))
1396
+ return;
1397
+ const retry = async () => {
1398
+ let recoveryAttempt = attempt;
1399
+ if (activeTurnId) {
1400
+ const session = this.store.getSession(attempt.localSessionId);
1401
+ if (session) {
1402
+ const runtime = this.runtimes.forAgent(session.agentName);
1403
+ const synchronized = await runtime.synchronizeSession(session.localSessionId);
1404
+ if (synchronized.activeTurnId === activeTurnId) {
1405
+ this.scheduleRecoveryRetry(attemptId, activeTurnId);
1406
+ return;
1407
+ }
1408
+ const current = this.store.getTurnAttempt(attemptId);
1409
+ if (!current || !isIncompleteTurnAttemptStatus(current.status))
1410
+ return;
1411
+ recoveryAttempt = current;
1412
+ }
1413
+ }
1414
+ await this.recoverTurnAttempt(recoveryAttempt, false);
1415
+ };
1416
+ void retry().catch((error) => {
1417
+ this.logger.warn({ error, attemptId, sessionId: attempt.localSessionId }, "Failed to retry an unfinished task recovery.");
1418
+ this.scheduleRecoveryRetry(attemptId, activeTurnId);
1419
+ });
1420
+ }, 5_000);
1421
+ timer.unref?.();
1422
+ this.recoveryRetryTimers.set(attemptId, timer);
1423
+ }
1424
+ async reattachActiveRecoveredTurn(attempt, session, turnId, replyTarget) {
1425
+ const oldCardTurnId = attempt.pendingTurnId ?? attempt.turnId;
1426
+ if (oldCardTurnId) {
1427
+ await this.outbound.interruptTurnForRecovery(session.localSessionId, attempt.contextKey, oldCardTurnId, "Agent Bot 已重启,执行仍在继续;进度已转移到新的思考卡片。");
1428
+ }
1429
+ const pendingTurnId = await this.outbound.startPendingTurn(session.localSessionId, attempt.contextKey, session.title, replyTarget, recoveryCardPrompt(attempt.promptText));
1430
+ this.store.updateTurnAttempt(attempt.attemptId, {
1431
+ pendingTurnId: pendingTurnId ?? null,
1432
+ turnId,
1433
+ status: "running",
1434
+ });
1435
+ await this.outbound.onEvent({
1436
+ type: "turn_started",
1437
+ sessionId: session.localSessionId,
1438
+ turnId,
1439
+ startedAt: Date.now(),
1440
+ });
1441
+ this.store.updateTurnAttempt(attempt.attemptId, { pendingTurnId: null });
1442
+ const current = this.store.getSession(session.localSessionId) ?? session;
1443
+ const loaded = await this.loadSession({
1444
+ ...current,
1445
+ contextKey: attempt.contextKey,
1446
+ status: "running",
1447
+ lastTurnId: turnId,
1448
+ lastTurnStatus: "running",
1449
+ });
1450
+ const synchronized = await loaded.runtime.synchronizeSession(session.localSessionId);
1451
+ if (synchronized.activeTurnId)
1452
+ this.scheduleRecoveryRetry(attempt.attemptId, synchronized.activeTurnId);
1453
+ }
1454
+ async continueInterruptedAttempt(attempt, session, sourceTurnId, replyTarget) {
1455
+ const oldCardTurnId = attempt.pendingTurnId ?? sourceTurnId;
1456
+ if (oldCardTurnId) {
1457
+ await this.outbound.interruptTurnForRecovery(session.localSessionId, attempt.contextKey, oldCardTurnId, "Agent Bot 重启中断了这次执行,正在新的思考卡片中继续。").catch((error) => {
1458
+ this.logger.warn({ error, attemptId: attempt.attemptId, turnId: oldCardTurnId }, "Failed to close the interrupted thinking card before recovery.");
1459
+ });
1460
+ }
1461
+ this.store.prepareTurnAttemptRecovery(attempt.attemptId, oldCardTurnId);
1462
+ this.store.updateSession(session.localSessionId, { status: "ready" });
1463
+ if (sourceTurnId) {
1464
+ this.store.updateRuntimeSession(session.localSessionId, {
1465
+ lastTurnId: sourceTurnId,
1466
+ lastTurnStatus: "cancelled",
1467
+ });
1468
+ }
1469
+ const current = this.store.getSession(session.localSessionId) ?? session;
1470
+ const loaded = await this.loadSession({ ...current, contextKey: attempt.contextKey });
1471
+ const recoveryText = recoveryRuntimePrompt(attempt.promptText);
1472
+ const turnId = await this.startTurn(loaded, recoveryText, replyTarget, attempt.localImagePaths, {
1473
+ attemptId: attempt.attemptId,
1474
+ messageId: attempt.messageId,
1475
+ displayPrompt: recoveryCardPrompt(attempt.promptText),
1476
+ preserveAttemptOnFailure: true,
1477
+ });
1478
+ if (attempt.messageId)
1479
+ await this.bindMessageReactionToTurn(attempt.messageId, session.localSessionId, turnId);
1480
+ }
770
1481
  async restorePersistedMessageReactions() {
771
1482
  const pending = this.store.listPendingMessageReactions();
772
1483
  const terminalTurns = new Map();
@@ -795,6 +1506,19 @@ export class ProxySessionController {
795
1506
  await this.promptSession(record, contextKey, text, messageId, replyTarget, localImagePaths);
796
1507
  }
797
1508
  async promptSession(record, contextKey, text, messageId, replyTarget, localImagePaths) {
1509
+ if (this.llmRetryingSessions.has(record.localSessionId)) {
1510
+ const queued = this.persistQueuedPrompt(record.localSessionId, contextKey, text, {
1511
+ localImagePaths,
1512
+ messageId,
1513
+ replyTarget,
1514
+ });
1515
+ this.store.audit(contextKey, "queued_prompt_added_during_llm_retry", {
1516
+ promptId: queued.promptId,
1517
+ localSessionId: record.localSessionId,
1518
+ });
1519
+ await this.presentPromptQueueCard(record.localSessionId, contextKey);
1520
+ return;
1521
+ }
798
1522
  const configuredRuntime = this.runtimes.forAgent(record.agentName);
799
1523
  if (!configuredRuntime.getSession(record.localSessionId)) {
800
1524
  this.outbound.registerSession(record.localSessionId, contextKey, record.title, record.cwd, this.agentLabel(record.agentName));
@@ -852,7 +1576,7 @@ export class ProxySessionController {
852
1576
  this.logger.warn({ error: syncError, sessionId: record.localSessionId }, "Failed to synchronize after steering failure.");
853
1577
  }
854
1578
  if (!current?.activeTurnId) {
855
- const turnId = await this.startTurn(loaded, text, replyTarget, localImagePaths);
1579
+ const turnId = await this.startTurn(loaded, text, replyTarget, localImagePaths, { messageId });
856
1580
  if (messageId)
857
1581
  await this.bindMessageReactionToTurn(messageId, record.localSessionId, turnId);
858
1582
  return;
@@ -877,11 +1601,12 @@ export class ProxySessionController {
877
1601
  return;
878
1602
  }
879
1603
  }
880
- const turnId = await this.startTurn(loaded, text, replyTarget, localImagePaths);
1604
+ const turnId = await this.startTurn(loaded, text, replyTarget, localImagePaths, { messageId });
881
1605
  if (messageId)
882
1606
  await this.bindMessageReactionToTurn(messageId, record.localSessionId, turnId);
883
1607
  }
884
1608
  async presentSteerMessage(localSessionId, turnId, text, messageId) {
1609
+ this.store.touchTurnAttempt(turnId);
885
1610
  try {
886
1611
  await this.outbound.appendSteerMessage(localSessionId, turnId, text, messageId);
887
1612
  }
@@ -992,6 +1717,10 @@ export class ProxySessionController {
992
1717
  const current = this.store.getUserContext(message.contextKey)?.currentSessionId;
993
1718
  if (current && this.store.getSession(current))
994
1719
  return;
1720
+ const hasPersistedSourceTurn = threadForkAnchorMessageIds(message)
1721
+ .some((messageId) => this.store.findTurnAnchorByMessageId(messageId) !== undefined);
1722
+ if (!hasPersistedSourceTurn)
1723
+ return;
995
1724
  const existing = this.threadInitializations.get(message.contextKey);
996
1725
  if (existing)
997
1726
  return existing;
@@ -1074,9 +1803,7 @@ export class ProxySessionController {
1074
1803
  if (!message.threadContext || !message.threadId || !message.chatId) {
1075
1804
  throw new Error("当前消息不属于可识别的飞书话题。");
1076
1805
  }
1077
- const anchorMessageIds = [message.rootMessageId, message.parentMessageId]
1078
- .filter((messageId) => Boolean(messageId && messageId !== message.messageId));
1079
- const anchor = [...new Set(anchorMessageIds)]
1806
+ const anchor = threadForkAnchorMessageIds(message)
1080
1807
  .map((messageId) => this.store.findTurnAnchorByMessageId(messageId))
1081
1808
  .find((candidate) => candidate !== undefined);
1082
1809
  if (!anchor) {
@@ -1381,22 +2108,51 @@ export class ProxySessionController {
1381
2108
  executionSettings,
1382
2109
  };
1383
2110
  }
1384
- async startTurn(loaded, text, replyTarget, localImagePaths) {
2111
+ async startTurn(loaded, text, replyTarget, localImagePaths, options = {}) {
1385
2112
  const currentRecord = this.store.getSession(loaded.record.localSessionId);
1386
2113
  const title = currentRecord?.title ?? normalizeTaskTitle(text);
1387
2114
  if (!currentRecord?.title && title)
1388
2115
  this.store.updateRuntimeSession(loaded.record.localSessionId, { title });
1389
2116
  if (title)
1390
2117
  this.outbound.updateSessionTitle(loaded.record.localSessionId, title);
1391
- await this.outbound.startPendingTurn(loaded.record.localSessionId, loaded.record.contextKey, title, replyTarget, text);
2118
+ const attemptId = options.attemptId ?? createId("attempt");
2119
+ if (options.attemptId) {
2120
+ this.store.updateTurnAttempt(attemptId, {
2121
+ messageId: options.messageId ?? undefined,
2122
+ replyMessageId: replyTarget?.messageId ?? undefined,
2123
+ status: "recovering",
2124
+ });
2125
+ }
2126
+ else {
2127
+ this.store.createTurnAttempt({
2128
+ attemptId,
2129
+ localSessionId: loaded.record.localSessionId,
2130
+ contextKey: loaded.record.contextKey,
2131
+ promptText: text,
2132
+ localImagePaths,
2133
+ messageId: options.messageId,
2134
+ replyMessageId: replyTarget?.messageId,
2135
+ });
2136
+ }
1392
2137
  let turnId;
1393
2138
  try {
2139
+ const pendingTurnId = await this.outbound.startPendingTurn(loaded.record.localSessionId, loaded.record.contextKey, title, replyTarget, options.displayPrompt ?? text);
2140
+ this.store.updateTurnAttempt(attemptId, { pendingTurnId: pendingTurnId ?? null });
1394
2141
  turnId = await loaded.runtime.startTurn(loaded.record.localSessionId, runtimePrompt(text, localImagePaths));
1395
2142
  }
1396
2143
  catch (error) {
2144
+ this.store.updateTurnAttempt(attemptId, {
2145
+ status: options.preserveAttemptOnFailure ? "recovering" : "failed",
2146
+ });
1397
2147
  await this.outbound.failPendingTurn(loaded.record.localSessionId, error instanceof Error ? error.message : String(error));
1398
2148
  throw error;
1399
2149
  }
2150
+ const attempt = this.store.getTurnAttempt(attemptId);
2151
+ this.store.updateTurnAttempt(attemptId, {
2152
+ pendingTurnId: null,
2153
+ turnId,
2154
+ ...(attempt && isIncompleteTurnAttemptStatus(attempt.status) ? { status: "running" } : {}),
2155
+ });
1400
2156
  const latest = this.store.getSession(loaded.record.localSessionId);
1401
2157
  const alreadyTerminal = latest?.lastTurnId === turnId
1402
2158
  && ["completed", "cancelled", "failed"].includes(latest.lastTurnStatus ?? "");
@@ -1665,14 +2421,34 @@ export class ProxySessionController {
1665
2421
  }
1666
2422
  async handleRuntimeEvent(event) {
1667
2423
  if ("turnId" in event) {
2424
+ this.store.touchTurnAttempt(event.turnId);
1668
2425
  const source = this.store.getSession(event.sessionId);
1669
2426
  if (source?.remoteSessionId && this.isCodexSession(source)) {
1670
2427
  this.store.saveTurnRuntimeOrigin(event.turnId, source.localSessionId, source.agentName, source.remoteSessionId);
1671
2428
  }
1672
2429
  if (event.type === "turn_started") {
2430
+ const attempt = this.store.bindTurnAttempt(event.sessionId, event.turnId);
2431
+ if (attempt?.recoveredFromTurnId && attempt.recoveredFromTurnId !== event.turnId) {
2432
+ this.store.rebindPendingTurnMessages(event.sessionId, attempt.recoveredFromTurnId, event.turnId);
2433
+ }
1673
2434
  this.store.saveTurnParent(event.turnId, event.sessionId, this.completedParentTurnId(source));
1674
2435
  }
1675
2436
  }
2437
+ const failedAttempt = event.type === "turn_failed"
2438
+ ? this.store.findIncompleteTurnAttemptByTurnId(event.turnId)
2439
+ : undefined;
2440
+ if (event.type === "turn_failed") {
2441
+ if (this.retriedFailureTurnIds.has(event.turnId))
2442
+ return;
2443
+ if (failedAttempt && isRetryableLlmTurnFailure(event.message) && failedAttempt.retryCount < MAX_LLM_TURN_RETRIES) {
2444
+ const retryAttempt = this.store.prepareTurnAttemptRetry(failedAttempt.attemptId, event.turnId);
2445
+ if (retryAttempt) {
2446
+ this.retriedFailureTurnIds.add(event.turnId);
2447
+ await this.retryFailedLlmTurn(event, retryAttempt);
2448
+ return;
2449
+ }
2450
+ }
2451
+ }
1676
2452
  if (event.type === "session_metadata_updated") {
1677
2453
  this.store.updateRuntimeSession(event.sessionId, { title: event.title });
1678
2454
  this.outbound.updateSessionTitle(event.sessionId, event.title);
@@ -1683,14 +2459,23 @@ export class ProxySessionController {
1683
2459
  this.store.updateRuntimeSession(event.sessionId, { lastTurnId: event.turnId, lastTurnStatus: "running" });
1684
2460
  }
1685
2461
  else if (event.type === "turn_completed" || event.type === "turn_cancelled" || event.type === "turn_failed") {
2462
+ this.store.markTurnAttemptTerminal(event.turnId, event.type === "turn_completed" ? "completed" : event.type === "turn_cancelled" ? "cancelled" : "failed");
1686
2463
  this.store.updateRuntimeSession(event.sessionId, {
1687
2464
  lastTurnId: event.turnId,
1688
2465
  lastTurnStatus: event.type === "turn_completed" ? "completed" : event.type === "turn_cancelled" ? "cancelled" : "failed",
1689
2466
  });
1690
2467
  }
2468
+ const presentationEvent = event.type === "turn_failed"
2469
+ && isRetryableLlmTurnFailure(event.message)
2470
+ && (failedAttempt?.retryCount ?? 0) >= MAX_LLM_TURN_RETRIES
2471
+ ? {
2472
+ ...event,
2473
+ message: `${event.message}\n\n已自动重试 ${MAX_LLM_TURN_RETRIES} 次,仍未成功。`,
2474
+ }
2475
+ : event;
1691
2476
  let presentationError;
1692
2477
  try {
1693
- await this.outbound.onEvent(event);
2478
+ await this.outbound.onEvent(presentationEvent);
1694
2479
  }
1695
2480
  catch (error) {
1696
2481
  presentationError = error;
@@ -1719,6 +2504,61 @@ export class ProxySessionController {
1719
2504
  if (presentationError)
1720
2505
  throw presentationError;
1721
2506
  }
2507
+ async retryFailedLlmTurn(event, attempt) {
2508
+ const retryNumber = attempt.retryCount;
2509
+ const session = this.store.getSession(event.sessionId);
2510
+ if (!session || session.status === "closed") {
2511
+ this.store.updateTurnAttempt(attempt.attemptId, { status: "failed" });
2512
+ return;
2513
+ }
2514
+ this.llmRetryingSessions.add(session.localSessionId);
2515
+ this.store.updateRuntimeSession(session.localSessionId, {
2516
+ lastTurnId: event.turnId,
2517
+ lastTurnStatus: "failed",
2518
+ });
2519
+ this.store.audit(attempt.contextKey, "llm_turn_retry_started", {
2520
+ attemptId: attempt.attemptId,
2521
+ failedTurnId: event.turnId,
2522
+ retryNumber,
2523
+ maxRetries: MAX_LLM_TURN_RETRIES,
2524
+ error: event.message,
2525
+ });
2526
+ try {
2527
+ await this.outbound.onEvent({
2528
+ ...event,
2529
+ message: `${event.message}\n\n检测到临时模型服务错误,正在自动重试(${retryNumber}/${MAX_LLM_TURN_RETRIES})。`,
2530
+ });
2531
+ }
2532
+ catch (error) {
2533
+ this.logger.warn({ error, sessionId: session.localSessionId, turnId: event.turnId }, "Failed to finalize the failed thinking card before an LLM retry.");
2534
+ }
2535
+ const replyTarget = attempt.replyMessageId
2536
+ ? { messageId: attempt.replyMessageId, replyInThread: true }
2537
+ : undefined;
2538
+ try {
2539
+ const current = this.store.getSession(session.localSessionId) ?? session;
2540
+ const loaded = await this.loadSession(current);
2541
+ const retryTurnId = await this.startTurn(loaded, llmRetryRuntimePrompt(attempt.promptText, retryNumber), replyTarget, attempt.localImagePaths, {
2542
+ attemptId: attempt.attemptId,
2543
+ messageId: attempt.messageId,
2544
+ displayPrompt: llmRetryCardPrompt(attempt.promptText, retryNumber),
2545
+ preserveAttemptOnFailure: true,
2546
+ });
2547
+ this.store.rebindPendingTurnMessages(session.localSessionId, event.turnId, retryTurnId);
2548
+ }
2549
+ catch (error) {
2550
+ this.store.updateTurnAttempt(attempt.attemptId, { status: "failed" });
2551
+ this.store.updateSession(session.localSessionId, { status: "failed" });
2552
+ await this.finalizeTurnMessageReactions(event.turnId, "failed").catch((reactionError) => {
2553
+ this.logger.warn({ error: reactionError, sessionId: session.localSessionId, turnId: event.turnId }, "Failed to finalize reactions after an LLM retry could not start.");
2554
+ });
2555
+ this.logger.error({ error, sessionId: session.localSessionId, retryNumber }, "Failed to start an automatic LLM turn retry.");
2556
+ await this.scheduleNextQueuedPrompt(session.localSessionId);
2557
+ }
2558
+ finally {
2559
+ this.llmRetryingSessions.delete(session.localSessionId);
2560
+ }
2561
+ }
1722
2562
  scheduleNextQueuedPrompt(sessionId) {
1723
2563
  const previous = this.queuedPromptStarts.get(sessionId) ?? Promise.resolve();
1724
2564
  const next = previous.catch(() => undefined).then(() => this.startNextQueuedPromptIfIdle(sessionId));
@@ -1749,7 +2589,7 @@ export class ProxySessionController {
1749
2589
  const record = this.store.getSessionForContext(sessionId, prompt.contextKey) ?? baseRecord;
1750
2590
  loaded = { ...loaded, record };
1751
2591
  await this.refreshPromptQueueCards(sessionId);
1752
- const turnId = await this.startTurn(loaded, prompt.text, prompt.replyMessageId ? { messageId: prompt.replyMessageId, replyInThread: true } : undefined, prompt.localImagePaths);
2592
+ const turnId = await this.startTurn(loaded, prompt.text, prompt.replyMessageId ? { messageId: prompt.replyMessageId, replyInThread: true } : undefined, prompt.localImagePaths, { messageId: prompt.messageId });
1753
2593
  if (prompt.messageId)
1754
2594
  await this.bindMessageReactionToTurn(prompt.messageId, sessionId, turnId);
1755
2595
  }
@@ -1770,7 +2610,9 @@ export class ProxySessionController {
1770
2610
  if (source.lastTurnStatus === "completed")
1771
2611
  return source.lastTurnId;
1772
2612
  const snapshot = turnViewSnapshot(this.store.getTurnSnapshot(source.lastTurnId));
1773
- return snapshot?.status === "completed" ? source.lastTurnId : undefined;
2613
+ return snapshot?.status === "completed"
2614
+ ? source.lastTurnId
2615
+ : this.store.findLatestCompletedTurnId(source.localSessionId, source.contextKey);
1774
2616
  }
1775
2617
  async finalizeStandaloneMessageReaction(messageId, status) {
1776
2618
  const reaction = this.store.claimMessageReaction(messageId);
@@ -1886,15 +2728,19 @@ export class ProxySessionController {
1886
2728
  if (current.localSessionId !== sessionId) {
1887
2729
  throw new Error("这张思考卡片不属于当前任务。请先切换回对应任务,再点击 Reset。");
1888
2730
  }
2731
+ await this.performSessionReset(contextKey, current, turnId, true);
2732
+ }
2733
+ async performSessionReset(contextKey, current, turnId, announce) {
1889
2734
  if (!current.remoteSessionId || !this.isCodexSession(current)) {
1890
2735
  throw new Error("当前任务不是可 Reset 的 App Server 任务。");
1891
2736
  }
1892
2737
  const snapshot = turnViewSnapshot(this.store.getTurnSnapshot(turnId));
1893
- if (!snapshot || snapshot.sessionId !== sessionId || snapshot.status !== "completed") {
2738
+ if (!snapshot || snapshot.sessionId !== current.localSessionId || snapshot.status !== "completed") {
1894
2739
  throw new Error("只能将当前任务 Reset 到已成功完成的轮次。");
1895
2740
  }
1896
2741
  if (current.lastTurnId === turnId && current.lastTurnStatus === "completed") {
1897
- await this.outbound.sendText(contextKey, "当前任务已经处于本轮完成后的对话状态,无需 Reset。");
2742
+ if (announce)
2743
+ await this.outbound.sendText(contextKey, "当前任务已经处于本轮完成后的对话状态,无需 Reset。");
1898
2744
  return;
1899
2745
  }
1900
2746
  const runtime = this.runtimes.forAgent(current.agentName);
@@ -1909,7 +2755,7 @@ export class ProxySessionController {
1909
2755
  throw new Error("当前任务仍在执行。请等待任务完成或先停止任务,再点击 Reset。");
1910
2756
  }
1911
2757
  const origin = this.store.getTurnRuntimeOrigin(turnId);
1912
- if (origin && (origin.localSessionId !== sessionId || origin.agentName !== current.agentName)) {
2758
+ if (origin && (origin.localSessionId !== current.localSessionId || origin.agentName !== current.agentName)) {
1913
2759
  throw new Error("这张思考卡片与当前任务不匹配,已拒绝 Reset。");
1914
2760
  }
1915
2761
  const sourceRemoteSessionId = origin?.remoteSessionId ?? current.remoteSessionId;
@@ -1941,13 +2787,15 @@ export class ProxySessionController {
1941
2787
  });
1942
2788
  const targetSummary = truncateText((snapshot.prompt ?? snapshot.taskTitle ?? "未记录对话内容").replace(/\s+/g, " ").trim(), 100) || "未记录对话内容";
1943
2789
  const targetTime = snapshot.completedAt ?? snapshot.startedAt;
1944
- await this.outbound.sendText(contextKey, [
1945
- "已将当前任务重置到:",
1946
- targetSummary,
1947
- `完成时间:${targetTime === undefined ? "未知" : formatResetTurnTime(targetTime)}`,
1948
- `Turn ID:${turnId}`,
1949
- "后续对话将从该轮完成后的状态继续;本地文件没有回退。",
1950
- ].join("\n"));
2790
+ if (announce) {
2791
+ await this.outbound.sendText(contextKey, [
2792
+ "已将当前任务重置到:",
2793
+ targetSummary,
2794
+ `完成时间:${targetTime === undefined ? "未知" : formatResetTurnTime(targetTime)}`,
2795
+ `Turn ID:${turnId}`,
2796
+ "后续对话将从该轮完成后的状态继续;本地文件没有回退。",
2797
+ ].join("\n"));
2798
+ }
1951
2799
  }
1952
2800
  async resolveApproval(action) {
1953
2801
  const sessionId = String(action.value.sessionId ?? "");
@@ -2497,6 +3345,165 @@ export class ProxySessionController {
2497
3345
  await this.outbound.sendInteractiveCard(contextKey, card);
2498
3346
  }
2499
3347
  }
3348
+ async openDirectoryBrowser(contextKey, requestedDirectory, options = {}) {
3349
+ if (requestedDirectory === WINDOWS_DRIVES_DIRECTORY) {
3350
+ await this.openWindowsDriveBrowser(contextKey, options);
3351
+ return;
3352
+ }
3353
+ const baseDirectory = this.currentSession(contextKey)?.cwd
3354
+ ?? this.store.getUserContext(contextKey)?.boundProjectCwd
3355
+ ?? this.config.defaults.cwd;
3356
+ const directory = requestedDirectory === undefined
3357
+ ? path.resolve(baseDirectory)
3358
+ : resolveUserPath(requestedDirectory, baseDirectory);
3359
+ await this.assertBrowsableDirectory(directory);
3360
+ let directoryEntries;
3361
+ try {
3362
+ directoryEntries = await fs.readdir(directory, { withFileTypes: true });
3363
+ }
3364
+ catch (error) {
3365
+ throw new Error(`无法读取目录 ${directory}:${runtimeErrorMessage(error)}`);
3366
+ }
3367
+ const entries = directoryEntries
3368
+ .map((entry) => ({
3369
+ name: entry.name,
3370
+ path: path.join(directory, entry.name),
3371
+ kind: directoryBrowserEntryKind(entry),
3372
+ }))
3373
+ .sort((left, right) => Number(right.kind === "directory") - Number(left.kind === "directory")
3374
+ || left.name.localeCompare(right.name, undefined, { numeric: true, sensitivity: "base" }));
3375
+ const parentDirectory = isWindowsDriveRoot(directory)
3376
+ ? WINDOWS_DRIVES_DIRECTORY
3377
+ : path.dirname(directory);
3378
+ const parentEntries = parentDirectory === directory ? [] : [{
3379
+ name: "..",
3380
+ kind: "directory",
3381
+ openAction: {
3382
+ text: "..",
3383
+ value: {
3384
+ action: "directory_open",
3385
+ directory: parentDirectory,
3386
+ contextKey,
3387
+ },
3388
+ },
3389
+ }];
3390
+ const pageSize = DIRECTORY_PAGE_SIZE;
3391
+ const requestedPage = Math.max(0, Math.trunc(options.page ?? 0));
3392
+ const totalPages = Math.max(1, Math.ceil(entries.length / pageSize));
3393
+ const page = Math.min(requestedPage, totalPages - 1);
3394
+ const offset = page * pageSize;
3395
+ const visibleEntries = [
3396
+ ...parentEntries,
3397
+ ...entries
3398
+ .slice(offset, offset + pageSize)
3399
+ .map((entry) => entry.kind === "directory"
3400
+ ? {
3401
+ name: entry.name,
3402
+ kind: entry.kind,
3403
+ openAction: {
3404
+ text: entry.name,
3405
+ value: {
3406
+ action: "directory_open",
3407
+ directory: entry.path,
3408
+ contextKey,
3409
+ },
3410
+ },
3411
+ actions: directoryCreationActions(entry.path, contextKey),
3412
+ }
3413
+ : {
3414
+ name: entry.name,
3415
+ kind: entry.kind,
3416
+ openAction: {
3417
+ text: entry.name,
3418
+ value: {
3419
+ action: "directory_send_file",
3420
+ filePath: entry.path,
3421
+ contextKey,
3422
+ },
3423
+ },
3424
+ }),
3425
+ ];
3426
+ const navigationActions = directoryPaginationActions(directory, page, totalPages, contextKey);
3427
+ const directoryCount = entries.filter((entry) => entry.kind === "directory").length;
3428
+ const card = this.cardRenderer.renderDirectoryBrowserCard({
3429
+ directory: abbreviateHomeDirectory(directory),
3430
+ entries: visibleEntries,
3431
+ currentActions: directoryCreationActions(directory, contextKey),
3432
+ navigationActions,
3433
+ footerLines: [
3434
+ `第 ${page + 1}/${totalPages} 页 · ${directoryCount} 个目录 · ${entries.length - directoryCount} 个文件`,
3435
+ "",
3436
+ "> 点击目录名称进入,点击文件名称发送到当前会话;**New** 在该目录创建任务,**NewGroup** 在该目录创建新群和任务。",
3437
+ ],
3438
+ });
3439
+ if (options.updateMessageId) {
3440
+ await this.outbound.updateInteractiveCard(contextKey, options.updateMessageId, card);
3441
+ }
3442
+ else {
3443
+ await this.outbound.sendInteractiveCard(contextKey, card);
3444
+ }
3445
+ }
3446
+ async openWindowsDriveBrowser(contextKey, options) {
3447
+ const drives = await this.windowsDriveLister();
3448
+ const requestedPage = Math.max(0, Math.trunc(options.page ?? 0));
3449
+ const totalPages = Math.max(1, Math.ceil(drives.length / DIRECTORY_PAGE_SIZE));
3450
+ const page = Math.min(requestedPage, totalPages - 1);
3451
+ const offset = page * DIRECTORY_PAGE_SIZE;
3452
+ const entries = drives
3453
+ .slice(offset, offset + DIRECTORY_PAGE_SIZE)
3454
+ .map((drive) => ({
3455
+ name: windowsDriveDisplayName(drive),
3456
+ kind: "drive",
3457
+ openAction: {
3458
+ text: windowsDriveDisplayName(drive),
3459
+ value: {
3460
+ action: "directory_open",
3461
+ directory: drive.root,
3462
+ contextKey,
3463
+ },
3464
+ },
3465
+ actions: directoryCreationActions(drive.root, contextKey),
3466
+ }));
3467
+ const card = this.cardRenderer.renderDirectoryBrowserCard({
3468
+ directory: "此电脑",
3469
+ entries,
3470
+ currentActions: [],
3471
+ navigationActions: directoryPaginationActions(WINDOWS_DRIVES_DIRECTORY, page, totalPages, contextKey),
3472
+ footerLines: [
3473
+ `第 ${page + 1}/${totalPages} 页 · ${drives.length} 个磁盘`,
3474
+ "",
3475
+ "> 点击磁盘名称进入;**New** 在磁盘根目录创建任务,**NewGroup** 在磁盘根目录创建新群和任务。",
3476
+ ],
3477
+ });
3478
+ if (options.updateMessageId) {
3479
+ await this.outbound.updateInteractiveCard(contextKey, options.updateMessageId, card);
3480
+ }
3481
+ else {
3482
+ await this.outbound.sendInteractiveCard(contextKey, card);
3483
+ }
3484
+ }
3485
+ async assertBrowsableDirectory(directory) {
3486
+ let stats;
3487
+ try {
3488
+ stats = await fs.stat(directory);
3489
+ }
3490
+ catch (error) {
3491
+ throw new Error(`目录不存在或无法访问:${directory}(${runtimeErrorMessage(error)})`);
3492
+ }
3493
+ if (!stats.isDirectory())
3494
+ throw new Error(`这不是目录:${directory}`);
3495
+ }
3496
+ async assertSendableFile(filePath) {
3497
+ let stats;
3498
+ try {
3499
+ stats = await fs.stat(filePath);
3500
+ }
3501
+ catch (error) {
3502
+ throw new Error(`文件不存在或无法访问:${filePath}(${runtimeErrorMessage(error)})`);
3503
+ }
3504
+ if (!stats.isFile())
3505
+ throw new Error(`这不是普通文件:${filePath}`);
3506
+ }
2500
3507
  async listSessions(contextKey, searchTerm, options = {}) {
2501
3508
  const context = this.store.getOrCreateUserContext(contextKey, this.config.defaults.agent);
2502
3509
  const normalizedSearch = searchTerm?.trim().toLowerCase();
@@ -2522,23 +3529,28 @@ export class ProxySessionController {
2522
3529
  const remoteHint = remoteErrors.length > 0
2523
3530
  ? `部分 Agent 的任务读取失败:${remoteErrors.join(";")}`
2524
3531
  : undefined;
2525
- const entries = mergeTaskList(localSessions, remoteSessions, context.currentSessionId)
3532
+ const entries = orderTaskListByProject(mergeTaskList(localSessions, remoteSessions, context.currentSessionId)
2526
3533
  .filter((entry) => !normalizedSearch
2527
3534
  || [entry.id, entry.title, entry.cwd, entry.agentName]
2528
- .some((value) => value.toLowerCase().includes(normalizedSearch)))
2529
- .sort((left, right) => Number(right.current) - Number(left.current)
2530
- || Number(right.active) - Number(left.active)
2531
- || right.updatedAt - left.updatedAt);
3535
+ .some((value) => value.toLowerCase().includes(normalizedSearch))));
2532
3536
  const activeCount = entries.filter((entry) => entry.active).length;
2533
3537
  const lastKnownPage = Math.max(0, Math.ceil(entries.length / SESSION_PAGE_SIZE) - 1);
2534
3538
  const page = remoteHasMore ? requestedPage : Math.min(requestedPage, lastKnownPage);
2535
3539
  const offset = page * SESSION_PAGE_SIZE;
2536
3540
  const visibleEntries = entries.slice(offset, offset + SESSION_PAGE_SIZE);
3541
+ const projectActionSources = new Map();
3542
+ for (const entry of entries) {
3543
+ const project = taskProjectInfo(entry);
3544
+ if (!projectActionSources.has(project.key))
3545
+ projectActionSources.set(project.key, entry);
3546
+ }
2537
3547
  const hasPrevious = page > 0;
2538
3548
  const hasNext = remoteHasMore || entries.length > offset + SESSION_PAGE_SIZE;
2539
3549
  this.lastSessionListings.set(contextKey, entries.map((entry) => entry.reference));
2540
3550
  const cardEntries = visibleEntries.map((entry, index) => {
2541
- const marker = entry.current ? "✅" : entry.active ? "🟢 **活跃**" : "•";
3551
+ const project = taskProjectInfo(entry);
3552
+ const projectActionSource = projectActionSources.get(project.key) ?? entry;
3553
+ const marker = entry.current ? "✅" : entry.active ? "🟢" : "•";
2542
3554
  const showStop = entry.status === "外部执行中"
2543
3555
  && entry.reference !== options.forceSwitchTaskId
2544
3556
  && entry.id !== options.forceSwitchTaskId;
@@ -2553,10 +3565,29 @@ export class ProxySessionController {
2553
3565
  contextKey,
2554
3566
  },
2555
3567
  }];
3568
+ const projectActions = [{
3569
+ text: "New",
3570
+ value: {
3571
+ action: "session_new",
3572
+ sessionId: projectActionSource.reference,
3573
+ ...(searchTerm ? { searchTerm } : {}),
3574
+ page: String(page),
3575
+ contextKey,
3576
+ },
3577
+ }, {
3578
+ text: "NewGroup",
3579
+ value: {
3580
+ action: "session_new_group",
3581
+ sessionId: projectActionSource.reference,
3582
+ ...(searchTerm ? { searchTerm } : {}),
3583
+ page: String(page),
3584
+ contextKey,
3585
+ },
3586
+ }];
2556
3587
  actions.push({
2557
- text: "New",
3588
+ text: "Fork",
2558
3589
  value: {
2559
- action: "session_new",
3590
+ action: "session_fork",
2560
3591
  sessionId: entry.reference,
2561
3592
  ...(searchTerm ? { searchTerm } : {}),
2562
3593
  page: String(page),
@@ -2564,9 +3595,9 @@ export class ProxySessionController {
2564
3595
  },
2565
3596
  });
2566
3597
  actions.push({
2567
- text: "NewGroup",
3598
+ text: "ForkGroup",
2568
3599
  value: {
2569
- action: "session_new_group",
3600
+ action: "session_fork_group",
2570
3601
  sessionId: entry.reference,
2571
3602
  ...(searchTerm ? { searchTerm } : {}),
2572
3603
  page: String(page),
@@ -2574,47 +3605,62 @@ export class ProxySessionController {
2574
3605
  },
2575
3606
  });
2576
3607
  actions.push({
2577
- text: "Fork",
3608
+ text: "Status",
2578
3609
  value: {
2579
- action: "session_fork",
3610
+ action: "session_status",
2580
3611
  sessionId: entry.reference,
2581
- ...(searchTerm ? { searchTerm } : {}),
2582
- page: String(page),
2583
3612
  contextKey,
2584
3613
  },
2585
3614
  });
2586
3615
  actions.push({
2587
- text: "ForkGroup",
3616
+ text: "Archive",
2588
3617
  value: {
2589
- action: "session_fork_group",
3618
+ action: "session_archive",
2590
3619
  sessionId: entry.reference,
2591
3620
  ...(searchTerm ? { searchTerm } : {}),
2592
3621
  page: String(page),
2593
3622
  contextKey,
2594
3623
  },
2595
3624
  });
2596
- actions.push({
2597
- text: "Status",
2598
- value: {
2599
- action: "session_status",
2600
- sessionId: entry.reference,
2601
- contextKey,
2602
- },
2603
- });
3625
+ const title = truncateText(entry.title.replace(/\s+/gu, " ").trim() || "未命名任务", 56);
2604
3626
  return {
2605
- lines: [
2606
- `**${offset + index + 1}.** ${marker} **${cardText(entry.title)}** ${cardText(entry.id)}`,
2607
- `${entry.status} · ${entry.updatedLabel} · ${cardText(entry.agentName)} · ${cardText(entry.cwd || "目录未知")}`,
2608
- ],
2609
- actions,
3627
+ project,
3628
+ projectActions,
3629
+ cardEntry: {
3630
+ reference: entry.reference,
3631
+ summary: `${offset + index + 1}. ${marker} ${title} · ${entry.agentName}`,
3632
+ detailLines: [
3633
+ `**状态 / 更新时间**:${cardText(entry.status)} / ${cardText(entry.updatedLabel)}`,
3634
+ `**Agent / 任务 ID**:${cardCode(entry.agentName)} / ${cardCode(entry.id)}`,
3635
+ `**目录**:${cardCode(entry.cwd || "目录未知")}`,
3636
+ ],
3637
+ actions,
3638
+ current: entry.current,
3639
+ },
2610
3640
  };
2611
3641
  });
2612
- const card = this.cardRenderer.renderTaskListCard(searchTerm ? `App Server 任务:${searchTerm}` : "App Server 任务", activeCount > 0 ? `任务(${activeCount} 个活跃)` : "任务", cardEntries, [
3642
+ const cardGroups = [];
3643
+ let currentProjectKey;
3644
+ for (const item of cardEntries) {
3645
+ const currentGroup = cardGroups.at(-1);
3646
+ if (currentGroup && currentProjectKey === item.project.key) {
3647
+ currentGroup.entries.push(item.cardEntry);
3648
+ }
3649
+ else {
3650
+ currentProjectKey = item.project.key;
3651
+ cardGroups.push({
3652
+ title: item.project.title,
3653
+ entries: [item.cardEntry],
3654
+ actions: item.projectActions,
3655
+ });
3656
+ }
3657
+ }
3658
+ const card = this.cardRenderer.renderSessionTaskListCard(searchTerm ? `任务列表:${searchTerm}` : "任务列表", activeCount > 0 ? `任务(${activeCount} 个活跃)` : "任务", cardGroups, [
2613
3659
  ...(remoteHint ? [remoteHint] : []),
2614
3660
  `第 ${page + 1} 页 · 每页 ${SESSION_PAGE_SIZE} 个任务${hasNext ? "" : ` · 当前共 ${entries.length} 个任务`}`,
2615
3661
  "",
2616
- "> **Switch** 切换任务;**Stop** 停止运行;**Status** 查看状态。",
2617
- "> **New** / **Fork** 新建 / 分支任务;**NewGroup** / **ForkGroup** 新建 / 分支群。",
3662
+ "> 项目行:**New** 新建任务,**NewGroup** 新建群。",
3663
+ "> 任务详情:**Switch** 切换,**Stop** 停止,**Fork** / **ForkGroup** 创建分支,**Status** 查看状态,**Archive** 归档。",
2618
3664
  ], [
2619
3665
  ...(hasPrevious ? [{
2620
3666
  text: "Previous",
@@ -2779,6 +3825,56 @@ export class ProxySessionController {
2779
3825
  });
2780
3826
  await this.outbound.sendText(contextKey, `已向 Agent 发送 Interrupt 请求:${turnId}`);
2781
3827
  }
3828
+ async archiveSessionReference(contextKey, reference, options) {
3829
+ const taskId = this.resolveSessionReference(contextKey, reference);
3830
+ const direct = this.store.getSession(taskId);
3831
+ if (direct?.status === "closed")
3832
+ throw new Error(`任务已经归档:${direct.title ?? taskId}`);
3833
+ const local = direct ?? this.findStoredSessionByReference(taskId);
3834
+ if (local && (!local.remoteSessionId || !this.isCodexSession(local))) {
3835
+ throw new Error("当前 Agent 不支持归档任务。");
3836
+ }
3837
+ if (local && (local.status === "running"
3838
+ || Boolean(this.runtimes.forAgent(local.agentName).getSession(local.localSessionId)?.activeTurnId))) {
3839
+ throw new Error("任务正在执行,无法归档。请先停止任务或等待本轮完成。");
3840
+ }
3841
+ if (local && this.store.countQueuedPrompts(local.localSessionId) > 0) {
3842
+ throw new Error("任务仍有排队消息,无法归档。请先处理或取消排队消息。");
3843
+ }
3844
+ const remoteReference = local
3845
+ ? remoteSessionReference(local.agentName, local.remoteSessionId)
3846
+ : taskId;
3847
+ const { agentName, runtime, remote } = await this.resolveRemoteCodexSession(remoteReference);
3848
+ if (isRemoteSessionActive(remote)) {
3849
+ throw new Error("任务正在执行,无法归档。请先停止任务或等待本轮完成。");
3850
+ }
3851
+ if (!runtime.archiveRemoteSession) {
3852
+ throw new Error(`Agent ${agentName} 不支持归档任务。`);
3853
+ }
3854
+ const wasCurrent = Boolean(local && this.store.getUserContext(contextKey)?.currentSessionId === local.localSessionId);
3855
+ await runtime.archiveRemoteSession(remote.id);
3856
+ const title = local?.title ?? remote.title ?? remote.preview ?? remote.id;
3857
+ if (local) {
3858
+ this.store.archiveSession(local.localSessionId);
3859
+ this.outbound.unregisterSession(local.localSessionId);
3860
+ }
3861
+ this.store.audit(contextKey, "session_archived", {
3862
+ ...(local ? { localSessionId: local.localSessionId } : {}),
3863
+ remoteSessionId: remote.id,
3864
+ agentName,
3865
+ source: options.source,
3866
+ });
3867
+ if (options.announce) {
3868
+ await this.outbound.sendText(contextKey, wasCurrent
3869
+ ? `已归档任务:${title}\n当前会话已没有绑定任务,直接发送消息即可创建新任务。`
3870
+ : `已归档任务:${title}`);
3871
+ }
3872
+ return {
3873
+ localSessionId: local?.localSessionId ?? "",
3874
+ remoteSessionId: remote.id,
3875
+ title,
3876
+ };
3877
+ }
2782
3878
  resolveSessionReference(contextKey, reference) {
2783
3879
  if (reference === undefined) {
2784
3880
  const previous = this.store.getOrCreateUserContext(contextKey, this.config.defaults.agent).previousSessionId;
@@ -3067,6 +4163,37 @@ export class ProxySessionController {
3067
4163
  "**[参数]** 可选 **&#60;参数&#62;** 必填",
3068
4164
  ], sections));
3069
4165
  }
4166
+ async setGroupMute(contextKey, enabled) {
4167
+ const chatContextKey = baseChatContextKey(contextKey);
4168
+ const chat = this.store.getChatContext(chatContextKey);
4169
+ if (chat?.chatType !== "group") {
4170
+ throw new Error("/mute 仅适用于群聊。");
4171
+ }
4172
+ this.store.setChatRequiresMention(chatContextKey, enabled);
4173
+ await this.outbound.sendText(contextKey, enabled
4174
+ ? "已开启当前群的静音模式。之后只有 @ 机器人的消息会被处理;发送 @机器人 /mute off 可恢复自动响应。"
4175
+ : "已关闭当前群的静音模式。机器人将恢复自动响应群消息。");
4176
+ }
4177
+ requireControlSession(localSessionId) {
4178
+ const record = this.store.getSession(localSessionId);
4179
+ if (!record || record.status === "closed")
4180
+ throw new Error(`Task not found: ${localSessionId}`);
4181
+ return record;
4182
+ }
4183
+ controlSessionContextKey(record) {
4184
+ return this.outbound.getSessionContextKey(record.localSessionId)
4185
+ ?? (record.lastTurnId ? this.store.getTurnContextKey(record.lastTurnId) : undefined)
4186
+ ?? record.contextKey;
4187
+ }
4188
+ controlSessionReplyTarget(record) {
4189
+ const routed = this.outbound.getSessionReplyTarget(record.localSessionId);
4190
+ if (routed)
4191
+ return routed;
4192
+ const snapshot = record.lastTurnId
4193
+ ? turnViewSnapshot(this.store.getTurnSnapshot(record.lastTurnId))
4194
+ : undefined;
4195
+ return snapshot?.replyTarget;
4196
+ }
3070
4197
  ensureAgent(agentName) {
3071
4198
  const agent = this.config.agents[agentName];
3072
4199
  if (!agent)
@@ -3127,6 +4254,10 @@ export class ProxySessionController {
3127
4254
  await this.outbound.sendText(contextKey, error instanceof Error ? error.message : String(error));
3128
4255
  }
3129
4256
  }
4257
+ function threadForkAnchorMessageIds(message) {
4258
+ return [...new Set([message.rootMessageId, message.parentMessageId]
4259
+ .filter((messageId) => Boolean(messageId && messageId !== message.messageId)))];
4260
+ }
3130
4261
  function sessionStatusLabel(status, activeTurnId) {
3131
4262
  if (activeTurnId || status === "running")
3132
4263
  return "执行中";
@@ -3157,6 +4288,50 @@ function statusRefreshAction(sessionId, contextKey) {
3157
4288
  },
3158
4289
  };
3159
4290
  }
4291
+ function orderTaskListByProject(entries) {
4292
+ const groups = new Map();
4293
+ for (const entry of entries) {
4294
+ const project = taskProjectInfo(entry);
4295
+ const group = groups.get(project.key);
4296
+ if (group)
4297
+ group.entries.push(entry);
4298
+ else
4299
+ groups.set(project.key, { project, entries: [entry] });
4300
+ }
4301
+ const orderedGroups = [...groups.values()]
4302
+ .map((group) => ({
4303
+ ...group,
4304
+ current: group.entries.some((entry) => entry.current),
4305
+ active: group.entries.some((entry) => entry.active),
4306
+ updatedAt: Math.max(...group.entries.map((entry) => entry.updatedAt)),
4307
+ }))
4308
+ .sort((left, right) => Number(right.current) - Number(left.current)
4309
+ || Number(right.active) - Number(left.active)
4310
+ || right.updatedAt - left.updatedAt
4311
+ || left.project.title.localeCompare(right.project.title, undefined, { sensitivity: "base" }));
4312
+ return orderedGroups.flatMap((group) => group.entries.sort(compareTaskListEntries));
4313
+ }
4314
+ function compareTaskListEntries(left, right) {
4315
+ return Number(right.current) - Number(left.current)
4316
+ || Number(right.active) - Number(left.active)
4317
+ || right.updatedAt - left.updatedAt
4318
+ || left.title.localeCompare(right.title, undefined, { sensitivity: "base" });
4319
+ }
4320
+ function taskProjectInfo(entry) {
4321
+ if (!entry.cwd)
4322
+ return { key: "unknown", title: "📁 目录未知" };
4323
+ if (detectProjectlessWorkspace(entry.cwd)) {
4324
+ return { key: "projectless", title: "🗒️ 未指定项目" };
4325
+ }
4326
+ const windowsPath = isWindowsAbsolutePath(entry.cwd);
4327
+ const normalized = windowsPath
4328
+ ? path.win32.normalize(entry.cwd).toLowerCase()
4329
+ : path.resolve(entry.cwd);
4330
+ return {
4331
+ key: `cwd:${normalized}`,
4332
+ title: `📁 ${abbreviateHomeDirectory(entry.cwd)}`,
4333
+ };
4334
+ }
3160
4335
  function mergeTaskList(localSessions, remoteSessions, currentLocalSessionId) {
3161
4336
  const localByRemoteId = new Map(localSessions
3162
4337
  .filter((session) => session.runtimeKind === "codex" && session.remoteSessionId)
@@ -3246,6 +4421,12 @@ function turnViewSnapshot(value) {
3246
4421
  ? candidate
3247
4422
  : undefined;
3248
4423
  }
4424
+ function isTerminalTurnViewStatus(status) {
4425
+ return status === "completed" || status === "cancelled" || status === "failed";
4426
+ }
4427
+ function isIncompleteTurnAttemptStatus(status) {
4428
+ return status === "accepted" || status === "running" || status === "recovering";
4429
+ }
3249
4430
  function executionDetailLines(record, snapshot, remote, activeTurnId, queued) {
3250
4431
  const remoteActive = isRemoteSessionActive(remote);
3251
4432
  const turnId = remoteActive
@@ -3519,7 +4700,7 @@ function isBotOwnedActiveTurn(record, remote) {
3519
4700
  && record.lastTurnId === remote.lastTurnId;
3520
4701
  }
3521
4702
  function isQueueIndependentCommand(command) {
3522
- if (["stop", "status", "restart", "help", "sessions", "goal", "nosteer"].includes(command.type))
4703
+ if (["archive", "stop", "status", "restart", "mute", "help", "sessions", "dir", "goal", "nosteer"].includes(command.type))
3523
4704
  return true;
3524
4705
  if (command.type === "agent")
3525
4706
  return command.agent === undefined;
@@ -3531,12 +4712,200 @@ function parseSessionPage(value) {
3531
4712
  const parsed = typeof value === "string" || typeof value === "number" ? Number(value) : Number.NaN;
3532
4713
  return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : 0;
3533
4714
  }
4715
+ function directoryPageValue(value) {
4716
+ const page = Number(value);
4717
+ if (!Number.isSafeInteger(page) || page < 0)
4718
+ throw new Error("文件浏览卡片页码无效,请重新发送 /dir。");
4719
+ return page;
4720
+ }
4721
+ function isWindowsDriveRoot(directory) {
4722
+ return /^[a-z]:\\$/iu.test(path.win32.normalize(directory));
4723
+ }
4724
+ function directoryBrowserEntryKind(entry) {
4725
+ if (entry.isDirectory())
4726
+ return "directory";
4727
+ const extension = path.extname(entry.name).toLowerCase();
4728
+ if (IMAGE_FILE_EXTENSIONS.has(extension))
4729
+ return "image";
4730
+ if (BINARY_FILE_EXTENSIONS.has(extension))
4731
+ return "binary";
4732
+ return "file";
4733
+ }
4734
+ function directoryActionPath(value) {
4735
+ if (typeof value !== "string" || !value.trim()) {
4736
+ throw new Error("文件浏览卡片中的目录无效,请重新发送 /dir。");
4737
+ }
4738
+ return value === WINDOWS_DRIVES_DIRECTORY ? value : path.resolve(value);
4739
+ }
4740
+ function directoryFileActionPath(value) {
4741
+ if (typeof value !== "string" || !value.trim()) {
4742
+ throw new Error("文件浏览卡片中的文件无效,请重新发送 /dir。");
4743
+ }
4744
+ return path.resolve(value);
4745
+ }
4746
+ function directoryCreationActions(directory, contextKey) {
4747
+ return [
4748
+ {
4749
+ text: "New",
4750
+ value: {
4751
+ action: "directory_new",
4752
+ directory,
4753
+ contextKey,
4754
+ },
4755
+ },
4756
+ {
4757
+ text: "NewGroup",
4758
+ value: {
4759
+ action: "directory_new_group",
4760
+ directory,
4761
+ contextKey,
4762
+ },
4763
+ },
4764
+ ];
4765
+ }
4766
+ function directoryPaginationActions(directory, page, totalPages, contextKey) {
4767
+ return [
4768
+ ...(page > 0 ? [{
4769
+ text: "Previous",
4770
+ value: {
4771
+ action: "directory_page",
4772
+ directory,
4773
+ page: String(page - 1),
4774
+ contextKey,
4775
+ },
4776
+ }] : []),
4777
+ ...(page < totalPages - 1 ? [{
4778
+ text: "Next",
4779
+ value: {
4780
+ action: "directory_page",
4781
+ directory,
4782
+ page: String(page + 1),
4783
+ contextKey,
4784
+ },
4785
+ }] : []),
4786
+ ];
4787
+ }
4788
+ function windowsDriveDisplayName(drive) {
4789
+ const driveLetter = drive.root.slice(0, 2).toUpperCase();
4790
+ const label = drive.label?.trim() || windowsDriveTypeLabel(drive.driveType);
4791
+ return `${label} (${driveLetter})`;
4792
+ }
4793
+ function windowsDriveTypeLabel(driveType) {
4794
+ switch (driveType?.toLowerCase()) {
4795
+ case "fixed": return "本地磁盘";
4796
+ case "network": return "网络驱动器";
4797
+ case "removable": return "可移动磁盘";
4798
+ case "cdrom": return "光驱";
4799
+ case "ram": return "RAM 磁盘";
4800
+ default: return "磁盘";
4801
+ }
4802
+ }
4803
+ async function listWindowsDriveRoots() {
4804
+ if (process.platform !== "win32")
4805
+ throw new Error("磁盘列表仅在 Windows 上可用。");
4806
+ const result = await executeShellCommand("$drives = @([System.IO.DriveInfo]::GetDrives() | ForEach-Object { $label = ''; if ($_.IsReady) { try { $label = $_.VolumeLabel } catch {} }; [PSCustomObject]@{ root = $_.Name; label = $label; driveType = $_.DriveType.ToString() } }); ConvertTo-Json -Compress -InputObject $drives", os.homedir(), { timeoutMs: 10_000, maxOutputBytes: 16 * 1024, platform: "win32" });
4807
+ if (result.timedOut || result.exitCode !== 0) {
4808
+ const detail = result.stderr.trim() || `退出码 ${result.exitCode ?? "未知"}`;
4809
+ throw new Error(`无法读取 Windows 磁盘列表:${detail}`);
4810
+ }
4811
+ let parsed;
4812
+ try {
4813
+ parsed = JSON.parse(result.stdout.trim().replace(/^\uFEFF/u, ""));
4814
+ }
4815
+ catch (error) {
4816
+ throw new Error(`无法解析 Windows 磁盘列表:${runtimeErrorMessage(error)}`);
4817
+ }
4818
+ const values = Array.isArray(parsed) ? parsed : [parsed];
4819
+ const drives = values.flatMap((value) => {
4820
+ if (typeof value !== "object" || value === null)
4821
+ return [];
4822
+ const record = value;
4823
+ if (typeof record.root !== "string")
4824
+ return [];
4825
+ const root = path.win32.normalize(record.root.trim());
4826
+ if (!isWindowsDriveRoot(root))
4827
+ return [];
4828
+ return [{
4829
+ root,
4830
+ ...(typeof record.label === "string" && record.label.trim() ? { label: record.label.trim() } : {}),
4831
+ ...(typeof record.driveType === "string" ? { driveType: record.driveType } : {}),
4832
+ }];
4833
+ }).sort((left, right) => left.root.localeCompare(right.root, undefined, { sensitivity: "base" }));
4834
+ if (drives.length === 0)
4835
+ throw new Error("没有检测到可用的 Windows 磁盘。");
4836
+ return drives;
4837
+ }
3534
4838
  function isPromptCommand(command) {
3535
4839
  return command.type === "prompt" || command.type === "nosteer";
3536
4840
  }
3537
4841
  function runtimePrompt(text, localImagePaths) {
3538
4842
  return localImagePaths?.length ? { text, localImagePaths } : text;
3539
4843
  }
4844
+ function recoveryCardPrompt(prompt) {
4845
+ return `恢复重启前的任务:${prompt}`;
4846
+ }
4847
+ function recoveryRuntimePrompt(prompt) {
4848
+ return [
4849
+ "Agent Bot restarted while the previous turn was still running.",
4850
+ "Continue the unfinished task in the current task and workspace.",
4851
+ "Inspect the existing conversation, files, and completed tool effects first. Do not repeat completed or irreversible actions.",
4852
+ "Finish the original request:",
4853
+ "",
4854
+ prompt,
4855
+ ].join("\n");
4856
+ }
4857
+ function llmRetryCardPrompt(prompt, retryNumber) {
4858
+ return `自动重试 ${retryNumber}/${MAX_LLM_TURN_RETRIES}:${prompt}`;
4859
+ }
4860
+ function llmRetryRuntimePrompt(prompt, retryNumber) {
4861
+ return [
4862
+ `Agent Bot is automatically retrying this request after a transient LLM service failure (${retryNumber}/${MAX_LLM_TURN_RETRIES}).`,
4863
+ "Continue in the current task and workspace. Inspect the conversation, files, and completed tool effects before acting.",
4864
+ "Do not repeat completed or irreversible actions. Finish the original request:",
4865
+ "",
4866
+ prompt,
4867
+ ].join("\n");
4868
+ }
4869
+ function isRetryableLlmTurnFailure(message) {
4870
+ const normalized = message.trim().toLowerCase();
4871
+ if (!normalized)
4872
+ return false;
4873
+ const permanentFailure = [
4874
+ /auth(?:entication|orization)?\b|unauthorized|forbidden|invalid api key|incorrect api key/u,
4875
+ /insufficient[_ -]?quota|quota (?:exceeded|exhausted)|usage limit|billing|credit balance/u,
4876
+ /context (?:length|window)|maximum context|too many tokens|token limit/u,
4877
+ /invalid request|bad request|invalid argument|malformed|unsupported|not supported/u,
4878
+ /model (?:not found|does not exist)|unknown model/u,
4879
+ /permission|approval|sandbox|policy violation|content policy/u,
4880
+ ].some((pattern) => pattern.test(normalized));
4881
+ if (permanentFailure)
4882
+ return false;
4883
+ return [
4884
+ /(?:^|\D)429(?:\D|$)|rate[_ -]?limit|too many requests/u,
4885
+ /overload|high demand|at capacity|capacity limit|temporar(?:y|ily) unavailable/u,
4886
+ /service unavailable|internal server error|bad gateway|gateway timeout/u,
4887
+ /(?:^|\D)(?:500|502|503|504)(?:\D|$)|upstream (?:error|failure|timeout)/u,
4888
+ /deadline exceeded|request timed out|request timeout|response timeout/u,
4889
+ /connection (?:reset|closed|aborted)|network error|socket hang up/u,
4890
+ /stream (?:closed|disconnected|interrupted|error)/u,
4891
+ /\b(?:llm|model|provider|inference)\b.*\b(?:error|failed|failure|unavailable|timeout|timed out)\b/u,
4892
+ ].some((pattern) => pattern.test(normalized));
4893
+ }
4894
+ function remoteTurnMatchesAttempt(remote, attempt) {
4895
+ if (!remote?.lastTurnId)
4896
+ return false;
4897
+ if (attempt.turnId)
4898
+ return remote.lastTurnId === attempt.turnId;
4899
+ const remoteUpdatedAt = remote.updatedAt === undefined ? undefined : normalizeRemoteTimestamp(remote.updatedAt);
4900
+ const acceptedAt = Date.parse(attempt.createdAt);
4901
+ return remoteUpdatedAt !== undefined
4902
+ && Number.isFinite(acceptedAt)
4903
+ && remoteUpdatedAt >= acceptedAt - 1_000;
4904
+ }
4905
+ function isRecoveryAttemptRecent(attempt, cutoff) {
4906
+ const updatedAt = Date.parse(attempt.updatedAt);
4907
+ return Number.isFinite(updatedAt) && updatedAt >= cutoff;
4908
+ }
3540
4909
  function formatGroupDateSuffix(date) {
3541
4910
  const month = String(date.getMonth() + 1).padStart(2, "0");
3542
4911
  const day = String(date.getDate()).padStart(2, "0");
@@ -3567,12 +4936,21 @@ function formatGroupProjectDirectory(projectCwd) {
3567
4936
  return `[${fitTrailingPathLevels(levels, GROUP_PROJECT_DIRECTORY_MAX_LENGTH, path.sep)}]`;
3568
4937
  }
3569
4938
  function abbreviateHomeDirectory(value) {
3570
- const relative = path.relative(os.homedir(), value);
4939
+ const homeDirectory = os.homedir();
4940
+ const valueIsWindows = isWindowsAbsolutePath(value);
4941
+ const homeIsWindows = isWindowsAbsolutePath(homeDirectory);
4942
+ if (valueIsWindows !== homeIsWindows && (valueIsWindows || path.posix.isAbsolute(value)))
4943
+ return value;
4944
+ const pathApi = valueIsWindows ? path.win32 : path;
4945
+ const relative = pathApi.relative(homeDirectory, value);
3571
4946
  if (relative === "")
3572
4947
  return "~";
3573
- if (path.isAbsolute(relative) || relative === ".." || relative.startsWith(`..${path.sep}`))
4948
+ if (pathApi.isAbsolute(relative) || relative === ".." || relative.startsWith(`..${pathApi.sep}`))
3574
4949
  return value;
3575
- return `~${path.sep}${relative}`;
4950
+ return `~${pathApi.sep}${relative}`;
4951
+ }
4952
+ function isWindowsAbsolutePath(value) {
4953
+ return /^[a-z]:[\\/]|^\\\\/iu.test(value);
3576
4954
  }
3577
4955
  function fitTrailingPathLevels(levels, maxLength, separator) {
3578
4956
  if (levels.length === 0)