@keyou007/agent-bot 0.1.24-alpha.5 → 0.1.24-alpha.6

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 (96) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +27 -3
  3. package/README.zh.md +27 -3
  4. package/SECURITY.md +12 -0
  5. package/config.example.yaml +5 -0
  6. package/dist/cli/controlProtocol.js.map +1 -1
  7. package/dist/cli/serverStatus.js +1 -0
  8. package/dist/cli/serverStatus.js.map +1 -1
  9. package/dist/cli.js +6 -1
  10. package/dist/cli.js.map +1 -1
  11. package/dist/codex/CodexRuntime.js +202 -115
  12. package/dist/codex/CodexRuntime.js.map +1 -1
  13. package/dist/codex/ProviderSwitch.js +12 -13
  14. package/dist/codex/ProviderSwitch.js.map +1 -1
  15. package/dist/config/AgentExecutionDefaultsStore.js +4 -0
  16. package/dist/config/AgentExecutionDefaultsStore.js.map +1 -1
  17. package/dist/config/loadConfig.js +4 -0
  18. package/dist/config/loadConfig.js.map +1 -1
  19. package/dist/config/schema.js +3 -0
  20. package/dist/config/schema.js.map +1 -1
  21. package/dist/feishu/CardRenderer.js +66 -8
  22. package/dist/feishu/CardRenderer.js.map +1 -1
  23. package/dist/feishu/FeishuConnector.js +1 -0
  24. package/dist/feishu/FeishuConnector.js.map +1 -1
  25. package/dist/feishu/FeishuTurnPresenter.js +121 -17
  26. package/dist/feishu/FeishuTurnPresenter.js.map +1 -1
  27. package/dist/index.js +43 -6
  28. package/dist/index.js.map +1 -1
  29. package/dist/local-files/LocalFileViewerServer.js +51 -4
  30. package/dist/local-files/LocalFileViewerServer.js.map +1 -1
  31. package/dist/local-files/TurnPreviewPage.js +7 -2
  32. package/dist/local-files/TurnPreviewPage.js.map +1 -1
  33. package/dist/presentation/TurnStateReducer.js +41 -6
  34. package/dist/presentation/TurnStateReducer.js.map +1 -1
  35. package/dist/proxy/ProxySessionController.js +615 -214
  36. package/dist/proxy/ProxySessionController.js.map +1 -1
  37. package/dist/proxy/SessionOperationQueue.js +14 -0
  38. package/dist/proxy/SessionOperationQueue.js.map +1 -0
  39. package/dist/runtime/AcpRuntimeAdapter.js +4 -2
  40. package/dist/runtime/AcpRuntimeAdapter.js.map +1 -1
  41. package/dist/state/StateStore.js +22 -0
  42. package/dist/state/StateStore.js.map +1 -1
  43. package/dist/state/TurnPreviewJournal.js +19 -7
  44. package/dist/state/TurnPreviewJournal.js.map +1 -1
  45. package/dist/state/migrations.js +6 -0
  46. package/dist/state/migrations.js.map +1 -1
  47. package/dist/supervision/SafeRestartNotifier.js +1 -0
  48. package/dist/supervision/SafeRestartNotifier.js.map +1 -1
  49. package/dist/supervision/SafeRestartScheduler.js +19 -6
  50. package/dist/supervision/SafeRestartScheduler.js.map +1 -1
  51. package/dist/terminal/TerminalCards.js +13 -0
  52. package/dist/terminal/TerminalCards.js.map +1 -0
  53. package/dist/terminal/TerminalLinks.js +76 -0
  54. package/dist/terminal/TerminalLinks.js.map +1 -0
  55. package/dist/terminal/TerminalPage.js +212 -0
  56. package/dist/terminal/TerminalPage.js.map +1 -0
  57. package/dist/terminal/TerminalProcess.js +71 -0
  58. package/dist/terminal/TerminalProcess.js.map +1 -0
  59. package/dist/terminal/TerminalServer.js +639 -0
  60. package/dist/terminal/TerminalServer.js.map +1 -0
  61. package/docs/technical-reference.md +48 -0
  62. package/docs/technical-reference.zh.md +48 -0
  63. package/npm-shrinkwrap.json +55 -5
  64. package/package.json +11 -4
  65. package/skills/agent-bot/SKILL.md +9 -2
  66. package/src/cli/controlProtocol.ts +2 -1
  67. package/src/cli/serverStatus.ts +1 -0
  68. package/src/cli.ts +6 -1
  69. package/src/codex/CodexRuntime.ts +200 -123
  70. package/src/codex/ProviderSwitch.ts +11 -12
  71. package/src/config/AgentExecutionDefaultsStore.ts +2 -0
  72. package/src/config/loadConfig.ts +4 -0
  73. package/src/config/schema.ts +5 -1
  74. package/src/feishu/CardRenderer.ts +81 -8
  75. package/src/feishu/FeishuConnector.ts +1 -0
  76. package/src/feishu/FeishuTurnPresenter.ts +108 -16
  77. package/src/feishu/types.ts +1 -0
  78. package/src/index.ts +44 -7
  79. package/src/local-files/LocalFileViewerServer.ts +48 -4
  80. package/src/local-files/TurnPreviewPage.ts +7 -2
  81. package/src/presentation/TurnStateReducer.ts +46 -7
  82. package/src/presentation/turnViewTypes.ts +5 -0
  83. package/src/proxy/ProxySessionController.ts +625 -211
  84. package/src/proxy/SessionOperationQueue.ts +13 -0
  85. package/src/runtime/AcpRuntimeAdapter.ts +5 -3
  86. package/src/runtime/types.ts +8 -3
  87. package/src/state/StateStore.ts +38 -1
  88. package/src/state/TurnPreviewJournal.ts +19 -7
  89. package/src/state/migrations.ts +6 -0
  90. package/src/supervision/SafeRestartNotifier.ts +1 -0
  91. package/src/supervision/SafeRestartScheduler.ts +18 -6
  92. package/src/terminal/TerminalCards.ts +18 -0
  93. package/src/terminal/TerminalLinks.ts +61 -0
  94. package/src/terminal/TerminalPage.ts +213 -0
  95. package/src/terminal/TerminalProcess.ts +71 -0
  96. package/src/terminal/TerminalServer.ts +513 -0
