@keyou007/agent-bot 0.1.18 → 0.1.19-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +3 -0
  3. package/README.zh.md +3 -0
  4. package/dist/cli/controlProtocol.js.map +1 -1
  5. package/dist/cli/help.js +2 -0
  6. package/dist/cli/help.js.map +1 -1
  7. package/dist/cli.js +20 -0
  8. package/dist/cli.js.map +1 -1
  9. package/dist/codex/CodexProcessManager.js +48 -0
  10. package/dist/codex/CodexProcessManager.js.map +1 -1
  11. package/dist/codex/CodexRuntime.js +43 -5
  12. package/dist/codex/CodexRuntime.js.map +1 -1
  13. package/dist/codex/ThreadWriterProcess.js +344 -0
  14. package/dist/codex/ThreadWriterProcess.js.map +1 -0
  15. package/dist/commands/CommandRouter.js +5 -0
  16. package/dist/commands/CommandRouter.js.map +1 -1
  17. package/dist/console/ConsoleTurnPresenter.js +1 -0
  18. package/dist/console/ConsoleTurnPresenter.js.map +1 -1
  19. package/dist/feishu/CardRenderer.js +166 -0
  20. package/dist/feishu/CardRenderer.js.map +1 -1
  21. package/dist/feishu/FeishuTurnPresenter.js +13 -3
  22. package/dist/feishu/FeishuTurnPresenter.js.map +1 -1
  23. package/dist/feishu/MergedForwardMessage.js +6 -1
  24. package/dist/feishu/MergedForwardMessage.js.map +1 -1
  25. package/dist/index.js +4 -2
  26. package/dist/index.js.map +1 -1
  27. package/dist/logging/errorLogValue.js +18 -0
  28. package/dist/logging/errorLogValue.js.map +1 -0
  29. package/dist/logging/logger.js +4 -0
  30. package/dist/logging/logger.js.map +1 -1
  31. package/dist/presentation/OutboundRouter.js +2 -2
  32. package/dist/presentation/OutboundRouter.js.map +1 -1
  33. package/dist/proxy/ProxySessionController.js +778 -19
  34. package/dist/proxy/ProxySessionController.js.map +1 -1
  35. package/dist/state/StateStore.js +10 -0
  36. package/dist/state/StateStore.js.map +1 -1
  37. package/dist/supervision/SafeRestartScheduler.js +1 -1
  38. package/dist/supervision/SafeRestartScheduler.js.map +1 -1
  39. package/docs/technical-reference.md +1 -1
  40. package/docs/technical-reference.zh.md +1 -1
  41. package/npm-shrinkwrap.json +2 -2
  42. package/package.json +1 -1
  43. package/skills/agent-bot/SKILL.md +3 -0
  44. package/src/cli/controlProtocol.ts +8 -0
  45. package/src/cli/help.ts +2 -0
  46. package/src/cli.ts +33 -0
  47. package/src/codex/CodexProcessManager.ts +43 -0
  48. package/src/codex/CodexRuntime.ts +41 -4
  49. package/src/codex/ThreadWriterProcess.ts +385 -0
  50. package/src/commands/CommandRouter.ts +4 -0
  51. package/src/commands/commandTypes.ts +1 -0
  52. package/src/console/ConsoleTurnPresenter.ts +2 -1
  53. package/src/feishu/CardRenderer.ts +205 -0
  54. package/src/feishu/FeishuTurnPresenter.ts +16 -3
  55. package/src/feishu/MergedForwardMessage.ts +5 -1
  56. package/src/index.ts +4 -2
  57. package/src/logging/errorLogValue.ts +26 -0
  58. package/src/logging/logger.ts +4 -0
  59. package/src/presentation/OutboundRouter.ts +15 -3
  60. package/src/proxy/ProxySessionController.ts +1003 -30
  61. package/src/runtime/types.ts +7 -0
  62. package/src/state/StateStore.ts +11 -0
  63. package/src/supervision/SafeRestartScheduler.ts +1 -1
@@ -4,6 +4,11 @@ import os from "node:os";
4
4
  import path from "node:path";
5
5
  import type { Logger } from "pino";
6
6
  import { createProjectlessWorkspace, detectProjectlessWorkspace } from "../codex/ProjectlessWorkspace.js";
7
+ import {
8
+ SystemThreadWriterProcessController,
9
+ type ThreadWriterProcess,
10
+ type ThreadWriterProcessController,
11
+ } from "../codex/ThreadWriterProcess.js";
7
12
  import { resolveUserPath } from "../config/paths.js";
