@keyou007/agent-bot 0.1.19-alpha.0 → 0.1.19

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 (42) hide show
  1. package/CHANGELOG.md +9 -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 +94 -3
  12. package/dist/codex/CodexRuntime.js.map +1 -1
  13. package/dist/commands/CommandRouter.js +5 -0
  14. package/dist/commands/CommandRouter.js.map +1 -1
  15. package/dist/feishu/CardRenderer.js +83 -0
  16. package/dist/feishu/CardRenderer.js.map +1 -1
  17. package/dist/feishu/MergedForwardMessage.js +6 -1
  18. package/dist/feishu/MergedForwardMessage.js.map +1 -1
  19. package/dist/index.js +2 -0
  20. package/dist/index.js.map +1 -1
  21. package/dist/proxy/ProxySessionController.js +455 -7
  22. package/dist/proxy/ProxySessionController.js.map +1 -1
  23. package/dist/state/StateStore.js +10 -0
  24. package/dist/state/StateStore.js.map +1 -1
  25. package/docs/technical-reference.md +2 -2
  26. package/docs/technical-reference.zh.md +2 -2
  27. package/npm-shrinkwrap.json +2 -2
  28. package/package.json +1 -1
  29. package/skills/agent-bot/SKILL.md +3 -0
  30. package/src/cli/controlProtocol.ts +8 -0
  31. package/src/cli/help.ts +2 -0
  32. package/src/cli.ts +33 -0
  33. package/src/codex/CodexProcessManager.ts +43 -0
  34. package/src/codex/CodexRuntime.ts +112 -3
  35. package/src/commands/CommandRouter.ts +4 -0
  36. package/src/commands/commandTypes.ts +1 -0
  37. package/src/feishu/CardRenderer.ts +96 -0
  38. package/src/feishu/MergedForwardMessage.ts +5 -1
  39. package/src/index.ts +2 -0
  40. package/src/proxy/ProxySessionController.ts +553 -9
  41. package/src/runtime/types.ts +7 -0
  42. package/src/state/StateStore.ts +11 -0
@@ -26,6 +26,7 @@ import type {
26
26
  ReferencedMessageContent,
27
27
  } from "../feishu/types.js";
