@keyou007/agent-bot 0.1.22 → 0.1.24-alpha.0

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 (77) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +26 -2
  3. package/README.zh.md +26 -2
  4. package/config.example.yaml +5 -0
  5. package/dist/cli/controlProtocol.js.map +1 -1
  6. package/dist/cli/help.js +4 -0
  7. package/dist/cli/help.js.map +1 -1
  8. package/dist/cli/taskGroupOptions.js +27 -0
  9. package/dist/cli/taskGroupOptions.js.map +1 -1
  10. package/dist/cli.js +21 -1
  11. package/dist/cli.js.map +1 -1
  12. package/dist/codex/CodexProcessManager.js +19 -3
  13. package/dist/codex/CodexProcessManager.js.map +1 -1
  14. package/dist/codex/CodexRuntime.js +92 -9
  15. package/dist/codex/CodexRuntime.js.map +1 -1
  16. package/dist/commands/CommandRouter.js +27 -0
  17. package/dist/commands/CommandRouter.js.map +1 -1
  18. package/dist/config/loadConfig.js +4 -0
  19. package/dist/config/loadConfig.js.map +1 -1
  20. package/dist/config/schema.js +1 -0
  21. package/dist/config/schema.js.map +1 -1
  22. package/dist/feishu/CardRenderer.js +31 -2
  23. package/dist/feishu/CardRenderer.js.map +1 -1
  24. package/dist/feishu/FeishuMessageClient.js +6 -3
  25. package/dist/feishu/FeishuMessageClient.js.map +1 -1
  26. package/dist/feishu/MergedForwardMessage.js +17 -7
  27. package/dist/feishu/MergedForwardMessage.js.map +1 -1
  28. package/dist/index.js +50 -0
  29. package/dist/index.js.map +1 -1
  30. package/dist/local-files/TurnPreviewPage.js +106 -87
  31. package/dist/local-files/TurnPreviewPage.js.map +1 -1
  32. package/dist/proxy/ConversationExport.js +41 -0
  33. package/dist/proxy/ConversationExport.js.map +1 -0
  34. package/dist/proxy/ProxySessionController.js +118 -7
  35. package/dist/proxy/ProxySessionController.js.map +1 -1
  36. package/dist/state/StateStore.js +65 -0
  37. package/dist/state/StateStore.js.map +1 -1
  38. package/dist/state/migrations.js +12 -0
  39. package/dist/state/migrations.js.map +1 -1
  40. package/dist/updates/AutomaticUpdatePreparer.js +66 -0
  41. package/dist/updates/AutomaticUpdatePreparer.js.map +1 -0
  42. package/dist/updates/DailyUpdateMonitor.js +261 -0
  43. package/dist/updates/DailyUpdateMonitor.js.map +1 -0
  44. package/dist/updates/PublishedRelease.js +43 -0
  45. package/dist/updates/PublishedRelease.js.map +1 -0
  46. package/dist/updates/types.js +2 -0
  47. package/dist/updates/types.js.map +1 -0
  48. package/docs/technical-reference.md +4 -0
  49. package/docs/technical-reference.zh.md +4 -0
  50. package/npm-shrinkwrap.json +2 -2
  51. package/package.json +1 -1
  52. package/skills/agent-bot/SKILL.md +11 -0
  53. package/src/cli/controlProtocol.ts +9 -0
  54. package/src/cli/help.ts +4 -0
  55. package/src/cli/taskGroupOptions.ts +33 -3
  56. package/src/cli.ts +21 -0
  57. package/src/codex/CodexProcessManager.ts +21 -4
  58. package/src/codex/CodexRuntime.ts +104 -12
  59. package/src/commands/CommandRouter.ts +24 -0
  60. package/src/commands/commandTypes.ts +1 -0
  61. package/src/config/loadConfig.ts +4 -0
  62. package/src/config/schema.ts +4 -1
  63. package/src/feishu/CardRenderer.ts +34 -2
  64. package/src/feishu/FeishuMessageClient.ts +6 -2
  65. package/src/feishu/MergedForwardMessage.ts +29 -7
  66. package/src/feishu/types.ts +1 -1
  67. package/src/index.ts +49 -0
  68. package/src/local-files/TurnPreviewPage.ts +105 -88
  69. package/src/proxy/ConversationExport.ts +43 -0
  70. package/src/proxy/ProxySessionController.ts +166 -8
  71. package/src/runtime/types.ts +6 -0
  72. package/src/state/StateStore.ts +69 -0
  73. package/src/state/migrations.ts +12 -0
  74. package/src/updates/AutomaticUpdatePreparer.ts +76 -0
  75. package/src/updates/DailyUpdateMonitor.ts +252 -0
  76. package/src/updates/PublishedRelease.ts +44 -0
  77. package/src/updates/types.ts +19 -0