@@ -57,6 +57,9 @@ export const agentConfigSchema = z.object({
57
57
  });
58
58
 
59
59
  export const appConfigSchema = z.object({
60
+ terminal: z.object({
61
+ enabled: z.boolean().default(true),
62
+ }).default({ enabled: true }),
60
63
  updates: z.object({ enabled: z.boolean().default(true) }).default({ enabled: true }),
61
64
  console: z
62
65
  .object({
@@ -108,6 +111,7 @@ export type AgentConfig = Omit<ParsedAgentConfig, "defaults"> & {
108
111
  defaults?: AgentExecutionDefaults;
109
112
  };
110
113
  export type GroupNameFormatConfig = z.infer<typeof groupNameFormatSchema>;
111
- export type AppConfig = Omit<z.infer<typeof appConfigSchema>, "updates"> & {
114
+ export type AppConfig = Omit<z.infer<typeof appConfigSchema>, "updates" | "terminal"> & {
112
115
  updates?: { enabled: boolean };
116
+ terminal?: z.infer<typeof appConfigSchema>["terminal"];
113
117
  };
@@ -1,4 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { approvalActivityId } from "../presentation/TurnStateReducer.js";
2
3
  import path from "node:path";
3
4
  import type { JsonValue } from "../acp/acpTypes.js";
4
5
  import type { RuntimeSession } from "../acp/AcpSessionManager.js";
@@ -14,6 +15,7 @@ import type {
14
15
  import type { TurnActivity, TurnViewState, TurnViewStatus } from "../presentation/turnViewTypes.js";
15
16
  import { truncateMiddle, truncateText } from "../utils/markdown.js";
16
17
  import type { UpdateNotice } from "../updates/types.js";
18
+ import type { TerminalCardView } from "../terminal/TerminalServer.js";
17
19
  import { localCardImage } from "./LocalCardImage.js";
18
20
 
19
21
  export interface StartupStatusView {
@@ -58,6 +60,7 @@ export interface InitializationWelcomeView {
58
60
  }
59
61
 
60
62
  export interface SafeRestartStatusView {
63
+ activeTerminals?: number;
61
64
  scheduleId: number;
62
65
  reason: string;
63
66
  phase: "waiting_tasks" | "waiting_delivery" | "countdown" | "restarting" | "cancelled" | "superseded";
@@ -85,6 +88,7 @@ export interface ModelSelectorCardView {
85
88
  sessionId: string;
86
89
  contextKey: string;
87
90
  currentModel?: string;
91
+ modelPage?: number;
88
92
  reasoningEffort?: string;
89
93
  models: ModelOption[];
90
94
  modelProvider?: string;
@@ -131,7 +135,7 @@ export type ExecutionSettingsTab = "agent" | "provider" | "model" | "thinking" |
131
135
 
132
136
  const EXECUTION_SETTING_EFFECTS: Record<ExecutionSettingsTab, string> = {
133
137
  agent: "只影响新建任务,不切换当前任务。",
134
- provider: "只影响当前任务,需空闲时切换。",
138
+ provider: "只影响当前任务;运行中选择将在本轮结束后生效。",
135
139
  model: "只影响当前任务,下一轮生效。",
136
140
  thinking: "只影响当前任务,下一轮生效。",
137
141
  permission: "只影响当前任务,下一轮生效。",
@@ -143,6 +147,8 @@ export interface ExecutionSettingsAgentOption {
143
147
  }
144
148
 
145
149
  export interface ExecutionSettingsCardView {
150
+ pendingNotice?: string;
151
+ pendingFailed?: boolean;
146
152
  sessionId?: string;
147
153
  contextKey: string;
148
154
  activeTab: ExecutionSettingsTab;
@@ -152,6 +158,7 @@ export interface ExecutionSettingsCardView {
152
158
  runtimeSettingsAvailable: boolean;
153
159
  currentProvider?: string;
154
160
  currentModel?: string;
161
+ modelPage?: number;
155
162
  currentEffort?: string;
156
163
  currentPermissionMode: PermissionMode;
157
164
  providers: ModelProviderOption[];
@@ -263,6 +270,7 @@ export interface DismissGroupCardView {
263
270
  }
264
271
 
265
272
  export interface ShellCommandCardView {
273
+ terminalUrl?: string;
266
274
  jobId?: string;
267
275
  contextKey?: string;
268
276
  command: string;
@@ -508,6 +516,9 @@ export class CardRenderer {
508
516
  ...(outputWasTruncated ? ["输出过长,已保留开头和结尾并截断中间内容。"] : []),
509
517
  ].join("\n")),
510
518
  ];
519
+ if (view.terminalUrl) {
520
+ elements.push(markdown(`[Open Terminal](${view.terminalUrl}) · 独立终端,需在本会话由所有者授权`));
521
+ }
511
522
  if (view.status === "running" && view.jobId && view.contextKey) {
512
523
  elements.push({
513
524
  tag: "button",
@@ -553,6 +564,7 @@ export class CardRenderer {
553
564
  `**思考强度 / 权限**:${inlineCode(view.currentEffort ?? "自动")} / ${inlineCode(permissionModeLabel(view.currentPermissionMode))}`,
554
565
  ]
555
566
  : []),
567
+ ...(view.pendingNotice ? [view.pendingNotice] : []),
556
568
  ...(view.notice ? [view.notice] : []),
557
569
  ...(view.warning ? [`**提示**:${escapeCardHtml(view.warning)}`] : []),
558
570
  ].join("\n")),
@@ -584,7 +596,8 @@ export class CardRenderer {
584
596
  inlineCode(provider.id),
585
597
  provider.isDefault ? " · 默认" : "",
586
598
  ].join(""),
587
- current: provider.id === view.currentProvider,
599
+ current: provider.id === view.currentProvider && !view.pendingFailed,
600
+ currentLabel: view.pendingNotice ? "待生效" : undefined,
588
601
  action: {
589
602
  text: "Switch",
590
603
  value: {
@@ -599,18 +612,24 @@ export class CardRenderer {
599
612
  if (view.models.length === 0) {
600
613
  elements.push(markdown("当前运行时未返回可用模型。"));
601
614
  } else {
602
- elements.push(...view.models.map((model) => settingsOptionRow({
615
+ const page = paginateModels(view.models, view.currentModel, view.modelPage, {
616
+ action: "settings_tab_open", ...baseAction, tab: "model", provider: view.currentProvider ?? "",
617
+ });
618
+ elements.push(...page.models.map((model) => settingsOptionRow({
603
619
  label: `${inlineCode(model.id)}${model.isDefault ? " · 默认" : ""}`,
604
- current: model.id === view.currentModel,
620
+ current: model.id === view.currentModel && !view.pendingFailed,
621
+ currentLabel: view.pendingNotice ? "待生效" : undefined,
605
622
  action: {
606
623
  text: "Switch",
607
624
  value: {
608
625
  action: "settings_model_select",
609
626
  ...baseAction,
627
+ provider: view.currentProvider ?? "",
610
628
  model: model.id,
611
629
  },
612
630
  },
613
631
  })));
632
+ elements.push(...page.navigation);
614
633
  }
615
634
  } else if (view.activeTab === "thinking") {
616
635
  if (!view.currentModel) {
@@ -622,11 +641,13 @@ export class CardRenderer {
622
641
  elements.push(...view.reasoningOptions.map((option) => settingsOptionRow({
623
642
  label: inlineCode(option.value),
624
643
  current: option.value === view.currentEffort,
644
+ currentLabel: view.pendingNotice ? "待生效" : undefined,
625
645
  action: {
626
646
  text: "Switch",
627
647
  value: {
628
648
  action: "settings_thinking_select",
629
649
  ...baseAction,
650
+ provider: view.currentProvider ?? "",
630
651
  model: currentModel,
631
652
  effort: option.value,
632
653
  },
@@ -637,6 +658,7 @@ export class CardRenderer {
637
658
  elements.push(...(["auto", "confirm"] as PermissionMode[]).map((mode) => settingsOptionRow({
638
659
  label: `${inlineCode(mode)} · ${permissionModeLabel(mode)}`,
639
660
  current: mode === view.currentPermissionMode,
661
+ currentLabel: view.pendingNotice ? "待生效" : undefined,
640
662
  action: {
641
663
  text: "Switch",
642
664
  value: {
@@ -649,7 +671,7 @@ export class CardRenderer {
649
671
  }
650
672
 
651
673
  elements.push(markdown(`> ${EXECUTION_SETTING_EFFECTS[view.activeTab]}`));
652
- return sectionCard("运行设置", elements, view.warning ? "orange" : view.notice ? "green" : "blue");
674
+ return sectionCard("运行设置", elements, view.warning || view.pendingFailed ? "orange" : view.notice ? "green" : "blue");
653
675
  }
654
676
 
655
677
  renderProviderSelector(view: ProviderSelectorCardView): Record<string, unknown> {
@@ -785,7 +807,14 @@ export class CardRenderer {
785
807
  if (view.models.length === 0) {
786
808
  elements.push(markdown("当前运行时未返回可用模型。"));
787
809
  } else {
788
- elements.push(...view.models.map((model) => {
810
+ const page = paginateModels(view.models, view.currentModel, view.modelPage, {
811
+ action: view.unifiedSettings ? "provider_model_open" : "model_open",
812
+ sessionId: view.sessionId,
813
+ contextKey: view.contextKey,
814
+ ...(view.modelProvider ? { provider: view.modelProvider } : {}),
815
+ ...(view.permissionMode ? { permissionMode: view.permissionMode } : {}),
816
+ });
817
+ elements.push(...page.models.map((model) => {
789
818
  const isCurrent = model.id === view.currentModel;
790
819
  return {
791
820
  tag: "column_set",
@@ -823,6 +852,7 @@ export class CardRenderer {
823
852
  ],
824
853
  };
825
854
  }));
855
+ elements.push(...page.navigation);
826
856
  }
827
857
  if (view.unifiedSettings) {
828
858
  elements.push(
@@ -994,6 +1024,24 @@ export class CardRenderer {
994
1024
  view.status === "completed" ? "green" : view.status === "cancelled" ? "grey" : "blue");
995
1025
  }
996
1026
 
1027
+ renderTerminalAuthorization(view: TerminalCardView): Record<string, unknown> {
1028
+ const action = (decision: string) => ({ action: "terminal_authorize", contextKey: view.contextKey, requestId: view.requestId, token: view.approvalToken, decision });
1029
+ const elements: Record<string, unknown>[] = [markdown(`**目录**:${inlineCode(view.cwd)}\n**终端**:${inlineCode(view.reopenToken)}`)];
1030
+ if (view.state === "pending") {
1031
+ elements.push(markdown(`**验证码**:${inlineCode(view.code)}\n仅 AgentBot 所有者可授权。授权后,该浏览器可在本机执行任意命令,拥有 AgentBot 的系统用户权限。请核对网页验证码,仅批准本人发起的请求。5 分钟内有效。${view.recovery ? (view.shellStarted ? "恢复原 Shell,不创建新终端;授权后旧网页失去访问权限。拒绝不影响原终端。" : "继续打开同一个终端;授权后旧网页失去访问权限,Shell 在连接成功后启动。拒绝不影响原授权。") : "不执行原卡片命令。"}`),
1032
+ taskActionRow([{ text: "Allow", value: action("allow") }, { text: "Deny", type: "danger", value: action("deny") }, { text: "刷新", value: action("refresh") }]));
1033
+ } else if (view.state === "closed") {
1034
+ elements.push(markdown(`**状态**:${view.message ? escapeCardHtml(view.message) : "已关闭"}\n原终端已结束时,请从命令卡片创建新终端。`));
1035
+ } else {
1036
+ const waiting = view.state === "approved";
1037
+ const label = waiting ? "打开终端" : "重新打开终端";
1038
+ elements.push(markdown(`**状态**:${waiting ? "已授权,等待网页连接" : view.connected ? "已连接" : "网页已断开,Shell 仍在运行"}\n${waiting ? "请返回原网页连接;原网页已关闭时,点击下方“打开终端”。Shell 尚未启动。" : "点击下方“重新打开终端”恢复原 Shell。前台连接时不因空闲关闭;后台空闲 30 分钟或网页断开 3 分钟后会自动关闭 Shell,成功重连取消断开计时并重置空闲计时;“关闭终端”会中断其中的命令。"}`));
1039
+ elements.push(markdown(`[${label}](${view.reopenUrl}) · 新网页需核对验证码重新授权${waiting ? ",连接成功后启动 Shell" : ",恢复原 Shell"}`));
1040
+ elements.push(taskActionRow([{ text: "关闭终端", type: "danger", value: action("close") }, { text: "刷新", value: action("refresh") }]));
1041
+ }
1042
+ return sectionCard(view.state === "pending" ? "网页终端授权" : "网页终端管理", elements, view.state === "closed" ? "grey" : view.state === "pending" ? "orange" : "blue");
1043
+ }
1044
+
997
1045
  renderSafeRestartStatus(view: SafeRestartStatusView): Record<string, unknown> {
998
1046
  const actionable = view.phase !== "restarting" && view.phase !== "cancelled" && view.phase !== "superseded";
999
1047
  const countdown = view.phase === "countdown"
@@ -1021,6 +1069,7 @@ export class CardRenderer {
1021
1069
  `**重启原因**:${inlineCode(view.reason)}`,
1022
1070
  `**重启倒计时**:${countdown}`,
1023
1071
  `**待投递结果**:${view.pendingFinalDeliveries} 条`,
1072
+ ...(view.activeTerminals ? [`**活动终端**:${view.activeTerminals} 个,请在网页或原会话的终端管理卡中关闭。ForceRestart 会中断终端。`] : []),
1024
1073
  ];
1025
1074
  const elements: Record<string, unknown>[] = [markdown(lines.join("\n"))];
1026
1075
  if (view.waitingTasks.length > 0) {
@@ -1782,6 +1831,10 @@ function renderApprovalElements(state: TurnViewState, request: ApprovalRequest):
1782
1831
  request.reason,
1783
1832
  ].filter(Boolean).join("\n")));
1784
1833
  }
1834
+ if (request.automatic) {
1835
+ elements.push(markdown("自动执行模式:正在自动同意。"));
1836
+ return elements;
1837
+ }
1785
1838
  elements.push({
1786
1839
  tag: "column_set",
1787
1840
  flex_mode: "flow",
@@ -2246,7 +2299,8 @@ function removeMarkdownBold(value: string): string {
2246
2299
  }
2247
2300
 
2248
2301
  function turnActivities(state: TurnViewState): TurnActivity[] {
2249
- if (state.activities?.length) return state.activities;
2302
+ if (state.activities?.length) return state.approval
2303
+ ? state.activities.filter((a) => a.id !== approvalActivityId(state.turnId, state.approval!.id)) : state.activities;
2250
2304
 
2251
2305
  const activities: TurnActivity[] = [];
2252
2306
  if (state.progressText) {
@@ -2845,6 +2899,24 @@ function acpPermissionOptionLabel(option: { name: string; kind: string }): strin
2845
2899
  return labels[option.kind] ?? (/^[\x20-\x7E]+$/.test(option.name) ? option.name : "Select");
2846
2900
  }
2847
2901
 
2902
+ const MODEL_PAGE_SIZE = 20;
2903
+
2904
+ function paginateModels(
2905
+ models: ModelOption[],
2906
+ currentModel: string | undefined,
2907
+ requestedPage: number | undefined,
2908
+ action: Record<string, string>,
2909
+ ): { models: ModelOption[]; navigation: Record<string, unknown>[] } {
2910
+ const totalPages = Math.max(1, Math.ceil(models.length / MODEL_PAGE_SIZE));
2911
+ const currentPage = Math.floor(models.findIndex((model) => model.id === currentModel) / MODEL_PAGE_SIZE);
2912
+ const page = Math.max(0, Math.min(requestedPage ?? currentPage, totalPages - 1));
2913
+ const navigation = totalPages > 1 ? [taskActionRow([
2914
+ ...(page > 0 ? [{ text: "上一页", value: { ...action, page: String(page - 1) } }] : []),
2915
+ ...(page < totalPages - 1 ? [{ text: "下一页", value: { ...action, page: String(page + 1) } }] : []),
2916
+ ], `第 ${page + 1}/${totalPages} 页 · 共 ${models.length} 个模型`)] : [];
2917
+ return { models: models.slice(page * MODEL_PAGE_SIZE, (page + 1) * MODEL_PAGE_SIZE), navigation };
2918
+ }
2919
+
2848
2920
  function settingsTabRow(
2849
2921
  activeTab: ExecutionSettingsTab,
2850
2922
  baseAction: Record<string, string>,
@@ -2918,6 +2990,7 @@ function settingsTabElement(
2918
2990
  function settingsOptionRow(input: {
2919
2991
  label: string;
2920
2992
  current: boolean;
2993
+ currentLabel?: string;
2921
2994
  action: TaskListCardAction;
2922
2995
  }): Record<string, unknown> {
2923
2996
  return {
@@ -2937,7 +3010,7 @@ function settingsOptionRow(input: {
2937
3010
  tag: "column",
2938
3011
  width: "auto",
2939
3012
  vertical_align: "center",
2940
- elements: input.current ? [markdown("✅ 当前")] : [taskActionElement(input.action)],
3013
+ elements: input.current ? [markdown(`✅ ${input.currentLabel ?? "当前"}`)] : [taskActionElement(input.action)],
2941
3014
  },
2942
3015
  ],
2943
3016
  };
@@ -262,6 +262,7 @@ function toIncomingMessage(
262
262
  const messageId = message.message_id ?? `${Date.now()}`;
263
263
  return {
264
264
  messageId,
265
+ ...(typeof messageType === "string" ? { messageType } : {}),
265
266
  ...(Number.isFinite(Number(message.create_time)) && Number(message.create_time) > 0
266
267
  ? { createdAt: Number(message.create_time) } : {}),
267
268
  contextKey: chatId
@@ -4,6 +4,7 @@ import {
4
4
  appendSteerMessage as appendSteerMessageToState,
5
5
  createTurnViewState,
6
6
  compactTurnView,
7
+ continueTurnView,
7
8
  reduceTurnEvent,
8
9
  } from "../presentation/TurnStateReducer.js";
9
10
  import { splitMarkdown } from "../presentation/splitMarkdown.js";
@@ -74,6 +75,7 @@ interface TurnEntry {
74
75
  elapsedTimer?: ReturnType<typeof setInterval>;
75
76
  finalizing?: Promise<void>;
76
77
  lastFinalizedStatus?: TurnViewState["status"];
78
+ continuing?: boolean;
77
79
  }
78
80
 
79
81
  export class FeishuTurnPresenter {
@@ -158,6 +160,13 @@ export class FeishuTurnPresenter {
158
160
  const existing = this.pendingEntries.get(sessionId);
159
161
  if (existing) {
160
162
  await existing.initializing;
163
+ if (existing.continuing) {
164
+ existing.state = { ...existing.state, status: "running", error: undefined, completedAt: undefined, durationMs: undefined };
165
+ this.persist(existing.state.turnId, sessionId, existing.state, existing.contextKey);
166
+ existing.scheduler?.update(existing.state, "critical");
167
+ this.startElapsedUpdates(existing);
168
+ return existing.state.turnId;
169
+ }
161
170
  const images = localImagePaths === undefined ? existing.state.promptImagePaths : [...new Set(localImagePaths)];
162
171
  if ((prompt && existing.state.prompt !== prompt) || JSON.stringify(images) !== JSON.stringify(existing.state.promptImagePaths)) {
163
172
  existing.state = { ...existing.state, ...(prompt ? { prompt } : {}), ...(images ? { promptImagePaths: images } : {}) };
@@ -204,15 +213,15 @@ export class FeishuTurnPresenter {
204
213
  ): Promise<boolean> {
205
214
  const entry = this.pendingEntries.get(sessionId);
206
215
  if (!entry) return false;
207
- this.pendingEntries.delete(sessionId);
208
- this.stopElapsedUpdates(entry);
216
+ if (!entry.continuing) this.pendingEntries.delete(sessionId);
217
+ if (!entry.continuing) this.stopElapsedUpdates(entry);
209
218
  const completedAt = Date.now();
210
219
  entry.state = {
211
220
  ...entry.state,
212
- status: "failed",
221
+ status: entry.continuing ? "running" : "failed",
213
222
  error: message,
214
- completedAt,
215
- durationMs: Math.max(0, completedAt - entry.state.startedAt),
223
+ completedAt: entry.continuing ? undefined : completedAt,
224
+ durationMs: entry.continuing ? undefined : Math.max(0, completedAt - entry.state.startedAt),
216
225
  };
217
226
  this.persist(entry.state.turnId, sessionId, entry.state, entry.contextKey);
218
227
  await entry.initializing;
@@ -298,7 +307,7 @@ export class FeishuTurnPresenter {
298
307
  status: "running" as const,
299
308
  };
300
309
  this.store.previews?.seed(initial);
301
- this.store.previews?.user(turnId, activityId, text, localImagePaths);
310
+ this.store.previews?.user(initial.previewTurnId ?? turnId, activityId, text, localImagePaths);
302
311
  const state = this.cardState(appendSteerMessageToState(initial, activityId, text, localImagePaths));
303
312
  entry = { contextKey, state, initializing: Promise.resolve() } as TurnEntry;
304
313
  this.entries.set(turnId, entry);
@@ -309,7 +318,7 @@ export class FeishuTurnPresenter {
309
318
  }
310
319
 
311
320
  await entry.initializing;
312
- this.store.previews?.user(turnId, activityId, text, localImagePaths);
321
+ this.store.previews?.user(entry.state.previewTurnId ?? turnId, activityId, text, localImagePaths);
313
322
  entry.state = this.cardState(appendSteerMessageToState(entry.state, activityId, text, localImagePaths));
314
323
  this.persist(turnId, sessionId, entry.state, entry.contextKey);
315
324
  if (!entry.historySnapshot) entry.scheduler?.update(entry.state, "critical");
@@ -317,10 +326,50 @@ export class FeishuTurnPresenter {
317
326
 
318
327
  async onEvent(event: AgentEvent): Promise<void> {
319
328
  let existing = this.entries.get(event.turnId);
329
+ if (!existing) {
330
+ const saved = this.store.getTurnSnapshot(event.turnId);
331
+ if (isTurnViewState(saved) && saved.continuedToTurnId) return;
332
+ }
320
333
  let eventApplied = false;
334
+ if (event.type === "turn_completed" && event.approvedPlan) {
335
+ const entry = existing ?? this.createEntry({ type: "progress", sessionId: event.sessionId,
336
+ turnId: event.turnId, activityId: "plan-handoff", text: "方案已确认,继续执行" });
337
+ if (!entry || entry.continuing) return;
338
+ entry.continuing = true;
339
+ entry.historySnapshot = undefined;
340
+ this.store.previews?.seed(entry.state);
341
+ entry.state = { ...entry.state, status: "running", approval: undefined };
342
+ this.pendingEntries.set(event.sessionId, entry);
343
+ this.persist(event.turnId, event.sessionId, entry.state, entry.contextKey);
344
+ await entry.initializing;
345
+ await entry.scheduler?.flush(entry.state);
346
+ return;
347
+ }
321
348
  if (!existing && event.type === "turn_started") {
322
349
  const pending = this.pendingEntries.get(event.sessionId);
323
- if (pending) {
350
+ if (pending?.continuing) {
351
+ const previous = pending.state;
352
+ const previewTurnId = previous.previewTurnId ?? previous.turnId;
353
+ this.flushPersistence();
354
+ const state = continueTurnView(previous, event.turnId, event.startedAt);
355
+ this.store.previews?.handoff(previewTurnId, event.turnId, event.startedAt);
356
+ this.store.saveTurnSnapshot(previous.turnId, event.sessionId, {
357
+ ...previous, status: "completed", completedAt: event.startedAt,
358
+ durationMs: Math.max(0, event.startedAt - previous.startedAt),
359
+ finalResponse: "", continuedToTurnId: event.turnId, previewTurnId,
360
+ }, pending.contextKey);
361
+ if (pending.messageId) this.store.saveTurnDelivery(event.turnId, { progressMessageId: pending.messageId });
362
+ this.entries.delete(previous.turnId);
363
+ this.pendingEntries.delete(event.sessionId);
364
+ pending.state = state;
365
+ pending.continuing = false;
366
+ pending.lastFinalizedStatus = undefined;
367
+ pending.historySnapshot = undefined;
368
+ this.entries.set(event.turnId, pending);
369
+ this.persist(event.turnId, event.sessionId, state, pending.contextKey);
370
+ existing = pending;
371
+ eventApplied = true;
372
+ } else if (pending) {
324
373
  const pendingTurnId = pending.state.turnId;
325
374
  const state = reduceTurnEvent(
326
375
  createTurnViewState(
@@ -362,8 +411,9 @@ export class FeishuTurnPresenter {
362
411
  if (!entry) return;
363
412
 
364
413
  if (existing && !eventApplied) {
414
+ event = presentationEvent(event, entry.state);
365
415
  this.store.previews?.seed(entry.state);
366
- this.store.previews?.event(event);
416
+ this.store.previews?.event(event, entry.state.previewTurnId);
367
417
  const previousStatus = entry.state.status;
368
418
  entry.state = this.cardState(reduceTurnEvent(entry.state, event));
369
419
  if (isTerminalViewStatus(previousStatus) && !event.type.startsWith("turn_")) entry.state.status = previousStatus;
@@ -374,6 +424,8 @@ export class FeishuTurnPresenter {
374
424
  if (!entry.scheduler) return;
375
425
 
376
426
  if (isTerminalEvent(event)) {
427
+ if (this.pendingEntries.get(event.sessionId) === entry) this.pendingEntries.delete(event.sessionId);
428
+ entry.continuing = false;
377
429
  this.stopElapsedUpdates(entry);
378
430
  if (entry.finalizing) await entry.finalizing;
379
431
  const terminalState = entry.state;
@@ -394,7 +446,8 @@ export class FeishuTurnPresenter {
394
446
  }
395
447
 
396
448
  async showDetails(contextKey: string, turnId: string): Promise<void> {
397
- const snapshot = this.store.previews?.load(turnId) ?? this.store.getTurnSnapshot(turnId);
449
+ const saved = this.store.getTurnSnapshot(turnId);
450
+ const snapshot = this.store.previews?.load(isTurnViewState(saved) ? saved.previewTurnId ?? turnId : turnId) ?? saved;
398
451
  if (!isTurnViewState(snapshot)) {
399
452
  await this.outbound.sendText(contextKey, "未找到这次执行的详情。可能已被清理。");
400
453
  return;
@@ -408,6 +461,7 @@ export class FeishuTurnPresenter {
408
461
  page: number | "latest",
409
462
  messageId?: string,
410
463
  ): Promise<void> {
464
+ turnId = this.latestPresentationTurn(turnId, contextKey, messageId);
411
465
  const entry = this.entries.get(turnId);
412
466
  if (entry) await entry.initializing;
413
467
  const ownsProgressCard = entry
@@ -430,7 +484,7 @@ export class FeishuTurnPresenter {
430
484
  }
431
485
 
432
486
  if (!entry.historySnapshot) {
433
- entry.historySnapshot = this.store.previews?.load(turnId) ?? entry.state;
487
+ entry.historySnapshot = this.store.previews?.load(entry.state.previewTurnId ?? turnId) ?? entry.state;
434
488
  await entry.scheduler?.flush();
435
489
  }
436
490
  const card = this.renderActivityHistory(entry.historySnapshot, page);
@@ -439,7 +493,8 @@ export class FeishuTurnPresenter {
439
493
  return;
440
494
  }
441
495
 
442
- const snapshot = this.store.previews?.load(turnId) ?? this.store.getTurnSnapshot(turnId);
496
+ const saved = this.store.getTurnSnapshot(turnId);
497
+ const snapshot = this.store.previews?.load(isTurnViewState(saved) ? saved.previewTurnId ?? turnId : turnId) ?? saved;
443
498
  if (!isTurnViewState(snapshot)) throw new Error("未找到这次执行的活动历史。");
444
499
  const card = page === "latest"
445
500
  ? this.renderTurn(snapshot)
@@ -477,9 +532,9 @@ export class FeishuTurnPresenter {
477
532
  const compact = compactTurnView(state);
478
533
  this.dirty.set(turnId, { state: compact, contextKey });
479
534
  if (!event || isTerminalViewStatus(state.status) || state.status === "waiting_for_approval") {
480
- this.store.previews.flush(turnId, true);
535
+ this.store.previews.flush(state.previewTurnId ?? turnId, true);
481
536
  this.flushPersistence();
482
- if (isTerminalViewStatus(state.status)) this.store.previews.release(turnId);
537
+ if (isTerminalViewStatus(state.status)) this.store.previews.release(state.previewTurnId ?? turnId);
483
538
  } else if (!this.persistTimer) {
484
539
  this.persistTimer = setTimeout(() => {
485
540
  try { this.flushPersistence(); } catch (error) { this.options.onError?.(error); }
@@ -519,7 +574,8 @@ export class FeishuTurnPresenter {
519
574
  this.sessionModelProviders.get(event.sessionId),
520
575
  );
521
576
  this.store.previews?.seed(initial);
522
- this.store.previews?.event(event);
577
+ event = presentationEvent(event, initial);
578
+ this.store.previews?.event(event, initial.previewTurnId);
523
579
  const state = this.cardState(reduceTurnEvent(initial, event));
524
580
  const entry = { contextKey, state, initializing: Promise.resolve() } as TurnEntry;
525
581
  this.entries.set(event.turnId, entry);
@@ -596,7 +652,21 @@ export class FeishuTurnPresenter {
596
652
  private turnPreviewUrl(state: TurnViewState): string | undefined {
597
653
  return state.turnId.startsWith("pending_")
598
654
  ? undefined
599
- : this.options.turnPreviewUrl?.(state.turnId);
655
+ : this.options.turnPreviewUrl?.(state.previewTurnId ?? state.turnId);
656
+ }
657
+
658
+ private latestPresentationTurn(turnId: string, contextKey: string, messageId?: string): string {
659
+ const seen = new Set<string>();
660
+ while (!seen.has(turnId)) {
661
+ seen.add(turnId);
662
+ const saved = this.store.getTurnSnapshot(turnId);
663
+ if (!isTurnViewState(saved) || !saved.continuedToTurnId) break;
664
+ const next = saved.continuedToTurnId;
665
+ if (this.store.getTurnContextKey?.(next) !== contextKey
666
+ || (messageId && this.store.getTurnDelivery(next)?.progressMessageId !== messageId)) break;
667
+ turnId = next;
668
+ }
669
+ return turnId;
600
670
  }
601
671
 
602
672
  private startElapsedUpdates(entry: TurnEntry): void {
@@ -717,6 +787,28 @@ export class FeishuTurnPresenter {
717
787
  }
718
788
  }
719
789
 
790
+ function presentationEvent(event: AgentEvent, state: TurnViewState): AgentEvent {
791
+ if (event.type === "turn_started" && state.segmentStartedAt !== undefined) return { ...event, startedAt: state.startedAt };
792
+ if (event.type === "turn_completed" && event.durationMs !== undefined && state.segmentStartedAt !== undefined) {
793
+ return { ...event, durationMs: Math.max(0, state.segmentStartedAt - state.startedAt) + event.durationMs };
794
+ }
795
+ if (state.continuedFromTurnId) {
796
+ // Tool/item IDs are only required to be unique within one runtime Turn.
797
+ const scoped = (id: string) => event.turnId + ":" + id;
798
+ if (event.type === "tool_started" || event.type === "tool_updated") return { ...event, tool: { ...event.tool, id: scoped(event.tool.id) } };
799
+ if (event.type === "tool_output_delta") return { ...event, toolId: scoped(event.toolId) };
800
+ if (event.type === "reasoning_delta" || event.type === "reasoning_completed") return { ...event, itemId: scoped(event.itemId) };
801
+ if (event.type === "progress") return { ...event,
802
+ activityId: (event.activityId?.startsWith("commentary:") ? "commentary:" : "") + scoped(event.activityId ?? "progress"),
803
+ ...(event.reasoning ? { reasoning: { ...event.reasoning, itemId: scoped(event.reasoning.itemId) } } : {}) };
804
+ if (event.type === "agent_text_delta" && event.replacesActivityId) return {
805
+ ...event, replacesActivityId: "commentary:" + scoped(event.replacesActivityId),
806
+ };
807
+ if (event.type === "context_compaction" && event.compactionId) return { ...event, compactionId: scoped(event.compactionId) };
808
+ }
809
+ return event;
810
+ }
811
+
720
812
  function normalizeTurnCardMarkdown(
721
813
  state: TurnViewState,
722
814
  localFileUrl: LocalFileUrlResolver | undefined,
@@ -7,6 +7,7 @@ export type { MergedForwardContent, ReferencedMessageContent } from "./MergedFor
7
7
 
8
8
  export interface IncomingMessage {
9
9
  messageId: string;
10
+ messageType?: string;
10
11
  createdAt?: number;
11
12
  contextKey: string;
12
13
  chatId?: string;
package/src/index.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  import path from "node:path";
2
2
  import { spawn } from "node:child_process";
3
3
  import { fileURLToPath } from "node:url";
4
+ import { TerminalServer } from "./terminal/TerminalServer.js";
5
+ import { sendTerminalAuthorization } from "./terminal/TerminalCards.js";
6
+ import { ShellCommandJobManager } from "./shell/ShellCommandJobManager.js";
4
7
  import { LocalControlServer } from "./cli/LocalControlServer.js";
5
8
  import { controlEndpoint, type ControlRequest, type ControlResponse } from "./cli/controlProtocol.js";
6
9
  import { readPackageVersion } from "./cli/packageVersion.js";
@@ -79,6 +82,8 @@ const transport = consoleOnly ? "console" : requireServerFeishuTransport(config.
79
82
  const logger = createLogger(config);
80
83
  const store = new StateStore(config.storage.sqlitePath, (error) => logger.error({ error }, "Failed to persist Turn preview journal."));
81
84
  let localFileViewer: LocalFileViewerServer | undefined;
85
+ let terminalServer: TerminalServer | undefined;
86
+ const shellCommandJobs = new ShellCommandJobManager(path.join(path.dirname(config.storage.sqlitePath), "command-jobs"));
82
87
  let loadTurnDetails: ((turnId: string) => Promise<unknown>) | undefined;
83
88
  if (config.fileViewer.enabled) {
84
89
  const viewer = new LocalFileViewerServer({
@@ -160,7 +165,7 @@ let restartRequested = false;
160
165
  let pendingSelfUpdatePlanPath: string | undefined;
161
166
  let preparingSelfUpdate = false;
162
167
  const safeRestart = new SafeRestartScheduler({
163
- readActivity: () => store.getServerActivityState(),
168
+ readActivity: () => serverActivity(),
164
169
  onReady: (reason, notificationTargets) => pendingSelfUpdatePlanPath
165
170
  ? initiateSelfUpdate(reason, notificationTargets)
166
171
  : initiateRestart(reason, notificationTargets),
@@ -226,8 +231,39 @@ async function prepareAndScheduleUpdate(version: string, target: RestartNotifica
226
231
  }
227
232
  }
228
233
 
234
+ if (feishuOutbound && localFileViewer && config.terminal?.enabled !== false) {
235
+ const server = new TerminalServer({
236
+ stateDirectory: path.join(path.dirname(config.storage.sqlitePath), "terminal"),
237
+ ownerId: () => config.feishu.userOpenId,
238
+ canStart: () => !shuttingDown && !restartRequested && !safeRestart.scheduled && !safeRestart.restarting,
239
+ readJob: (jobId) => shellCommandJobs.readJob(jobId),
240
+ requestApproval: (authorization) => sendTerminalAuthorization(feishuOutbound, authorization),
241
+ updateCard: (messageId, view) => feishuOutbound.updateInteractiveCard(messageId, new CardRenderer().renderTerminalAuthorization(view)),
242
+ logger,
243
+ });
244
+ try {
245
+ const publicBaseUrl = await server.start(localFileViewer);
246
+ terminalServer = server;
247
+ logger.info({ publicBaseUrl }, "Interactive terminal service started (owner authorization required).");
248
+ } catch (error) {
249
+ await server.close();
250
+ logger.warn({ error }, "Terminal service unavailable; command cards remain usable.");
251
+ }
252
+ }
253
+
254
+ function serverActivity() {
255
+ const activity = store.getServerActivityState();
256
+ const activeTerminals = terminalServer?.activeCount ?? 0;
257
+ return { ...activity, activeTerminals };
258
+ }
259
+
229
260
  const controller = new ProxySessionController(config, store, runtimes, outbound, logger, {
230
261
  supervised,
262
+ terminalUrl: (jobId) => terminalServer?.createUrl(jobId),
263
+ authorizeTerminal: (action) => {
264
+ if (!terminalServer) throw new Error("网页终端未启用。");
265
+ return terminalServer.resolveApproval(action);
266
+ },
231
267
  showUpdates: (contextKey, userId) => manualUpdates.show(contextKey, userId),
232
268
  selectUpdate: (action, replyTarget) => manualUpdates.select(action, replyTarget),
233
269
  restart: requestRestart,
@@ -264,7 +300,7 @@ const controller = new ProxySessionController(config, store, runtimes, outbound,
264
300
  const changed = writeAgentExecutionDefaults(activeConfigPath, agentName, defaults);
265
301
  logger.info({ agentName, changed }, "Stored Agent execution defaults.");
266
302
  },
267
- });
303
+ }, undefined, undefined, shellCommandJobs);
268
304
  loadTurnDetails = (turnId) => controller.loadTurnDetails(turnId);
269
305
 
270
306
  if (feishuOutbound) {
@@ -327,7 +363,7 @@ function recoverPendingSelfUpdate(): void {
327
363
  pending.plan.reason ?? `Resume Agent Bot update to ${pending.plan.toVersion}`,
328
364
  notificationTarget,
329
365
  {
330
- restartImmediatelyIfIdle: isServerIdle(store.getServerActivityState()),
366
+ restartImmediatelyIfIdle: isServerIdle(serverActivity()),
331
367
  },
332
368
  );
333
369
  logger.warn(
@@ -468,7 +504,7 @@ async function handleControlRequest(request: ControlRequest): Promise<ControlRes
468
504
  }),
469
505
  safeRestartScheduled: safeRestart.scheduled,
470
506
  safeRestartReason: safeRestart.pendingReason,
471
- activity: store.getServerActivityState(),
507
+ activity: serverActivity(),
472
508
  },
473
509
  };
474
510
  case "server_restart": {
@@ -497,7 +533,7 @@ async function handleControlRequest(request: ControlRequest): Promise<ControlRes
497
533
  }
498
534
  assertSelfUpdatePlanPath(request.planPath, agentBotHome());
499
535
  const notificationTarget = inferServerRestartTarget(request.notificationSessionId);
500
- const restartImmediatelyIfIdle = isServerIdle(store.getServerActivityState());
536
+ const restartImmediatelyIfIdle = isServerIdle(serverActivity());
501
537
  pendingSelfUpdatePlanPath = request.planPath;
502
538
  safeRestart.schedule(request.reason, notificationTarget, { restartImmediatelyIfIdle });
503
539
  return {
@@ -636,6 +672,7 @@ async function shutdown(
636
672
  outbound.flushAll().catch((error: unknown) => logger.warn({ error }, "Failed to flush presenters.")),
637
673
  delay(5_000),
638
674
  ]);
675
+ await terminalServer?.close().catch((error: unknown) => logger.warn({ error }, "Failed to close terminals."));
639
676
  runtimes.close();
640
677
  await controlServer?.close().catch((error: unknown) => logger.warn({ error }, "Failed to close local control endpoint."));
641
678
  await localFileViewer?.close().catch((error: unknown) => logger.warn({ error }, "Failed to close local file viewer."));
@@ -708,8 +745,8 @@ function inferServerRestartTarget(notificationSessionId?: string): RestartNotifi
708
745
  return privateRestartNotificationTarget(config.feishu.userOpenId);
709
746
  }
710
747
 
711
- function isServerIdle(activity: { runningSessions: number; pendingFinalDeliveries: number }): boolean {
712
- return activity.runningSessions === 0 && activity.pendingFinalDeliveries === 0;
748
+ function isServerIdle(activity: { runningSessions: number; pendingFinalDeliveries: number; activeTerminals?: number }): boolean {
749
+ return activity.runningSessions === 0 && activity.pendingFinalDeliveries === 0 && !activity.activeTerminals;
713
750
  }
714
751
 
715
752
  function restartNotificationTargetForSession(