8
13
  import {
9
14
  DEFAULT_GROUP_NAME_FORMAT,
@@ -21,6 +26,7 @@ import type {
21
26
  ReferencedMessageContent,
22
27
  } from "../feishu/types.js";
23
28
  import {
29
+ type AppServerReleaseCardView,
24
30
  CardRenderer,
25
31
  type CardSection,
26
32
  type DirectoryBrowserCardEntry,
@@ -31,6 +37,7 @@ import {
31
37
  type SessionTaskCardGroup,
32
38
  type ShellCommandCardView,
33
39
  type TaskListCardAction,
40
+ type ThreadWriterConflictCardView,
34
41
  } from "../feishu/CardRenderer.js";
35
42
  import { CardUpdateScheduler } from "../feishu/CardUpdateScheduler.js";
36
43
  import { generateGroupAvatarPng, resolveGroupAvatarProjectName } from "../feishu/GroupAvatarGenerator.js";
@@ -42,6 +49,7 @@ import {
42
49
  import { normalizeFeishuPostText } from "../feishu/InboundText.js";
43
50
  import { allowsFeishuUser } from "../feishu/ownerAccess.js";
44
51
  import { classifyFileContent } from "../local-files/LocalFileViewerServer.js";
52
+ import { errorLogValue } from "../logging/errorLogValue.js";
45
53
  import type { OutboundRouter } from "../presentation/OutboundRouter.js";
46
54
  import type { TurnActivity, TurnViewState } from "../presentation/turnViewTypes.js";
47
55
  import { buildTurnGraphRows } from "../presentation/turnGraph.js";
@@ -52,6 +60,7 @@ import type {
52
60
  PermissionMode,
53
61
  RemoteCompletedTurnSummary,
54
62
  RemoteSessionActivity,
63
+ RemoteSessionPage,
55
64
  RemoteSessionSummary,
56
65
  RuntimeGoal,
57
66
  RuntimeEvent,
@@ -110,12 +119,55 @@ interface ForwardAttachmentReservation {
110
119
  registry: Map<string, PendingForwardAttachment>;
111
120
  }
112
121
 
122
+ interface PreparedTopicRootPrompt {
123
+ text: string;
124
+ localImagePaths?: string[];
125
+ displayPrompt: string;
126
+ injectedRootMessageId?: string;
127
+ }
128
+
113
129
  interface ShellCommandJobMonitor {
114
130
  scheduler: CardUpdateScheduler<ShellCommandCardView>;
115
131
  timer: NodeJS.Timeout;
116
132
  refreshing: boolean;
117
133
  }
118
134
 
135
+ interface AppServerReleaseCardTarget {
136
+ contextKey: string;
137
+ messageId: string;
138
+ }
139
+
140
+ interface AppServerReleaseSchedule {
141
+ id: number;
142
+ agentName: string;
143
+ runtime: AgentRuntime;
144
+ status: AppServerReleaseCardView["status"];
145
+ blockingTaskCount: number;
146
+ targets: Map<string, AppServerReleaseCardTarget>;
147
+ forced: boolean;
148
+ releaseAt?: number;
149
+ releaseCountdownSeconds?: number;
150
+ releaseStarted?: boolean;
151
+ error?: string;
152
+ timer?: NodeJS.Timeout;
153
+ inFlight?: Promise<void>;
154
+ }
155
+
156
+ interface RequestFailureDetails {
157
+ requestId?: string;
158
+ messageId?: string;
159
+ actionId?: string;
160
+ command?: string;
161
+ phase?: string;
162
+ }
163
+
164
+ class PresentedRequestError extends Error {
165
+ constructor(public readonly originalError: unknown) {
166
+ super(originalError instanceof Error ? originalError.message : String(originalError), { cause: originalError });
167
+ this.name = "PresentedRequestError";
168
+ }
169
+ }
170
+
119
171
  const MESSAGE_RECEIVED_REACTION = "OnIt";
120
172
  const MESSAGE_COMPLETED_REACTION = "DONE";
121
173
  const MESSAGE_FAILED_REACTION = "ERROR";
@@ -124,12 +176,15 @@ const SESSION_PAGE_SIZE = 10;
124
176
  const SESSION_PROMPT_PREVIEW_LENGTH = 50;
125
177
  const DIRECTORY_PAGE_SIZE = 15;
126
178
  const RESET_HISTORY_PAGE_SIZE = 10;
179
+ const REMOTE_TURN_HISTORY_REFRESH_INTERVAL_MS = 30_000;
127
180
  const MAX_LLM_TURN_RETRIES = 3;
128
181
  const QUEUED_PROMPT_RETRY_DELAY_MS = 5_000;
129
182
  const RECOVERY_ACTIVITY_WINDOW_MS = 10 * 60 * 1_000;
130
183
  const RECOVERY_HEARTBEAT_INTERVAL_MS = 60 * 1_000;
131
184
  const SHELL_COMMAND_JOB_POLL_INTERVAL_MS = 1_000;
132
185
  const SHELL_COMMAND_JOB_RELAUNCH_DELAY_MS = 5_000;
186
+ const APP_SERVER_RELEASE_POLL_INTERVAL_MS = 500;
187
+ const APP_SERVER_RELEASE_IDLE_DELAY_MS = 5_000;
133
188
  const FORWARD_ATTACHMENT_WINDOW_MS = 800;
134
189
  const DEFAULT_MERGED_FORWARD_INSTRUCTION = "请参考以下内容回复用户";
135
190
  const DEFAULT_REFERENCED_MESSAGE_INSTRUCTION = "请参考引用消息回复用户";
@@ -263,6 +318,10 @@ const HELP_COMMAND_SECTIONS: Array<{
263
318
  usage: "[--force]",
264
319
  description: "默认安全重启;--force 立即重启",
265
320
  },
321
+ {
322
+ command: "/release",
323
+ description: "释放 Agent Bot 占用的 App Server 任务",
324
+ },
266
325
  {
267
326
  command: "/mute",
268
327
  usage: "[on|off]",
@@ -391,6 +450,13 @@ export interface ControlTaskGroupResult {
391
450
  task: SessionRecord;
392
451
  }
393
452
 
453
+ export interface ControlTaskReleaseResult {
454
+ agentName: string;
455
+ status: "waiting" | "released";
456
+ blockingTaskCount: number;
457
+ releaseInSeconds?: number;
458
+ }
459
+
394
460
  export interface ProxyLifecycle {
395
461
  supervised?: boolean;
396
462
  restart(contextKey: string, force: boolean, replyTarget?: MessageReplyTarget): Promise<void>;
@@ -425,6 +491,7 @@ export class ProxySessionController {
425
491
  private readonly queuedPromptCardWrites = new Map<string, Promise<void>>();
426
492
  private readonly sessionResets = new Map<string, Promise<void>>();
427
493
  private readonly resetHistoryOperations = new Map<string, string>();
494
+ private readonly remoteTurnHistoryRefreshes = new Map<string, number>();
428
495
  private readonly lastSessionListings = new Map<string, string[]>();
429
496
  private readonly threadInitializations = new Map<string, Promise<void>>();
430
497
  private readonly llmRetryingSessions = new Set<string>();
@@ -435,6 +502,8 @@ export class ProxySessionController {
435
502
  private readonly pendingResourceForwards = new Map<string, PendingForwardAttachment>();
436
503
  private readonly relatedReactionMessageIds = new Map<string, string[]>();
437
504
  private readonly shellCommandJobMonitors = new Map<string, ShellCommandJobMonitor>();
505
+ private readonly appServerReleaseSchedules = new Map<string, AppServerReleaseSchedule>();
506
+ private nextAppServerReleaseScheduleId = 1;
438
507
  private readonly unsubscribe: Array<() => void> = [];
439
508
  private readonly recoveryActivityHeartbeat: NodeJS.Timeout;
440
509
  private startupRecovery?: Promise<void>;
@@ -452,6 +521,7 @@ export class ProxySessionController {
452
521
  private readonly shellCommandJobs: ShellCommandJobManagerLike = new ShellCommandJobManager(
453
522
  path.join(path.dirname(config.storage.sqlitePath), "command-jobs"),
454
523
  ),
524
+ private readonly threadWriterProcesses: ThreadWriterProcessController = new SystemThreadWriterProcessController(),
455
525
  ) {
456
526
  for (const [, runtime] of this.runtimes.entries()) {
457
527
  this.unsubscribe.push(
@@ -608,7 +678,11 @@ export class ProxySessionController {
608
678
  await this.outbound.withReplyTarget(
609
679
  message.contextKey,
610
680
  replyTarget,
611
- () => this.sendError(message.contextKey, new Error(`无法读取合并转发消息:${detail}`)),
681
+ () => this.sendError(message.contextKey, new Error(`无法读取合并转发消息:${detail}`), {
682
+ requestId: message.messageId,
683
+ messageId: message.messageId,
684
+ phase: "merged_forward_read",
685
+ }),
612
686
  );
613
687
  return;
614
688
  }
@@ -643,7 +717,11 @@ export class ProxySessionController {
643
717
  await this.outbound.withReplyTarget(
644
718
  message.contextKey,
645
719
  replyTarget,
646
- () => this.sendError(message.contextKey, new Error(`无法读取引用消息:${detail}`)),
720
+ () => this.sendError(message.contextKey, new Error(`无法读取引用消息:${detail}`), {
721
+ requestId: message.messageId,
722
+ messageId: message.messageId,
723
+ phase: "referenced_message_read",
724
+ }),
647
725
  );
648
726
  return;
649
727
  }
@@ -681,7 +759,11 @@ export class ProxySessionController {
681
759
  await this.outbound.withReplyTarget(
682
760
  message.contextKey,
683
761
  replyTarget,
684
- () => this.sendError(message.contextKey, error),
762
+ () => this.sendError(message.contextKey, error, {
763
+ requestId: message.messageId,
764
+ messageId: message.messageId,
765
+ phase: "image_download",
766
+ }),
685
767
  );
686
768
  return;
687
769
  }
@@ -708,7 +790,11 @@ export class ProxySessionController {
708
790
  await this.outbound.withReplyTarget(
709
791
  message.contextKey,
710
792
  replyTarget,
711
- () => this.sendError(message.contextKey, error),
793
+ () => this.sendError(message.contextKey, error, {
794
+ requestId: message.messageId,
795
+ messageId: message.messageId,
796
+ phase: "file_download",
797
+ }),
712
798
  );
713
799
  return;
714
800
  }
@@ -724,10 +810,25 @@ export class ProxySessionController {
724
810
  await this.outbound.withReplyTarget(
725
811
  message.contextKey,
726
812
  replyTarget,
727
- () => this.sendError(message.contextKey, error),
813
+ () => this.sendError(message.contextKey, error, {
814
+ requestId: message.messageId,
815
+ messageId: message.messageId,
816
+ phase: "command_parse",
817
+ }),
728
818
  );
729
819
  return;
730
820
  }
821
+ if (command.type !== "prompt") {
822
+ this.logger.info(
823
+ {
824
+ requestId: message.messageId,
825
+ messageId: message.messageId,
826
+ contextKey: message.contextKey,
827
+ command: command.type,
828
+ },
829
+ "Processing Agent Bot command.",
830
+ );
831
+ }
731
832
  // Operational and read-only commands must remain available even if a prompt operation is slow.
732
833
  if (isQueueIndependentCommand(command)) {
733
834
  await this.outbound.withReplyTarget(message.contextKey, replyTarget, async () => {
@@ -747,7 +848,12 @@ export class ProxySessionController {
747
848
  }
748
849
  } catch (error) {
749
850
  await this.finalizeStandaloneMessageReaction(message.messageId, "failed");
750
- await this.sendError(message.contextKey, error);
851
+ await this.sendError(message.contextKey, error, {
852
+ requestId: message.messageId,
853
+ messageId: message.messageId,
854
+ command: command.type,
855
+ phase: "command_execute",
856
+ });
751
857
  }
752
858
  });
753
859
  return;
@@ -757,22 +863,48 @@ export class ProxySessionController {
757
863
  const next = previous.catch(() => undefined).then(() =>
758
864
  this.outbound.withReplyTarget(message.contextKey, replyTarget, async () => {
759
865
  try {
760
- if (command.type === "prompt") await this.ensureThreadFork(message);
866
+ let executableCommand = command;
867
+ let executableImagePaths = localImagePaths;
868
+ let executableMessage = message;
869
+ let injectedRootMessageId: string | undefined;
870
+ if (command.type === "prompt") {
871
+ await this.ensureThreadFork(message);
872
+ const prepared = await this.prepareTopicRootPrompt(message, command.text, localImagePaths);
873
+ executableCommand = { ...command, text: prepared.text };
874
+ executableImagePaths = prepared.localImagePaths;
875
+ executableMessage = { ...message, displayText: prepared.displayPrompt };
876
+ injectedRootMessageId = prepared.injectedRootMessageId;
877
+ }
761
878
  await this.execute(
762
879
  message.contextKey,
763
- command,
880
+ executableCommand,
764
881
  message.messageId,
765
882
  replyTarget,
766
- localImagePaths,
883
+ executableImagePaths,
767
884
  message.userId,
768
- message,
885
+ executableMessage,
769
886
  );
887
+ if (injectedRootMessageId) {
888
+ const current = this.currentSession(message.contextKey);
889
+ if (current) {
890
+ this.store.audit(message.contextKey, "topic_root_injected", {
891
+ localSessionId: current.localSessionId,
892
+ rootMessageId: injectedRootMessageId,
893
+ promptMessageId: message.messageId,
894
+ });
895
+ }
896
+ }
770
897
  if (!commandDefersReactionFinalization(command)) {
771
898
  await this.finalizeStandaloneMessageReaction(message.messageId, "completed");
772
899
  }
773
900
  } catch (error) {
774
901
  await this.finalizeStandaloneMessageReaction(message.messageId, "failed");
775
- await this.sendError(message.contextKey, error);
902
+ await this.sendError(message.contextKey, error, {
903
+ requestId: message.messageId,
904
+ messageId: message.messageId,
905
+ command: command.type,
906
+ phase: "command_execute",
907
+ });
776
908
  }
777
909
  }),
778
910
  );
@@ -794,7 +926,11 @@ export class ProxySessionController {
794
926
  try {
795
927
  resolvedAction = this.resolveCardActionBinding(action);
796
928
  } catch (error) {
797
- await this.sendError(action.contextKey, error);
929
+ await this.sendError(action.contextKey, error, {
930
+ requestId: action.actionId,
931
+ actionId: action.actionId,
932
+ phase: "card_action_resolve",
933
+ });
798
934
  return;
799
935
  }
800
936
  const contextKey = this.cardActionContextKey(resolvedAction);
@@ -829,6 +965,20 @@ export class ProxySessionController {
829
965
  contextKey,
830
966
  String(scopedAction.value.jobId ?? ""),
831
967
  );
968
+ } else if (kind === "thread_writer_close") {
969
+ await this.closeThreadWriter(scopedAction, contextKey);
970
+ } else if (kind === "thread_writer_fork") {
971
+ const sessionId = String(scopedAction.value.sessionId ?? "");
972
+ const threadId = String(scopedAction.value.threadId ?? "");
973
+ const record = this.requireSession(contextKey, sessionId);
974
+ if (!threadId || record.remoteSessionId !== threadId) {
975
+ throw new Error("任务占用卡片已经失效,请重新发送消息获取最新状态。");
976
+ }
977
+ await this.forkSessionReference(contextKey, sessionId);
978
+ } else if (kind === "app_server_release_now") {
979
+ await this.releaseAppServerNow(scopedAction);
980
+ } else if (kind === "app_server_release_cancel") {
981
+ await this.cancelAppServerRelease(scopedAction);
832
982
  } else if (kind === "turn_reset") {
833
983
  const sessionId = String(scopedAction.value.sessionId ?? "");
834
984
  const turnId = String(scopedAction.value.turnId ?? "");
@@ -1069,7 +1219,12 @@ export class ProxySessionController {
1069
1219
  await this.resolveApproval(scopedAction);
1070
1220
  }
1071
1221
  } catch (error) {
1072
- await this.sendError(contextKey, error);
1222
+ await this.sendError(contextKey, error, {
1223
+ requestId: scopedAction.actionId,
1224
+ actionId: scopedAction.actionId,
1225
+ command: String(scopedAction.value.action ?? "unknown"),
1226
+ phase: "card_action_execute",
1227
+ });
1073
1228
  }
1074
1229
  });
1075
1230
  }
@@ -1094,6 +1249,7 @@ export class ProxySessionController {
1094
1249
  this.queuedPromptCardWrites.clear();
1095
1250
  this.sessionResets.clear();
1096
1251
  this.resetHistoryOperations.clear();
1252
+ this.remoteTurnHistoryRefreshes.clear();
1097
1253
  for (const pending of this.pendingMergedForwards.values()) pending.resolveAttachment(undefined);
1098
1254
  this.pendingMergedForwards.clear();
1099
1255
  for (const pending of this.pendingResourceForwards.values()) pending.resolveAttachment(undefined);
@@ -1103,6 +1259,10 @@ export class ProxySessionController {
1103
1259
  this.queuedPromptRetryTimers.clear();
1104
1260
  for (const timer of this.recoveryRetryTimers.values()) clearTimeout(timer);
1105
1261
  this.recoveryRetryTimers.clear();
1262
+ for (const schedule of this.appServerReleaseSchedules.values()) {
1263
+ if (schedule.timer) clearTimeout(schedule.timer);
1264
+ }
1265
+ this.appServerReleaseSchedules.clear();
1106
1266
  for (const [jobId] of this.shellCommandJobMonitors) this.stopShellCommandJobMonitor(jobId);
1107
1267
  clearInterval(this.recoveryActivityHeartbeat);
1108
1268
  }
@@ -1114,6 +1274,23 @@ export class ProxySessionController {
1114
1274
  return `Task stop requested: ${record.title ?? record.remoteSessionId ?? record.localSessionId}`;
1115
1275
  }
1116
1276
 
1277
+ async controlReleaseTask(localSessionId: string): Promise<ControlTaskReleaseResult> {
1278
+ const record = this.requireControlSession(localSessionId);
1279
+ const contextKey = this.controlSessionContextKey(record);
1280
+ const result = await this.outbound.withReplyTarget(
1281
+ contextKey,
1282
+ this.controlSessionReplyTarget(record),
1283
+ () => this.requestAppServerRelease(contextKey, record),
1284
+ );
1285
+ if (result.status === "failed") throw new Error(result.error ?? "Failed to release App Server tasks.");
1286
+ return {
1287
+ agentName: result.agentName,
1288
+ status: result.status,
1289
+ blockingTaskCount: result.blockingTaskCount,
1290
+ ...(result.releaseInSeconds !== undefined ? { releaseInSeconds: result.releaseInSeconds } : {}),
1291
+ };
1292
+ }
1293
+
1117
1294
  async controlArchiveTask(localSessionId: string): Promise<{
1118
1295
  localSessionId: string;
1119
1296
  remoteSessionId: string;
@@ -1852,6 +2029,9 @@ export class ProxySessionController {
1852
2029
  if (!this.lifecycle) throw new Error("当前运行方式不支持自动重启。");
1853
2030
  await this.lifecycle.restart(contextKey, command.force === true, replyTarget);
1854
2031
  return;
2032
+ case "release":
2033
+ await this.requestAppServerRelease(contextKey);
2034
+ return;
1855
2035
  case "mute":
1856
2036
  await this.setGroupMute(contextKey, command.enabled);
1857
2037
  return;
@@ -2401,6 +2581,9 @@ export class ProxySessionController {
2401
2581
  try {
2402
2582
  loaded = await this.loadSession(record);
2403
2583
  } catch (error) {
2584
+ if (await this.presentThreadWriterConflict(record, contextKey, error, true)) {
2585
+ throw new PresentedRequestError(error);
2586
+ }
2404
2587
  await this.outbound.failPendingTurn(record.localSessionId, error instanceof Error ? error.message : String(error));
2405
2588
  throw error;
2406
2589
  }
@@ -2654,6 +2837,61 @@ export class ProxySessionController {
2654
2837
  }
2655
2838
  }
2656
2839
 
2840
+ private async prepareTopicRootPrompt(
2841
+ message: IncomingMessage,
2842
+ text: string,
2843
+ localImagePaths?: string[],
2844
+ ): Promise<PreparedTopicRootPrompt> {
2845
+ const displayPrompt = message.displayText ?? text;
2846
+ const rootMessageId = topicRootMessageId(message);
2847
+ if (!rootMessageId) return { text, localImagePaths, displayPrompt };
2848
+
2849
+ const current = this.currentSession(message.contextKey);
2850
+ if (current && (
2851
+ this.store.hasInjectedTopicRoot(current.localSessionId, rootMessageId)
2852
+ || this.taskHistoryContainsMessage(current, rootMessageId)
2853
+ )) {
2854
+ return { text, localImagePaths, displayPrompt };
2855
+ }
2856
+
2857
+ try {
2858
+ const root = await this.resolveReferencedMessage(message.contextKey, rootMessageId);
2859
+ const rootImagePaths = await Promise.all(root.images.map((image) =>
2860
+ this.outbound.downloadImage(message.contextKey, image.messageId, image.imageKey)));
2861
+ let promptText = topicRootMessagePrompt(text, root.text);
2862
+ if (root.files.length > 0) {
2863
+ const downloadedFiles = await Promise.all(root.files.map(async (file) => ({
2864
+ fileName: file.fileName,
2865
+ filePath: await this.outbound.downloadFile(
2866
+ message.contextKey,
2867
+ file.messageId,
2868
+ file.fileKey,
2869
+ file.fileName,
2870
+ ),
2871
+ })));
2872
+ promptText = appendDownloadedFiles(promptText, downloadedFiles);
2873
+ }
2874
+ const combinedImagePaths = [...new Set([...(localImagePaths ?? []), ...rootImagePaths])];
2875
+ return {
2876
+ text: promptText,
2877
+ ...(combinedImagePaths.length > 0 ? { localImagePaths: combinedImagePaths } : {}),
2878
+ displayPrompt,
2879
+ injectedRootMessageId: rootMessageId,
2880
+ };
2881
+ } catch (error) {
2882
+ const detail = error instanceof Error ? error.message : String(error);
2883
+ throw new Error(`无法读取话题根消息:${detail}`);
2884
+ }
2885
+ }
2886
+
2887
+ private taskHistoryContainsMessage(record: SessionRecord, messageId: string): boolean {
2888
+ const anchor = this.store.findTurnAnchorByMessageId(messageId);
2889
+ if (!anchor) return false;
2890
+ if (record.lastTurnId === anchor.turnId) return true;
2891
+ return this.store.listTaskTurnGraph(record.localSessionId)
2892
+ .some((turn) => turn.turnId === anchor.turnId);
2893
+ }
2894
+
2657
2895
  private async forkThreadSession(message: IncomingMessage): Promise<void> {
2658
2896
  const { anchor, source, snapshot } = this.resolveThreadForkAnchor(message);
2659
2897
  const agent = this.ensureAgent(source.agentName);
@@ -3249,6 +3487,39 @@ export class ProxySessionController {
3249
3487
  }
3250
3488
  }
3251
3489
 
3490
+ private async hydrateRemoteTurnHistory(
3491
+ contextKey: string,
3492
+ current: SessionRecord,
3493
+ ): Promise<void> {
3494
+ if (!current.remoteSessionId) return;
3495
+ const refreshedAt = this.remoteTurnHistoryRefreshes.get(current.localSessionId);
3496
+ if (refreshedAt !== undefined && Date.now() - refreshedAt < REMOTE_TURN_HISTORY_REFRESH_INTERVAL_MS) return;
3497
+ const forkSource = this.store.getForkHistorySource(current.localSessionId);
3498
+ if (forkSource?.sourceTurnId === current.lastTurnId
3499
+ && this.store.hasImportedForkTurnHistory(current.localSessionId)) return;
3500
+ const runtime = this.runtimes.forAgent(current.agentName);
3501
+ if (runtime.kind !== "codex" || !runtime.readRemoteSession) return;
3502
+ try {
3503
+ const remote = await runtime.readRemoteSession(current.remoteSessionId);
3504
+ this.remoteTurnHistoryRefreshes.set(current.localSessionId, Date.now());
3505
+ const completedTurns = remote.completedTurns ?? [];
3506
+ if (completedTurns.length === 0) return;
3507
+ this.persistCompletedTurnHistory({
3508
+ localSessionId: current.localSessionId,
3509
+ contextKey,
3510
+ agentName: current.agentName,
3511
+ remoteSessionId: current.remoteSessionId,
3512
+ completedTurns,
3513
+ fallbackStart: parseIsoTimestamp(current.createdAt) ?? Date.now() - completedTurns.length,
3514
+ });
3515
+ } catch (error) {
3516
+ this.logger.warn(
3517
+ { error, localSessionId: current.localSessionId, remoteSessionId: current.remoteSessionId },
3518
+ "Failed to hydrate App Server Turn history; rendering the locally available history.",
3519
+ );
3520
+ }
3521
+ }
3522
+
3252
3523
  private async createProjectSessionFromReference(contextKey: string, reference: string): Promise<void> {
3253
3524
  const resolved = await this.resolveProjectSessionReference(contextKey, reference);
3254
3525
  const created = await this.createSession(
@@ -3809,6 +4080,7 @@ export class ProxySessionController {
3809
4080
  lastTurnStatus: record.lastTurnStatus,
3810
4081
  });
3811
4082
  } catch (error) {
4083
+ if (activeWriterThreadId(error)) throw error;
3812
4084
  if (!(agent.kind === "app-server" && !record.lastTurnId && isMissingRolloutError(error))) throw error;
3813
4085
  this.logger.warn({ error, sessionId: record.localSessionId }, "App Server task has no rollout; creating a replacement task.");
3814
4086
  session = await runtime.createSession({
@@ -5330,6 +5602,7 @@ export class ProxySessionController {
5330
5602
  throw new Error("当前任务不是可 Reset 的 App Server 任务。");
5331
5603
  }
5332
5604
  await this.hydrateLegacyForkTurnHistory(contextKey, current);
5605
+ await this.hydrateRemoteTurnHistory(contextKey, current);
5333
5606
  const completedTurns = this.store.listTaskTurnGraph(current.localSessionId);
5334
5607
  const completedTurnIds = new Set(completedTurns.map((turn) => turn.turnId));
5335
5608
  const runningTurnId = current.lastTurnStatus === "running" && current.lastTurnId
@@ -5664,7 +5937,7 @@ export class ProxySessionController {
5664
5937
  await Promise.all(this.runtimes.entries("codex").map(async ([agentName, runtime]) => {
5665
5938
  if (!runtime.listRemoteSessions) return;
5666
5939
  try {
5667
- const result = await runtime.listRemoteSessions({ searchTerm, limit: fetchLimit });
5940
+ const result = await this.listRemoteSessionsThroughCursor(runtime, searchTerm, fetchLimit);
5668
5941
  remoteSessions.push(...result.sessions.map((session) => ({ agentName, session })));
5669
5942
  remoteHasMore ||= Boolean(result.nextCursor);
5670
5943
  } catch (error) {
@@ -5876,6 +6149,42 @@ export class ProxySessionController {
5876
6149
  }
5877
6150
  }
5878
6151
 
6152
+ private async listRemoteSessionsThroughCursor(
6153
+ runtime: AgentRuntime,
6154
+ searchTerm: string | undefined,
6155
+ limit: number,
6156
+ ): Promise<RemoteSessionPage> {
6157
+ const sessions: RemoteSessionSummary[] = [];
6158
+ const seenSessionIds = new Set<string>();
6159
+ const seenCursors = new Set<string>();
6160
+ let cursor: string | undefined;
6161
+
6162
+ while (sessions.length < limit) {
6163
+ const page = await runtime.listRemoteSessions!({
6164
+ searchTerm,
6165
+ ...(cursor ? { cursor } : {}),
6166
+ limit: limit - sessions.length,
6167
+ });
6168
+ for (const session of page.sessions) {
6169
+ if (seenSessionIds.has(session.id)) continue;
6170
+ seenSessionIds.add(session.id);
6171
+ sessions.push(session);
6172
+ }
6173
+
6174
+ const nextCursor = page.nextCursor?.trim() || undefined;
6175
+ if (!nextCursor) return { sessions };
6176
+ if (sessions.length >= limit) return { sessions, nextCursor };
6177
+ if (seenCursors.has(nextCursor)) {
6178
+ this.logger.warn({ cursor: nextCursor }, "App Server repeated a thread/list cursor; stopping pagination.");
6179
+ return { sessions };
6180
+ }
6181
+ seenCursors.add(nextCursor);
6182
+ cursor = nextCursor;
6183
+ }
6184
+
6185
+ return { sessions };
6186
+ }
6187
+
5879
6188
  private async refreshSessionsCardFromAction(
5880
6189
  action: CardAction,
5881
6190
  options: { forceSwitchTaskId?: string; page?: number } = {},
@@ -6509,18 +6818,34 @@ export class ProxySessionController {
6509
6818
  description: command.description,
6510
6819
  })),
6511
6820
  }));
6512
- await this.outbound.sendInteractiveCard(contextKey, this.cardRenderer.renderHelpCard(
6513
- "Agent Bot 使用帮助",
6514
- [
6515
- "直接发送消息即可继续当前任务;执行中发送的新消息会追加到本次任务。",
6516
- "点击命令按钮可执行默认形式;有必填参数的命令需要手动发送。",
6517
- "> 命令前缀示例:/sess 等同于 /sessions。",
6518
- "> 命令缩写示例:/fg 等同于 /forkgroup。",
6519
- "前缀或缩写匹配多个命令时,需要输入更长的形式。",
6520
- "**[参数]** 可选 **&#60;参数&#62;** 必填",
6521
- ],
6522
- sections,
6523
- ));
6821
+ const introLines = [
6822
+ "直接发送消息即可继续当前任务;执行中发送的新消息会追加到本次任务。",
6823
+ "点击命令按钮可执行默认形式;有必填参数的命令需要手动发送。",
6824
+ "> 命令前缀示例:/sess 等同于 /sessions。",
6825
+ "> 命令缩写示例:/fg 等同于 /forkgroup。",
6826
+ "前缀或缩写匹配多个命令时,需要输入更长的形式。",
6827
+ "**[参数]** 可选 **&#60;参数&#62;** 必填",
6828
+ ];
6829
+ try {
6830
+ await this.outbound.sendInteractiveCard(contextKey, this.cardRenderer.renderHelpCard(
6831
+ "Agent Bot 使用帮助",
6832
+ introLines,
6833
+ sections,
6834
+ ));
6835
+ } catch (cardError) {
6836
+ this.logger.warn(
6837
+ { error: cardError, contextKey, command: "help", phase: "help_card_send" },
6838
+ "Failed to send the help card; falling back to plain text.",
6839
+ );
6840
+ try {
6841
+ await this.outbound.sendText(contextKey, renderPlainHelp(introLines, sections));
6842
+ } catch (textError) {
6843
+ throw new AggregateError(
6844
+ [cardError, textError],
6845
+ "帮助卡片和纯文本帮助均发送失败。",
6846
+ );
6847
+ }
6848
+ }
6524
6849
  }
6525
6850
 
6526
6851
  private async setGroupMute(contextKey: string, enabled: boolean): Promise<void> {
@@ -6636,10 +6961,647 @@ export class ProxySessionController {
6636
6961
  return record;
6637
6962
  }
6638
6963
 
6639
- private async sendError(contextKey: string, error: unknown): Promise<void> {
6640
- this.logger.warn({ error, contextKey }, "Request failed.");
6641
- await this.outbound.sendText(contextKey, error instanceof Error ? error.message : String(error));
6964
+ private async requestAppServerRelease(
6965
+ contextKey: string,
6966
+ requestedRecord?: SessionRecord,
6967
+ ): Promise<{
6968
+ agentName: string;
6969
+ status: "waiting" | "released" | "failed";
6970
+ blockingTaskCount: number;
6971
+ releaseInSeconds?: number;
6972
+ error?: string;
6973
+ }> {
6974
+ const record = requestedRecord ?? this.requireCurrentSession(contextKey);
6975
+ const runtime = this.runtimes.forAgent(record.agentName);
6976
+ if (!this.isCodexSession(record) || runtime.kind !== "codex" || !runtime.release) {
6977
+ throw new Error("当前 Agent 不使用可释放的 App Server。");
6978
+ }
6979
+
6980
+ let schedule = this.appServerReleaseSchedules.get(record.agentName);
6981
+ const created = !schedule;
6982
+ if (!schedule) {
6983
+ const blockingTaskCount = this.appServerReleaseBlockers(record.agentName, runtime).length;
6984
+ schedule = {
6985
+ id: this.nextAppServerReleaseScheduleId++,
6986
+ agentName: record.agentName,
6987
+ runtime,
6988
+ status: "waiting",
6989
+ blockingTaskCount,
6990
+ targets: new Map(),
6991
+ forced: false,
6992
+ ...(blockingTaskCount === 0
6993
+ ? {
6994
+ releaseCountdownSeconds: Math.ceil(APP_SERVER_RELEASE_IDLE_DELAY_MS / 1_000),
6995
+ }
6996
+ : {}),
6997
+ };
6998
+ this.appServerReleaseSchedules.set(record.agentName, schedule);
6999
+ }
7000
+
7001
+ try {
7002
+ const messageId = await this.outbound.sendInteractiveCard(
7003
+ contextKey,
7004
+ this.cardRenderer.renderAppServerRelease(this.appServerReleaseCardView(schedule, contextKey)),
7005
+ );
7006
+ if (messageId) {
7007
+ schedule.targets.set(`${contextKey}\u0000${messageId}`, { contextKey, messageId });
7008
+ }
7009
+ if (
7010
+ schedule.status === "waiting"
7011
+ && schedule.blockingTaskCount === 0
7012
+ && schedule.releaseCountdownSeconds !== undefined
7013
+ && schedule.releaseAt === undefined
7014
+ ) {
7015
+ schedule.releaseAt = Date.now() + APP_SERVER_RELEASE_IDLE_DELAY_MS;
7016
+ }
7017
+ } catch (error) {
7018
+ if (created && schedule.targets.size === 0) {
7019
+ this.clearAppServerReleaseSchedule(schedule);
7020
+ }
7021
+ throw error;
7022
+ }
7023
+
7024
+ this.armAppServerReleaseCheck(schedule);
7025
+ return {
7026
+ agentName: schedule.agentName,
7027
+ status: schedule.status === "failed" ? "failed" : schedule.status === "released" ? "released" : "waiting",
7028
+ blockingTaskCount: schedule.blockingTaskCount,
7029
+ ...(schedule.releaseCountdownSeconds !== undefined
7030
+ ? { releaseInSeconds: schedule.releaseCountdownSeconds }
7031
+ : {}),
7032
+ ...(schedule.error ? { error: schedule.error } : {}),
7033
+ };
7034
+ }
7035
+
7036
+ private async releaseAppServerNow(action: CardAction): Promise<void> {
7037
+ const scheduleId = Number(action.value.scheduleId);
7038
+ const agentName = typeof action.value.agentName === "string" ? action.value.agentName : "";
7039
+ const schedule = this.appServerReleaseSchedules.get(agentName);
7040
+ if (!Number.isSafeInteger(scheduleId) || !schedule || schedule.id !== scheduleId || schedule.status !== "waiting") {
7041
+ throw new Error("释放卡片已失效,请重新发送 /release。");
7042
+ }
7043
+ await this.performAppServerRelease(schedule, true);
7044
+ }
7045
+
7046
+ private async cancelAppServerRelease(action: CardAction): Promise<void> {
7047
+ const scheduleId = Number(action.value.scheduleId);
7048
+ const agentName = typeof action.value.agentName === "string" ? action.value.agentName : "";
7049
+ const schedule = this.appServerReleaseSchedules.get(agentName);
7050
+ const cancellable = schedule?.status === "waiting"
7051
+ || (schedule?.status === "releasing" && schedule.releaseStarted !== true);
7052
+ if (!Number.isSafeInteger(scheduleId) || !schedule || schedule.id !== scheduleId || !cancellable) {
7053
+ throw new Error("释放卡片已失效,请重新发送 /release。");
7054
+ }
7055
+ const pendingRelease = schedule.inFlight;
7056
+ schedule.status = "cancelled";
7057
+ if (schedule.timer) {
7058
+ clearTimeout(schedule.timer);
7059
+ schedule.timer = undefined;
7060
+ }
7061
+ if (pendingRelease) await pendingRelease;
7062
+ await this.publishAppServerReleaseSchedule(schedule);
7063
+ this.clearAppServerReleaseSchedule(schedule);
7064
+ }
7065
+
7066
+ private appServerReleaseBlockers(agentName: string, runtime: AgentRuntime): SessionRecord[] {
7067
+ return this.store.listAllSessions().filter((session) => {
7068
+ if (session.agentName !== agentName || session.status === "closed") return false;
7069
+ return session.status === "starting"
7070
+ || session.status === "running"
7071
+ || this.store.countQueuedPrompts(session.localSessionId) > 0
7072
+ || this.queuedPromptStarts.has(session.localSessionId)
7073
+ || this.sessionLoads.has(session.localSessionId)
7074
+ || Boolean(runtime.getSession(session.localSessionId)?.activeTurnId);
7075
+ });
7076
+ }
7077
+
7078
+ private armAppServerReleaseCheck(schedule: AppServerReleaseSchedule): void {
7079
+ if (schedule.status !== "waiting" || schedule.timer || schedule.inFlight) return;
7080
+ schedule.timer = setTimeout(() => {
7081
+ schedule.timer = undefined;
7082
+ void this.refreshAppServerReleaseSchedule(schedule).catch((error: unknown) => {
7083
+ this.logger.warn({ error, agentName: schedule.agentName }, "Failed to refresh App Server release status.");
7084
+ this.armAppServerReleaseCheck(schedule);
7085
+ });
7086
+ }, APP_SERVER_RELEASE_POLL_INTERVAL_MS);
7087
+ schedule.timer.unref?.();
7088
+ }
7089
+
7090
+ private async refreshAppServerReleaseSchedule(schedule: AppServerReleaseSchedule): Promise<void> {
7091
+ if (this.appServerReleaseSchedules.get(schedule.agentName) !== schedule || schedule.status !== "waiting") return;
7092
+ const blockingTaskCount = this.appServerReleaseBlockers(schedule.agentName, schedule.runtime).length;
7093
+ if (blockingTaskCount > 0) {
7094
+ const changed = schedule.blockingTaskCount !== blockingTaskCount || schedule.releaseAt !== undefined;
7095
+ schedule.releaseAt = undefined;
7096
+ schedule.releaseCountdownSeconds = undefined;
7097
+ if (changed) {
7098
+ schedule.blockingTaskCount = blockingTaskCount;
7099
+ await this.publishAppServerReleaseSchedule(schedule);
7100
+ }
7101
+ this.armAppServerReleaseCheck(schedule);
7102
+ return;
7103
+ }
7104
+ schedule.blockingTaskCount = 0;
7105
+ if (schedule.releaseAt !== undefined) {
7106
+ const releaseCountdownSeconds = Math.max(0, Math.ceil((schedule.releaseAt - Date.now()) / 1_000));
7107
+ if (releaseCountdownSeconds > 0) {
7108
+ if (schedule.releaseCountdownSeconds !== releaseCountdownSeconds) {
7109
+ schedule.releaseCountdownSeconds = releaseCountdownSeconds;
7110
+ await this.publishAppServerReleaseSchedule(schedule);
7111
+ }
7112
+ this.armAppServerReleaseCheck(schedule);
7113
+ return;
7114
+ }
7115
+ }
7116
+ await this.performAppServerRelease(schedule, false);
7117
+ }
7118
+
7119
+ private async performAppServerRelease(schedule: AppServerReleaseSchedule, force: boolean): Promise<void> {
7120
+ if (schedule.inFlight) return schedule.inFlight;
7121
+ const operation = this.performAppServerReleaseNow(schedule, force);
7122
+ schedule.inFlight = operation;
7123
+ try {
7124
+ await operation;
7125
+ } finally {
7126
+ if (schedule.inFlight === operation) schedule.inFlight = undefined;
7127
+ if (schedule.status === "waiting") this.armAppServerReleaseCheck(schedule);
7128
+ }
7129
+ }
7130
+
7131
+ private async performAppServerReleaseNow(schedule: AppServerReleaseSchedule, force: boolean): Promise<void> {
7132
+ if (schedule.timer) {
7133
+ clearTimeout(schedule.timer);
7134
+ schedule.timer = undefined;
7135
+ }
7136
+ const interruptedWork = force && this.appServerReleaseBlockers(schedule.agentName, schedule.runtime).length > 0;
7137
+ if (force) await this.cancelQueuedPromptsForAppServerRelease(schedule.agentName);
7138
+ else {
7139
+ const blockingTaskCount = this.appServerReleaseBlockers(schedule.agentName, schedule.runtime).length;
7140
+ if (blockingTaskCount > 0) {
7141
+ schedule.status = "waiting";
7142
+ schedule.blockingTaskCount = blockingTaskCount;
7143
+ schedule.releaseAt = undefined;
7144
+ schedule.releaseCountdownSeconds = undefined;
7145
+ schedule.releaseStarted = false;
7146
+ await this.publishAppServerReleaseSchedule(schedule);
7147
+ this.armAppServerReleaseCheck(schedule);
7148
+ return;
7149
+ }
7150
+ }
7151
+
7152
+ schedule.status = "releasing";
7153
+ schedule.blockingTaskCount = 0;
7154
+ schedule.releaseAt = undefined;
7155
+ schedule.releaseCountdownSeconds = undefined;
7156
+ schedule.releaseStarted = false;
7157
+ schedule.forced = interruptedWork;
7158
+ await this.publishAppServerReleaseSchedule(schedule);
7159
+ if (
7160
+ this.appServerReleaseWasCancelled(schedule)
7161
+ || this.appServerReleaseSchedules.get(schedule.agentName) !== schedule
7162
+ ) return;
7163
+ schedule.releaseStarted = true;
7164
+ try {
7165
+ const result = await schedule.runtime.release?.({ force });
7166
+ if (!result) throw new Error("当前 Agent 不支持释放 App Server。");
7167
+ if (result.status === "busy") {
7168
+ schedule.status = "waiting";
7169
+ schedule.blockingTaskCount = Math.max(
7170
+ result.activeSessionIds?.length ?? 0,
7171
+ this.appServerReleaseBlockers(schedule.agentName, schedule.runtime).length,
7172
+ 1,
7173
+ );
7174
+ schedule.forced = false;
7175
+ schedule.releaseAt = undefined;
7176
+ schedule.releaseCountdownSeconds = undefined;
7177
+ schedule.releaseStarted = false;
7178
+ await this.publishAppServerReleaseSchedule(schedule);
7179
+ this.armAppServerReleaseCheck(schedule);
7180
+ return;
7181
+ }
7182
+ schedule.status = "released";
7183
+ } catch (error) {
7184
+ schedule.status = "failed";
7185
+ schedule.error = error instanceof Error ? error.message : String(error);
7186
+ this.logger.warn({ error, agentName: schedule.agentName }, "Failed to release App Server tasks.");
7187
+ }
7188
+ await this.publishAppServerReleaseSchedule(schedule);
7189
+ this.clearAppServerReleaseSchedule(schedule);
7190
+ }
7191
+
7192
+ private appServerReleaseWasCancelled(schedule: AppServerReleaseSchedule): boolean {
7193
+ return schedule.status === "cancelled";
7194
+ }
7195
+
7196
+ private async cancelQueuedPromptsForAppServerRelease(agentName: string): Promise<void> {
7197
+ for (const session of this.store.listAllSessions()) {
7198
+ if (session.agentName !== agentName) continue;
7199
+ const retryTimer = this.queuedPromptRetryTimers.get(session.localSessionId);
7200
+ if (retryTimer) {
7201
+ clearTimeout(retryTimer);
7202
+ this.queuedPromptRetryTimers.delete(session.localSessionId);
7203
+ }
7204
+ const queued = this.store.listQueuedPrompts(session.localSessionId);
7205
+ for (const prompt of queued) {
7206
+ const cancelled = this.store.cancelQueuedPrompt(prompt.promptId, session.localSessionId);
7207
+ if (!cancelled?.messageId) continue;
7208
+ await this.finalizeStandaloneMessageReaction(cancelled.messageId, "cancelled").catch((error: unknown) => {
7209
+ this.logger.warn(
7210
+ { error, agentName, sessionId: session.localSessionId, promptId: cancelled.promptId },
7211
+ "Failed to finalize a queued Prompt reaction during App Server release.",
7212
+ );
7213
+ });
7214
+ }
7215
+ if (queued.length > 0) {
7216
+ await this.refreshPromptQueueCards(session.localSessionId).catch((error: unknown) => {
7217
+ this.logger.warn(
7218
+ { error, agentName, sessionId: session.localSessionId },
7219
+ "Failed to refresh queued Prompt cards during App Server release.",
7220
+ );
7221
+ });
7222
+ }
7223
+ }
7224
+ }
7225
+
7226
+ private appServerReleaseCardView(
7227
+ schedule: AppServerReleaseSchedule,
7228
+ contextKey: string,
7229
+ ): AppServerReleaseCardView {
7230
+ return {
7231
+ status: schedule.status,
7232
+ contextKey,
7233
+ scheduleId: schedule.id,
7234
+ agentName: schedule.agentName,
7235
+ ...(schedule.blockingTaskCount > 0 ? { blockingTaskCount: schedule.blockingTaskCount } : {}),
7236
+ ...(schedule.releaseCountdownSeconds !== undefined
7237
+ ? { releaseInSeconds: schedule.releaseCountdownSeconds }
7238
+ : {}),
7239
+ ...(schedule.forced ? { forced: true } : {}),
7240
+ ...(schedule.error ? { error: schedule.error } : {}),
7241
+ };
6642
7242
  }
7243
+
7244
+ private async publishAppServerReleaseSchedule(schedule: AppServerReleaseSchedule): Promise<void> {
7245
+ await Promise.all([...schedule.targets.values()].map(async (target) => {
7246
+ try {
7247
+ await this.outbound.updateInteractiveCard(
7248
+ target.contextKey,
7249
+ target.messageId,
7250
+ this.cardRenderer.renderAppServerRelease(this.appServerReleaseCardView(schedule, target.contextKey)),
7251
+ );
7252
+ } catch (error) {
7253
+ this.logger.warn(
7254
+ { error, agentName: schedule.agentName, messageId: target.messageId },
7255
+ "Failed to update App Server release card.",
7256
+ );
7257
+ }
7258
+ }));
7259
+ }
7260
+
7261
+ private clearAppServerReleaseSchedule(schedule: AppServerReleaseSchedule): void {
7262
+ if (schedule.timer) clearTimeout(schedule.timer);
7263
+ schedule.timer = undefined;
7264
+ if (this.appServerReleaseSchedules.get(schedule.agentName) === schedule) {
7265
+ this.appServerReleaseSchedules.delete(schedule.agentName);
7266
+ }
7267
+ }
7268
+
7269
+ private async presentThreadWriterConflict(
7270
+ record: SessionRecord,
7271
+ contextKey: string,
7272
+ error: unknown,
7273
+ replacePendingTurn: boolean,
7274
+ ): Promise<boolean> {
7275
+ if (contextKey.startsWith("console:")) return false;
7276
+ const threadId = activeWriterThreadId(error);
7277
+ if (!threadId || record.remoteSessionId !== threadId) return false;
7278
+ const runtime = this.runtimes.forAgent(record.agentName);
7279
+ const lockPath = runtime.getThreadWriterLockPath?.(threadId);
7280
+ let owners: ThreadWriterProcess[] = [];
7281
+ if (lockPath) {
7282
+ try {
7283
+ owners = await this.threadWriterProcesses.inspect(lockPath);
7284
+ } catch (inspectionError) {
7285
+ this.logger.warn(
7286
+ { error: inspectionError, threadId, lockPath },
7287
+ "Failed to inspect the process holding a thread writer lock.",
7288
+ );
7289
+ }
7290
+ }
7291
+ const owner = owners.length === 1 ? owners[0] : undefined;
7292
+ const applicationIdle = owner && lockPath
7293
+ ? await this.inspectThreadWriterApplicationIdle(runtime, lockPath, threadId, owner)
7294
+ : undefined;
7295
+ const card = this.cardRenderer.renderThreadWriterConflict(this.threadWriterConflictView(
7296
+ record,
7297
+ contextKey,
7298
+ threadId,
7299
+ owners,
7300
+ "occupied",
7301
+ lockPath
7302
+ ? undefined
7303
+ : "无法识别占用进程。",
7304
+ applicationIdle,
7305
+ ));
7306
+ if (replacePendingTurn) {
7307
+ const replaced = await this.outbound.failPendingTurn(
7308
+ record.localSessionId,
7309
+ "任务被占用。",
7310
+ card,
7311
+ );
7312
+ if (!replaced) await this.outbound.sendInteractiveCard(contextKey, card);
7313
+ } else {
7314
+ await this.outbound.sendInteractiveCard(contextKey, card);
7315
+ }
7316
+ return true;
7317
+ }
7318
+
7319
+ private threadWriterConflictView(
7320
+ record: SessionRecord,
7321
+ contextKey: string,
7322
+ threadId: string,
7323
+ owners: ThreadWriterProcess[],
7324
+ status: ThreadWriterConflictCardView["status"],
7325
+ notice?: string,
7326
+ applicationIdle?: boolean,
7327
+ ): ThreadWriterConflictCardView {
7328
+ const owner = owners.length === 1 ? owners[0] : undefined;
7329
+ return {
7330
+ status,
7331
+ contextKey,
7332
+ sessionId: record.localSessionId,
7333
+ threadId,
7334
+ taskTitle: record.title,
7335
+ notice: notice ?? (owners.length > 1
7336
+ ? `检测到 ${owners.length} 个相关进程,无法安全地自动关闭。`
7337
+ : undefined),
7338
+ applicationIdle,
7339
+ ...(owner ? {
7340
+ owner: {
7341
+ displayName: owner.displayName,
7342
+ writerPid: owner.writerPid,
7343
+ writerProcessName: owner.writerProcessName,
7344
+ writerStartedAt: owner.writerStartedAt,
7345
+ applicationPid: owner.applicationPid,
7346
+ applicationProcessName: owner.applicationProcessName,
7347
+ applicationStartedAt: owner.applicationStartedAt,
7348
+ canClose: owner.canClose,
7349
+ },
7350
+ } : {}),
7351
+ };
7352
+ }
7353
+
7354
+ private async closeThreadWriter(action: CardAction, contextKey: string): Promise<void> {
7355
+ const messageId = requiredCardMessageId(action.messageId);
7356
+ const sessionId = String(action.value.sessionId ?? "");
7357
+ const threadId = String(action.value.threadId ?? "");
7358
+ const expectedWriterPid = positiveActionPid(action.value.writerPid);
7359
+ const expectedApplicationPid = positiveActionPid(action.value.applicationPid);
7360
+ const expectedWriterStartedAt = String(action.value.writerStartedAt ?? "");
7361
+ const expectedApplicationStartedAt = String(action.value.applicationStartedAt ?? "");
7362
+ const force = action.value.force === true || action.value.force === "true";
7363
+ const record = this.requireSession(contextKey, sessionId);
7364
+ if (!threadId || record.remoteSessionId !== threadId) {
7365
+ throw new Error("任务占用卡片已经失效,请重新发送消息获取最新状态。");
7366
+ }
7367
+ const runtime = this.runtimes.forAgent(record.agentName);
7368
+ const lockPath = runtime.getThreadWriterLockPath?.(threadId);
7369
+ if (!lockPath) throw new Error("当前 Agent 不支持自动关闭占用程序。");
7370
+
7371
+ let owners = await this.threadWriterProcesses.inspect(lockPath);
7372
+ const expectedOwner = owners.length === 1 && threadWriterMatches(
7373
+ owners[0],
7374
+ expectedWriterPid,
7375
+ expectedWriterStartedAt,
7376
+ expectedApplicationPid,
7377
+ expectedApplicationStartedAt,
7378
+ ) ? owners[0] : undefined;
7379
+ if (!expectedOwner) {
7380
+ const currentOwner = owners.length === 1 ? owners[0] : undefined;
7381
+ const applicationIdle = currentOwner
7382
+ ? await this.inspectThreadWriterApplicationIdle(runtime, lockPath, threadId, currentOwner)
7383
+ : undefined;
7384
+ await this.outbound.updateInteractiveCard(
7385
+ contextKey,
7386
+ messageId,
7387
+ this.cardRenderer.renderThreadWriterConflict(this.threadWriterConflictView(
7388
+ record,
7389
+ contextKey,
7390
+ threadId,
7391
+ owners,
7392
+ owners.length === 0 ? "released" : "occupied",
7393
+ owners.length === 0
7394
+ ? "请重新发送消息。"
7395
+ : "占用进程已变化,请重试。",
7396
+ applicationIdle,
7397
+ )),
7398
+ );
7399
+ return;
7400
+ }
7401
+ if (!expectedOwner.canClose) throw new Error("无法确认占用者是 Agent 程序,已拒绝关闭。");
7402
+
7403
+ try {
7404
+ await this.threadWriterProcesses.close(expectedOwner, force);
7405
+ } catch (error) {
7406
+ owners = await this.threadWriterProcesses.inspect(lockPath);
7407
+ if (owners.some((owner) => threadWriterMatches(
7408
+ owner,
7409
+ expectedWriterPid,
7410
+ expectedWriterStartedAt,
7411
+ expectedApplicationPid,
7412
+ expectedApplicationStartedAt,
7413
+ ))) throw error;
7414
+ }
7415
+ owners = await this.waitForThreadWriterChange(lockPath, expectedOwner);
7416
+ const sameOwnerRemains = owners.some((owner) => threadWriterMatches(
7417
+ owner,
7418
+ expectedOwner.writerPid,
7419
+ expectedOwner.writerStartedAt ?? "",
7420
+ expectedOwner.applicationPid,
7421
+ expectedOwner.applicationStartedAt ?? "",
7422
+ ));
7423
+ if (sameOwnerRemains && force) {
7424
+ throw new Error(`无法关闭 ${expectedOwner.displayName}(PID ${expectedOwner.applicationPid})。`);
7425
+ }
7426
+ const status: ThreadWriterConflictCardView["status"] = owners.length === 0
7427
+ ? "released"
7428
+ : sameOwnerRemains ? "force_required" : "occupied";
7429
+ const notice = owners.length === 0
7430
+ ? "请重新发送消息。"
7431
+ : sameOwnerRemains
7432
+ ? "普通关闭失败,可强制关闭。"
7433
+ : "占用进程已变化,请重试。";
7434
+ const currentOwner = owners.length === 1 ? owners[0] : undefined;
7435
+ const applicationIdle = currentOwner
7436
+ ? await this.inspectThreadWriterApplicationIdle(runtime, lockPath, threadId, currentOwner)
7437
+ : undefined;
7438
+ await this.outbound.updateInteractiveCard(
7439
+ contextKey,
7440
+ messageId,
7441
+ this.cardRenderer.renderThreadWriterConflict(this.threadWriterConflictView(
7442
+ record,
7443
+ contextKey,
7444
+ threadId,
7445
+ owners,
7446
+ status,
7447
+ notice,
7448
+ applicationIdle,
7449
+ )),
7450
+ );
7451
+ }
7452
+
7453
+ private async inspectThreadWriterApplicationIdle(
7454
+ runtime: AgentRuntime,
7455
+ lockPath: string,
7456
+ threadId: string,
7457
+ owner: ThreadWriterProcess,
7458
+ ): Promise<boolean | undefined> {
7459
+ if (!owner.applicationStartedAt
7460
+ || !this.threadWriterProcesses.inspectApplicationThreadIds
7461
+ || !runtime.inspectRemoteSessionActivity) return undefined;
7462
+ try {
7463
+ const ownedThreadIds = await this.threadWriterProcesses.inspectApplicationThreadIds(lockPath, owner);
7464
+ if (!ownedThreadIds.includes(threadId)) return undefined;
7465
+ const activities = await Promise.all(
7466
+ ownedThreadIds.map((ownedThreadId) => runtime.inspectRemoteSessionActivity!(ownedThreadId)),
7467
+ );
7468
+ return activities.every((activity) => !activity.active);
7469
+ } catch (error) {
7470
+ this.logger.warn(
7471
+ { error, threadId, applicationPid: owner.applicationPid },
7472
+ "Failed to inspect thread-writer application activity.",
7473
+ );
7474
+ return undefined;
7475
+ }
7476
+ }
7477
+
7478
+ private async waitForThreadWriterChange(
7479
+ lockPath: string,
7480
+ expectedOwner: ThreadWriterProcess,
7481
+ ): Promise<ThreadWriterProcess[]> {
7482
+ const deadline = Date.now() + 5_000;
7483
+ let owners: ThreadWriterProcess[] = [];
7484
+ do {
7485
+ owners = await this.threadWriterProcesses.inspect(lockPath);
7486
+ if (!owners.some((owner) => threadWriterMatches(
7487
+ owner,
7488
+ expectedOwner.writerPid,
7489
+ expectedOwner.writerStartedAt ?? "",
7490
+ expectedOwner.applicationPid,
7491
+ expectedOwner.applicationStartedAt ?? "",
7492
+ ))) return owners;
7493
+ await delay(250);
7494
+ } while (Date.now() < deadline);
7495
+ return owners;
7496
+ }
7497
+
7498
+ private async sendError(
7499
+ contextKey: string,
7500
+ error: unknown,
7501
+ details: RequestFailureDetails = {},
7502
+ ): Promise<void> {
7503
+ if (error instanceof PresentedRequestError) {
7504
+ this.logger.warn(
7505
+ { error: errorLogValue(error.originalError), contextKey, ...details },
7506
+ "Request failed after a dedicated error card was presented.",
7507
+ );
7508
+ return;
7509
+ }
7510
+ const record = this.currentSession(contextKey);
7511
+ if (record) {
7512
+ try {
7513
+ if (await this.presentThreadWriterConflict(record, contextKey, error, false)) return;
7514
+ } catch (presentationError) {
7515
+ this.logger.warn(
7516
+ { error: presentationError, originalError: errorLogValue(error), contextKey, ...details },
7517
+ "Failed to present the thread writer conflict card.",
7518
+ );
7519
+ }
7520
+ }
7521
+ this.logger.warn({ error, contextKey, ...details }, "Request failed.");
7522
+ try {
7523
+ await this.outbound.sendText(contextKey, error instanceof Error ? error.message : String(error));
7524
+ } catch (deliveryError) {
7525
+ this.logger.error(
7526
+ {
7527
+ error: deliveryError,
7528
+ originalError: errorLogValue(error),
7529
+ contextKey,
7530
+ ...details,
7531
+ phase: "error_response_send",
7532
+ },
7533
+ "Failed to deliver the request failure to the user.",
7534
+ );
7535
+ }
7536
+ }
7537
+ }
7538
+
7539
+ function activeWriterThreadId(error: unknown): string | undefined {
7540
+ const visited = new Set<unknown>();
7541
+ let current = error;
7542
+ while (current !== undefined && current !== null && !visited.has(current)) {
7543
+ visited.add(current);
7544
+ const message = current instanceof Error
7545
+ ? current.message
7546
+ : typeof current === "string" ? current : undefined;
7547
+ const match = message?.match(
7548
+ /thread(?:\/resume failed:\s*thread)?\s+([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\s+already has an active writer/iu,
7549
+ );
7550
+ if (match?.[1]) return match[1];
7551
+ current = current instanceof Error ? current.cause : undefined;
7552
+ }
7553
+ return undefined;
7554
+ }
7555
+
7556
+ function positiveActionPid(value: unknown): number {
7557
+ const pid = typeof value === "number" ? value : Number(value);
7558
+ if (!Number.isSafeInteger(pid) || pid <= 4) {
7559
+ throw new Error("任务占用卡片中的进程信息无效,请重新发送消息获取最新状态。");
7560
+ }
7561
+ return pid;
7562
+ }
7563
+
7564
+ function threadWriterMatches(
7565
+ owner: ThreadWriterProcess,
7566
+ writerPid: number,
7567
+ writerStartedAt: string,
7568
+ applicationPid: number,
7569
+ applicationStartedAt: string,
7570
+ ): boolean {
7571
+ return Boolean(writerStartedAt)
7572
+ && Boolean(applicationStartedAt)
7573
+ && owner.writerPid === writerPid
7574
+ && owner.writerStartedAt === writerStartedAt
7575
+ && owner.applicationPid === applicationPid
7576
+ && owner.applicationStartedAt === applicationStartedAt;
7577
+ }
7578
+
7579
+ function delay(milliseconds: number): Promise<void> {
7580
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
7581
+ }
7582
+
7583
+ function renderPlainHelp(introLines: string[], sections: HelpCardSection[]): string {
7584
+ return [
7585
+ "Agent Bot 使用帮助",
7586
+ "",
7587
+ ...introLines.map((line) => decodeHelpEntities(line.replace(/^>\s*/u, "").replaceAll("**", ""))),
7588
+ ...sections.flatMap((section) => [
7589
+ "",
7590
+ section.title,
7591
+ ...section.commands.map((command) => [
7592
+ command.text,
7593
+ command.usage ? decodeHelpEntities(command.usage) : "",
7594
+ `- ${command.description}`,
7595
+ ].filter(Boolean).join(" ")),
7596
+ ]),
7597
+ ].join("\n");
7598
+ }
7599
+
7600
+ function decodeHelpEntities(value: string): string {
7601
+ return value
7602
+ .replaceAll("&#60;", "<")
7603
+ .replaceAll("&#62;", ">")
7604
+ .replaceAll("&amp;", "&");
6643
7605
  }
6644
7606
 
6645
7607
  function threadForkAnchorMessageIds(message: IncomingMessage): string[] {
@@ -7194,7 +8156,7 @@ function isBotOwnedActiveTurn(record: SessionRecord, remote: RemoteSessionSummar
7194
8156
  }
7195
8157
 
7196
8158
  function isQueueIndependentCommand(command: Command): boolean {
7197
- if (["archive", "dismiss", "stop", "status", "restart", "mute", "help", "sessions", "dir", "file", "goal", "nosteer", "shell"].includes(command.type)) return true;
8159
+ if (["archive", "dismiss", "stop", "status", "restart", "release", "mute", "help", "sessions", "dir", "file", "goal", "nosteer", "shell"].includes(command.type)) return true;
7198
8160
  if (command.type === "agent") return command.agent === undefined;
7199
8161
  if (["model", "provider", "thinking", "permissions"].includes(command.type)) return true;
7200
8162
  return false;
@@ -7423,6 +8385,17 @@ function referencedMessagePrompt(instruction: string, referencedMessage: string)
7423
8385
  return `${instruction}\n\n引用消息:\n${referencedMessage}`;
7424
8386
  }
7425
8387
 
8388
+ function topicRootMessagePrompt(instruction: string, rootMessage: string): string {
8389
+ return `${instruction}\n\n话题根消息:\n${rootMessage}`;
8390
+ }
8391
+
8392
+ function topicRootMessageId(message: IncomingMessage): string | undefined {
8393
+ if (!message.threadContext) return undefined;
8394
+ const rootMessageId = message.rootMessageId?.trim();
8395
+ if (!rootMessageId || rootMessageId === message.messageId) return undefined;
8396
+ return rootMessageId;
8397
+ }
8398
+
7426
8399
  function quotedMessageId(message: IncomingMessage): string | undefined {
7427
8400
  const messageId = message.parentMessageId?.trim();
7428
8401
  if (!messageId || message.contextKey.startsWith("console:")) return undefined;