28
28
  import {
29
+ type AppServerReleaseCardView,
29
30
  CardRenderer,
30
31
  type CardSection,
31
32
  type DirectoryBrowserCardEntry,
@@ -59,6 +60,7 @@ import type {
59
60
  PermissionMode,
60
61
  RemoteCompletedTurnSummary,
61
62
  RemoteSessionActivity,
63
+ RemoteSessionPage,
62
64
  RemoteSessionSummary,
63
65
  RuntimeGoal,
64
66
  RuntimeEvent,
@@ -117,12 +119,40 @@ interface ForwardAttachmentReservation {
117
119
  registry: Map<string, PendingForwardAttachment>;
118
120
  }
119
121
 
122
+ interface PreparedTopicRootPrompt {
123
+ text: string;
124
+ localImagePaths?: string[];
125
+ displayPrompt: string;
126
+ injectedRootMessageId?: string;
127
+ }
128
+
120
129
  interface ShellCommandJobMonitor {
121
130
  scheduler: CardUpdateScheduler<ShellCommandCardView>;
122
131
  timer: NodeJS.Timeout;
123
132
  refreshing: boolean;
124
133
  }
125
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
+
126
156
  interface RequestFailureDetails {
127
157
  requestId?: string;
128
158
  messageId?: string;
@@ -146,12 +176,15 @@ const SESSION_PAGE_SIZE = 10;
146
176
  const SESSION_PROMPT_PREVIEW_LENGTH = 50;
147
177
  const DIRECTORY_PAGE_SIZE = 15;
148
178
  const RESET_HISTORY_PAGE_SIZE = 10;
179
+ const REMOTE_TURN_HISTORY_REFRESH_INTERVAL_MS = 30_000;
149
180
  const MAX_LLM_TURN_RETRIES = 3;
150
181
  const QUEUED_PROMPT_RETRY_DELAY_MS = 5_000;
151
182
  const RECOVERY_ACTIVITY_WINDOW_MS = 10 * 60 * 1_000;
152
183
  const RECOVERY_HEARTBEAT_INTERVAL_MS = 60 * 1_000;
153
184
  const SHELL_COMMAND_JOB_POLL_INTERVAL_MS = 1_000;
154
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;
155
188
  const FORWARD_ATTACHMENT_WINDOW_MS = 800;
156
189
  const DEFAULT_MERGED_FORWARD_INSTRUCTION = "请参考以下内容回复用户";
157
190
  const DEFAULT_REFERENCED_MESSAGE_INSTRUCTION = "请参考引用消息回复用户";
@@ -285,6 +318,10 @@ const HELP_COMMAND_SECTIONS: Array<{
285
318
  usage: "[--force]",
286
319
  description: "默认安全重启;--force 立即重启",
287
320
  },
321
+ {
322
+ command: "/release",
323
+ description: "释放 Agent Bot 占用的 App Server 任务",
324
+ },
288
325
  {
289
326
  command: "/mute",
290
327
  usage: "[on|off]",
@@ -413,6 +450,13 @@ export interface ControlTaskGroupResult {
413
450
  task: SessionRecord;
414
451
  }
415
452
 
453
+ export interface ControlTaskReleaseResult {
454
+ agentName: string;
455
+ status: "waiting" | "released";
456
+ blockingTaskCount: number;
457
+ releaseInSeconds?: number;
458
+ }
459
+
416
460
  export interface ProxyLifecycle {
417
461
  supervised?: boolean;
418
462
  restart(contextKey: string, force: boolean, replyTarget?: MessageReplyTarget): Promise<void>;
@@ -447,6 +491,7 @@ export class ProxySessionController {
447
491
  private readonly queuedPromptCardWrites = new Map<string, Promise<void>>();
448
492
  private readonly sessionResets = new Map<string, Promise<void>>();
449
493
  private readonly resetHistoryOperations = new Map<string, string>();
494
+ private readonly remoteTurnHistoryRefreshes = new Map<string, number>();
450
495
  private readonly lastSessionListings = new Map<string, string[]>();
451
496
  private readonly threadInitializations = new Map<string, Promise<void>>();
452
497
  private readonly llmRetryingSessions = new Set<string>();
@@ -457,6 +502,8 @@ export class ProxySessionController {
457
502
  private readonly pendingResourceForwards = new Map<string, PendingForwardAttachment>();
458
503
  private readonly relatedReactionMessageIds = new Map<string, string[]>();
459
504
  private readonly shellCommandJobMonitors = new Map<string, ShellCommandJobMonitor>();
505
+ private readonly appServerReleaseSchedules = new Map<string, AppServerReleaseSchedule>();
506
+ private nextAppServerReleaseScheduleId = 1;
460
507
  private readonly unsubscribe: Array<() => void> = [];
461
508
  private readonly recoveryActivityHeartbeat: NodeJS.Timeout;
462
509
  private startupRecovery?: Promise<void>;
@@ -816,16 +863,37 @@ export class ProxySessionController {
816
863
  const next = previous.catch(() => undefined).then(() =>
817
864
  this.outbound.withReplyTarget(message.contextKey, replyTarget, async () => {
818
865
  try {
819
- 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
+ }
820
878
  await this.execute(
821
879
  message.contextKey,
822
- command,
880
+ executableCommand,
823
881
  message.messageId,
824
882
  replyTarget,
825
- localImagePaths,
883
+ executableImagePaths,
826
884
  message.userId,
827
- message,
885
+ executableMessage,
828
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
+ }
829
897
  if (!commandDefersReactionFinalization(command)) {
830
898
  await this.finalizeStandaloneMessageReaction(message.messageId, "completed");
831
899
  }
@@ -907,6 +975,10 @@ export class ProxySessionController {
907
975
  throw new Error("任务占用卡片已经失效,请重新发送消息获取最新状态。");
908
976
  }
909
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);
910
982
  } else if (kind === "turn_reset") {
911
983
  const sessionId = String(scopedAction.value.sessionId ?? "");
912
984
  const turnId = String(scopedAction.value.turnId ?? "");
@@ -1177,6 +1249,7 @@ export class ProxySessionController {
1177
1249
  this.queuedPromptCardWrites.clear();
1178
1250
  this.sessionResets.clear();
1179
1251
  this.resetHistoryOperations.clear();
1252
+ this.remoteTurnHistoryRefreshes.clear();
1180
1253
  for (const pending of this.pendingMergedForwards.values()) pending.resolveAttachment(undefined);
1181
1254
  this.pendingMergedForwards.clear();
1182
1255
  for (const pending of this.pendingResourceForwards.values()) pending.resolveAttachment(undefined);
@@ -1186,6 +1259,10 @@ export class ProxySessionController {
1186
1259
  this.queuedPromptRetryTimers.clear();
1187
1260
  for (const timer of this.recoveryRetryTimers.values()) clearTimeout(timer);
1188
1261
  this.recoveryRetryTimers.clear();
1262
+ for (const schedule of this.appServerReleaseSchedules.values()) {
1263
+ if (schedule.timer) clearTimeout(schedule.timer);
1264
+ }
1265
+ this.appServerReleaseSchedules.clear();
1189
1266
  for (const [jobId] of this.shellCommandJobMonitors) this.stopShellCommandJobMonitor(jobId);
1190
1267
  clearInterval(this.recoveryActivityHeartbeat);
1191
1268
  }
@@ -1197,6 +1274,23 @@ export class ProxySessionController {
1197
1274
  return `Task stop requested: ${record.title ?? record.remoteSessionId ?? record.localSessionId}`;
1198
1275
  }
1199
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
+
1200
1294
  async controlArchiveTask(localSessionId: string): Promise<{
1201
1295
  localSessionId: string;
1202
1296
  remoteSessionId: string;
@@ -1935,6 +2029,9 @@ export class ProxySessionController {
1935
2029
  if (!this.lifecycle) throw new Error("当前运行方式不支持自动重启。");
1936
2030
  await this.lifecycle.restart(contextKey, command.force === true, replyTarget);
1937
2031
  return;
2032
+ case "release":
2033
+ await this.requestAppServerRelease(contextKey);
2034
+ return;
1938
2035
  case "mute":
1939
2036
  await this.setGroupMute(contextKey, command.enabled);
1940
2037
  return;
@@ -2740,6 +2837,61 @@ export class ProxySessionController {
2740
2837
  }
2741
2838
  }
2742
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
+
2743
2895
  private async forkThreadSession(message: IncomingMessage): Promise<void> {
2744
2896
  const { anchor, source, snapshot } = this.resolveThreadForkAnchor(message);
2745
2897
  const agent = this.ensureAgent(source.agentName);
@@ -2889,7 +3041,7 @@ export class ProxySessionController {
2889
3041
  runtime = this.runtimes.forAgent(agentName);
2890
3042
  } else {
2891
3043
  if (!taskId) throw new Error("缺少要 fork 的 App Server 任务 ID。");
2892
- const resolved = await this.resolveRemoteCodexSession(taskId);
3044
+ const resolved = await this.resolveRemoteCodexSession(taskId, { forFork: true });
2893
3045
  agentName = resolved.agentName;
2894
3046
  runtime = resolved.runtime;
2895
3047
  remote = resolved.remote;
@@ -3335,6 +3487,39 @@ export class ProxySessionController {
3335
3487
  }
3336
3488
  }
3337
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
+
3338
3523
  private async createProjectSessionFromReference(contextKey: string, reference: string): Promise<void> {
3339
3524
  const resolved = await this.resolveProjectSessionReference(contextKey, reference);
3340
3525
  const created = await this.createSession(
@@ -5417,6 +5602,7 @@ export class ProxySessionController {
5417
5602
  throw new Error("当前任务不是可 Reset 的 App Server 任务。");
5418
5603
  }
5419
5604
  await this.hydrateLegacyForkTurnHistory(contextKey, current);
5605
+ await this.hydrateRemoteTurnHistory(contextKey, current);
5420
5606
  const completedTurns = this.store.listTaskTurnGraph(current.localSessionId);
5421
5607
  const completedTurnIds = new Set(completedTurns.map((turn) => turn.turnId));
5422
5608
  const runningTurnId = current.lastTurnStatus === "running" && current.lastTurnId
@@ -5751,7 +5937,7 @@ export class ProxySessionController {
5751
5937
  await Promise.all(this.runtimes.entries("codex").map(async ([agentName, runtime]) => {
5752
5938
  if (!runtime.listRemoteSessions) return;
5753
5939
  try {
5754
- const result = await runtime.listRemoteSessions({ searchTerm, limit: fetchLimit });
5940
+ const result = await this.listRemoteSessionsThroughCursor(runtime, searchTerm, fetchLimit);
5755
5941
  remoteSessions.push(...result.sessions.map((session) => ({ agentName, session })));
5756
5942
  remoteHasMore ||= Boolean(result.nextCursor);
5757
5943
  } catch (error) {
@@ -5963,6 +6149,42 @@ export class ProxySessionController {
5963
6149
  }
5964
6150
  }
5965
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
+
5966
6188
  private async refreshSessionsCardFromAction(
5967
6189
  action: CardAction,
5968
6190
  options: { forceSwitchTaskId?: string; page?: number } = {},
@@ -5996,7 +6218,10 @@ export class ProxySessionController {
5996
6218
  : this.store.findSessionByRemoteSessionId(reference, contextKey);
5997
6219
  }
5998
6220
 
5999
- private async resolveRemoteCodexSession(reference: string): Promise<AgentRemoteSession> {
6221
+ private async resolveRemoteCodexSession(
6222
+ reference: string,
6223
+ options: { forFork?: boolean } = {},
6224
+ ): Promise<AgentRemoteSession> {
6000
6225
  const scoped = parseRemoteSessionReference(reference);
6001
6226
  const candidates = scoped
6002
6227
  ? [[scoped.agentName, this.runtimes.forAgent(scoped.agentName)] as const]
@@ -6007,10 +6232,13 @@ export class ProxySessionController {
6007
6232
  if (runtime.kind !== "codex" || !runtime.readRemoteSession) {
6008
6233
  throw new Error(`Agent ${agentName} 不支持读取远端任务。`);
6009
6234
  }
6235
+ const remoteSessionId = scoped?.remoteSessionId ?? reference;
6010
6236
  return {
6011
6237
  agentName,
6012
6238
  runtime,
6013
- remote: await runtime.readRemoteSession(scoped?.remoteSessionId ?? reference),
6239
+ remote: options.forFork && runtime.readRemoteForkSource
6240
+ ? await runtime.readRemoteForkSource(remoteSessionId)
6241
+ : await runtime.readRemoteSession(remoteSessionId),
6014
6242
  } satisfies AgentRemoteSession;
6015
6243
  }));
6016
6244
  const matches = reads
@@ -6739,6 +6967,311 @@ export class ProxySessionController {
6739
6967
  return record;
6740
6968
  }
6741
6969
 
6970
+ private async requestAppServerRelease(
6971
+ contextKey: string,
6972
+ requestedRecord?: SessionRecord,
6973
+ ): Promise<{
6974
+ agentName: string;
6975
+ status: "waiting" | "released" | "failed";
6976
+ blockingTaskCount: number;
6977
+ releaseInSeconds?: number;
6978
+ error?: string;
6979
+ }> {
6980
+ const record = requestedRecord ?? this.requireCurrentSession(contextKey);
6981
+ const runtime = this.runtimes.forAgent(record.agentName);
6982
+ if (!this.isCodexSession(record) || runtime.kind !== "codex" || !runtime.release) {
6983
+ throw new Error("当前 Agent 不使用可释放的 App Server。");
6984
+ }
6985
+
6986
+ let schedule = this.appServerReleaseSchedules.get(record.agentName);
6987
+ const created = !schedule;
6988
+ if (!schedule) {
6989
+ const blockingTaskCount = this.appServerReleaseBlockers(record.agentName, runtime).length;
6990
+ schedule = {
6991
+ id: this.nextAppServerReleaseScheduleId++,
6992
+ agentName: record.agentName,
6993
+ runtime,
6994
+ status: "waiting",
6995
+ blockingTaskCount,
6996
+ targets: new Map(),
6997
+ forced: false,
6998
+ ...(blockingTaskCount === 0
6999
+ ? {
7000
+ releaseCountdownSeconds: Math.ceil(APP_SERVER_RELEASE_IDLE_DELAY_MS / 1_000),
7001
+ }
7002
+ : {}),
7003
+ };
7004
+ this.appServerReleaseSchedules.set(record.agentName, schedule);
7005
+ }
7006
+
7007
+ try {
7008
+ const messageId = await this.outbound.sendInteractiveCard(
7009
+ contextKey,
7010
+ this.cardRenderer.renderAppServerRelease(this.appServerReleaseCardView(schedule, contextKey)),
7011
+ );
7012
+ if (messageId) {
7013
+ schedule.targets.set(`${contextKey}\u0000${messageId}`, { contextKey, messageId });
7014
+ }
7015
+ if (
7016
+ schedule.status === "waiting"
7017
+ && schedule.blockingTaskCount === 0
7018
+ && schedule.releaseCountdownSeconds !== undefined
7019
+ && schedule.releaseAt === undefined
7020
+ ) {
7021
+ schedule.releaseAt = Date.now() + APP_SERVER_RELEASE_IDLE_DELAY_MS;
7022
+ }
7023
+ } catch (error) {
7024
+ if (created && schedule.targets.size === 0) {
7025
+ this.clearAppServerReleaseSchedule(schedule);
7026
+ }
7027
+ throw error;
7028
+ }
7029
+
7030
+ this.armAppServerReleaseCheck(schedule);
7031
+ return {
7032
+ agentName: schedule.agentName,
7033
+ status: schedule.status === "failed" ? "failed" : schedule.status === "released" ? "released" : "waiting",
7034
+ blockingTaskCount: schedule.blockingTaskCount,
7035
+ ...(schedule.releaseCountdownSeconds !== undefined
7036
+ ? { releaseInSeconds: schedule.releaseCountdownSeconds }
7037
+ : {}),
7038
+ ...(schedule.error ? { error: schedule.error } : {}),
7039
+ };
7040
+ }
7041
+
7042
+ private async releaseAppServerNow(action: CardAction): Promise<void> {
7043
+ const scheduleId = Number(action.value.scheduleId);
7044
+ const agentName = typeof action.value.agentName === "string" ? action.value.agentName : "";
7045
+ const schedule = this.appServerReleaseSchedules.get(agentName);
7046
+ if (!Number.isSafeInteger(scheduleId) || !schedule || schedule.id !== scheduleId || schedule.status !== "waiting") {
7047
+ throw new Error("释放卡片已失效,请重新发送 /release。");
7048
+ }
7049
+ await this.performAppServerRelease(schedule, true);
7050
+ }
7051
+
7052
+ private async cancelAppServerRelease(action: CardAction): Promise<void> {
7053
+ const scheduleId = Number(action.value.scheduleId);
7054
+ const agentName = typeof action.value.agentName === "string" ? action.value.agentName : "";
7055
+ const schedule = this.appServerReleaseSchedules.get(agentName);
7056
+ const cancellable = schedule?.status === "waiting"
7057
+ || (schedule?.status === "releasing" && schedule.releaseStarted !== true);
7058
+ if (!Number.isSafeInteger(scheduleId) || !schedule || schedule.id !== scheduleId || !cancellable) {
7059
+ throw new Error("释放卡片已失效,请重新发送 /release。");
7060
+ }
7061
+ const pendingRelease = schedule.inFlight;
7062
+ schedule.status = "cancelled";
7063
+ if (schedule.timer) {
7064
+ clearTimeout(schedule.timer);
7065
+ schedule.timer = undefined;
7066
+ }
7067
+ if (pendingRelease) await pendingRelease;
7068
+ await this.publishAppServerReleaseSchedule(schedule);
7069
+ this.clearAppServerReleaseSchedule(schedule);
7070
+ }
7071
+
7072
+ private appServerReleaseBlockers(agentName: string, runtime: AgentRuntime): SessionRecord[] {
7073
+ return this.store.listAllSessions().filter((session) => {
7074
+ if (session.agentName !== agentName || session.status === "closed") return false;
7075
+ return session.status === "starting"
7076
+ || session.status === "running"
7077
+ || this.store.countQueuedPrompts(session.localSessionId) > 0
7078
+ || this.queuedPromptStarts.has(session.localSessionId)
7079
+ || this.sessionLoads.has(session.localSessionId)
7080
+ || Boolean(runtime.getSession(session.localSessionId)?.activeTurnId);
7081
+ });
7082
+ }
7083
+
7084
+ private armAppServerReleaseCheck(schedule: AppServerReleaseSchedule): void {
7085
+ if (schedule.status !== "waiting" || schedule.timer || schedule.inFlight) return;
7086
+ schedule.timer = setTimeout(() => {
7087
+ schedule.timer = undefined;
7088
+ void this.refreshAppServerReleaseSchedule(schedule).catch((error: unknown) => {
7089
+ this.logger.warn({ error, agentName: schedule.agentName }, "Failed to refresh App Server release status.");
7090
+ this.armAppServerReleaseCheck(schedule);
7091
+ });
7092
+ }, APP_SERVER_RELEASE_POLL_INTERVAL_MS);
7093
+ schedule.timer.unref?.();
7094
+ }
7095
+
7096
+ private async refreshAppServerReleaseSchedule(schedule: AppServerReleaseSchedule): Promise<void> {
7097
+ if (this.appServerReleaseSchedules.get(schedule.agentName) !== schedule || schedule.status !== "waiting") return;
7098
+ const blockingTaskCount = this.appServerReleaseBlockers(schedule.agentName, schedule.runtime).length;
7099
+ if (blockingTaskCount > 0) {
7100
+ const changed = schedule.blockingTaskCount !== blockingTaskCount || schedule.releaseAt !== undefined;
7101
+ schedule.releaseAt = undefined;
7102
+ schedule.releaseCountdownSeconds = undefined;
7103
+ if (changed) {
7104
+ schedule.blockingTaskCount = blockingTaskCount;
7105
+ await this.publishAppServerReleaseSchedule(schedule);
7106
+ }
7107
+ this.armAppServerReleaseCheck(schedule);
7108
+ return;
7109
+ }
7110
+ schedule.blockingTaskCount = 0;
7111
+ if (schedule.releaseAt !== undefined) {
7112
+ const releaseCountdownSeconds = Math.max(0, Math.ceil((schedule.releaseAt - Date.now()) / 1_000));
7113
+ if (releaseCountdownSeconds > 0) {
7114
+ if (schedule.releaseCountdownSeconds !== releaseCountdownSeconds) {
7115
+ schedule.releaseCountdownSeconds = releaseCountdownSeconds;
7116
+ await this.publishAppServerReleaseSchedule(schedule);
7117
+ }
7118
+ this.armAppServerReleaseCheck(schedule);
7119
+ return;
7120
+ }
7121
+ }
7122
+ await this.performAppServerRelease(schedule, false);
7123
+ }
7124
+
7125
+ private async performAppServerRelease(schedule: AppServerReleaseSchedule, force: boolean): Promise<void> {
7126
+ if (schedule.inFlight) return schedule.inFlight;
7127
+ const operation = this.performAppServerReleaseNow(schedule, force);
7128
+ schedule.inFlight = operation;
7129
+ try {
7130
+ await operation;
7131
+ } finally {
7132
+ if (schedule.inFlight === operation) schedule.inFlight = undefined;
7133
+ if (schedule.status === "waiting") this.armAppServerReleaseCheck(schedule);
7134
+ }
7135
+ }
7136
+
7137
+ private async performAppServerReleaseNow(schedule: AppServerReleaseSchedule, force: boolean): Promise<void> {
7138
+ if (schedule.timer) {
7139
+ clearTimeout(schedule.timer);
7140
+ schedule.timer = undefined;
7141
+ }
7142
+ const interruptedWork = force && this.appServerReleaseBlockers(schedule.agentName, schedule.runtime).length > 0;
7143
+ if (force) await this.cancelQueuedPromptsForAppServerRelease(schedule.agentName);
7144
+ else {
7145
+ const blockingTaskCount = this.appServerReleaseBlockers(schedule.agentName, schedule.runtime).length;
7146
+ if (blockingTaskCount > 0) {
7147
+ schedule.status = "waiting";
7148
+ schedule.blockingTaskCount = blockingTaskCount;
7149
+ schedule.releaseAt = undefined;
7150
+ schedule.releaseCountdownSeconds = undefined;
7151
+ schedule.releaseStarted = false;
7152
+ await this.publishAppServerReleaseSchedule(schedule);
7153
+ this.armAppServerReleaseCheck(schedule);
7154
+ return;
7155
+ }
7156
+ }
7157
+
7158
+ schedule.status = "releasing";
7159
+ schedule.blockingTaskCount = 0;
7160
+ schedule.releaseAt = undefined;
7161
+ schedule.releaseCountdownSeconds = undefined;
7162
+ schedule.releaseStarted = false;
7163
+ schedule.forced = interruptedWork;
7164
+ await this.publishAppServerReleaseSchedule(schedule);
7165
+ if (
7166
+ this.appServerReleaseWasCancelled(schedule)
7167
+ || this.appServerReleaseSchedules.get(schedule.agentName) !== schedule
7168
+ ) return;
7169
+ schedule.releaseStarted = true;
7170
+ try {
7171
+ const result = await schedule.runtime.release?.({ force });
7172
+ if (!result) throw new Error("当前 Agent 不支持释放 App Server。");
7173
+ if (result.status === "busy") {
7174
+ schedule.status = "waiting";
7175
+ schedule.blockingTaskCount = Math.max(
7176
+ result.activeSessionIds?.length ?? 0,
7177
+ this.appServerReleaseBlockers(schedule.agentName, schedule.runtime).length,
7178
+ 1,
7179
+ );
7180
+ schedule.forced = false;
7181
+ schedule.releaseAt = undefined;
7182
+ schedule.releaseCountdownSeconds = undefined;
7183
+ schedule.releaseStarted = false;
7184
+ await this.publishAppServerReleaseSchedule(schedule);
7185
+ this.armAppServerReleaseCheck(schedule);
7186
+ return;
7187
+ }
7188
+ schedule.status = "released";
7189
+ } catch (error) {
7190
+ schedule.status = "failed";
7191
+ schedule.error = error instanceof Error ? error.message : String(error);
7192
+ this.logger.warn({ error, agentName: schedule.agentName }, "Failed to release App Server tasks.");
7193
+ }
7194
+ await this.publishAppServerReleaseSchedule(schedule);
7195
+ this.clearAppServerReleaseSchedule(schedule);
7196
+ }
7197
+
7198
+ private appServerReleaseWasCancelled(schedule: AppServerReleaseSchedule): boolean {
7199
+ return schedule.status === "cancelled";
7200
+ }
7201
+
7202
+ private async cancelQueuedPromptsForAppServerRelease(agentName: string): Promise<void> {
7203
+ for (const session of this.store.listAllSessions()) {
7204
+ if (session.agentName !== agentName) continue;
7205
+ const retryTimer = this.queuedPromptRetryTimers.get(session.localSessionId);
7206
+ if (retryTimer) {
7207
+ clearTimeout(retryTimer);
7208
+ this.queuedPromptRetryTimers.delete(session.localSessionId);
7209
+ }
7210
+ const queued = this.store.listQueuedPrompts(session.localSessionId);
7211
+ for (const prompt of queued) {
7212
+ const cancelled = this.store.cancelQueuedPrompt(prompt.promptId, session.localSessionId);
7213
+ if (!cancelled?.messageId) continue;
7214
+ await this.finalizeStandaloneMessageReaction(cancelled.messageId, "cancelled").catch((error: unknown) => {
7215
+ this.logger.warn(
7216
+ { error, agentName, sessionId: session.localSessionId, promptId: cancelled.promptId },
7217
+ "Failed to finalize a queued Prompt reaction during App Server release.",
7218
+ );
7219
+ });
7220
+ }
7221
+ if (queued.length > 0) {
7222
+ await this.refreshPromptQueueCards(session.localSessionId).catch((error: unknown) => {
7223
+ this.logger.warn(
7224
+ { error, agentName, sessionId: session.localSessionId },
7225
+ "Failed to refresh queued Prompt cards during App Server release.",
7226
+ );
7227
+ });
7228
+ }
7229
+ }
7230
+ }
7231
+
7232
+ private appServerReleaseCardView(
7233
+ schedule: AppServerReleaseSchedule,
7234
+ contextKey: string,
7235
+ ): AppServerReleaseCardView {
7236
+ return {
7237
+ status: schedule.status,
7238
+ contextKey,
7239
+ scheduleId: schedule.id,
7240
+ agentName: schedule.agentName,
7241
+ ...(schedule.blockingTaskCount > 0 ? { blockingTaskCount: schedule.blockingTaskCount } : {}),
7242
+ ...(schedule.releaseCountdownSeconds !== undefined
7243
+ ? { releaseInSeconds: schedule.releaseCountdownSeconds }
7244
+ : {}),
7245
+ ...(schedule.forced ? { forced: true } : {}),
7246
+ ...(schedule.error ? { error: schedule.error } : {}),
7247
+ };
7248
+ }
7249
+
7250
+ private async publishAppServerReleaseSchedule(schedule: AppServerReleaseSchedule): Promise<void> {
7251
+ await Promise.all([...schedule.targets.values()].map(async (target) => {
7252
+ try {
7253
+ await this.outbound.updateInteractiveCard(
7254
+ target.contextKey,
7255
+ target.messageId,
7256
+ this.cardRenderer.renderAppServerRelease(this.appServerReleaseCardView(schedule, target.contextKey)),
7257
+ );
7258
+ } catch (error) {
7259
+ this.logger.warn(
7260
+ { error, agentName: schedule.agentName, messageId: target.messageId },
7261
+ "Failed to update App Server release card.",
7262
+ );
7263
+ }
7264
+ }));
7265
+ }
7266
+
7267
+ private clearAppServerReleaseSchedule(schedule: AppServerReleaseSchedule): void {
7268
+ if (schedule.timer) clearTimeout(schedule.timer);
7269
+ schedule.timer = undefined;
7270
+ if (this.appServerReleaseSchedules.get(schedule.agentName) === schedule) {
7271
+ this.appServerReleaseSchedules.delete(schedule.agentName);
7272
+ }
7273
+ }
7274
+
6742
7275
  private async presentThreadWriterConflict(
6743
7276
  record: SessionRecord,
6744
7277
  contextKey: string,
@@ -7629,7 +8162,7 @@ function isBotOwnedActiveTurn(record: SessionRecord, remote: RemoteSessionSummar
7629
8162
  }
7630
8163
 
7631
8164
  function isQueueIndependentCommand(command: Command): boolean {
7632
- if (["archive", "dismiss", "stop", "status", "restart", "mute", "help", "sessions", "dir", "file", "goal", "nosteer", "shell"].includes(command.type)) return true;
8165
+ if (["archive", "dismiss", "stop", "status", "restart", "release", "mute", "help", "sessions", "dir", "file", "goal", "nosteer", "shell"].includes(command.type)) return true;
7633
8166
  if (command.type === "agent") return command.agent === undefined;
7634
8167
  if (["model", "provider", "thinking", "permissions"].includes(command.type)) return true;
7635
8168
  return false;
@@ -7858,6 +8391,17 @@ function referencedMessagePrompt(instruction: string, referencedMessage: string)
7858
8391
  return `${instruction}\n\n引用消息:\n${referencedMessage}`;
7859
8392
  }
7860
8393
 
8394
+ function topicRootMessagePrompt(instruction: string, rootMessage: string): string {
8395
+ return `${instruction}\n\n话题根消息:\n${rootMessage}`;
8396
+ }
8397
+
8398
+ function topicRootMessageId(message: IncomingMessage): string | undefined {
8399
+ if (!message.threadContext) return undefined;
8400
+ const rootMessageId = message.rootMessageId?.trim();
8401
+ if (!rootMessageId || rootMessageId === message.messageId) return undefined;
8402
+ return rootMessageId;
8403
+ }
8404
+
7861
8405
  function quotedMessageId(message: IncomingMessage): string | undefined {
7862
8406
  const messageId = message.parentMessageId?.trim();
7863
8407
  if (!messageId || message.contextKey.startsWith("console:")) return undefined;