@keyou007/agent-bot 0.1.16 → 0.1.17-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 (57) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +4 -2
  3. package/README.zh.md +4 -2
  4. package/config.example.yaml +8 -0
  5. package/dist/cli/help.js +4 -4
  6. package/dist/cli/taskTarget.js +7 -0
  7. package/dist/cli/taskTarget.js.map +1 -1
  8. package/dist/cli.js +12 -12
  9. package/dist/cli.js.map +1 -1
  10. package/dist/codex/CodexProcessManager.js +2 -1
  11. package/dist/codex/CodexProcessManager.js.map +1 -1
  12. package/dist/codex/CodexRuntime.js +62 -14
  13. package/dist/codex/CodexRuntime.js.map +1 -1
  14. package/dist/config/AgentExecutionDefaultsStore.js +71 -0
  15. package/dist/config/AgentExecutionDefaultsStore.js.map +1 -0
  16. package/dist/config/loadConfig.js +8 -0
  17. package/dist/config/loadConfig.js.map +1 -1
  18. package/dist/config/schema.js +7 -0
  19. package/dist/config/schema.js.map +1 -1
  20. package/dist/feishu/CardRenderer.js +29 -27
  21. package/dist/feishu/CardRenderer.js.map +1 -1
  22. package/dist/feishu/FeishuConnector.js +61 -5
  23. package/dist/feishu/FeishuConnector.js.map +1 -1
  24. package/dist/feishu/FeishuTurnPresenter.js +2 -2
  25. package/dist/feishu/FeishuTurnPresenter.js.map +1 -1
  26. package/dist/index.js +17 -2
  27. package/dist/index.js.map +1 -1
  28. package/dist/proxy/ProxySessionController.js +255 -30
  29. package/dist/proxy/ProxySessionController.js.map +1 -1
  30. package/dist/state/StateStore.js +62 -9
  31. package/dist/state/StateStore.js.map +1 -1
  32. package/dist/supervision/restartNotificationTarget.js +9 -0
  33. package/dist/supervision/restartNotificationTarget.js.map +1 -0
  34. package/dist/utils/spawnCommand.js +2 -1
  35. package/dist/utils/spawnCommand.js.map +1 -1
  36. package/docs/technical-reference.md +7 -3
  37. package/docs/technical-reference.zh.md +7 -3
  38. package/npm-shrinkwrap.json +9 -9
  39. package/package.json +2 -2
  40. package/skills/agent-bot/SKILL.md +3 -1
  41. package/src/cli/help.ts +4 -4
  42. package/src/cli/taskTarget.ts +11 -0
  43. package/src/cli.ts +16 -20
  44. package/src/codex/CodexProcessManager.ts +3 -1
  45. package/src/codex/CodexRuntime.ts +63 -12
  46. package/src/config/AgentExecutionDefaultsStore.ts +71 -0
  47. package/src/config/loadConfig.ts +8 -0
  48. package/src/config/schema.ts +13 -1
  49. package/src/feishu/CardRenderer.ts +30 -22
  50. package/src/feishu/FeishuConnector.ts +70 -5
  51. package/src/feishu/FeishuTurnPresenter.ts +2 -2
  52. package/src/index.ts +17 -2
  53. package/src/proxy/ProxySessionController.ts +330 -40
  54. package/src/runtime/types.ts +8 -0
  55. package/src/state/StateStore.ts +100 -9
  56. package/src/supervision/restartNotificationTarget.ts +12 -0
  57. package/src/utils/spawnCommand.ts +2 -0
package/src/cli.ts CHANGED
@@ -100,6 +100,7 @@ import {
100
100
  } from "./cli/taskListOutput.js";
101
101
  import {
102
102
  resolveCurrentTaskFromEnvironment,
103
+ resolveRestartNotificationTask,
103
104
  resolveTask,
104
105
  resolveTaskCommandTarget,
105
106
  } from "./cli/taskTarget.js";
@@ -1186,33 +1187,28 @@ function resolveRestartNotificationSessionId(sqlitePath: string, args: string[])
1186
1187
  const store = new StateStore(sqlitePath);
