@keyou007/agent-bot 0.1.13-alpha.0 → 0.1.13-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 (66) hide show
  1. package/.env.example +2 -2
  2. package/CHANGELOG.md +9 -0
  3. package/README.md +10 -4
  4. package/README.zh.md +10 -4
  5. package/dist/cli/ServerStarter.js +17 -2
  6. package/dist/cli/ServerStarter.js.map +1 -1
  7. package/dist/cli/controlProtocol.js.map +1 -1
  8. package/dist/cli/help.js +12 -2
  9. package/dist/cli/help.js.map +1 -1
  10. package/dist/cli.js +39 -0
  11. package/dist/cli.js.map +1 -1
  12. package/dist/commands/CommandRouter.js +5 -0
  13. package/dist/commands/CommandRouter.js.map +1 -1
  14. package/dist/config/FeishuUserOpenIdStore.js +76 -0
  15. package/dist/config/FeishuUserOpenIdStore.js.map +1 -0
  16. package/dist/config/loadConfig.js +3 -0
  17. package/dist/config/loadConfig.js.map +1 -1
  18. package/dist/feishu/CardRenderer.js +82 -22
  19. package/dist/feishu/CardRenderer.js.map +1 -1
  20. package/dist/index.js +103 -23
  21. package/dist/index.js.map +1 -1
  22. package/dist/presentation/turnGraph.js +36 -0
  23. package/dist/presentation/turnGraph.js.map +1 -0
  24. package/dist/proxy/ProxySessionController.js +270 -47
  25. package/dist/proxy/ProxySessionController.js.map +1 -1
  26. package/dist/startup/StartupNotifier.js +16 -7
  27. package/dist/startup/StartupNotifier.js.map +1 -1
  28. package/dist/startup/startFeishu.js +2 -2
  29. package/dist/startup/startFeishu.js.map +1 -1
  30. package/dist/state/StateStore.js +169 -0
  31. package/dist/state/StateStore.js.map +1 -1
  32. package/dist/state/migrations.js +21 -0
  33. package/dist/state/migrations.js.map +1 -1
  34. package/dist/supervision/SafeRestartNotifier.js +46 -3
  35. package/dist/supervision/SafeRestartNotifier.js.map +1 -1
  36. package/dist/supervision/SafeRestartScheduler.js +65 -6
  37. package/dist/supervision/SafeRestartScheduler.js.map +1 -1
  38. package/dist/supervision/replacementSupervisor.js +27 -2
  39. package/dist/supervision/replacementSupervisor.js.map +1 -1
  40. package/dist/supervisor.js +27 -2
  41. package/dist/supervisor.js.map +1 -1
  42. package/docs/technical-reference.md +11 -4
  43. package/docs/technical-reference.zh.md +11 -4
  44. package/npm-shrinkwrap.json +2 -2
  45. package/package.json +1 -1
  46. package/skills/agent-bot/SKILL.md +10 -4
  47. package/src/cli/ServerStarter.ts +25 -2
  48. package/src/cli/controlProtocol.ts +6 -1
  49. package/src/cli/help.ts +12 -2
  50. package/src/cli.ts +57 -0
  51. package/src/commands/CommandRouter.ts +4 -0
  52. package/src/commands/commandTypes.ts +1 -0
  53. package/src/config/FeishuUserOpenIdStore.ts +81 -0
  54. package/src/config/loadConfig.ts +3 -0
  55. package/src/feishu/CardRenderer.ts +109 -22
  56. package/src/index.ts +161 -21
  57. package/src/presentation/turnGraph.ts +48 -0
  58. package/src/proxy/ProxySessionController.ts +323 -55
  59. package/src/startup/StartupNotifier.ts +23 -7
  60. package/src/startup/startFeishu.ts +3 -2
  61. package/src/state/StateStore.ts +221 -0
  62. package/src/state/migrations.ts +21 -0
  63. package/src/supervision/SafeRestartNotifier.ts +53 -4
  64. package/src/supervision/SafeRestartScheduler.ts +78 -7
  65. package/src/supervision/replacementSupervisor.ts +27 -1
  66. package/src/supervisor.ts +33 -2
@@ -17,11 +17,14 @@ import {
17
17
  CardRenderer,
18
18
  type CardSection,
19
19
  type ExecutionSettingsTab,
20
+ type ResetHistoryCardEntry,
20
21
  type TaskListCardAction,
22
+ type TaskListCardEntry,
21
23
  } from "../feishu/CardRenderer.js";
22
24
  import { generateGroupAvatarPng, resolveGroupAvatarProjectName } from "../feishu/GroupAvatarGenerator.js";
23
25
  import type { OutboundRouter } from "../presentation/OutboundRouter.js";
24
26
  import type { TurnActivity, TurnViewState } from "../presentation/turnViewTypes.js";
27
+ import { buildTurnGraphRows } from "../presentation/turnGraph.js";
25
28
  import type { AgentRuntimeRegistry } from "../runtime/AgentRuntimeRegistry.js";
