@keyou007/agent-bot 0.1.13-alpha.0 → 0.1.13-alpha.2

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 (97) hide show
  1. package/.env.example +2 -2
  2. package/CHANGELOG.md +15 -0
  3. package/README.md +107 -98
  4. package/README.zh.md +81 -72
  5. package/dist/acp/AcpProcessManager.js +8 -3
  6. package/dist/acp/AcpProcessManager.js.map +1 -1
  7. package/dist/acp/AcpSessionManager.js +15 -0
  8. package/dist/acp/AcpSessionManager.js.map +1 -1
  9. package/dist/cli/ServerStarter.js +17 -2
  10. package/dist/cli/ServerStarter.js.map +1 -1
  11. package/dist/cli/controlProtocol.js.map +1 -1
  12. package/dist/cli/help.js +16 -4
  13. package/dist/cli/help.js.map +1 -1
  14. package/dist/cli/serverStatus.js +27 -0
  15. package/dist/cli/serverStatus.js.map +1 -1
  16. package/dist/cli/taskListOutput.js +72 -0
  17. package/dist/cli/taskListOutput.js.map +1 -0
  18. package/dist/cli.js +88 -54
  19. package/dist/cli.js.map +1 -1
  20. package/dist/codex/CodexProcessManager.js +34 -4
  21. package/dist/codex/CodexProcessManager.js.map +1 -1
  22. package/dist/codex/CodexRuntime.js +3 -0
  23. package/dist/codex/CodexRuntime.js.map +1 -1
  24. package/dist/commands/CommandRouter.js +5 -0
  25. package/dist/commands/CommandRouter.js.map +1 -1
  26. package/dist/config/FeishuUserOpenIdStore.js +76 -0
  27. package/dist/config/FeishuUserOpenIdStore.js.map +1 -0
  28. package/dist/config/loadConfig.js +3 -0
  29. package/dist/config/loadConfig.js.map +1 -1
  30. package/dist/feishu/CardRenderer.js +123 -22
  31. package/dist/feishu/CardRenderer.js.map +1 -1
  32. package/dist/feishu/FeishuConnector.js +16 -5
  33. package/dist/feishu/FeishuConnector.js.map +1 -1
  34. package/dist/index.js +125 -26
  35. package/dist/index.js.map +1 -1
  36. package/dist/presentation/turnGraph.js +36 -0
  37. package/dist/presentation/turnGraph.js.map +1 -0
  38. package/dist/proxy/ProxySessionController.js +425 -104
  39. package/dist/proxy/ProxySessionController.js.map +1 -1
  40. package/dist/runtime/AcpRuntimeAdapter.js +3 -0
  41. package/dist/runtime/AcpRuntimeAdapter.js.map +1 -1
  42. package/dist/runtime/agentEnvironment.js +24 -4
  43. package/dist/runtime/agentEnvironment.js.map +1 -1
  44. package/dist/runtime/createAgentRuntimeRegistry.js +7 -3
  45. package/dist/runtime/createAgentRuntimeRegistry.js.map +1 -1
  46. package/dist/startup/StartupNotifier.js +16 -7
  47. package/dist/startup/StartupNotifier.js.map +1 -1
  48. package/dist/startup/startFeishu.js +2 -2
  49. package/dist/startup/startFeishu.js.map +1 -1
  50. package/dist/state/StateStore.js +172 -0
  51. package/dist/state/StateStore.js.map +1 -1
  52. package/dist/state/migrations.js +21 -0
  53. package/dist/state/migrations.js.map +1 -1
  54. package/dist/supervision/SafeRestartNotifier.js +46 -3
  55. package/dist/supervision/SafeRestartNotifier.js.map +1 -1
  56. package/dist/supervision/SafeRestartScheduler.js +65 -6
  57. package/dist/supervision/SafeRestartScheduler.js.map +1 -1
  58. package/dist/supervision/replacementSupervisor.js +27 -2
  59. package/dist/supervision/replacementSupervisor.js.map +1 -1
  60. package/dist/supervisor.js +27 -2
  61. package/dist/supervisor.js.map +1 -1
  62. package/docs/technical-reference.md +20 -7
  63. package/docs/technical-reference.zh.md +20 -7
  64. package/npm-shrinkwrap.json +2 -2
  65. package/package.json +1 -1
  66. package/skills/agent-bot/SKILL.md +63 -26
  67. package/src/acp/AcpProcessManager.ts +16 -3
  68. package/src/acp/AcpSessionManager.ts +14 -0
  69. package/src/cli/ServerStarter.ts +25 -2
  70. package/src/cli/controlProtocol.ts +6 -1
  71. package/src/cli/help.ts +16 -4
  72. package/src/cli/serverStatus.ts +28 -0
  73. package/src/cli/taskListOutput.ts +91 -0
  74. package/src/cli.ts +123 -51
  75. package/src/codex/CodexProcessManager.ts +38 -3
  76. package/src/codex/CodexRuntime.ts +6 -0
  77. package/src/commands/CommandRouter.ts +4 -0
  78. package/src/commands/commandTypes.ts +1 -0
  79. package/src/config/FeishuUserOpenIdStore.ts +81 -0
  80. package/src/config/loadConfig.ts +3 -0
  81. package/src/feishu/CardRenderer.ts +169 -22
  82. package/src/feishu/FeishuConnector.ts +13 -4
  83. package/src/index.ts +195 -23
  84. package/src/presentation/turnGraph.ts +48 -0
  85. package/src/proxy/ProxySessionController.ts +507 -112
  86. package/src/runtime/AcpRuntimeAdapter.ts +5 -0
  87. package/src/runtime/agentEnvironment.ts +41 -3
  88. package/src/runtime/createAgentRuntimeRegistry.ts +18 -3
  89. package/src/runtime/types.ts +5 -0
  90. package/src/startup/StartupNotifier.ts +23 -7
  91. package/src/startup/startFeishu.ts +3 -2
  92. package/src/state/StateStore.ts +224 -0
  93. package/src/state/migrations.ts +21 -0
  94. package/src/supervision/SafeRestartNotifier.ts +53 -4
  95. package/src/supervision/SafeRestartScheduler.ts +78 -7
  96. package/src/supervision/replacementSupervisor.ts +27 -1
  97. package/src/supervisor.ts +33 -2