1187
1188
  try {
1188
1189
  const sessions = store.listAllSessions();
1190
+ let explicitReference: string | undefined;
1189
1191
  if (args.includes("--task")) {
1190
1192
  const reference = optionValue(args, "--task")?.trim();
1191
1193
  if (!reference || reference.startsWith("--")) throw new Error(cliText(
1192
1194
  "server restart --task requires a task number or task ID.",
1193
1195
  "server restart --task 需要任务序号或任务 ID。",
1194
1196
  ));
1195
- const session = resolveTask(sessions, reference);
1196
- if (
1197
- isThreadContextKey(session.contextKey)
1198
- && !store.findLatestMessageIdForSession(session.localSessionId, session.contextKey)
1199
- && !store.findLatestMessageIdForContext(session.contextKey)
1200
- ) {
1201
- throw new Error(cliText(
1202
- "The task topic has no message anchor for restart notifications. Send /restart in that topic instead.",
1203
- "该任务话题没有可用于重启通知的消息锚点,请直接在该话题中发送 /restart。",
1204
- ));
1205
- }
1206
- return session.localSessionId;
1197
+ explicitReference = reference;
1207
1198
  }
1208
- const runningContexts = new Set(
1209
- sessions.filter((session) => session.status === "running").map((session) => session.contextKey),
1210
- );
1211
- if (runningContexts.size > 1) throw new Error(cliText(
1212
- "Multiple conversations are running. Pass --task <task> so restart notifications return to the requester.",
1213
- "当前有多个会话正在运行。请传入 --task <任务>,确保重启通知返回发起会话。",
1214
- ));
1215
- return undefined;
1199
+ const session = resolveRestartNotificationTask(sessions, explicitReference);
1200
+ if (!session) return undefined;
1201
+ if (
1202
+ isThreadContextKey(session.contextKey)
1203
+ && !store.findLatestMessageIdForSession(session.localSessionId, session.contextKey)
1204
+ && !store.findLatestMessageIdForContext(session.contextKey)
1205
+ ) {
1206
+ throw new Error(cliText(
1207
+ "The task topic has no message anchor for restart notifications. Send /restart in that topic instead.",
1208
+ "该任务话题没有可用于重启通知的消息锚点,请直接在该话题中发送 /restart。",
1209
+ ));
1210
+ }
1211
+ return session.localSessionId;
1216
1212
  } finally {
1217
1213
  store.close();
1218
1214
  }