26
29
  import type {
27
30
  AgentRuntime,
@@ -61,6 +64,7 @@ const MESSAGE_COMPLETED_REACTION = "DONE";
61
64
  const MESSAGE_FAILED_REACTION = "ERROR";
62
65
  const MESSAGE_CANCELLED_REACTION = "CrossMark";
63
66
  const SESSION_PAGE_SIZE = 5;
67
+ const RESET_HISTORY_PAGE_SIZE = 10;
64
68
  const REMOTE_SESSION_REFERENCE_PREFIX = "agent-runtime:";
65
69
 
66
70
  interface AgentRemoteSession {
@@ -77,7 +81,7 @@ interface AgentRemoteSessionSummary {
77
81
  interface SessionsCardOptions {
78
82
  updateMessageId?: string;
79
83
  forceSwitchTaskId?: string;
80
- visibleCount?: number;
84
+ page?: number;
81
85
  }
82
86
 
83
87
  interface StatusCardOptions {
@@ -85,6 +89,12 @@ interface StatusCardOptions {
85
89
  forceSwitchTaskId?: string;
86
90
  }
87
91
 
92
+ interface ResetHistoryCardOptions {
93
+ expectedSessionId?: string;
94
+ updateMessageId?: string;
95
+ page?: number;
96
+ }
97
+
88
98
  interface ModelCardOptions {
89
99
  sessionId?: string;
90
100
  updateMessageId?: string;
@@ -167,8 +177,9 @@ export interface ControlTaskGroupResult {
167
177
 
168
178
  export interface ProxyLifecycle {
169
179
  supervised?: boolean;
170
- restart(contextKey: string, force: boolean): Promise<void>;
180
+ restart(contextKey: string, force: boolean, replyTarget?: MessageReplyTarget): Promise<void>;
171
181
  cancelSafeRestart?(scheduleId: number): Promise<boolean>;
182
+ rememberFeishuUserOpenId?(userOpenId: string): Promise<void> | void;
172
183
  }
173
184
 
174
185
  export type ShellCommandExecutor = (command: string, cwd: string) => Promise<ShellCommandResult>;
@@ -181,6 +192,7 @@ export class ProxySessionController {
181
192
  private readonly queuedPromptStarts = new Map<string, Promise<void>>();
182
193
  private readonly queuedPromptCards = new Map<string, Map<string, string>>();
183
194
  private readonly queuedPromptCardWrites = new Map<string, Promise<void>>();
195
+ private readonly sessionResets = new Map<string, Promise<void>>();
184
196
  private readonly lastSessionListings = new Map<string, string[]>();
185
197
  private readonly threadInitializations = new Map<string, Promise<void>>();
186
198
  private readonly unsubscribe: Array<() => void> = [];
@@ -240,8 +252,20 @@ export class ProxySessionController {
240
252
  "Failed to acknowledge the incoming Feishu message with a reaction.",
241
253
  );
242
254
  }
255
+ if (message.chatType === "p2p" && message.userId?.startsWith("ou_")) {
256
+ try {
257
+ await this.lifecycle?.rememberFeishuUserOpenId?.(message.userId);
258
+ } catch (error) {
259
+ this.logger.warn(
260
+ { error, messageId: message.messageId },
261
+ "Failed to persist the first private-chat Lark user Open ID.",
262
+ );
263
+ }
264
+ }
243
265
  if (message.chatId && message.chatType) {
244
- this.store.recordChatContext(baseChatContextKey(message.contextKey), message.chatType);
266
+ const chatContextKey = baseChatContextKey(message.contextKey);
267
+ this.store.recordChatContext(chatContextKey, message.chatType);
268
+ this.store.markChatActive(chatContextKey);
245
269
  }
246
270
  const replyTarget = message.replyInThread
247
271
  ? { messageId: message.messageId, replyInThread: true as const }
@@ -252,9 +276,6 @@ export class ProxySessionController {
252
276
  text: message.text,
253
277
  ...(imageCount > 0 ? { imageCount } : {}),
254
278
  });
255
- if (imageCount > 0 && message.chatId && message.chatType) {
256
- this.store.markChatActive(baseChatContextKey(message.contextKey));
257
- }
258
279
  let localImagePaths: string[] | undefined;
259
280
  if (imageCount > 0) {
260
281
  try {
@@ -284,17 +305,6 @@ export class ProxySessionController {
284
305
  );
285
306
  return;
286
307
  }
287
- if (
288
- message.chatId
289
- && message.chatType
290
- && (
291
- command.type === "shell"
292
- || (command.type === "prompt" && !message.text.trimStart().startsWith("/"))
293
- )
294
- ) {
295
- this.store.markChatActive(baseChatContextKey(message.contextKey));
296
- }
297
-
298
308
  // Operational and read-only commands must remain available even if a prompt operation is slow.
299
309
  if (isQueueIndependentCommand(command)) {
300
310
  await this.outbound.withReplyTarget(message.contextKey, replyTarget, async () => {
@@ -369,6 +379,26 @@ export class ProxySessionController {
369
379
  } else if (kind === "turn_cancel") {
370
380
  const sessionId = String(scopedAction.value.sessionId ?? "");
371
381
  await this.cancelSession(this.requireSession(contextKey, sessionId));
382
+ } else if (kind === "turn_reset") {
383
+ const sessionId = String(scopedAction.value.sessionId ?? "");
384
+ await this.resetCurrentSessionToTurn(
385
+ contextKey,
386
+ sessionId,
387
+ String(scopedAction.value.turnId ?? ""),
388
+ );
389
+ if (scopedAction.value.cardView === "reset_history") {
390
+ await this.openResetHistory(contextKey, {
391
+ expectedSessionId: sessionId,
392
+ updateMessageId: requiredCardMessageId(scopedAction.messageId),
393
+ page: resetHistoryPageValue(scopedAction.value.page),
394
+ });
395
+ }
396
+ } else if (kind === "turn_reset_page") {
397
+ await this.openResetHistory(contextKey, {
398
+ expectedSessionId: String(scopedAction.value.sessionId ?? ""),
399
+ updateMessageId: requiredCardMessageId(scopedAction.messageId),
400
+ page: resetHistoryPageValue(scopedAction.value.page),
401
+ });
372
402
  } else if (kind === "queued_prompt_cancel") {
373
403
  await this.cancelQueuedPrompt(scopedAction);
374
404
  } else if (kind === "safe_restart_cancel") {
@@ -383,17 +413,17 @@ export class ProxySessionController {
383
413
  if (!cancelled) {
384
414
  await this.outbound.sendText(contextKey, "该安全重启计划已失效,请查看最新状态卡片。");
385
415
  }
386
- } else if (kind === "session_more") {
387
- await this.refreshSessionsCardFromAction(scopedAction, undefined, true);
416
+ } else if (kind === "session_page") {
417
+ await this.refreshSessionsCardFromAction(scopedAction);
388
418
  } else if (kind === "session_switch") {
389
419
  const sessionId = String(scopedAction.value.sessionId ?? "");
390
420
  await this.switchSession(contextKey, sessionId);
391
421
  if (scopedAction.value.cardView === "status") await this.refreshStatusCardFromAction(scopedAction);
392
- else await this.refreshSessionsCardFromAction(scopedAction);
422
+ else await this.refreshSessionsCardFromAction(scopedAction, { page: 0 });
393
423
  } else if (kind === "session_fork") {
394
424
  const sessionId = String(scopedAction.value.sessionId ?? "");
395
425
  await this.forkSessionReference(contextKey, sessionId);
396
- await this.refreshSessionsCardFromAction(scopedAction);
426
+ await this.refreshSessionsCardFromAction(scopedAction, { page: 0 });
397
427
  } else if (kind === "session_fork_group") {
398
428
  const sessionId = String(scopedAction.value.sessionId ?? "");
399
429
  await this.forkSessionReferenceToFeishuGroup(contextKey, sessionId, scopedAction.userId);
@@ -401,7 +431,7 @@ export class ProxySessionController {
401
431
  } else if (kind === "session_new") {
402
432
  const sessionId = String(scopedAction.value.sessionId ?? "");
403
433
  await this.createProjectSessionFromReference(contextKey, sessionId);
404
- await this.refreshSessionsCardFromAction(scopedAction);
434
+ await this.refreshSessionsCardFromAction(scopedAction, { page: 0 });
405
435
  } else if (kind === "session_new_group") {
406
436
  const sessionId = String(scopedAction.value.sessionId ?? "");
407
437
  await this.createFeishuGroupFromReference(contextKey, sessionId, scopedAction.userId);
@@ -410,7 +440,7 @@ export class ProxySessionController {
410
440
  const sessionId = String(scopedAction.value.sessionId ?? "");
411
441
  await this.stopSessionReference(contextKey, sessionId);
412
442
  if (scopedAction.value.cardView === "status") await this.refreshStatusCardFromAction(scopedAction, sessionId);
413
- else await this.refreshSessionsCardFromAction(scopedAction, sessionId);
443
+ else await this.refreshSessionsCardFromAction(scopedAction, { forceSwitchTaskId: sessionId });
414
444
  } else if (kind === "session_status") {
415
445
  await this.status(contextKey, String(scopedAction.value.sessionId ?? ""));
416
446
  } else if (kind === "session_status_refresh") {
@@ -541,6 +571,7 @@ export class ProxySessionController {
541
571
  this.lastSessionListings.clear();
542
572
  this.queuedPromptCards.clear();
543
573
  this.queuedPromptCardWrites.clear();
574
+ this.sessionResets.clear();
544
575
  }
545
576
 
546
577
  async controlStopTask(localSessionId: string): Promise<string> {
@@ -831,7 +862,10 @@ export class ProxySessionController {
831
862
  return;
832
863
  case "restart":
833
864
  if (!this.lifecycle) throw new Error("当前运行方式不支持自动重启。");
834
- await this.lifecycle.restart(contextKey, command.force === true);
865
+ await this.lifecycle.restart(contextKey, command.force === true, replyTarget);
866
+ return;
867
+ case "turns":
868
+ await this.openResetHistory(contextKey);
835
869
  return;
836
870
  case "model":
837
871
  await this.openExecutionSettings(contextKey, "model");
@@ -2063,6 +2097,24 @@ export class ProxySessionController {
2063
2097
  }
2064
2098
 
2065
2099
  private async handleRuntimeEvent(event: RuntimeEvent): Promise<void> {
2100
+ if ("turnId" in event) {
2101
+ const source = this.store.getSession(event.sessionId);
2102
+ if (source?.remoteSessionId && this.isCodexSession(source)) {
2103
+ this.store.saveTurnRuntimeOrigin(
2104
+ event.turnId,
2105
+ source.localSessionId,
2106
+ source.agentName,
2107
+ source.remoteSessionId,
2108
+ );
2109
+ }
2110
+ if (event.type === "turn_started") {
2111
+ this.store.saveTurnParent(
2112
+ event.turnId,
2113
+ event.sessionId,
2114
+ source?.lastTurnStatus === "completed" ? source.lastTurnId : undefined,
2115
+ );
2116
+ }
2117
+ }
2066
2118
  if (event.type === "session_metadata_updated") {
2067
2119
  this.store.updateRuntimeSession(event.sessionId, { title: event.title });
2068
2120
  this.outbound.updateSessionTitle(event.sessionId, event.title);
@@ -2271,6 +2323,111 @@ export class ProxySessionController {
2271
2323
  await loaded.runtime.cancelTurn(record.localSessionId, turnId);
2272
2324
  }
2273
2325
 
2326
+ private async resetCurrentSessionToTurn(
2327
+ contextKey: string,
2328
+ sessionId: string,
2329
+ turnId: string,
2330
+ ): Promise<void> {
2331
+ if (!sessionId || !turnId) throw new Error("无效的 Reset 请求。请使用最新的思考卡片重试。");
2332
+ const previous = this.sessionResets.get(sessionId) ?? Promise.resolve();
2333
+ const reset = previous.catch(() => undefined).then(() =>
2334
+ this.performCurrentSessionReset(contextKey, sessionId, turnId));
2335
+ this.sessionResets.set(sessionId, reset);
2336
+ try {
2337
+ await reset;
2338
+ } finally {
2339
+ if (this.sessionResets.get(sessionId) === reset) this.sessionResets.delete(sessionId);
2340
+ }
2341
+ }
2342
+
2343
+ private async performCurrentSessionReset(
2344
+ contextKey: string,
2345
+ sessionId: string,
2346
+ turnId: string,
2347
+ ): Promise<void> {
2348
+ const current = this.requireCurrentSession(contextKey);
2349
+ if (current.localSessionId !== sessionId) {
2350
+ throw new Error("这张思考卡片不属于当前任务。请先切换回对应任务,再点击 Reset。");
2351
+ }
2352
+ if (!current.remoteSessionId || !this.isCodexSession(current)) {
2353
+ throw new Error("当前任务不是可 Reset 的 App Server 任务。");
2354
+ }
2355
+
2356
+ const snapshot = turnViewSnapshot(this.store.getTurnSnapshot(turnId));
2357
+ if (!snapshot || snapshot.sessionId !== sessionId || snapshot.status !== "completed") {
2358
+ throw new Error("只能将当前任务 Reset 到已成功完成的轮次。");
2359
+ }
2360
+ if (current.lastTurnId === turnId && current.lastTurnStatus === "completed") {
2361
+ await this.outbound.sendText(contextKey, "当前任务已经处于本轮完成后的对话状态,无需 Reset。");
2362
+ return;
2363
+ }
2364
+
2365
+ const runtime = this.runtimes.forAgent(current.agentName);
2366
+ if (runtime.kind !== "codex" || !runtime.forkSession) {
2367
+ throw new Error("当前 App Server Agent 不支持 Reset。");
2368
+ }
2369
+ const loaded = await this.loadSession(current);
2370
+ const activity = await this.assertSessionTurnOwnership(current, runtime);
2371
+ const active = activity?.active
2372
+ ?? Boolean(loaded.session.activeTurnId || current.status === "running" || current.lastTurnStatus === "running");
2373
+ if (active) {
2374
+ throw new Error("当前任务仍在执行。请等待任务完成或先停止任务,再点击 Reset。");
2375
+ }
2376
+
2377
+ const origin = this.store.getTurnRuntimeOrigin(turnId);
2378
+ if (origin && (origin.localSessionId !== sessionId || origin.agentName !== current.agentName)) {
2379
+ throw new Error("这张思考卡片与当前任务不匹配,已拒绝 Reset。");
2380
+ }
2381
+ const sourceRemoteSessionId = origin?.remoteSessionId ?? current.remoteSessionId;
2382
+ const previousRemoteSessionId = current.remoteSessionId;
2383
+ const forked = await runtime.forkSession({
2384
+ localSessionId: current.localSessionId,
2385
+ remoteSessionId: sourceRemoteSessionId,
2386
+ lastTurnId: turnId,
2387
+ agentName: current.agentName,
2388
+ cwd: current.cwd,
2389
+ title: current.title,
2390
+ modelProvider: current.modelProvider,
2391
+ model: current.model,
2392
+ reasoningEffort: current.reasoningEffort,
2393
+ permissionMode: current.permissionMode ?? "auto",
2394
+ });
2395
+ this.persistRuntimeSession(current, forked, "ready");
2396
+ this.store.updateRuntimeSession(current.localSessionId, {
2397
+ lastTurnId: turnId,
2398
+ lastTurnStatus: "completed",
2399
+ });
2400
+ this.outbound.registerSession(
2401
+ current.localSessionId,
2402
+ contextKey,
2403
+ forked.title ?? current.title,
2404
+ current.cwd,
2405
+ this.agentLabel(current.agentName),
2406
+ );
2407
+ this.store.audit(contextKey, "session_reset_to_turn", {
2408
+ localSessionId: current.localSessionId,
2409
+ previousRemoteSessionId,
2410
+ sourceRemoteSessionId,
2411
+ resetTurnId: turnId,
2412
+ forkedRemoteSessionId: forked.remoteSessionId,
2413
+ });
2414
+ const targetSummary = truncateText(
2415
+ (snapshot.prompt ?? snapshot.taskTitle ?? "未记录对话内容").replace(/\s+/g, " ").trim(),
2416
+ 100,
2417
+ ) || "未记录对话内容";
2418
+ const targetTime = snapshot.completedAt ?? snapshot.startedAt;
2419
+ await this.outbound.sendText(
2420
+ contextKey,
2421
+ [
2422
+ "已将当前任务重置到:",
2423
+ targetSummary,
2424
+ `完成时间:${targetTime === undefined ? "未知" : formatResetTurnTime(targetTime)}`,
2425
+ `Turn ID:${turnId}`,
2426
+ "后续对话将从该轮完成后的状态继续;本地文件没有回退。",
2427
+ ].join("\n"),
2428
+ );
2429
+ }
2430
+
2274
2431
  private async resolveApproval(action: CardAction): Promise<void> {
2275
2432
  const sessionId = String(action.value.sessionId ?? "");
2276
2433
  const requestId = String(action.value.requestId ?? "");
@@ -2824,6 +2981,92 @@ export class ProxySessionController {
2824
2981
  await this.outbound.sendText(contextKey, mode === "auto" ? "已切换为自动执行模式。" : "已切换为执行前确认模式。");
2825
2982
  }
2826
2983
 
2984
+ private async openResetHistory(
2985
+ contextKey: string,
2986
+ options: ResetHistoryCardOptions = {},
2987
+ ): Promise<void> {
2988
+ const current = this.requireCurrentSession(contextKey);
2989
+ if (options.expectedSessionId && current.localSessionId !== options.expectedSessionId) {
2990
+ throw new Error("这张历史轮次卡片不属于当前任务。请先切换回对应任务,再重新发送 /turns。");
2991
+ }
2992
+ if (!current.remoteSessionId || !this.isCodexSession(current)) {
2993
+ throw new Error("当前任务不是可 Reset 的 App Server 任务。");
2994
+ }
2995
+ const allTurns = this.store.listCompletedTurnGraph(current.localSessionId, contextKey);
2996
+ const graphRows = buildTurnGraphRows(allTurns.map((turn) => ({
2997
+ turnId: turn.turnId,
2998
+ parentTurnId: turn.parentTurnId,
2999
+ })));
3000
+ const total = allTurns.length;
3001
+ const totalPages = Math.max(1, Math.ceil(total / RESET_HISTORY_PAGE_SIZE));
3002
+ const page = Math.max(0, Math.min(Math.trunc(options.page ?? 0), totalPages - 1));
3003
+ const offset = page * RESET_HISTORY_PAGE_SIZE;
3004
+ const turns = allTurns.slice(offset, offset + RESET_HISTORY_PAGE_SIZE);
3005
+ const entries: ResetHistoryCardEntry[] = turns.flatMap((turn, index) => {
3006
+ const snapshot = turnViewSnapshot(turn.snapshot);
3007
+ if (!snapshot) return [];
3008
+ const graph = graphRows[offset + index]!;
3009
+ const summary = truncateText(
3010
+ (snapshot.prompt ?? snapshot.taskTitle ?? "未记录对话内容").replace(/\s+/g, " ").trim(),
3011
+ 100,
3012
+ );
3013
+ const isCurrent = current.lastTurnId === turn.turnId && current.lastTurnStatus === "completed";
3014
+ return [{
3015
+ sequence: graph.sequence,
3016
+ graphNodeLine: graph.nodeLine,
3017
+ graphConnectorLine: graph.connectorLine,
3018
+ lines: [
3019
+ `**${cardText(summary || "未记录对话内容")}**`,
3020
+ `${formatResetTurnTime(snapshot.completedAt ?? snapshot.startedAt)} · ${cardCode(turn.turnId)}`,
3021
+ ],
3022
+ current: isCurrent,
3023
+ actions: isCurrent ? undefined : [{
3024
+ text: "Reset",
3025
+ value: {
3026
+ action: "turn_reset",
3027
+ cardView: "reset_history",
3028
+ sessionId: current.localSessionId,
3029
+ turnId: turn.turnId,
3030
+ contextKey,
3031
+ page: String(page),
3032
+ },
3033
+ }],
3034
+ }];
3035
+ });
3036
+ const pageActions: TaskListCardAction[] = [
3037
+ ...(page > 0 ? [{
3038
+ text: "Previous",
3039
+ value: {
3040
+ action: "turn_reset_page",
3041
+ sessionId: current.localSessionId,
3042
+ contextKey,
3043
+ page: String(page - 1),
3044
+ },
3045
+ }] : []),
3046
+ ...(page < totalPages - 1 ? [{
3047
+ text: "Next",
3048
+ value: {
3049
+ action: "turn_reset_page",
3050
+ sessionId: current.localSessionId,
3051
+ contextKey,
3052
+ page: String(page + 1),
3053
+ },
3054
+ }] : []),
3055
+ ];
3056
+ const card = this.cardRenderer.renderResetHistoryCard({
3057
+ entries,
3058
+ footerLines: [
3059
+ `第 ${page + 1}/${totalPages} 页 · 共 ${total} 个已完成 turn`,
3060
+ ],
3061
+ pageActions,
3062
+ });
3063
+ if (options.updateMessageId) {
3064
+ await this.outbound.updateInteractiveCard(contextKey, options.updateMessageId, card);
3065
+ } else {
3066
+ await this.outbound.sendInteractiveCard(contextKey, card);
3067
+ }
3068
+ }
3069
+
2827
3070
  private async listSessions(
2828
3071
  contextKey: string,
2829
3072
  searchTerm?: string,
@@ -2831,7 +3074,8 @@ export class ProxySessionController {
2831
3074
  ): Promise<void> {
2832
3075
  const context = this.store.getOrCreateUserContext(contextKey, this.config.defaults.agent!);
2833
3076
  const normalizedSearch = searchTerm?.trim().toLowerCase();
2834
- const visibleCount = options.visibleCount ?? SESSION_PAGE_SIZE;
3077
+ const requestedPage = Math.max(0, Math.trunc(options.page ?? 0));
3078
+ const fetchLimit = (requestedPage + 1) * SESSION_PAGE_SIZE;
2835
3079
  const localSessions = this.store.listSessions(contextKey).filter((session) =>
2836
3080
  session.status !== "closed" && this.isCodexSession(session),
2837
3081
  );
@@ -2841,9 +3085,9 @@ export class ProxySessionController {
2841
3085
  await Promise.all(this.runtimes.entries("codex").map(async ([agentName, runtime]) => {
2842
3086
  if (!runtime.listRemoteSessions) return;
2843
3087
  try {
2844
- const page = await runtime.listRemoteSessions({ searchTerm, limit: visibleCount });
2845
- remoteSessions.push(...page.sessions.map((session) => ({ agentName, session })));
2846
- remoteHasMore ||= Boolean(page.nextCursor);
3088
+ const result = await runtime.listRemoteSessions({ searchTerm, limit: fetchLimit });
3089
+ remoteSessions.push(...result.sessions.map((session) => ({ agentName, session })));
3090
+ remoteHasMore ||= Boolean(result.nextCursor);
2847
3091
  } catch (error) {
2848
3092
  this.logger.warn({ error, contextKey, agentName }, "Failed to list App Server sessions for an Agent.");
2849
3093
  remoteErrors.push(`${agentName}: ${error instanceof Error ? error.message : String(error)}`);
@@ -2861,9 +3105,13 @@ export class ProxySessionController {
2861
3105
  || Number(right.active) - Number(left.active)
2862
3106
  || right.updatedAt - left.updatedAt);
2863
3107
  const activeCount = entries.filter((entry) => entry.active).length;
2864
- const visibleEntries = entries.slice(0, visibleCount);
2865
- const hasMore = remoteHasMore || entries.length > visibleCount;
2866
- this.lastSessionListings.set(contextKey, visibleEntries.map((entry) => entry.reference));
3108
+ const lastKnownPage = Math.max(0, Math.ceil(entries.length / SESSION_PAGE_SIZE) - 1);
3109
+ const page = remoteHasMore ? requestedPage : Math.min(requestedPage, lastKnownPage);
3110
+ const offset = page * SESSION_PAGE_SIZE;
3111
+ const visibleEntries = entries.slice(offset, offset + SESSION_PAGE_SIZE);
3112
+ const hasPrevious = page > 0;
3113
+ const hasNext = remoteHasMore || entries.length > offset + SESSION_PAGE_SIZE;
3114
+ this.lastSessionListings.set(contextKey, entries.map((entry) => entry.reference));
2867
3115
  const cardEntries = visibleEntries.map((entry, index) => {
2868
3116
  const marker = entry.current ? "✅" : entry.active ? "🟢 **活跃**" : "•";
2869
3117
  const showStop = entry.status === "外部执行中"
@@ -2876,7 +3124,7 @@ export class ProxySessionController {
2876
3124
  action: showStop ? "session_stop" : "session_switch",
2877
3125
  sessionId: entry.reference,
2878
3126
  ...(searchTerm ? { searchTerm } : {}),
2879
- visibleCount: String(visibleCount),
3127
+ page: String(page),
2880
3128
  contextKey,
2881
3129
  },
2882
3130
  }];
@@ -2886,7 +3134,7 @@ export class ProxySessionController {
2886
3134
  action: "session_new",
2887
3135
  sessionId: entry.reference,
2888
3136
  ...(searchTerm ? { searchTerm } : {}),
2889
- visibleCount: String(visibleCount),
3137
+ page: String(page),
2890
3138
  contextKey,
2891
3139
  },
2892
3140
  });
@@ -2896,7 +3144,7 @@ export class ProxySessionController {
2896
3144
  action: "session_new_group",
2897
3145
  sessionId: entry.reference,
2898
3146
  ...(searchTerm ? { searchTerm } : {}),
2899
- visibleCount: String(visibleCount),
3147
+ page: String(page),
2900
3148
  contextKey,
2901
3149
  },
2902
3150
  });
@@ -2906,7 +3154,7 @@ export class ProxySessionController {
2906
3154
  action: "session_fork",
2907
3155
  sessionId: entry.reference,
2908
3156
  ...(searchTerm ? { searchTerm } : {}),
2909
- visibleCount: String(visibleCount),
3157
+ page: String(page),
2910
3158
  contextKey,
2911
3159
  },
2912
3160
  });
@@ -2916,7 +3164,7 @@ export class ProxySessionController {
2916
3164
  action: "session_fork_group",
2917
3165
  sessionId: entry.reference,
2918
3166
  ...(searchTerm ? { searchTerm } : {}),
2919
- visibleCount: String(visibleCount),
3167
+ page: String(page),
2920
3168
  contextKey,
2921
3169
  },
2922
3170
  });
@@ -2930,7 +3178,7 @@ export class ProxySessionController {
2930
3178
  });
2931
3179
  return {
2932
3180
  lines: [
2933
- `**${index + 1}.** ${marker} **${cardText(entry.title)}** ${cardText(entry.id)}`,
3181
+ `**${offset + index + 1}.** ${marker} **${cardText(entry.title)}** ${cardText(entry.id)}`,
2934
3182
  `${entry.status} · ${entry.updatedLabel} · ${cardText(entry.agentName)} · ${cardText(entry.cwd || "目录未知")}`,
2935
3183
  ],
2936
3184
  actions,
@@ -2942,22 +3190,33 @@ export class ProxySessionController {
2942
3190
  cardEntries,
2943
3191
  [
2944
3192
  ...(remoteHint ? [remoteHint] : []),
3193
+ `第 ${page + 1} 页 · 每页 ${SESSION_PAGE_SIZE} 个任务${hasNext ? "" : ` · 当前共 ${entries.length} 个任务`}`,
2945
3194
  "点击 **New** 在对应任务的项目中创建新任务;点击 **Switch** 快速切换;点击 **Fork** 从任务最新已完成轮次创建分支;外部正在运行的任务显示 **Stop**,点击后发送 Interrupt 并变为 **Switch**。",
2946
3195
  "点击 **NewGroup** 在对应任务的项目中创建新群和新任务;点击 **ForkGroup** 从对应任务最新已完成轮次创建分支群。",
2947
3196
  "也可发送 **/switch [序号或任务 ID]**;不带参数切回上一个任务。外部正在执行的回合不会被接管。",
2948
3197
  "发送 **/fork [序号或任务 ID]**,可从当前或指定任务创建分支;任务运行中时使用最近已完成轮次。",
2949
3198
  "点击 **Status**,或发送 **/status [序号或任务 ID]**,查看当前或指定任务状态。",
2950
3199
  ],
2951
- hasMore ? {
2952
- text: "More",
2953
- type: "primary",
2954
- value: {
2955
- action: "session_more",
2956
- ...(searchTerm ? { searchTerm } : {}),
2957
- visibleCount: String(visibleCount),
2958
- contextKey,
2959
- },
2960
- } : undefined,
3200
+ [
3201
+ ...(hasPrevious ? [{
3202
+ text: "Previous",
3203
+ value: {
3204
+ action: "session_page",
3205
+ ...(searchTerm ? { searchTerm } : {}),
3206
+ page: String(page - 1),
3207
+ contextKey,
3208
+ },
3209
+ }] : []),
3210
+ ...(hasNext ? [{
3211
+ text: "Next",
3212
+ value: {
3213
+ action: "session_page",
3214
+ ...(searchTerm ? { searchTerm } : {}),
3215
+ page: String(page + 1),
3216
+ contextKey,
3217
+ },
3218
+ }] : []),
3219
+ ],
2961
3220
  );
2962
3221
  if (options.updateMessageId) {
2963
3222
  await this.outbound.updateInteractiveCard(contextKey, options.updateMessageId, card);
@@ -2968,18 +3227,16 @@ export class ProxySessionController {
2968
3227
 
2969
3228
  private async refreshSessionsCardFromAction(
2970
3229
  action: CardAction,
2971
- forceSwitchTaskId?: string,
2972
- loadMore = false,
3230
+ options: { forceSwitchTaskId?: string; page?: number } = {},
2973
3231
  ): Promise<void> {
2974
3232
  if (!action.messageId) return;
2975
3233
  const searchTerm = typeof action.value.searchTerm === "string" && action.value.searchTerm.trim()
2976
3234
  ? action.value.searchTerm
2977
3235
  : undefined;
2978
- const currentVisibleCount = parseSessionVisibleCount(action.value.visibleCount);
2979
3236
  await this.listSessions(action.contextKey, searchTerm, {
2980
3237
  updateMessageId: action.messageId,
2981
- forceSwitchTaskId,
2982
- visibleCount: currentVisibleCount + (loadMore ? SESSION_PAGE_SIZE : 0),
3238
+ forceSwitchTaskId: options.forceSwitchTaskId,
3239
+ page: options.page ?? parseSessionPage(action.value.page),
2983
3240
  });
2984
3241
  }
2985
3242
 
@@ -3418,6 +3675,7 @@ export class ProxySessionController {
3418
3675
  "**/newgroup [title] [--dir &#60;cwd&#62; | --nodir]** 创建飞书群和新任务;参数与 /new 相同",
3419
3676
  "**/forkgroup [title]** 从当前任务最新已完成轮次创建分支并绑定到新群",
3420
3677
  "**/fork [序号或任务 ID]** 从当前或指定任务创建分支;运行中使用最近已完成轮次",
3678
+ "**/turns** 浏览当前任务的历史对话轮次,并可将对话上下文恢复到所选轮次",
3421
3679
  "**/title &#60;新标题&#62;** 修改当前任务的标题",
3422
3680
  "**/sessions [关键词]** 查找本机任务",
3423
3681
  "**/switch [序号或任务 ID]** 不填参数切回上一个任务",
@@ -3825,6 +4083,10 @@ function formatStatusTime(value: string): string {
3825
4083
  }).format(date);
3826
4084
  }
3827
4085
 
4086
+ function formatResetTurnTime(value?: number): string {
4087
+ return value === undefined ? "完成时间未知" : formatStatusTime(new Date(value).toISOString());
4088
+ }
4089
+
3828
4090
  function cardText(value: string): string {
3829
4091
  return value.replaceAll("&", "&amp;").replaceAll("`", "&#96;").replaceAll("<", "&#60;").replaceAll(">", "&#62;");
3830
4092
  }
@@ -3843,9 +4105,15 @@ function executionSettingsTabValue(value: unknown): ExecutionSettingsTab {
3843
4105
  throw new Error("设置卡片的 tab 无效,请重新发送设置命令。");
3844
4106
  }
3845
4107
 
4108
+ function resetHistoryPageValue(value: unknown): number {
4109
+ const page = Number(value);
4110
+ if (!Number.isSafeInteger(page) || page < 0) throw new Error("历史轮次卡片页码无效,请重新发送 /turns。");
4111
+ return page;
4112
+ }
4113
+
3846
4114
  function requiredCardMessageId(value: string | undefined): string {
3847
4115
  if (value) return value;
3848
- throw new Error("无法更新设置卡片,请重新发送设置命令。");
4116
+ throw new Error("无法更新卡片,请重新发送对应命令。");
3849
4117
  }
3850
4118
 
3851
4119
  function remoteSessionStatusLabel(status: RemoteSessionSummary["status"]): string {
@@ -3947,9 +4215,9 @@ function isQueueIndependentCommand(command: Command): boolean {
3947
4215
  return false;
3948
4216
  }
3949
4217
 
3950
- function parseSessionVisibleCount(value: unknown): number {
4218
+ function parseSessionPage(value: unknown): number {
3951
4219
  const parsed = typeof value === "string" || typeof value === "number" ? Number(value) : Number.NaN;
3952
- return Number.isSafeInteger(parsed) && parsed >= SESSION_PAGE_SIZE ? parsed : SESSION_PAGE_SIZE;
4220
+ return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : 0;
3953
4221
  }
3954
4222
 
3955
4223
  function isPromptCommand(command: Command): boolean {
@@ -1,6 +1,7 @@
1
1
  import type { Logger } from "pino";
2
2
  import { detectProjectlessWorkspace } from "../codex/ProjectlessWorkspace.js";
3
3
  import { CardRenderer } from "../feishu/CardRenderer.js";
4
+ import { baseChatContextKey, isThreadContextKey } from "../feishu/contextKey.js";
4
5
  import type { FeishuOutbound } from "../feishu/types.js";
5
6
  import type { SessionRecord, StateStore, UserContextRecord } from "../state/StateStore.js";
6
7
 
@@ -32,15 +33,30 @@ export class StartupNotifier {
32
33
  private readonly metadataHydrator?: StartupTaskMetadataHydrator,
33
34
  ) {}
34
35
 
35
- async notify(startedAt: Date, restartReason: string): Promise<void> {
36
+ async notify(
37
+ startedAt: Date,
38
+ restartReason: string,
39
+ restartGroupContextKeys: string[] = [],
40
+ ): Promise<void> {
36
41
  let targets: StartupNotificationTarget[];
37
42
  try {
38
- const activeGroupSince = new Date(startedAt.getTime() - 3 * 60 * 1_000);
39
- const chats = [
40
- ...this.store.listChatContexts("p2p"),
41
- ...this.store.listRecentlyActiveChatContexts(activeGroupSince)
42
- .filter((chat) => chat.chatType === "group"),
43
- ];
43
+ const activeGroupSince = new Date(startedAt.getTime() - 60_000);
44
+ const knownGroups = new Map(
45
+ this.store.listChatContexts("group")
46
+ .filter((chat) => !isThreadContextKey(chat.contextKey))
47
+ .map((chat) => [chat.contextKey, chat]),
48
+ );
49
+ const targetGroups = new Map(
50
+ this.store.listRecentlyActiveChatContexts(activeGroupSince)
51
+ .filter((chat) => chat.chatType === "group" && !isThreadContextKey(chat.contextKey))
52
+ .map((chat) => [chat.contextKey, chat]),
53
+ );
54
+ for (const contextKey of restartGroupContextKeys) {
55
+ const baseContextKey = baseChatContextKey(contextKey.trim());
56
+ const group = knownGroups.get(baseContextKey);
57
+ if (group) targetGroups.set(baseContextKey, group);
58
+ }
59
+ const chats = [...this.store.listChatContexts("p2p"), ...targetGroups.values()];
44
60
  targets = chats.map((chat) => ({
45
61
  contextKey: chat.contextKey,
46
62
  context: this.store.getUserContext(chat.contextKey),