@@ -0,0 +1,91 @@
1
+ import type { SessionRecord } from "../state/StateStore.js";
2
+ import { cliLanguage, cliText, type CliLanguage } from "./i18n.js";
3
+
4
+ export type CurrentTaskResolution =
5
+ | { status: "found"; session: SessionRecord }
6
+ | { status: "missing-thread-id" }
7
+ | { status: "not-found"; threadIds: string[] }
8
+ | { status: "ambiguous"; sessions: SessionRecord[]; threadIds: string[] };
9
+
10
+ export function formatTaskList(
11
+ sessions: SessionRecord[],
12
+ language: CliLanguage = cliLanguage,
13
+ currentThreadIds: readonly string[] = [],
14
+ ): string {
15
+ if (sessions.length === 0) return cliText("No tasks.\n", "没有任务。\n", language);
16
+
17
+ const current = resolveCurrentTask(sessions, currentThreadIds);
18
+ const currentLocalSessionId = current.status === "found" ? current.session.localSessionId : undefined;
19
+ return sessions.map((session, index) => {
20
+ const title = session.title ?? cliText("Untitled task", "未命名任务", language);
21
+ const current = session.localSessionId === currentLocalSessionId
22
+ ? ` [${cliText("Current", "当前任务", language)}]`
23
+ : "";
24
+ const nativeId = nativeTaskIdLabel(session, language);
25
+ return [
26
+ `${index + 1}.${current} [${taskStateLabel(session.status, language)}/${taskStateLabel(session.lastTurnStatus, language)}] ${title}`,
27
+ ` ${cliText("Agent: ", "Agent:", language)}${session.agentName} · ${cliText("AgentBot task ID: ", "AgentBot 任务 ID:", language)}${session.localSessionId}`,
28
+ ` ${nativeId ? `${nativeId} · ` : ""}${session.contextKey} · ${session.updatedAt}`,
29
+ ].join("\n");
30
+ }).join("\n") + "\n";
31
+ }
32
+
33
+ export function currentAppServerThreadIds(env: NodeJS.ProcessEnv): string[] {
34
+ return [...new Set([
35
+ env.CODEX_THREAD_ID?.trim(),
36
+ env.TRAECLI_THREAD_ID?.trim(),
37
+ ].filter((value): value is string => Boolean(value)))];
38
+ }
39
+
40
+ export function resolveCurrentTask(
41
+ sessions: SessionRecord[],
42
+ currentThreadIds: readonly string[],
43
+ ): CurrentTaskResolution {
44
+ const threadIds = [...new Set(currentThreadIds.map((value) => value.trim()).filter(Boolean))];
45
+ if (threadIds.length === 0) return { status: "missing-thread-id" };
46
+
47
+ const candidates = new Set(threadIds);
48
+ const matches = sessions.filter((session) => session.remoteSessionId && candidates.has(session.remoteSessionId));
49
+ if (matches.length === 0) return { status: "not-found", threadIds };
50
+ if (matches.length === 1) return { status: "found", session: matches[0]! };
51
+
52
+ const running = matches.filter((session) =>
53
+ session.status === "running" || session.lastTurnStatus === "running");
54
+ if (running.length === 1) return { status: "found", session: running[0]! };
55
+ return { status: "ambiguous", sessions: matches, threadIds };
56
+ }
57
+
58
+ export function taskStateLabel(
59
+ status: string | undefined,
60
+ language: CliLanguage = cliLanguage,
61
+ ): string {
62
+ if (!status) return "-";
63
+ const chinese: Record<string, string> = {
64
+ starting: "启动中",
65
+ ready: "就绪",
66
+ running: "运行中",
67
+ closed: "已关闭",
68
+ failed: "失败",
69
+ completed: "已完成",
70
+ cancelled: "已取消",
71
+ interrupted: "已中断",
72
+ inProgress: "进行中",
73
+ active: "活动中",
74
+ idle: "空闲",
75
+ not_loaded: "未加载",
76
+ error: "错误",
77
+ };
78
+ return language === "zh" ? chinese[status] ?? status : status;
79
+ }
80
+
81
+ function nativeTaskIdLabel(session: SessionRecord, language: CliLanguage): string | undefined {
82
+ if (session.runtimeKind === "acp" || session.acpSessionId) {
83
+ const sessionId = session.acpSessionId ?? session.remoteSessionId;
84
+ return sessionId
85
+ ? `${cliText("ACP session ID: ", "ACP Session ID:", language)}${sessionId}`
86
+ : undefined;
87
+ }
88
+ return session.remoteSessionId
89
+ ? `${cliText("App Server thread ID: ", "App Server 原生任务 ID(Thread ID):", language)}${session.remoteSessionId}`
90
+ : undefined;
91
+ }
package/src/cli.ts CHANGED
@@ -65,6 +65,13 @@ import {
65
65
  type ServerStartResult,
66
66
  } from "./cli/ServerStarter.js";