@@ -29,10 +29,12 @@ export class CodexProcessManager implements AppServerClientProvider {
29
29
  async getClient(): Promise<AppServerClient> {
30
30
  if (this.client) return this.client;
31
31
  this.version = undefined;
32
+ const environmentContext = this.environmentContext();
32
33
  const child = spawnStdioCommand(
33
34
  this.command,
34
35
  this.args,
35
- agentBotEnvironment(process.env, this.env, this.environmentContext()),
36
+ agentBotEnvironment(process.env, this.env, environmentContext),
37
+ environmentContext.profilePath ?? os.homedir(),
36
38
  );
37
39
  this.child = child;
38
40
  const client = new AppServerConnection(child, this.logger.child({ component: "codex-app-server" }));
@@ -191,7 +191,7 @@ export class CodexRuntime implements AgentRuntime {
191
191
  const session = this.requireSession(sessionId);
192
192
  const client = await this.client();
193
193
  await this.ensureSessionResumed(session, client);
194
- const response = await client.request<{ turn: { id: string } }>("turn/start", {
194
+ const start = () => client.request<{ turn: { id: string } }>("turn/start", {
195
195
  threadId: session.remoteSessionId,
196
196
  input: codexUserInput(prompt),
197
197
  cwd: session.cwd,
@@ -200,6 +200,19 @@ export class CodexRuntime implements AgentRuntime {
200
200
  summary: "auto",
201
201
  approvalPolicy: session.permissionMode === "auto" ? "never" : "on-request",
202
202
  }, SESSION_REQUEST_TIMEOUT_MS);
203
+ let response: { turn: { id: string } };
204
+ try {
205
+ response = await start();
206
+ } catch (error) {
207
+ if (!isInvalidWorkingDirectoryError(error)) throw error;
208
+ this.logger.warn(
209
+ { error, sessionId: session.localSessionId, cwd: session.cwd },
210
+ "App Server rejected the task working directory; refreshing the current thread before retrying.",
211
+ );
212
+ await this.resumeAppServerSession(session, client);
213
+ session.needsResume = false;
214
+ response = await start();
215
+ }
203
216
  if (session.activeTurnId !== response.turn.id) {
204
217
  this.adoptTurn(session, response.turn.id, Date.now());
205
218
  }
@@ -746,6 +759,11 @@ export class CodexRuntime implements AgentRuntime {
746
759
 
747
760
  private async ensureSessionResumed(session: CodexSession, client: AppServerClient): Promise<void> {
748
761
  if (!session.needsResume) return;
762
+ await this.resumeAppServerSession(session, client);
763
+ session.needsResume = false;
764
+ }
765
+
766
+ private async resumeAppServerSession(session: CodexSession, client: AppServerClient): Promise<void> {
749
767
  await client.request("thread/resume", {
750
768
  threadId: session.remoteSessionId,
751
769
  excludeTurns: true,
@@ -755,7 +773,6 @@ export class CodexRuntime implements AgentRuntime {
755
773
  ...threadLifecycleParams(session.cwd),
756
774
  ...permissionParams(session.permissionMode),
757
775
  }, SESSION_REQUEST_TIMEOUT_MS);
758
- session.needsResume = false;
759
776
  }
760
777
 
761
778
  private async synchronizeSessionNow(sessionId: string): Promise<RuntimeSession> {
@@ -1084,6 +1101,12 @@ function isUnsupportedExcludeTurnsError(error: unknown): boolean {
1084
1101
  || /unknown|unrecognized|unexpected|unsupported|not supported|experimental/i.test(details);
1085
1102
  }
1086
1103
 
1104
+ function isInvalidWorkingDirectoryError(error: unknown): boolean {
1105
+ return error instanceof AppServerRequestError
1106
+ && error.method === "turn/start"
1107
+ && /invalid cwd/i.test(`${error.serverMessage} ${error.data === undefined ? "" : JSON.stringify(error.data)}`);
1108
+ }
1109
+
1087
1110
  function isUnsupportedRecencySortError(error: unknown): boolean {
1088
1111
  if (!(error instanceof AppServerRequestError) || error.method !== "thread/list") return false;
1089
1112
  const data = typeof error.data === "string"
@@ -1112,6 +1135,18 @@ function remoteSessionSummary(thread: CodexThreadSnapshot): RemoteSessionSummary
1112
1135
  const lastText = [...(lastTurn?.items ?? [])]
1113
1136
  .reverse()
1114
1137
  .find((item) => typeof item.text === "string" && item.text.trim())?.text?.trim();
1138
+ const completedTurns = (thread.turns ?? []).flatMap((turn) => {
1139
+ if (turn.status !== "completed") return [];
1140
+ const startedAt = remoteTurnStartedAt(turn);
1141
+ return [{
1142
+ id: turn.id,
1143
+ prompt: extractTurnUserPrompt(turn),
1144
+ startedAt,
1145
+ completedAt: startedAt === undefined
1146
+ ? undefined
1147
+ : startedAt + Math.max(0, turn.durationMs ?? 0),
1148
+ }];
1149
+ });
1115
1150
  return {
1116
1151
  id: thread.id,
1117
1152
  title: normalizeTaskTitle(thread.name) ?? normalizeTaskTitle(thread.preview),
@@ -1136,25 +1171,41 @@ function remoteSessionSummary(thread: CodexThreadSnapshot): RemoteSessionSummary
1136
1171
  lastTurnCompletedToolCount: toolCounts?.completed,
1137
1172
  lastTurnFailedToolCount: toolCounts?.failed,
1138
1173
  lastTurnRunningToolCount: toolCounts?.running,
1174
+ completedTurns,
1139
1175
  };
1140
1176
  }
1141
1177
 
1142
1178
  function extractLastUserPrompt(turns: CodexTurnSnapshot[] | undefined): string | undefined {
1143
1179
  for (const turn of [...(turns ?? [])].reverse()) {
1144
- for (const item of [...(turn.items ?? [])].reverse()) {
1145
- if (item.type !== "userMessage") continue;
1146
- const text = item.content
1147
- ?.filter((content) => content.type === "text" && typeof content.text === "string")
1148
- .map((content) => content.text!.trim())
1149
- .filter(Boolean)
1150
- .join("\n\n")
1151
- || item.text?.trim();
1152
- if (text) return text;
1153
- }
1180
+ const prompt = extractTurnUserPrompt(turn);
1181
+ if (prompt) return prompt;
1182
+ }
1183
+ return undefined;
1184
+ }
1185
+
1186
+ function extractTurnUserPrompt(turn: CodexTurnSnapshot): string | undefined {
1187
+ for (const item of [...(turn.items ?? [])].reverse()) {
1188
+ if (item.type !== "userMessage") continue;
1189
+ const text = item.content
1190
+ ?.filter((content) => content.type === "text" && typeof content.text === "string")
1191
+ .map((content) => content.text!.trim())
1192
+ .filter(Boolean)
1193
+ .join("\n\n")
1194
+ || item.text?.trim();
1195
+ if (text) return text;
1154
1196
  }
1155
1197
  return undefined;
1156
1198
  }
1157
1199
 
1200
+ function remoteTurnStartedAt(turn: CodexTurnSnapshot): number | undefined {
1201
+ return remoteTimestampMs(turn.startedAt ?? undefined);
1202
+ }
1203
+
1204
+ function remoteTimestampMs(value: number | undefined): number | undefined {
1205
+ if (value === undefined || !Number.isFinite(value)) return undefined;
1206
+ return value >= 10_000_000_000 ? value : value * 1_000;
1207
+ }
1208
+
1158
1209
  function summarizeTurnTools(turn: CodexTurnSnapshot): {
1159
1210
  total: number;
1160
1211
  completed: number;
@@ -0,0 +1,71 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { isMap, parseDocument } from "yaml";
4
+ import type { AgentExecutionDefaults } from "./schema.js";
5
+
6
+ const DEFAULT_KEYS = ["modelProvider", "model", "reasoningEffort", "permissionMode"] as const;
7
+
8
+ export function writeAgentExecutionDefaults(
9
+ configPath: string,
10
+ agentName: string,
11
+ defaults: AgentExecutionDefaults,
12
+ ): boolean {
13
+ const original = fs.readFileSync(configPath, "utf8");
14
+ const document = parseDocument(original);
15
+ if (document.errors.length > 0) {
16
+ throw new Error(`Could not update configuration file ${configPath}: ${document.errors[0]?.message ?? "invalid YAML"}`);
17
+ }
18
+ if (!isMap(document.contents)) {
19
+ throw new Error(`Configuration file must contain a YAML mapping: ${configPath}`);
20
+ }
21
+ const agent = document.getIn(["agents", agentName], true);
22
+ if (!isMap(agent)) {
23
+ throw new Error(`Cannot save defaults for an Agent that is not configured: ${agentName}`);
24
+ }
25
+ const existingDefaults = agent.get("defaults", true);
26
+ if (existingDefaults !== undefined && !isMap(existingDefaults)) {
27
+ throw new Error(`Configuration agents.${agentName}.defaults must contain a YAML mapping: ${configPath}`);
28
+ }
29
+ if (!isMap(existingDefaults)) agent.set("defaults", {});
30
+ const target = agent.get("defaults", true);
31
+ if (!isMap(target)) throw new Error(`Could not create defaults for Agent: ${agentName}`);
32
+
33
+ for (const key of DEFAULT_KEYS) {
34
+ const value = defaults[key];
35
+ if (value !== undefined) target.set(key, value);
36
+ }
37
+
38
+ const updated = document.toString();
39
+ if (updated === original) return false;
40
+ writeFileAtomically(configPath, updated);
41
+ return true;
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
+ temporaryFile = fs.openSync(temporaryPath, "wx", fs.statSync(filePath).mode);
49
+ fs.writeFileSync(temporaryFile, contents, "utf8");
50
+ fs.fsyncSync(temporaryFile);
51
+ fs.closeSync(temporaryFile);
52
+ temporaryFile = undefined;
53
+ fs.renameSync(temporaryPath, filePath);
54
+ syncDirectory(path.dirname(filePath));
55
+ } finally {
56
+ if (temporaryFile !== undefined) fs.closeSync(temporaryFile);
57
+ fs.rmSync(temporaryPath, { force: true });
58
+ }
59
+ }
60
+
61
+ function syncDirectory(directory: string): void {
62
+ let handle: number | undefined;
63
+ try {
64
+ handle = fs.openSync(directory, "r");
65
+ fs.fsyncSync(handle);
66
+ } catch (error) {
67
+ if (process.platform !== "win32") throw error;
68
+ } finally {
69
+ if (handle !== undefined) fs.closeSync(handle);
70
+ }
71
+ }
@@ -38,6 +38,12 @@ agents:
38
38
  - "--listen"
39
39
  - "stdio://"
40
40
  env: {}
41
+ # Updated automatically by the Provider, Model, Thinking, and Permission settings.
42
+ defaults:
43
+ # modelProvider: "openai"
44
+ # model: "gpt-5"
45
+ # reasoningEffort: "high"
46
+ permissionMode: "auto"
41
47
 
42
48
  traex:
43
49
  kind: "app-server"
@@ -48,6 +54,8 @@ agents:
48
54
  - "--listen"
49
55
  - "stdio://"
50
56
  env: {}
57
+ defaults:
58
+ permissionMode: "auto"
51
59
 
52
60
  defaults:
53
61
  agent: "codex"
@@ -40,12 +40,20 @@ const agentKindSchema = z.union([
40
40
  z.literal("codex").transform(() => "app-server" as const),
41
41
  ]).default("acp");
42
42
 
43
+ export const agentExecutionDefaultsSchema = z.object({
44
+ modelProvider: z.string().min(1).optional(),
45
+ model: z.string().min(1).optional(),
46
+ reasoningEffort: z.string().min(1).optional(),
47
+ permissionMode: z.enum(["auto", "confirm"]).optional(),
48
+ }).default({});
49
+
43
50
  export const agentConfigSchema = z.object({
44
51
  kind: agentKindSchema,
45
52
  title: z.string().min(1),
46
53
  command: z.string().min(1),
47
54
  args: z.array(z.string()).default([]),
48
55
  env: envRecordSchema,
56
+ defaults: agentExecutionDefaultsSchema,
49
57
  });
50
58
 
51
59
  export const appConfigSchema = z.object({
@@ -84,6 +92,10 @@ export const appConfigSchema = z.object({
84
92
  }).default({ level: "info", path: DEFAULT_LOG_PATH }),
85
93
  });
86
94
 
87
- export type AgentConfig = z.infer<typeof agentConfigSchema>;
95
+ export type AgentExecutionDefaults = z.infer<typeof agentExecutionDefaultsSchema>;
96
+ type ParsedAgentConfig = z.infer<typeof agentConfigSchema>;
97
+ export type AgentConfig = Omit<ParsedAgentConfig, "defaults"> & {
98
+ defaults?: AgentExecutionDefaults;
99
+ };
88
100
  export type GroupNameFormatConfig = z.infer<typeof groupNameFormatSchema>;
89
101
  export type AppConfig = z.infer<typeof appConfigSchema>;
@@ -225,7 +225,9 @@ export interface ResetHistoryCardEntry extends TaskListCardEntry {
225
225
  sequence: number;
226
226
  graphNodeLine: string;
227
227
  graphConnectorLine?: string;
228
+ timestamp?: string;
228
229
  running?: boolean;
230
+ resetting?: boolean;
229
231
  }
230
232
 
231
233
  export interface ResetHistoryCardView {
@@ -1328,9 +1330,11 @@ export class CardRenderer {
1328
1330
  entry.graphNodeLine,
1329
1331
  entry.graphConnectorLine,
1330
1332
  entry.lines,
1333
+ entry.timestamp,
1331
1334
  action,
1332
1335
  entry.current === true,
1333
1336
  entry.running === true,
1337
+ entry.resetting === true,
1334
1338
  ));
1335
1339
  });
1336
1340
  }
@@ -2415,15 +2419,8 @@ function inlineCode(value: string): string {
2415
2419
  }
2416
2420
 
2417
2421
  function formatStartupTime(value: Date): string {
2418
- return new Intl.DateTimeFormat("zh-CN", {
2419
- year: "numeric",
2420
- month: "2-digit",
2421
- day: "2-digit",
2422
- hour: "2-digit",
2423
- minute: "2-digit",
2424
- second: "2-digit",
2425
- hourCycle: "h23",
2426
- }).format(value);
2422
+ const pad = (part: number) => String(part).padStart(2, "0");
2423
+ return `${value.getFullYear()}/${pad(value.getMonth() + 1)}/${pad(value.getDate())} ${pad(value.getHours())}:${pad(value.getMinutes())}:${pad(value.getSeconds())}`;
2427
2424
  }
2428
2425
 
2429
2426
  function permissionModeLabel(mode?: PermissionMode): string {
@@ -2633,16 +2630,30 @@ function resetHistoryEntryRow(
2633
2630
  graphNodeLine: string,
2634
2631
  graphConnectorLine: string | undefined,
2635
2632
  lines: string[],
2633
+ timestamp: string | undefined,
2636
2634
  action: TaskListCardAction | undefined,
2637
2635
  current: boolean,
2638
2636
  running: boolean,
2637
+ resetting: boolean,
2639
2638
  ): Record<string, unknown> {
2639
+ const branchConnectorLine = graphConnectorLine && /[╱╲]/u.test(graphConnectorLine)
2640
+ ? graphConnectorLine
2641
+ : undefined;
2640
2642
  const graph = [
2641
- `<font color='${current ? "green" : running ? "orange" : "blue"}'>${escapeCardHtml(graphNodeLine)}</font>`,
2642
- ...(graphConnectorLine
2643
- ? [`<font color='grey'>${escapeCardHtml(graphConnectorLine)}</font>`]
2643
+ `<font color='${current ? "green" : running || resetting ? "orange" : "blue"}'>${escapeCardHtml(graphNodeLine)}</font>`,
2644
+ ...(branchConnectorLine
2645
+ ? [`<font color='grey'>${escapeCardHtml(branchConnectorLine)}</font>`]
2644
2646
  : []),
2645
2647
  ].join("\n");
2648
+ const trailingElements = current
2649
+ ? [markdown("✅ 当前")]
2650
+ : resetting
2651
+ ? [markdown("⏳ 正在 Reset")]
2652
+ : running
2653
+ ? [markdown("⏳ 运行中")]
2654
+ : action
2655
+ ? [taskActionElement(action)]
2656
+ : [];
2646
2657
  return {
2647
2658
  tag: "column_set",
2648
2659
  flex_mode: "none",
@@ -2664,25 +2675,22 @@ function resetHistoryEntryRow(
2664
2675
  weight: 1,
2665
2676
  vertical_align: "top",
2666
2677
  elements: [
2667
- markdown(lines[0] ?? ""),
2678
+ markdown([
2679
+ lines[0] ?? "",
2680
+ ...(timestamp ? [`<font color='grey'>${escapeCardHtml(timestamp)}</font>`] : []),
2681
+ ].join(" ")),
2668
2682
  ...lines.slice(1).map((line) => ({
2669
2683
  ...markdown(`<font color='grey'>${line}</font>`),
2670
2684
  text_size: "notation",
2671
2685
  })),
2672
2686
  ],
2673
2687
  },
2674
- {
2688
+ ...(trailingElements.length > 0 ? [{
2675
2689
  tag: "column",
2676
2690
  width: "auto",
2677
2691
  vertical_align: "top",
2678
- elements: current
2679
- ? [markdown("✅ 当前")]
2680
- : running
2681
- ? [markdown("⏳ 运行中")]
2682
- : action
2683
- ? [taskActionElement(action)]
2684
- : [],
2685
- },
2692
+ elements: trailingElements,
2693
+ }] : []),
2686
2694
  ],
2687
2695
  };
2688
2696
  }
@@ -1,4 +1,5 @@
1
1
  import type { Logger } from "pino";
2
+ import type { WSClient as LarkWsClient, WSConnectionStatus } from "@larksuiteoapi/node-sdk";
2
3
  import type { AppConfig } from "../config/schema.js";
3
4
  import { threadContextKey } from "./contextKey.js";
4
5
  import { resolveFeishuBotOpenId } from "./FeishuBotIdentity.js";
@@ -9,7 +10,12 @@ import type { CardAction, ChatUpdatedEvent, FeishuEventHandler, IncomingMessage
9
10
  type BotOpenIdResolver = (appId: string, appSecret: string) => Promise<string>;
10
11
  type BotOpenIdListener = (botOpenId: string) => void;
11
12
 
13
+ const LARK_WS_PING_TIMEOUT_SECONDS = 60;
14
+ const LARK_WS_HANDSHAKE_TIMEOUT_MS = 60_000;
15
+
12
16
  export class FeishuConnector {
17
+ private wsClient?: LarkWsClient;
18
+
13
19
  constructor(
14
20
  private readonly config: AppConfig,
15
21
  private readonly handler: FeishuEventHandler,
@@ -39,7 +45,12 @@ export class FeishuConnector {
39
45
  }
40
46
 
41
47
  stop(): void {
42
- // SDK connector currently relies on process lifetime.
48
+ this.wsClient?.close();
49
+ this.wsClient = undefined;
50
+ }
51
+
52
+ getConnectionStatus(): WSConnectionStatus | undefined {
53
+ return this.wsClient?.getConnectionStatus();
43
54
  }
44
55
 
45
56
  private async startFeishuWs(
@@ -48,7 +59,7 @@ export class FeishuConnector {
48
59
  botOpenId?: string,
49
60
  requireMention = false,
50
61
  ): Promise<void> {
51
- const lark = (await import("@larksuiteoapi/node-sdk")) as Record<string, any>;
62
+ const lark = await import("@larksuiteoapi/node-sdk");
52
63
  const eventDispatcher = new lark.EventDispatcher({}).register({
53
64
  "im.message.receive_v1": async (data: unknown) => {
54
65
  const senderOpenId = getFeishuEvent(data)?.sender?.sender_id?.open_id;
@@ -117,12 +128,66 @@ export class FeishuConnector {
117
128
  };
118
129
  },
119
130
  });
120
- const wsClient = new lark.WSClient({
131
+ let resolveInitialConnection!: () => void;
132
+ let rejectInitialConnection!: (error: Error) => void;
133
+ let initialConnectionSettled = false;
134
+ const initialConnection = new Promise<void>((resolve, reject) => {
135
+ resolveInitialConnection = resolve;
136
+ rejectInitialConnection = reject;
137
+ });
138
+ let wsClient: LarkWsClient | undefined;
139
+ wsClient = new lark.WSClient({
121
140
  appId,
122
141
  appSecret,
142
+ handshakeTimeoutMs: LARK_WS_HANDSHAKE_TIMEOUT_MS,
143
+ wsConfig: {
144
+ pingTimeout: LARK_WS_PING_TIMEOUT_SECONDS,
145
+ },
146
+ onReady: () => {
147
+ if (initialConnectionSettled) return;
148
+ initialConnectionSettled = true;
149
+ resolveInitialConnection();
150
+ },
151
+ onError: (error) => {
152
+ this.logger.error(
153
+ { error, connectionStatus: wsClient?.getConnectionStatus() },
154
+ "Feishu WebSocket connection failed.",
155
+ );
156
+ if (initialConnectionSettled) return;
157
+ initialConnectionSettled = true;
158
+ rejectInitialConnection(error);
159
+ },
160
+ onReconnecting: () => {
161
+ this.logger.warn(
162
+ { connectionStatus: wsClient?.getConnectionStatus() },
163
+ "Feishu WebSocket connection lost; reconnecting.",
164
+ );
165
+ },
166
+ onReconnected: () => {
167
+ this.logger.info("Feishu WebSocket connection restored.");
168
+ },
123
169
  });
124
- await wsClient.start({ eventDispatcher });
125
- this.logger.info("Feishu WebSocket connector started.");
170
+ this.wsClient = wsClient;
171
+ try {
172
+ await wsClient.start({ eventDispatcher });
173
+ const initialStatus = wsClient.getConnectionStatus();
174
+ if ((initialStatus.state === "idle" || initialStatus.state === "failed") && !initialConnectionSettled) {
175
+ throw new Error(`Feishu WebSocket failed to start (state: ${initialStatus.state}).`);
176
+ }
177
+ await initialConnection;
178
+ const connectedStatus = wsClient.getConnectionStatus();
179
+ if (connectedStatus.state !== "connected") {
180
+ throw new Error(`Feishu WebSocket failed to become ready (state: ${connectedStatus.state}).`);
181
+ }
182
+ this.logger.info(
183
+ { connectionStatus: connectedStatus },
184
+ "Feishu WebSocket connector started.",
185
+ );
186
+ } catch (error) {
187
+ wsClient.close({ force: true });
188
+ if (this.wsClient === wsClient) this.wsClient = undefined;
189
+ throw error;
190
+ }
126
191
  }
127
192
 
128
193
  private deferCardAction(action: CardAction, receivedAt: number): void {
@@ -54,7 +54,7 @@ export interface FeishuTurnPresenterOptions {
54
54
 
55
55
  const MAX_FINAL_TABLES_PER_CARD = 5;
56
56
  const DEFAULT_ELAPSED_UPDATE_INTERVAL_MS = 3_000;
57
- const FINAL_RESPONSE_BRANDING_MIN_LENGTH = 1_000;
57
+ const FINAL_RESPONSE_BRANDING_THRESHOLD = 600;
58
58
 
59
59
  interface TurnEntry {
60
60
  contextKey: string;
@@ -581,7 +581,7 @@ export class FeishuTurnPresenter {
581
581
 
582
582
  function brandedFinalChunks(response: string, maxLength: number): string[] {
583
583
  const chunks = splitMarkdown(response, maxLength, MAX_FINAL_TABLES_PER_CARD);
584
- if (chunks.length <= 1 && response.length < FINAL_RESPONSE_BRANDING_MIN_LENGTH) return chunks;
584
+ if (chunks.length <= 1 && response.length <= FINAL_RESPONSE_BRANDING_THRESHOLD) return chunks;
585
585
 
586
586
  const last = chunks.pop();
587
587
  if (last === undefined) return chunks;
package/src/index.ts CHANGED
@@ -6,6 +6,7 @@ import { controlEndpoint, type ControlRequest, type ControlResponse } from "./cl
6
6
  import { readPackageVersion } from "./cli/packageVersion.js";
7
7
  import { loadConfig } from "./config/loadConfig.js";
8
8
  import { persistFeishuUserOpenIdIfMissing } from "./config/FeishuUserOpenIdStore.js";
9
+ import { writeAgentExecutionDefaults } from "./config/AgentExecutionDefaultsStore.js";
9
10
  import {
10
11
  agentBotHome,
11
12
  defaultConfigPath,
@@ -42,6 +43,7 @@ import {
42
43
  import { SafeRestartScheduler } from "./supervision/SafeRestartScheduler.js";
43
44
  import type { RestartNotificationTarget } from "./supervision/SafeRestartScheduler.js";
44
45
  import { SafeRestartNotifier } from "./supervision/SafeRestartNotifier.js";
46
+ import { privateRestartNotificationTarget } from "./supervision/restartNotificationTarget.js";
45
47
  import {
46
48
  nodeDiagnosticReportArguments,
47
49
  prepareCrashReportDirectory,
@@ -72,9 +74,10 @@ const logger = createLogger(config);
72
74
  const store = new StateStore(config.storage.sqlitePath);
73
75
 
74
76
  const configuredConfigPath = process.env.AGENT_BOT_CONFIG?.trim();
77
+ const activeConfigPath = configuredConfigPath ? resolveUserPath(configuredConfigPath) : defaultConfigPath();
75
78
  const agentEnvironmentContext: AgentEnvironmentContext = {
76
79
  profilePath: agentBotHome(),
77
- configPath: configuredConfigPath ? resolveUserPath(configuredConfigPath) : defaultConfigPath(),
80
+ configPath: activeConfigPath,
78
81
  larkAppId: config.feishu.appId,
79
82
  larkUserOpenId: config.feishu.userOpenId,
80
83
  };
@@ -156,6 +159,10 @@ const controller = new ProxySessionController(config, store, runtimes, outbound,
156
159
  "Stored the default Lark user Open ID from the first private chat message.",
157
160
  );
158
161
  },
162
+ persistAgentExecutionDefaults: (agentName, defaults) => {
163
+ const changed = writeAgentExecutionDefaults(activeConfigPath, agentName, defaults);
164
+ logger.info({ agentName, changed }, "Stored Agent execution defaults.");
165
+ },
159
166
  });
160
167
 
161
168
  if (feishuOutbound) {
@@ -362,7 +369,7 @@ async function handleControlRequest(request: ControlRequest): Promise<ControlRes
362
369
  if (pendingSelfUpdatePlanPath) {
363
370
  return { ok: false, message: "An Agent Bot update is already pending." };
364
371
  }
365
- const notificationTarget = inferControlRestartTarget(request.notificationSessionId);
372
+ const notificationTarget = inferServerRestartTarget(request.notificationSessionId);
366
373
  if (request.mode === "safe") {
367
374
  const newlyScheduled = safeRestart.schedule(request.reason, notificationTarget);
368
375
  return {
@@ -571,6 +578,14 @@ function inferControlRestartTarget(notificationSessionId?: string): RestartNotif
571
578
  return session ? restartNotificationTargetForSession(session) : undefined;
572
579
  }
573
580
 
581
+ function inferServerRestartTarget(notificationSessionId?: string): RestartNotificationTarget {
582
+ if (notificationSessionId?.trim()) {
583
+ const target = inferControlRestartTarget(notificationSessionId);
584
+ if (target) return target;
585
+ }
586
+ return privateRestartNotificationTarget(config.feishu.userOpenId);
587
+ }
588
+
574
589
  function restartNotificationTargetForSession(
575
590
  session: { localSessionId: string; contextKey: string },
576
591
  ): RestartNotificationTarget {