@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
@@ -6,7 +6,12 @@ import type { SessionRecord } from "../state/StateStore.js";
6
6
 
7
7
  export type ControlRequest =
8
8
  | { action: "health" }
9
- | { action: "server_restart"; mode: "safe" | "immediate"; reason: string }
9
+ | {
10
+ action: "server_restart";
11
+ mode: "safe" | "immediate";
12
+ reason: string;
13
+ notificationSessionId?: string;
14
+ }
10
15
  | { action: "server_stop" }
11
16
  | { action: "task_status"; localSessionId: string }
12
17
  | { action: "task_stop"; localSessionId: string }
package/src/cli/help.ts CHANGED
@@ -14,7 +14,7 @@ export function renderCliHelp(version: string, language: CliLanguage = cliLangua
14
14
  console 打开本地控制台
15
15
  server start 在后台启动 Agent Bot
16
16
  server status 显示服务、飞书 App ID 和安全重启状态
17
- server restart 安排安全重启
17
+ server restart [--task <任务>] 安排安全重启并将状态发回指定任务会话
18
18
  server stop 停止 Agent Bot
19
19
  task list 列出任务
20
20
  task status <任务> 显示任务状态
@@ -39,6 +39,11 @@ export function renderCliHelp(version: string, language: CliLanguage = cliLangua
39
39
  --skip-feishu 仅初始化 Console 环境
40
40
  --reconfigure-feishu 仅替换现有飞书凭据
41
41
 
42
+ 重启选项:
43
+ --task <任务> 将安全重启状态发回任务所在会话
44
+ --reason <原因> 设置重启原因
45
+ --immediate 立即重启,可能中断任务
46
+
42
47
  示例:
43
48
  agentbot init
44
49
  agentbot --profile ~/.agent-bot init --reset
@@ -62,7 +67,7 @@ Common commands:
62
67
  console Open the local console
63
68
  server start Start Agent Bot in the background
64
69
  server status Show the server, Lark App ID, and safe-restart status
65
- server restart Schedule a safe restart
70
+ server restart [--task <task>] Schedule a safe restart and return status to the task's conversation
66
71
  server stop Stop Agent Bot
67
72
  task list List tasks
68
73
  task status <task> Show task status
@@ -87,6 +92,11 @@ Init options:
87
92
  --skip-feishu Initialize for Console only
88
93
  --reconfigure-feishu Replace only the existing Lark credentials
89
94
 
95
+ Restart options:
96
+ --task <task> Return safe-restart status to the task's conversation
97
+ --reason <reason> Set the restart reason
98
+ --immediate Restart immediately and possibly interrupt tasks
99
+
90
100
  Examples:
91
101
  agentbot init
92
102
  agentbot --profile ~/.agent-bot init --reset
package/src/cli.ts CHANGED
@@ -65,6 +65,7 @@ import {
65
65
  type ServerStartResult,
66
66
  } from "./cli/ServerStarter.js";
67
67
  import { taskChatRoute } from "./cli/taskChatRoute.js";
68
+ import { isThreadContextKey } from "./feishu/contextKey.js";
68
69
  import { refreshedSystemEnvironment } from "./supervision/systemEnvironment.js";
69
70
  import {
70
71
  parseTaskForkGroupOptions,
@@ -136,6 +137,7 @@ interface InitCommandResult extends InitializationResult {
136
137
  feishu: {
137
138
  status: FeishuInitializationStatus;
138
139
  appId?: string;
140
+ userOpenIdStatus?: "configured" | "pending";
139
141
  configuration?: EnsureFeishuAppConfigurationResult;
140
142
  };
141
143
  server: InitializationServerResult;
@@ -448,6 +450,9 @@ async function initializeFeishu(
448
450
  return {
449
451
  status: "skipped",
450
452
  ...(existing.appId ? { appId: existing.appId } : {}),
453
+ ...(existing.appId && existing.appSecret
454
+ ? { userOpenIdStatus: existing.userOpenId ? "configured" as const : "pending" as const }
455
+ : {}),
451
456
  };
452
457
  }
453
458
  if (!options.json) {
@@ -513,6 +518,7 @@ async function initializeFeishu(
513
518
  return {
514
519
  status,
515
520
  appId: credentials.appId,
521
+ userOpenIdStatus: credentials.userOpenId ? "configured" : "pending",
516
522
  configuration,
517
523
  };
518
524
  } finally {
@@ -659,12 +665,14 @@ async function serverCommand(input: string[]): Promise<void> {
659
665
  }
660
666
  if (action === "restart") {
661
667
  const immediate = rest.includes("--immediate") || rest.includes("--force");
668
+ const notificationSessionId = resolveRestartNotificationSessionId(config.storage.sqlitePath, rest);
662
669
  const reason = optionValue(rest, "--reason")
663
670
  ?? (immediate ? "通过 Agent Bot CLI 立即重启" : "通过 Agent Bot CLI 安全重启");
664
671
  ensureOk(await sendControlRequest(endpoint, {
665
672
  action: "server_restart",
666
673
  mode: immediate ? "immediate" : "safe",
667
674
  reason,
675
+ notificationSessionId,
668
676
  }));
669
677
  process.stdout.write(immediate
670
678
  ? cliText("Immediate restart requested.\n", "已请求立即重启。\n")
@@ -674,6 +682,42 @@ async function serverCommand(input: string[]): Promise<void> {
674
682
  throw new Error(cliText(`Unknown server command: ${action}`, `未知的 server 命令:${action}`));
675
683
  }
676
684
 
685
+ function resolveRestartNotificationSessionId(sqlitePath: string, args: string[]): string | undefined {
686
+ const store = new StateStore(sqlitePath);
687
+ try {
688
+ const sessions = store.listAllSessions();
689
+ if (args.includes("--task")) {
690
+ const reference = optionValue(args, "--task")?.trim();
691
+ if (!reference || reference.startsWith("--")) throw new Error(cliText(
692
+ "server restart --task requires a task number or task ID.",
693
+ "server restart --task 需要任务序号或任务 ID。",
694
+ ));
695
+ const session = resolveTask(sessions, reference);
696
+ if (
697
+ isThreadContextKey(session.contextKey)
698
+ && !store.findLatestMessageIdForSession(session.localSessionId, session.contextKey)
699
+ && !store.findLatestMessageIdForContext(session.contextKey)
700
+ ) {
701
+ throw new Error(cliText(
702
+ "The task topic has no message anchor for restart notifications. Send /restart in that topic instead.",
703
+ "该任务话题没有可用于重启通知的消息锚点,请直接在该话题中发送 /restart。",
704
+ ));
705
+ }
706
+ return session.localSessionId;
707
+ }
708
+ const runningContexts = new Set(
709
+ sessions.filter((session) => session.status === "running").map((session) => session.contextKey),
710
+ );
711
+ if (runningContexts.size > 1) throw new Error(cliText(
712
+ "Multiple conversations are running. Pass --task <task> so restart notifications return to the requester.",
713
+ "当前有多个会话正在运行。请传入 --task <任务>,确保重启通知返回发起会话。",
714
+ ));
715
+ return undefined;
716
+ } finally {
717
+ store.close();
718
+ }
719
+ }
720
+
677
721
  async function taskCommand(input: string[]): Promise<void> {
678
722
  const [action = "list", ...rest] = input;
679
723
  const config = loadConfig();
@@ -945,6 +989,12 @@ function printInitializationResult(result: Omit<InitCommandResult, "server">): v
945
989
  "飞书应用:已跳过;请使用 Console 模式或稍后重新运行 init。\n",
946
990
  ));
947
991
  }
992
+ if (result.feishu.userOpenIdStatus === "pending") {
993
+ process.stdout.write(cliText(
994
+ "Lark user: pending; send the bot a direct message to save FEISHU_USER_OPEN_ID\n",
995
+ "飞书用户:等待补全;请私聊机器人以保存 FEISHU_USER_OPEN_ID\n",
996
+ ));
997
+ }
948
998
  if (result.feishu.configuration?.status === "updated") {
949
999
  const added = result.feishu.configuration.added;
950
1000
  process.stdout.write(cliText(
@@ -984,6 +1034,13 @@ function printInitializationServerResult(result: InitCommandResult["server"]): v
984
1034
  ));
985
1035
  return;
986
1036
  }
1037
+ if (result.status === "restart-scheduled") {
1038
+ process.stdout.write(cliText(
1039
+ "Agent Bot server: safe restart scheduled to load the current version.\n",
1040
+ "Agent Bot 服务:已安排安全重启,以加载当前版本。\n",
1041
+ ));
1042
+ return;
1043
+ }
987
1044
  printServerStartResult(result);
988
1045
  }
989
1046
 
@@ -20,6 +20,7 @@ const COMMAND_NAMES = [
20
20
  "switch",
21
21
  "thinking",
22
22
  "title",
23
+ "turns",
23
24
  ] as const;
24
25
 
25
26
  type CommandName = (typeof COMMAND_NAMES)[number];
@@ -93,6 +94,9 @@ export class CommandRouter {
93
94
  if (args.length === 0) return { type: "restart" };
94
95
  if (args.length === 1 && args[0] === "--force") return { type: "restart", force: true };
95
96
  throw new Error("/restart 只接受一个可选的 --force 参数。");
97
+ case "turns":
98
+ if (args.length > 0) throw new Error("/turns 不接受参数,请在历史轮次卡片中选择 turn。");
99
+ return { type: "turns" };
96
100
  case "model":
97
101
  return settingsCommand("/model", args, { type: "model" });
98
102
  case "provider":
@@ -15,6 +15,7 @@ export type Command =
15
15
  | { type: "goal"; action: "set" | "edit"; objective: string }
16
16
  | { type: "goal"; action: "pause" | "resume" | "clear" }
17
17
  | { type: "restart"; force?: boolean }
18
+ | { type: "turns" }
18
19
  | { type: "model" }
19
20
  | { type: "provider" }
20
21
  | { type: "thinking" }
@@ -0,0 +1,81 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { parse as parseDotEnv } from "dotenv";
4
+
5
+ const USER_OPEN_ID_KEY = "FEISHU_USER_OPEN_ID";
6
+
7
+ export interface FeishuUserOpenIdPersistenceResult {
8
+ status: "stored" | "existing";
9
+ userOpenId: string;
10
+ }
11
+
12
+ export function persistFeishuUserOpenIdIfMissing(
13
+ envPath: string,
14
+ userOpenId: string,
15
+ ): FeishuUserOpenIdPersistenceResult {
16
+ if (!/^ou_[A-Za-z0-9_-]+$/u.test(userOpenId)) {
17
+ throw new Error("Cannot persist an invalid Lark user Open ID.");
18
+ }
19
+
20
+ const original = fs.existsSync(envPath) ? fs.readFileSync(envPath, "utf8") : "";
21
+ const existing = parseDotEnv(original)[USER_OPEN_ID_KEY]?.trim();
22
+ if (existing) return { status: "existing", userOpenId: existing };
23
+
24
+ fs.mkdirSync(path.dirname(envPath), { recursive: true });
25
+ writeFileAtomically(envPath, upsertDotEnvValue(original, USER_OPEN_ID_KEY, userOpenId));
26
+ return { status: "stored", userOpenId };
27
+ }
28
+
29
+ function upsertDotEnvValue(contents: string, key: string, value: string): string {
30
+ const newline = contents.includes("\r\n") ? "\r\n" : "\n";
31
+ const lines = contents.length > 0 ? contents.split(/\r?\n/u) : [];
32
+ const matcher = new RegExp(`^\\s*(?:export\\s+)?${key}\\s*=`);
33
+ let replaced = false;
34
+ const updated = lines.map((line) => {
35
+ if (!matcher.test(line)) return line;
36
+ replaced = true;
37
+ return `${key}=${value}`;
38
+ });
39
+ while (updated.length > 0 && updated.at(-1) === "") updated.pop();
40
+ if (!replaced) updated.push(`${key}=${value}`);
41
+ return `${updated.join(newline)}${newline}`;
42
+ }
43
+
44
+ function writeFileAtomically(filePath: string, contents: string): void {
45
+ const temporaryPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
46
+ let temporaryFile: number | undefined;
47
+ try {
48
+ const mode = fs.statSync(filePath, { throwIfNoEntry: false })?.mode ?? 0o600;
49
+ temporaryFile = fs.openSync(temporaryPath, "wx", mode);
50
+ fs.writeFileSync(temporaryFile, contents, "utf8");
51
+ fs.fsyncSync(temporaryFile);
52
+ fs.closeSync(temporaryFile);
53
+ temporaryFile = undefined;
54
+ fs.renameSync(temporaryPath, filePath);
55
+ restrictFilePermissions(filePath);
56
+ syncDirectory(path.dirname(filePath));
57
+ } finally {
58
+ if (temporaryFile !== undefined) fs.closeSync(temporaryFile);
59
+ fs.rmSync(temporaryPath, { force: true });
60
+ }
61
+ }
62
+
63
+ function restrictFilePermissions(filePath: string): void {
64
+ try {
65
+ fs.chmodSync(filePath, 0o600);
66
+ } catch {
67
+ // Windows and some mounted filesystems do not implement POSIX modes.
68
+ }
69
+ }
70
+
71
+ function syncDirectory(directoryPath: string): void {
72
+ let directory: number | undefined;
73
+ try {
74
+ directory = fs.openSync(directoryPath, "r");
75
+ fs.fsyncSync(directory);
76
+ } catch {
77
+ // Directory fsync is not supported on every Windows or mounted filesystem.
78
+ } finally {
79
+ if (directory !== undefined) fs.closeSync(directory);
80
+ }
81
+ }
@@ -58,6 +58,9 @@ logging:
58
58
  export function loadConfig(
59
59
  configPath?: string,
60
60
  ): AppConfig {
61
+ if (process.env.FEISHU_USER_OPEN_ID !== undefined && !process.env.FEISHU_USER_OPEN_ID.trim()) {
62
+ delete process.env.FEISHU_USER_OPEN_ID;
63
+ }
61
64
  loadDotEnv({
62
65
  path: defaultDotEnvPath(),
63
66
  quiet: true,
@@ -142,6 +142,19 @@ export interface TaskListCardAction {
142
142
  export interface TaskListCardEntry {
143
143
  lines: string[];
144
144
  actions?: TaskListCardAction[];
145
+ current?: boolean;
146
+ }
147
+
148
+ export interface ResetHistoryCardEntry extends TaskListCardEntry {
149
+ sequence: number;
150
+ graphNodeLine: string;
151
+ graphConnectorLine?: string;
152
+ }
153
+
154
+ export interface ResetHistoryCardView {
155
+ entries: ResetHistoryCardEntry[];
156
+ footerLines: string[];
157
+ pageActions: TaskListCardAction[];
145
158
  }
146
159
 
147
160
  export class CardRenderer {
@@ -662,6 +675,18 @@ export class CardRenderer {
662
675
  type: "danger",
663
676
  value: { action: "turn_cancel", sessionId: state.sessionId, turnId: state.turnId },
664
677
  }]));
678
+ } else if (state.status === "completed") {
679
+ elements.push(
680
+ { tag: "hr" },
681
+ taskActionRow([{
682
+ text: "Reset",
683
+ value: { action: "turn_reset", sessionId: state.sessionId, turnId: state.turnId },
684
+ }]),
685
+ {
686
+ ...markdown("<font color='grey'>Reset 会将当前任务的对话上下文恢复到本轮完成时;不会回退本地文件。</font>"),
687
+ text_size: "notation",
688
+ },
689
+ );
665
690
  }
666
691
  return turnCard(
667
692
  turnTitle(state.status, state.prompt ?? state.taskTitle),
@@ -774,7 +799,7 @@ export class CardRenderer {
774
799
  sectionTitle: string,
775
800
  entries: TaskListCardEntry[],
776
801
  footerLines: string[],
777
- footerAction?: TaskListCardAction,
802
+ footerActions: TaskListCardAction[] = [],
778
803
  ): Record<string, unknown> {
779
804
  const elements: Record<string, unknown>[] = [markdown(`**${sectionTitle}**`)];
780
805
  if (entries.length === 0) {
@@ -791,27 +816,7 @@ export class CardRenderer {
791
816
  if (footerLines.length > 0) {
792
817
  elements.push({ tag: "hr" }, markdown(footerLines.join("\n")));
793
818
  }
794
- if (footerAction) {
795
- elements.push({
796
- tag: "column_set",
797
- flex_mode: "none",
798
- columns: [{
799
- tag: "column",
800
- width: "weighted",
801
- weight: 1,
802
- elements: [{
803
- tag: "button",
804
- text: { tag: "plain_text", content: footerAction.text },
805
- type: footerAction.type ?? "default",
806
- width: "fill",
807
- behaviors: [{
808
- type: "callback",
809
- value: footerAction.value,
810
- }],
811
- }],
812
- }],
813
- });
814
- }
819
+ if (footerActions.length > 0) elements.push(taskActionRow(footerActions));
815
820
  return {
816
821
  schema: "2.0",
817
822
  config: {
@@ -831,6 +836,39 @@ export class CardRenderer {
831
836
  };
832
837
  }
833
838
 
839
+ renderResetHistoryCard(view: ResetHistoryCardView): Record<string, unknown> {
840
+ const elements: Record<string, unknown>[] = [
841
+ {
842
+ ...markdown("<font color='grey'>Reset 会将当前任务的对话上下文恢复到所选轮次完成时;不会回退本地文件。</font>"),
843
+ text_size: "notation",
844
+ },
845
+ { tag: "hr" },
846
+ markdown("**历史对话轮次**"),
847
+ ];
848
+ if (view.entries.length === 0) {
849
+ elements.push(markdown("当前任务还没有成功完成的 turn。"));
850
+ } else {
851
+ view.entries.forEach((entry) => {
852
+ const action = entry.actions?.[0];
853
+ elements.push(resetHistoryEntryRow(
854
+ entry.graphNodeLine,
855
+ entry.graphConnectorLine,
856
+ entry.lines,
857
+ action,
858
+ entry.current === true,
859
+ ));
860
+ });
861
+ }
862
+ if (view.footerLines.length > 0) {
863
+ elements.push({ tag: "hr" }, {
864
+ ...markdown(view.footerLines.join("\n")),
865
+ text_size: "notation",
866
+ });
867
+ }
868
+ if (view.pageActions.length > 0) elements.push(taskActionRow(view.pageActions));
869
+ return sectionCard("历史对话轮次", elements);
870
+ }
871
+
834
872
  private baseCard(title: string, template: string, elements: unknown[]): Record<string, unknown> {
835
873
  return {
836
874
  config: {
@@ -1470,6 +1508,55 @@ function settingsOptionRow(input: {
1470
1508
  };
1471
1509
  }
1472
1510
 
1511
+ function resetHistoryEntryRow(
1512
+ graphNodeLine: string,
1513
+ graphConnectorLine: string | undefined,
1514
+ lines: string[],
1515
+ action: TaskListCardAction | undefined,
1516
+ current: boolean,
1517
+ ): Record<string, unknown> {
1518
+ const graph = [
1519
+ `<font color='${current ? "green" : "blue"}'>${escapeCardHtml(graphNodeLine)}</font>`,
1520
+ ...(graphConnectorLine
1521
+ ? [`<font color='grey'>${escapeCardHtml(graphConnectorLine)}</font>`]
1522
+ : []),
1523
+ ].join("\n");
1524
+ return {
1525
+ tag: "column_set",
1526
+ flex_mode: "none",
1527
+ horizontal_spacing: "12px",
1528
+ vertical_align: "top",
1529
+ columns: [
1530
+ {
1531
+ tag: "column",
1532
+ width: "auto",
1533
+ vertical_align: "top",
1534
+ elements: [{
1535
+ ...markdown(graph),
1536
+ text_align: "center",
1537
+ }],
1538
+ },
1539
+ {
1540
+ tag: "column",
1541
+ width: "weighted",
1542
+ weight: 1,
1543
+ vertical_align: "top",
1544
+ elements: [markdown(lines.join("\n"))],
1545
+ },
1546
+ {
1547
+ tag: "column",
1548
+ width: "auto",
1549
+ vertical_align: "top",
1550
+ elements: current
1551
+ ? [markdown("✅ 当前")]
1552
+ : action
1553
+ ? [taskActionElement(action)]
1554
+ : [],
1555
+ },
1556
+ ],
1557
+ };
1558
+ }
1559
+
1473
1560
  function taskActionRow(actions: TaskListCardAction[]): Record<string, unknown> {
1474
1561
  return {
1475
1562
  tag: "column_set",