67
67
  import { taskChatRoute } from "./cli/taskChatRoute.js";
68
+ import {
69
+ currentAppServerThreadIds,
70
+ formatTaskList,
71
+ resolveCurrentTask,
72
+ taskStateLabel,
73
+ } from "./cli/taskListOutput.js";
74
+ import { isThreadContextKey } from "./feishu/contextKey.js";
68
75
  import { refreshedSystemEnvironment } from "./supervision/systemEnvironment.js";
69
76
  import {
70
77
  parseTaskForkGroupOptions,
@@ -136,6 +143,7 @@ interface InitCommandResult extends InitializationResult {
136
143
  feishu: {
137
144
  status: FeishuInitializationStatus;
138
145
  appId?: string;
146
+ userOpenIdStatus?: "configured" | "pending";
139
147
  configuration?: EnsureFeishuAppConfigurationResult;
140
148
  };
141
149
  server: InitializationServerResult;
@@ -448,6 +456,9 @@ async function initializeFeishu(
448
456
  return {
449
457
  status: "skipped",
450
458
  ...(existing.appId ? { appId: existing.appId } : {}),
459
+ ...(existing.appId && existing.appSecret
460
+ ? { userOpenIdStatus: existing.userOpenId ? "configured" as const : "pending" as const }
461
+ : {}),
451
462
  };
452
463
  }
453
464
  if (!options.json) {
@@ -513,6 +524,7 @@ async function initializeFeishu(
513
524
  return {
514
525
  status,
515
526
  appId: credentials.appId,
527
+ userOpenIdStatus: credentials.userOpenId ? "configured" : "pending",
516
528
  configuration,
517
529
  };
518
530
  } finally {
@@ -659,12 +671,14 @@ async function serverCommand(input: string[]): Promise<void> {
659
671
  }
660
672
  if (action === "restart") {
661
673
  const immediate = rest.includes("--immediate") || rest.includes("--force");
674
+ const notificationSessionId = resolveRestartNotificationSessionId(config.storage.sqlitePath, rest);
662
675
  const reason = optionValue(rest, "--reason")
663
676
  ?? (immediate ? "通过 Agent Bot CLI 立即重启" : "通过 Agent Bot CLI 安全重启");
664
677
  ensureOk(await sendControlRequest(endpoint, {
665
678
  action: "server_restart",
666
679
  mode: immediate ? "immediate" : "safe",
667
680
  reason,
681
+ notificationSessionId,
668
682
  }));
669
683
  process.stdout.write(immediate
670
684
  ? cliText("Immediate restart requested.\n", "已请求立即重启。\n")
@@ -674,12 +688,58 @@ async function serverCommand(input: string[]): Promise<void> {
674
688
  throw new Error(cliText(`Unknown server command: ${action}`, `未知的 server 命令:${action}`));
675
689
  }
676
690
 
691
+ function resolveRestartNotificationSessionId(sqlitePath: string, args: string[]): string | undefined {
692
+ const store = new StateStore(sqlitePath);
693
+ try {
694
+ const sessions = store.listAllSessions();
695
+ if (args.includes("--task")) {
696
+ const reference = optionValue(args, "--task")?.trim();
697
+ if (!reference || reference.startsWith("--")) throw new Error(cliText(
698
+ "server restart --task requires a task number or task ID.",
699
+ "server restart --task 需要任务序号或任务 ID。",
700
+ ));
701
+ const session = resolveTask(sessions, reference);
702
+ if (
703
+ isThreadContextKey(session.contextKey)
704
+ && !store.findLatestMessageIdForSession(session.localSessionId, session.contextKey)
705
+ && !store.findLatestMessageIdForContext(session.contextKey)
706
+ ) {
707
+ throw new Error(cliText(
708
+ "The task topic has no message anchor for restart notifications. Send /restart in that topic instead.",
709
+ "该任务话题没有可用于重启通知的消息锚点,请直接在该话题中发送 /restart。",
710
+ ));
711
+ }
712
+ return session.localSessionId;
713
+ }
714
+ const runningContexts = new Set(
715
+ sessions.filter((session) => session.status === "running").map((session) => session.contextKey),
716
+ );
717
+ if (runningContexts.size > 1) throw new Error(cliText(
718
+ "Multiple conversations are running. Pass --task <task> so restart notifications return to the requester.",
719
+ "当前有多个会话正在运行。请传入 --task <任务>,确保重启通知返回发起会话。",
720
+ ));
721
+ return undefined;
722
+ } finally {
723
+ store.close();
724
+ }
725
+ }
726
+
677
727
  async function taskCommand(input: string[]): Promise<void> {
678
728
  const [action = "list", ...rest] = input;
679
729
  const config = loadConfig();
680
730
  const store = new StateStore(config.storage.sqlitePath);
681
731
  try {
682
732
  const allSessions = store.listAllSessions();
733
+ if (action === "current") {
734
+ const unsupported = rest.filter((value) => value !== "--json");
735
+ if (unsupported.length > 0) throw new Error(cliText(
736
+ `task current does not accept arguments: ${unsupported.join(" ")}`,
737
+ `task current 不接受这些参数:${unsupported.join(" ")}`,
738
+ ));
739
+ const session = resolveCurrentTaskFromEnvironment(allSessions);
740
+ await outputTaskStatus(config.storage.sqlitePath, store, session, rest.includes("--json"));
741
+ return;
742
+ }
683
743
  if (action === "prompt" || action === "send") {
684
744
  const [reference, ...promptParts] = rest;
685
745
  if (!reference) throw new Error(cliText(
@@ -765,28 +825,7 @@ async function taskCommand(input: string[]): Promise<void> {
765
825
  return;
766
826
  }
767
827
  if (action === "status") {
768
- const endpoint = controlEndpoint(config.storage.sqlitePath);
769
- let live: TaskStatusControlData | undefined;
770
- let response: ControlResponse | undefined;
771
- try {
772
- response = await sendControlRequest(endpoint, {
773
- action: "task_status",
774
- localSessionId: session.localSessionId,
775
- });
776
- } catch {
777
- // The server can be stopped or still run an older control protocol
778
- // while a safe restart is pending. Preserve the local read-only fallback.
779
- }
780
- if (response) {
781
- ensureOk(response);
782
- live = response.data as TaskStatusControlData;
783
- }
784
- const statusSession = live?.session ?? session;
785
- const snapshot = live?.snapshot
786
- ?? (statusSession.lastTurnId ? store.getTurnSnapshot(statusSession.lastTurnId) : undefined);
787
- const result = { ...statusSession, snapshot, ...(live?.remote ? { remote: live.remote } : {}) };
788
- if (rest.includes("--json")) printJson(result);
789
- else printTaskStatus(statusSession, snapshot, live?.remote);
828
+ await outputTaskStatus(config.storage.sqlitePath, store, session, rest.includes("--json"));
790
829
  return;
791
830
  }
792
831
  const endpoint = controlEndpoint(config.storage.sqlitePath);
@@ -818,6 +857,53 @@ async function taskCommand(input: string[]): Promise<void> {
818
857
  }
819
858
  }
820
859
 
860
+ function resolveCurrentTaskFromEnvironment(sessions: SessionRecord[]): SessionRecord {
861
+ const resolution = resolveCurrentTask(sessions, currentAppServerThreadIds(process.env));
862
+ if (resolution.status === "found") return resolution.session;
863
+ if (resolution.status === "missing-thread-id") throw new Error(cliText(
864
+ "The current task cannot be detected because neither CODEX_THREAD_ID nor TRAECLI_THREAD_ID is set. Run this command from a Codex or TraeX task.",
865
+ "无法检测当前任务:CODEX_THREAD_ID 和 TRAECLI_THREAD_ID 均未设置。请从 Codex 或 TraeX 任务中运行此命令。",
866
+ ));
867
+ if (resolution.status === "not-found") throw new Error(cliText(
868
+ `No AgentBot task matches the current App Server Thread ID (${resolution.threadIds.join(", ")}). Check that the correct Profile is selected.`,
869
+ `没有 AgentBot 任务匹配当前 App Server Thread ID(${resolution.threadIds.join(",")})。请检查是否选择了正确的 Profile。`,
870
+ ));
871
+ throw new Error(cliText(
872
+ "The current task is ambiguous because multiple injected Thread IDs match AgentBot tasks. Use task status <task> with an explicit AgentBot task ID.",
873
+ "当前任务存在歧义:多个注入的 Thread ID 匹配到了 AgentBot 任务。请使用明确的 AgentBot 任务 ID 执行 task status <任务>。",
874
+ ));
875
+ }
876
+
877
+ async function outputTaskStatus(
878
+ sqlitePath: string,
879
+ store: StateStore,
880
+ session: SessionRecord,
881
+ json: boolean,
882
+ ): Promise<void> {
883
+ const endpoint = controlEndpoint(sqlitePath);
884
+ let live: TaskStatusControlData | undefined;
885
+ let response: ControlResponse | undefined;
886
+ try {
887
+ response = await sendControlRequest(endpoint, {
888
+ action: "task_status",
889
+ localSessionId: session.localSessionId,
890
+ });
891
+ } catch {
892
+ // The server can be stopped or still run an older control protocol
893
+ // while a safe restart is pending. Preserve the local read-only fallback.
894
+ }
895
+ if (response) {
896
+ ensureOk(response);
897
+ live = response.data as TaskStatusControlData;
898
+ }
899
+ const statusSession = live?.session ?? session;
900
+ const snapshot = live?.snapshot
901
+ ?? (statusSession.lastTurnId ? store.getTurnSnapshot(statusSession.lastTurnId) : undefined);
902
+ const result = { ...statusSession, snapshot, ...(live?.remote ? { remote: live.remote } : {}) };
903
+ if (json) printJson(result);
904
+ else printTaskStatus(statusSession, snapshot, live?.remote);
905
+ }
906
+
821
907
  function filterSessions(sessions: SessionRecord[], args: string[]): SessionRecord[] {
822
908
  const context = optionValue(args, "--context");
823
909
  const status = optionValue(args, "--status");
@@ -852,19 +938,12 @@ function resolveTask(sessions: SessionRecord[], reference: string): SessionRecor
852
938
  }
853
939
 
854
940
  function printTaskList(sessions: SessionRecord[]): void {
855
- if (sessions.length === 0) {
856
- process.stdout.write(cliText("No tasks.\n", "没有任务。\n"));
857
- return;
858
- }
859
- for (const [index, session] of sessions.entries()) {
860
- const id = session.remoteSessionId ?? session.acpSessionId ?? session.localSessionId;
861
- process.stdout.write(`${index + 1}. [${taskStateLabel(session.status)}/${taskStateLabel(session.lastTurnStatus)}] ${session.title ?? cliText("Untitled task", "未命名任务")}\n`);
862
- process.stdout.write(` ${id} · ${session.contextKey} · ${session.updatedAt}\n`);
863
- }
941
+ process.stdout.write(formatTaskList(sessions, cliLanguage, currentAppServerThreadIds(process.env)));
864
942
  }
865
943
 
866
944
  function printTaskStatus(session: SessionRecord, snapshot: unknown, remote?: TaskStatusControlData["remote"]): void {
867
945
  process.stdout.write(`${cliText("Title: ", "标题:")}${session.title ?? cliText("Untitled task", "未命名任务")}\n`);
946
+ process.stdout.write(`${cliText("Agent: ", "Agent:")}${session.agentName}\n`);
868
947
  process.stdout.write(`${cliText("Task ID: ", "任务 ID:")}${session.remoteSessionId ?? session.acpSessionId ?? session.localSessionId}\n`);
869
948
  process.stdout.write(`${cliText("Local ID: ", "本地 ID:")}${session.localSessionId}\n`);
870
949
  process.stdout.write(`${cliText("Context: ", "上下文:")}${session.contextKey}\n`);
@@ -945,6 +1024,12 @@ function printInitializationResult(result: Omit<InitCommandResult, "server">): v
945
1024
  "飞书应用:已跳过;请使用 Console 模式或稍后重新运行 init。\n",
946
1025
  ));
947
1026
  }
1027
+ if (result.feishu.userOpenIdStatus === "pending") {
1028
+ process.stdout.write(cliText(
1029
+ "Lark user: pending; send the bot a direct message to save FEISHU_USER_OPEN_ID\n",
1030
+ "飞书用户:等待补全;请私聊机器人以保存 FEISHU_USER_OPEN_ID\n",
1031
+ ));
1032
+ }
948
1033
  if (result.feishu.configuration?.status === "updated") {
949
1034
  const added = result.feishu.configuration.added;
950
1035
  process.stdout.write(cliText(
@@ -984,6 +1069,13 @@ function printInitializationServerResult(result: InitCommandResult["server"]): v
984
1069
  ));
985
1070
  return;
986
1071
  }
1072
+ if (result.status === "restart-scheduled") {
1073
+ process.stdout.write(cliText(
1074
+ "Agent Bot server: safe restart scheduled to load the current version.\n",
1075
+ "Agent Bot 服务:已安排安全重启,以加载当前版本。\n",
1076
+ ));
1077
+ return;
1078
+ }
987
1079
  printServerStartResult(result);
988
1080
  }
989
1081
 
@@ -1144,26 +1236,6 @@ function initializationStatusLabel(status: InitializationStatus): string {
1144
1236
  return cliText("already exists; unchanged", "已存在,未修改");
1145
1237
  }
1146
1238
 
1147
- function taskStateLabel(status: string | undefined): string {
1148
- if (!status) return "-";
1149
- const chinese: Record<string, string> = {
1150
- starting: "启动中",
1151
- ready: "就绪",
1152
- running: "运行中",
1153
- closed: "已关闭",
1154
- failed: "失败",
1155
- completed: "已完成",
1156
- cancelled: "已取消",
1157
- interrupted: "已中断",
1158
- inProgress: "进行中",
1159
- active: "活动中",
1160
- idle: "空闲",
1161
- not_loaded: "未加载",
1162
- error: "错误",
1163
- };
1164
- return cliLanguage === "zh" ? chinese[status] ?? status : status;
1165
- }
1166
-
1167
1239
  function ensureOk(response: ControlResponse): void {
1168
1240
  if (response.ok) return;
1169
1241
  const message = response.message?.trim();
@@ -5,12 +5,17 @@ import readline from "node:readline";
5
5
  import type { Logger } from "pino";
6
6
  import { AppServerConnection } from "./AppServerConnection.js";
7
7
  import type { AppServerClient, AppServerClientProvider } from "./CodexRuntime.js";
8
- import { agentBotEnvironment } from "../runtime/agentEnvironment.js";
8
+ import {
9
+ agentBotEnvironment,
10
+ type AgentEnvironmentContext,
11
+ } from "../runtime/agentEnvironment.js";
12
+ import type { AgentProcessInfo } from "../runtime/types.js";
9
13
  import { spawnStdioCommand } from "../utils/spawnCommand.js";
10
14
 
11
15
  export class CodexProcessManager implements AppServerClientProvider {
12
16
  private client?: AppServerConnection;
13
17
  private child?: ChildProcessWithoutNullStreams;
18
+ private version?: string;
14
19
  private readonly disconnectListeners = new Set<(error: Error) => void>();
15
20
 
16
21
  constructor(
@@ -18,11 +23,17 @@ export class CodexProcessManager implements AppServerClientProvider {
18
23
  private readonly args: string[],
19
24
  private readonly env: Record<string, string>,
20
25
  private readonly logger: Logger,
26
+ private readonly environmentContext: () => AgentEnvironmentContext = () => ({}),
21
27
  ) {}
22
28
 
23
29
  async getClient(): Promise<AppServerClient> {
24
30
  if (this.client) return this.client;
25
- const child = spawnStdioCommand(this.command, this.args, agentBotEnvironment(process.env, this.env));
31
+ this.version = undefined;
32
+ const child = spawnStdioCommand(
33
+ this.command,
34
+ this.args,
35
+ agentBotEnvironment(process.env, this.env, this.environmentContext()),
36
+ );
26
37
  this.child = child;
27
38
  const client = new AppServerConnection(child, this.logger.child({ component: "codex-app-server" }));
28
39
  this.client = client;
@@ -38,14 +49,24 @@ export class CodexProcessManager implements AppServerClientProvider {
38
49
  const error = new Error(`App Server exited (code=${code ?? "null"}, signal=${signal ?? "null"}).`);
39
50
  for (const listener of this.disconnectListeners) listener(error);
40
51
  });
41
- await client.request("initialize", {
52
+ const initializeResult = await client.request("initialize", {
42
53
  clientInfo: { name: "agent-bot", title: "Agent Bot", version: "0.1.0" },
43
54
  capabilities: { experimentalApi: true },
44
55
  });
56
+ this.version = initializedAgentVersion(initializeResult);
45
57
  client.notify("initialized", {});
46
58
  return client;
47
59
  }
48
60
 
61
+ getProcessInfo(): AgentProcessInfo {
62
+ const pid = this.child?.pid;
63
+ if (!pid) return {};
64
+ return {
65
+ pid,
66
+ ...(this.version ? { version: this.version } : {}),
67
+ };
68
+ }
69
+
49
70
  getCodexHome(): string {
50
71
  return this.env.CODEX_HOME ?? process.env.CODEX_HOME ?? path.join(os.homedir(), ".codex");
51
72
  }
@@ -62,3 +83,17 @@ export class CodexProcessManager implements AppServerClientProvider {
62
83
  return () => this.disconnectListeners.delete(listener);
63
84
  }
64
85
  }
86
+
87
+ function initializedAgentVersion(value: unknown): string | undefined {
88
+ if (!value || typeof value !== "object") return undefined;
89
+ const result = value as Record<string, unknown>;
90
+ const serverInfo = result.serverInfo && typeof result.serverInfo === "object"
91
+ ? result.serverInfo as Record<string, unknown>
92
+ : undefined;
93
+ for (const candidate of [serverInfo?.version, result.version, result.userAgent]) {
94
+ if (typeof candidate !== "string") continue;
95
+ const match = /\b(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)\b/u.exec(candidate);
96
+ if (match?.[1]) return match[1];
97
+ }
98
+ return undefined;
99
+ }
@@ -1,6 +1,7 @@
1
1
  import type { Logger } from "pino";
2
2
  import type {
3
3
  AgentRuntime,
4
+ AgentProcessInfo,
4
5
  ApprovalDecision,
5
6
  CreateRuntimeSessionInput,
6
7
  ForkRuntimeSessionInput,
@@ -55,6 +56,7 @@ export interface AppServerClient {
55
56
 
56
57
  export interface AppServerClientProvider {
57
58
  getClient(): Promise<AppServerClient>;
59
+ getProcessInfo?(): AgentProcessInfo;
58
60
  getCodexHome?(): string;
59
61
  onDisconnect?(listener: (error: Error) => void): () => void;
60
62
  close(): void;
@@ -100,6 +102,10 @@ export class CodexRuntime implements AgentRuntime {
100
102
  return this.sessions.get(localSessionId);
101
103
  }
102
104
 
105
+ getProcessInfo(): AgentProcessInfo {
106
+ return this.provider.getProcessInfo?.() ?? {};
107
+ }
108
+
103
109
  async createSession(input: CreateRuntimeSessionInput): Promise<RuntimeSession> {
104
110
  const client = await this.client();
105
111
  const response = await client.request<ThreadResponse>("thread/start", {
@@ -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,