@@ -5,6 +5,7 @@ import type {
5
5
  AgentProcessInfo,
6
6
  ApprovalDecision,
7
7
  CreateRuntimeSessionInput,
8
+ ConversationTurn,
8
9
  ForkRuntimeSessionInput,
9
10
  ModelOption,
10
11
  ModelProviderOption,
@@ -383,6 +384,46 @@ export class CodexRuntime implements AgentRuntime {
383
384
  };
384
385
  }
385
386
 
387
+ async *readConversation(remoteSessionId: string, throughTurnId?: string): AsyncIterable<ConversationTurn> {
388
+ const client = await this.client();
389
+ const seenCursors = new Set<string>();
390
+ let cursor: string | undefined;
391
+ const boundary = throughTurnId
392
+ ?? (await this.listRecentThreadTurnsThroughCompleted(client, remoteSessionId))
393
+ .find((turn) => turn.status === "completed")?.id;
394
+ if (!boundary) return;
395
+ do {
396
+ const page = await this.readTurnPage(client, remoteSessionId, {
397
+ cursor, limit: 50, itemsView: "summary", sortDirection: "asc",
398
+ }, CONTROL_REQUEST_TIMEOUT_MS);
399
+ for (const turn of page.data) {
400
+ if (turn.status === "inProgress" || (turn.id === boundary && turn.status !== "completed")) {
401
+ throw new Error("上下文历史发生变化,请重试。");
402
+ }
403
+ if (turn.status !== "completed") continue;
404
+ const messages: ConversationTurn["messages"] = [];
405
+ for (const item of turn.items ?? []) {
406
+ if (item.type === "userMessage") {
407
+ const text = (item.content ?? []).filter((block) => block.type === "text")
408
+ .map((block) => block.text ?? "").join("\n");
409
+ if (text.trim()) messages.push({ role: "user", text });
410
+ } else if (item.type === "agentMessage" && item.phase !== "commentary" && item.text?.trim()) {
411
+ messages.push({ role: "assistant", text: item.text });
412
+ }
413
+ }
414
+ yield { turnId: turn.id, messages };
415
+ if (turn.id === boundary) return;
416
+ }
417
+ const nextCursor = page.nextCursor ?? undefined;
418
+ if (nextCursor && (nextCursor === cursor || seenCursors.has(nextCursor))) {
419
+ throw new Error("App Server repeated a conversation history cursor.");
420
+ }
421
+ if (nextCursor) seenCursors.add(nextCursor);
422
+ cursor = nextCursor;
423
+ } while (cursor);
424
+ throw new Error("找不到上下文截止轮次,未创建克隆任务。");
425
+ }
426
+
386
427
  async readRemoteForkSource(remoteSessionId: string): Promise<RemoteSessionSummary> {
387
428
  const client = await this.client();
388
429
  const metadata = await client.request<ThreadReadResponse>(
@@ -454,14 +495,14 @@ export class CodexRuntime implements AgentRuntime {
454
495
  private async readTurnPage(
455
496
  client: AppServerClient,
456
497
  remoteSessionId: string,
457
- input: { cursor?: string; limit: number; itemsView: "summary" | "full" },
498
+ input: { cursor?: string; limit: number; itemsView: "summary" | "full"; sortDirection?: "asc" | "desc" },
458
499
  timeoutMs = SYNC_REQUEST_TIMEOUT_MS,
459
500
  ): Promise<ThreadTurnsListResponse> {
460
501
  try {
461
502
  return await client.request<ThreadTurnsListResponse>("thread/turns/list", {
462
503
  threadId: remoteSessionId,
463
504
  ...(input.cursor ? { cursor: input.cursor } : {}),
464
- limit: input.limit, sortDirection: "desc", itemsView: input.itemsView,
505
+ limit: input.limit, sortDirection: input.sortDirection ?? "desc", itemsView: input.itemsView,
465
506
  }, timeoutMs);
466
507
  } catch (error) {
467
508
  if (error instanceof AppServerRequestError && error.method === "thread/turns/list"
@@ -674,17 +715,38 @@ export class CodexRuntime implements AgentRuntime {
674
715
  await this.detachThreadForSettings(client, session);
675
716
  detached = true;
676
717
  }
677
- const response = empty
678
- ? await client.request<ThreadResponse>("thread/start", {
679
- ...params, threadSource: "user", allowProviderModelFallback: false,
680
- }, SESSION_REQUEST_TIMEOUT_MS)
681
- : await client.request<ThreadResponse>("thread/resume", {
682
- ...params, threadId: session.remoteSessionId, excludeTurns: true,
683
- }, SESSION_REQUEST_TIMEOUT_MS);
684
- if (empty) replacementId = response.thread.id;
718
+ let response: ThreadResponse;
719
+ if (empty) {
720
+ response = await client.request<ThreadResponse>("thread/start", {
721
+ ...params, threadSource: "user", allowProviderModelFallback: false,
722
+ }, SESSION_REQUEST_TIMEOUT_MS);
723
+ replacementId = response.thread.id;
724
+ } else {
725
+ response = await client.request<ThreadResponse>("thread/resume", {
726
+ ...params, threadId: session.remoteSessionId, excludeTurns: true,
727
+ }, SESSION_REQUEST_TIMEOUT_MS);
728
+ if (!providerSettingsMatch(response, settings)) {
729
+ this.logger.warn(
730
+ {
731
+ sessionId,
732
+ threadId: session.remoteSessionId,
733
+ requestedProvider: settings.modelProvider,
734
+ actualProvider: response.modelProvider,
735
+ requestedModel: settings.model,
736
+ actualModel: response.model,
737
+ },
738
+ "App Server did not apply Provider settings while resuming; forking the latest completed Turn.",
739
+ );
740
+ await this.detachThreadForSettings(client, session);
741
+ response = await this.forkThreadForSettings(client, session, settings);
742
+ replacementId = response.thread.id;
743
+ }
744
+ }
685
745
  assertProviderSettingsApplied(response, settings);
686
- if (!empty && response.thread.id !== session.remoteSessionId) throw new Error("App Server 返回了不同的任务 ID,未保存设置。");
687
- if (empty && session.title) await client.request("thread/name/set", {
746
+ if (!empty && !replacementId && response.thread.id !== session.remoteSessionId) {
747
+ throw new Error("App Server 返回了不同的任务 ID,未保存设置。");
748
+ }
749
+ if (session.title && (empty || replacementId)) await client.request("thread/name/set", {
688
750
  threadId: response.thread.id, name: session.title,
689
751
  }, SESSION_REQUEST_TIMEOUT_MS);
690
752
  const candidate = { ...session, ...settings, remoteSessionId: response.thread.id,
@@ -734,6 +796,29 @@ export class CodexRuntime implements AgentRuntime {
734
796
  }
735
797
  }
736
798
 
799
+ private async forkThreadForSettings(
800
+ client: AppServerClient,
801
+ session: CodexSession,
802
+ settings: RuntimeExecutionSettings,
803
+ ): Promise<ThreadResponse> {
804
+ const turns = await this.listRecentThreadTurnsThroughCompleted(client, session.remoteSessionId);
805
+ const lastTurnId = turns.find((turn) => turn.status === "completed")?.id;
806
+ if (!lastTurnId) {
807
+ throw new Error("无法为 Provider 切换找到已完成的 Turn。请先完成一轮任务后重试。");
808
+ }
809
+ return client.request<ThreadResponse>("thread/fork", {
810
+ threadId: session.remoteSessionId,
811
+ lastTurnId,
812
+ cwd: session.cwd,
813
+ model: settings.model,
814
+ modelProvider: settings.modelProvider,
815
+ threadSource: "user",
816
+ excludeTurns: true,
817
+ ...threadLifecycleParams(session.cwd),
818
+ ...permissionParams(settings.permissionMode),
819
+ }, FORK_REQUEST_TIMEOUT_MS);
820
+ }
821
+
737
822
  private async discardUnusedThread(client: AppServerClient, threadId: string): Promise<void> {
738
823
  try {
739
824
  await client.request("thread/unsubscribe", { threadId }, CONTROL_REQUEST_TIMEOUT_MS);
@@ -1688,6 +1773,13 @@ function remoteThreadStatus(value: string | undefined): RemoteSessionSummary["st
1688
1773
  return "not_loaded";
1689
1774
  }
1690
1775
 
1776
+ function providerSettingsMatch(
1777
+ response: Pick<ThreadResponse, "modelProvider" | "model">,
1778
+ settings: RuntimeExecutionSettings,
1779
+ ): boolean {
1780
+ return response.modelProvider === settings.modelProvider && response.model === settings.model;
1781
+ }
1782
+
1691
1783
  function codexSourceLabel(source: unknown): string {
1692
1784
  if (typeof source === "string") return source;
1693
1785
  if (isRecord(source)) {
@@ -3,6 +3,8 @@ import type { Command } from "./commandTypes.js";
3
3
  export const COMMAND_NAMES = [
4
4
  "agent",
5
5
  "archive",
6
+ "clone",
7
+ "clonegroup",
6
8
  "dir",
7
9
  "dismiss",
8
10
  "file",
@@ -34,6 +36,7 @@ type CommandName = (typeof COMMAND_NAMES)[number];
34
36
  const COMMAND_INITIALISMS: Partial<Record<CommandName, string>> = {
35
37
  dir: "di",
36
38
  forkgroup: "fg",
39
+ clonegroup: "cg",
37
40
  newgroup: "ng",
38
41
  nosteer: "ns",
39
42
  };
@@ -67,6 +70,9 @@ export class CommandRouter {
67
70
  return parseNewCommand(args);
68
71
  case "newgroup":
69
72
  return parseNewGroupCommand(args);
73
+ case "clone":
74
+ case "clonegroup":
75
+ return parseCloneCommand(command, args);
70
76
  case "forkgroup":
71
77
  return parseForkGroupCommand(args);
72
78
  case "fork":
@@ -242,6 +248,24 @@ function parseNewTaskOptions(
242
248
  };
243
249
  }
244
250
 
251
+ function parseCloneCommand(type: "clone" | "clonegroup", args: string[]): Command {
252
+ const title: string[] = [];
253
+ let agentName: string | undefined;
254
+ for (let index = 0; index < args.length; index += 1) {
255
+ const argument = args[index]!;
256
+ if (argument === "--agent") {
257
+ if (agentName !== undefined) throw new Error(`/${type} 只能指定一次 --agent。`);
258
+ const name = args[++index];
259
+ if (!name?.trim() || name.startsWith("--")) throw new Error("请在 --agent 后指定 Agent 名称。");
260
+ agentName = name.trim();
261
+ } else {
262
+ if (argument.startsWith("--")) throw new Error(`/${type} 不支持参数:${argument}。`);
263
+ title.push(argument);
264
+ }
265
+ }
266
+ return { type, title: title.join(" ").trim() || undefined, agentName };
267
+ }
268
+
245
269
  function parseForkCommand(args: string[]): Extract<Command, { type: "fork" }> {
246
270
  rejectOptions("/fork", args);
247
271
  if (args.length > 1) throw new Error("/fork 只接受一个可选的任务序号或任务 ID。");
@@ -4,6 +4,7 @@ export type Command =
4
4
  | { type: "newgroup"; title?: string; cwd?: string; projectless?: boolean }
5
5
  | { type: "dir"; directory?: string }
6
6
  | { type: "file"; filePath: string }
7
+ | { type: "clone" | "clonegroup"; title?: string; agentName?: string }
7
8
  | { type: "forkgroup"; title?: string }
8
9
  | { type: "fork"; sessionId?: string }
9
10
  | { type: "title"; title: string }
@@ -26,6 +26,10 @@ const DEFAULT_CONFIG = `feishu:
26
26
  console:
27
27
  enabled: true
28
28
 
29
+ updates:
30
+ # Daily stable update check, private 60-second cancellation window, then safe update.
31
+ enabled: true
32
+
29
33
  fileViewer:
30
34
  enabled: true
31
35
  # Use 0.0.0.0 to accept LAN connections and automatically prefer
@@ -57,6 +57,7 @@ export const agentConfigSchema = z.object({
57
57
  });
58
58
 
59
59
  export const appConfigSchema = z.object({
60
+ updates: z.object({ enabled: z.boolean().default(true) }).default({ enabled: true }),
60
61
  console: z
61
62
  .object({
62
63
  enabled: z.boolean().default(true),
@@ -107,4 +108,6 @@ export type AgentConfig = Omit<ParsedAgentConfig, "defaults"> & {
107
108
  defaults?: AgentExecutionDefaults;
108
109
  };
109
110
  export type GroupNameFormatConfig = z.infer<typeof groupNameFormatSchema>;
110
- export type AppConfig = z.infer<typeof appConfigSchema>;
111
+ export type AppConfig = Omit<z.infer<typeof appConfigSchema>, "updates"> & {
112
+ updates?: { enabled: boolean };
113
+ };
@@ -12,6 +12,7 @@ import type {
12
12
  } from "../runtime/types.js";
13
13
  import type { TurnActivity, TurnViewState, TurnViewStatus } from "../presentation/turnViewTypes.js";
14
14
  import { truncateMiddle, truncateText } from "../utils/markdown.js";
15
+ import type { UpdateNotice } from "../updates/types.js";
15
16
  import { localCardImage } from "./LocalCardImage.js";
16
17
 
17
18
  export interface StartupStatusView {
@@ -953,6 +954,33 @@ export class CardRenderer {
953
954
  };
954
955
  }
955
956
 
957
+ renderUpdateNotice(view: UpdateNotice, now = Date.now()): Record<string, unknown> {
958
+ const cancellable = view.status === "announcing" || view.status === "countdown";
959
+ const status = cancellable
960
+ ? `**自动更新倒计时:${Math.max(0, Math.ceil(((view.deadline ?? now + 60_000) - now) / 1_000))} 秒**`
961
+ : view.status === "cancelled" ? "已取消本版本自动更新,不再提醒。"
962
+ : view.status === "preparing" ? "正在下载并校验更新;任务结束后自动安装并重启。"
963
+ : view.status === "scheduled" ? "更新已就绪,等待任务和结果投递完成后自动重启。"
964
+ : view.status === "completed" ? "更新已完成。"
965
+ : "自动更新未完成,当前版本继续运行。";
966
+ const elements = [
967
+ markdown(`**Agent Bot** ${inlineCode(view.currentVersion)} → ${inlineCode(view.version)}`),
968
+ markdown(`**本版更新**\n${truncateText(view.notes, 4_000)}`),
969
+ { tag: "hr" },
970
+ markdown(status),
971
+ ];
972
+ if (view.error) elements.push(markdown(inlineCode(truncateText(view.error, 600))));
973
+ if (cancellable) {
974
+ elements.push(markdown("> 1 分钟内未取消将自动更新;不会中断正在执行的任务。"));
975
+ elements.push(taskActionRow([{
976
+ text: "取消本次更新",
977
+ value: { action: "agentbot_update_cancel", version: view.version, token: view.token },
978
+ }]));
979
+ }
980
+ return sectionCard("Agent Bot 更新提醒", elements,
981
+ view.status === "completed" ? "green" : view.status === "cancelled" ? "grey" : "blue");
982
+ }
983
+
956
984
  renderSafeRestartStatus(view: SafeRestartStatusView): Record<string, unknown> {
957
985
  const countdown = view.phase === "countdown"
958
986
  ? `${Math.max(0, Math.ceil((view.remainingMs ?? 0) / 1_000))}s`
@@ -2370,7 +2398,7 @@ function truncateToolResult(result: string): string {
2370
2398
  return `${result.slice(0, headLength)}${marker}${result.slice(-tailLength)}`;
2371
2399
  }
2372
2400
 
2373
- function displayFilePath(filePath: string, projectCwd?: string): string {
2401
+ export function displayFilePath(filePath: string, projectCwd?: string): string {
2374
2402
  if (!projectCwd) return filePath;
2375
2403
  const pathApi = usesWindowsPaths(projectCwd, filePath) ? path.win32 : path;
2376
2404
  const normalizedCwd = pathApi.resolve(projectCwd);
@@ -2393,8 +2421,12 @@ function usesWindowsPaths(...values: string[]): boolean {
2393
2421
  return values.some((value) => /^[A-Za-z]:[\\/]/.test(value) || /^\\\\/.test(value));
2394
2422
  }
2395
2423
 
2424
+ export function toolStatusIcon(status: ToolState["status"]): string {
2425
+ return status === "failed" ? "❌" : status === "running" ? "⏳" : "✅";
2426
+ }
2427
+
2396
2428
  function toolPanelTitle(tool: ToolState): string {
2397
- const icon = tool.status === "failed" ? "❌" : tool.status === "running" ? "⏳" : "✅";
2429
+ const icon = toolStatusIcon(tool.status);
2398
2430
  const command = stripAnsi(tool.command ?? tool.title).trim();
2399
2431
  const meaningfulCommand = tool.kind === "web_search"
2400
2432
  ? tool.title
@@ -259,8 +259,11 @@ export class FeishuMessageClient implements FeishuOutbound {
259
259
  private async readMessageItems(messageId: string, operation: string): Promise<MergedForwardMessageItem[]> {
260
260
  try {
261
261
  const token = await this.getTenantAccessToken();
262
+ const url = new URL(`https://open.feishu.cn/open-apis/im/v1/messages/${encodeURIComponent(messageId)}`);
263
+ // The default compatibility format can replace Card 2.0 content with an upgrade notice.
264
+ url.searchParams.set("card_msg_content_type", "user_card_content");
262
265
  const response = await fetch(
263
- `https://open.feishu.cn/open-apis/im/v1/messages/${encodeURIComponent(messageId)}`,
266
+ url,
264
267
  { headers: { Authorization: `Bearer ${token}` } },
265
268
  );
266
269
  const payload = (await response.json()) as GetMessageResponse;
@@ -303,8 +306,9 @@ export class FeishuMessageClient implements FeishuOutbound {
303
306
  async sendInteractiveCard(
304
307
  contextKey: string,
305
308
  card: Record<string, unknown>,
309
+ idempotencyKey?: string,
306
310
  ): Promise<string | undefined> {
307
- return this.sendMessage(contextKey, "interactive", await this.prepareInteractiveCard(card));
311
+ return this.sendMessage(contextKey, "interactive", await this.prepareInteractiveCard(card), idempotencyKey);
308
312
  }
309
313
 
310
314
  async replyText(
@@ -187,7 +187,7 @@ function renderMessageContent(
187
187
  case "sticker":
188
188
  return "[表情包]";
189
189
  case "interactive":
190
- return renderInteractiveCard(content) || "[消息卡片]";
190
+ return renderInteractiveCard(content, imageLabel) || "[消息卡片]";
191
191
  case "share_chat":
192
192
  return "[群名片]";
193
193
  case "share_user":
@@ -235,27 +235,49 @@ function messageTypeLabel(messageType: string): string {
235
235
  }
236
236
  }
237
237
 
238
- function renderInteractiveCard(content: Record<string, unknown>): string {
238
+ function renderInteractiveCard(
239
+ content: Record<string, unknown>,
240
+ imageLabel: (imageKey: string) => string,
241
+ ): string {
239
242
  const values: string[] = [];
240
- collectCardText(content, undefined, values);
243
+ collectCardText(content, undefined, values, imageLabel);
241
244
  return [...new Set(values.map((value) => normalizeFeishuPostText(value)).filter(Boolean))].join("\n");
242
245
  }
243
246
 
244
- function collectCardText(value: unknown, key: string | undefined, output: string[]): void {
247
+ function collectCardText(
248
+ value: unknown,
249
+ key: string | undefined,
250
+ output: string[],
251
+ imageLabel: (imageKey: string) => string,
252
+ ): void {
245
253
  if (typeof value === "string") {
246
- if (key === "content" || key === "text" || key === "title" || key === "subtitle") output.push(value);
254
+ if (
255
+ (key === "content" || key === "text" || key === "title" || key === "subtitle")
256
+ && !isUnsupportedCardClientPlaceholder(value)
257
+ ) {
258
+ output.push(value);
259
+ }
247
260
  return;
248
261
  }
249
262
  if (Array.isArray(value)) {
250
- for (const item of value) collectCardText(item, key, output);
263
+ for (const item of value) collectCardText(item, key, output, imageLabel);
251
264
  return;
252
265
  }
253
266
  if (!isRecord(value)) return;
267
+ const imageKey = textValue(value.image_key ?? value.img_key);
268
+ if (imageKey) {
269
+ output.push(imageLabel(imageKey));
270
+ }
254
271
  for (const [childKey, childValue] of Object.entries(value)) {
255
- collectCardText(childValue, childKey, output);
272
+ collectCardText(childValue, childKey, output, imageLabel);
256
273
  }
257
274
  }
258
275
 
276
+ function isUnsupportedCardClientPlaceholder(value: string): boolean {
277
+ return value.trim() === "请升级至最新版本客户端,以查看内容"
278
+ || /^please upgrade to the latest .*client.* view/iu.test(value.trim());
279
+ }
280
+
259
281
  function renderPost(
260
282
  content: Record<string, unknown>,
261
283
  mentions: MergedForwardMessageItem["mentions"],
@@ -72,7 +72,7 @@ export interface FeishuOutbound {
72
72
  sendText(contextKey: string, text: string): Promise<string | undefined>;
73
73
  sendFile?(contextKey: string, filePath: string): Promise<string | undefined>;
74
74
  sendMarkdown(contextKey: string, markdown: string, idempotencyKey?: string): Promise<string | undefined>;
75
- sendInteractiveCard(contextKey: string, card: Record<string, unknown>): Promise<string | undefined>;
75
+ sendInteractiveCard(contextKey: string, card: Record<string, unknown>, idempotencyKey?: string): Promise<string | undefined>;
76
76
  replyText?(
77
77
  contextKey: string,
78
78
  target: MessageReplyTarget,
package/src/index.ts CHANGED
@@ -29,6 +29,9 @@ import { ProxySessionController } from "./proxy/ProxySessionController.js";
29
29
  import type { AgentEnvironmentContext } from "./runtime/agentEnvironment.js";
30
30
  import { createAgentRuntimeRegistry } from "./runtime/createAgentRuntimeRegistry.js";
31
31
  import { StateStore } from "./state/StateStore.js";
32
+ import { DailyUpdateMonitor } from "./updates/DailyUpdateMonitor.js";
33
+ import { readLatestStableVersion, readReleaseNotes } from "./updates/PublishedRelease.js";
34
+ import { checkAutomaticUpdateSupport, prepareAutomaticUpdate } from "./updates/AutomaticUpdatePreparer.js";
32
35
  import { StartupNotifier } from "./startup/StartupNotifier.js";
33
36
  import { SessionMetadataHydrator } from "./startup/SessionMetadataHydrator.js";
34
37
  import { startFeishu } from "./startup/startFeishu.js";
@@ -106,6 +109,7 @@ let feishuConnector: FeishuConnector | undefined;
106
109
  let consoleConnector: ConsoleConnector | undefined;
107
110
  let startupNotifier: StartupNotifier | undefined;
108
111
  let safeRestartNotifier: SafeRestartNotifier | undefined;
112
+ let dailyUpdateMonitor: DailyUpdateMonitor | undefined;
109
113
  let controlServer: LocalControlServer | undefined;
110
114
  let serverReady = false;
111
115
 
@@ -157,14 +161,48 @@ const safeRestart = new SafeRestartScheduler({
157
161
  onStatus: (status) => safeRestartNotifier?.update(status),
158
162
  onStatusError: (error) => logger.warn({ error }, "Failed to publish safe restart status."),
159
163
  });
164
+ if (feishuOutbound && config.updates?.enabled !== false) {
165
+ dailyUpdateMonitor = new DailyUpdateMonitor({
166
+ store, outbound: feishuOutbound, logger, currentVersion: agentBotVersion,
167
+ userOpenId: () => config.feishu.userOpenId,
168
+ readLatest: readLatestStableVersion,
169
+ readNotes: readReleaseNotes,
170
+ checkSupport: () => checkAutomaticUpdateSupport({ home: agentBotHome(), configPath: activeConfigPath }),
171
+ hasPendingUpdate: () => shuttingDown || restartRequested || !!pendingSelfUpdatePlanPath || safeRestart.scheduled,
172
+ pendingVersion: () => pendingSelfUpdatePlanPath ? readPendingSelfUpdate(agentBotHome())?.plan.toVersion : undefined,
173
+ applyUpdate: async (version) => {
174
+ const prepared = await prepareAutomaticUpdate(version, { home: agentBotHome(), configPath: activeConfigPath });
175
+ try {
176
+ if (shuttingDown || restartRequested || pendingSelfUpdatePlanPath || safeRestart.scheduled) {
177
+ throw new Error("服务正在停止或已有更新/重启等待执行,本次自动更新已停止。");
178
+ }
179
+ const target = privateRestartNotificationTarget(config.feishu.userOpenId);
180
+ if (prepared.status === "prepared") {
181
+ assertSelfUpdatePlanPath(prepared.planPath, agentBotHome());
182
+ pendingSelfUpdatePlanPath = prepared.planPath;
183
+ }
184
+ safeRestart.schedule(`Agent Bot 自动更新到 ${version}`, target);
185
+ } catch (error) {
186
+ if (prepared.status === "prepared") releaseSelfUpdatePlan(prepared.planPath);
187
+ throw error;
188
+ }
189
+ },
190
+ });
191
+ }
160
192
  const controller = new ProxySessionController(config, store, runtimes, outbound, logger, {
161
193
  supervised,
162
194
  restart: requestRestart,
195
+ cancelAutomaticUpdate: async (action) => {
196
+ if (!dailyUpdateMonitor) throw new Error("自动更新功能未启用。");
197
+ await dailyUpdateMonitor.cancel(action);
198
+ },
163
199
  cancelSafeRestart: async (scheduleId) => {
164
200
  const cancelled = await safeRestart.cancelScheduled(scheduleId);
165
201
  if (cancelled && pendingSelfUpdatePlanPath) {
202
+ const version = readPendingSelfUpdate(agentBotHome())?.plan.toVersion;
166
203
  releaseSelfUpdatePlan(pendingSelfUpdatePlanPath);
167
204
  pendingSelfUpdatePlanPath = undefined;
205
+ if (version) await dailyUpdateMonitor?.cancelPreparedUpdate(version);
168
206
  }
169
207
  return cancelled;
170
208
  },
@@ -229,6 +267,7 @@ await controller.recoverInterruptedTasks().catch((error: unknown) => {
229
267
  });
230
268
  consoleConnector?.start();
231
269
  recoverPendingSelfUpdate();
270
+ await dailyUpdateMonitor?.start();
232
271
 
233
272
  function recoverPendingSelfUpdate(): void {
234
273
  const pending = readPendingSelfUpdate(agentBotHome());
@@ -511,6 +550,15 @@ async function handleControlRequest(request: ControlRequest): Promise<ControlRes
511
550
  request.agentName,
512
551
  ),
513
552
  };
553
+ case "task_clone":
554
+ case "task_clone_group":
555
+ return {
556
+ ok: true,
557
+ data: await controller.controlCloneTask(
558
+ request.localSessionId, request.title, request.agentName,
559
+ request.action === "task_clone_group", config.feishu.userOpenId,
560
+ ),
561
+ };
514
562
  case "task_fork_group":
515
563
  return {
516
564
  ok: true,
@@ -530,6 +578,7 @@ async function shutdown(
530
578
  ): Promise<void> {
531
579
  if (shuttingDown) return;
532
580
  shuttingDown = true;
581
+ await dailyUpdateMonitor?.stop();
533
582
  if (pendingSelfUpdatePlanPath) {
534
583
  releaseSelfUpdatePlan(pendingSelfUpdatePlanPath);
535
584
  pendingSelfUpdatePlanPath = undefined;